Better design pattern for creating a polymorphic API

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?