In the end, I stuck with my approach of having a Parser module pattern-match on the action name and delegate to the correct module for parsing. This way I only have to specify a parser once.
def parse({:ok, %{body: xml, status_code: status} = resp}, action) when status in 200..299 do
parsed_body = dispatch(xml, action)
{:ok, %{resp | body: parsed_body}}
end
def parse(resp, _), do: resp
@user_actions ~w[ListUsers GetUser] # etc ...
defp dispatch(xml, action) when action in @user_actions do
User.parse(xml, action)
end
I also realise that the API I wrote was contrived. A single generic operation/2 function would have allowed me to interact with the entire IAM API without having to write a function for each IAM action/endpoint, as I was doing. It can be used by the internal API for convenience functions too.
def operation(action, params, opts \\ []) do
{parser, params} = Keyword.pop(params, :parser, &Parser.parse/2)
opts = Keyword.put_new(opts, :parser, parser)
@shared_opts
|> Keyword.merge(params)
|> Keyword.put(:action, camelize(action))
|> list_to_camelized_map()
|> to_operation(opts)
end
operation(:create_user, user_name: "foo")
# or for internal use
def create_user(username, opts \\ []) do
operation([user_name: username] ++ opts)
end
Below is what I had until now. Those functions (create_user/2, list_users, etc…) are now relegated being to convenience functions only.
def create_user(username, opts \\ []) do
operation(:create_user, [user_name: username] ++ opts)
end
defp to_operation(params, opts) do
%ExAws.Operation.Query{
action: params["Action"],
params: params,
parser: Keyword.get(opts, :parser),
path: params["Path"] || "/",
service: :iam
}
end
So, what good are they? Well, maybe I can convert them to execute the operation on AWS instead of returning an ExAws op. For example:
def create_user(username, opts \\ []) do
:create_user
|> operation([user_name: username] ++ opts)
|> ExAws.request()
|> to_user_struct()
end
%User{
arn: ...,
create_date: ...,
path: ...,
user_name: ...,
user_id: ...
}
Finally, it would be nice to have a parser for all those actions. But it’s hard work to write one for 60 or so actions. I wrote my first macro ever (be very afraid) to define DSL for parsers:
defparser(:get_user,
fields: [
get_user_result: [
~x"//GetUserResult",
user: [
~x"./User",
:path,
:user_name,
:arn,
:user_id,
:create_date
]
],
response_metadata: [
~x"//ResponseMetadata",
:request_id
]
]
)
The macro itself, below, is still not optimal. I would rather do away with passing the XML paths (~x"//GetUserResult") and handle that internally in the macro, but I have no way passing the type.
defmodule ExAws.Iam.TestMacro do
import SweetXml, only: [sigil_x: 2]
defmacro defparser(action, opts) do
action_name = to_camel(action)
fields =
opts
|> Keyword.get(:fields)
|> Enum.map(fn field ->
compile(field)
end)
quote do
def parse(xml, unquote(action_name)) do
SweetXml.xpath(xml, ~x"//#{unquote(xml_path(action_name))}", [
{
unquote(xml_node(action_name)),
[~x"//#{unquote(xml_path(action_name))}" | unquote(fields)]
}
])
end
end
end
defp xml_path(action), do: action <> "Response"
defp xml_node(action), do: xml_path(action) |> to_snake()
defp compile(field) when is_atom(field) do
quote do
{unquote(field), ~x"./#{unquote(to_camel(field))}/text()"s}
end
end
defp compile({:sigil_x, _, _} = field), do: field
defp compile({key, value}) do
quote do
{unquote(key), unquote(compile(value))}
end
end
defp compile(list) when is_list(list) do
Enum.map(list, fn field ->
compile(field)
end)
end
defp to_camel(atom), do: atom |> Atom.to_string() |> Macro.camelize()
defp to_snake(string), do: string |> Macro.underscore() |> String.to_atom()
end
Here’s the code on a separate branch.
Thoughts?






















