abitdodgy

abitdodgy

The other day I wrote a small lib to interact with AWS IAM (on top of ExAws). I wanted the internal API to have one Parser.parse/2 function that would handle any response. ExAws executes the request and calls this function with the response and the action name as the two arguments. For example:

parse.call(response, action)

Where response is {:ok, %{body: xml, status_code: status}} and action is the AWS IAM operation (“CreateUser”, “DeleteUser”, etc…).

I created a single module with a parse/2 action that dispatched to a function, which in turn called the right parser in the right module (pattern matching on the action name).

defmodule ExAws.Iam.Parser do
  alias ExAws.Iam.Parsers.{AccessKey, User}

  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 CreateUser]
  defp dispatch(xml, action) when action in @user_actions do
    User.parse(xml, action)
  end

  @access_key_actions ~w[ListAccessKeys GetAccessKeyLastUsed]
  defp dispatch(xml, action) when action in @access_key_actions do
    AccessKey.parse(xml, action)
  end
end

I then created individual parsing modules for each entity:

defmodule ExAws.Iam.Parsers.User do
  def parse(xml, "ListUsers") do
    # parse the xml
  end

  def parse(xml, "GetUser") do    
    # parse the xml
  end

  # etc...
end

defmodule ExAws.Iam.Parsers.AccessKey do
  # ...
end

I’m not a fan, though. The Parsers.AccessKey and Parsers.User should be private (which we don’t have in Elixir). I can’t import the functions because the names would clash. I also feel like I’m abusing pattern matching.

Protocols won’t work because the data types don’t change. What other patterns can I use to achieve the same API?

Showing Posts 1 to 10

abitdodgy

abitdodgy OP

Thinking out loud, I suppose behaviours could be a solution? If I’m willing to accept that passing the right parser as an argument is better than having a polymorphic parse/2 function.

I could declare parse/2 as a behaviour and implement it in each module (Parsers.User, and Parsers.AccessKey). I would then pass the right parser as an argument.

  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

Still, it would be nice to just call parse/2 and have the API figure out which module to call.

peerreynders

peerreynders

Have you considered:

  # in some module ..

  def list_users, do: {User,:list_users}
  def create_user, do: {User,:create_user}
  def list_access_keys, do: {AccessKey, :list_access_keys}
  def get_access_key_last_used, do: {AccessKey, :get_access_key_last_used}

  # somewhere else ...

  def parse({:ok, %{body: xml, status_code: status} = resp}, {mod,fun}) when status in 200..299 do
    parsed_body = apply(mod, fun, [xml])
    {:ok, %{resp | body: parsed_body}}
  end

abitdodgy

abitdodgy OP

I don’t control the calling code. ExAws does that part. It just calls the parser I provide as an argument.

Here’s how I currently send the parser:

  def list_users(opts \\ []) do
    :list_users
    |> to_params(opts)
    |> to_op(parser: &Parser.parse/2)
  end

So it’s easy enough to replace |> to_op(parser: &Parser.parse/2) with |> to_op(parser: &Parsers.User.parse/2). But I was looking for a better way.

yurko

yurko

I do this kind of things with adapter pattern where I set the adapter in config, have a behavior that is an interface for adapters and few adapter modules (some call API, some use ETS some just serve hardcoced responses). A “public” wrapper module can then call configured adapter and depending on the env different implementations can be used, the context (that is the only really public module) can delegate to that wrapper module then.

dimitarvp

dimitarvp

The best way in Elixir I could see is a mix of your original approach, and behaviours.

  1. Behaviour:
defmodule ExAws.Iam.Consumer do
  @callback consume_response(String.t, String.t) :: any # (xml, action)
end
  1. Implementers:
defmodule User do
  @behaviour ExAws.Iam.Consumer
  def consume_response(xml, action), do: #...
end
  1. Refactor dispatch to use router functions like so:
defp consumer(action) when action in @user_actions, do: User
defp consumer(action) when action in @access_key_actions, do: AccessKey

#...

defp dispatch(xml, action), do: consumer(action).consume_response(xml, action)

Code is not tested. But IMO this brings you close enough to some practical polymorphism while remaining terse enough.

(Another alternative would be to have a big module where you match on all possible combos but I don’t think it would be an improvement over your current approach – except maybe for having less source files.)

peerreynders

peerreynders

def SomeModule do

  @action_parsers %{
    "ListUsers" => User,
    "CreateUser" => User,
    "ListAccessKeys" => AccessKey,
    "GetAccessKeyLastUsed" => AccessKey
  }
 
  def parse({:ok, %{body: xml, status_code: status} = resp}, action) when status in 200..299 do
    with {:ok, mod} <- Map.fetch(@action_parsers, action) do
       parsed_body = apply(mod, :parse, [xml, action])
       {:ok, %{resp | body: parsed_body}}
    else
      _ ->
        # BOOM
    end
  end

end

But as it is you’ll probably be best off by pursuing the behaviour angle further if just to capture the implementation commonalities between your various parsers.

abitdodgy

abitdodgy OP

That’s essentially what I had before I went into a refactoring rabbit hole. It might just be simpler to pass the parser in the public function. For example:

  def list_users(opts \\ []) do
    :list_users
    |> to_params(opts)
    |> to_op(parser: &Parsers.User.parse/2)
  end

  def list_access_keys(opts \\ []) do
    :list_access_keys
    |> to_params(opts)
    |> to_op(parser: &Parsers.AccessKey.parse/2)
  end
abitdodgy

abitdodgy OP

That’s pretty cool, but it sacrifices explicitness especially as the codebase gets bigger (my current approach also sacrifices explicitness).

dimitarvp

dimitarvp

Valid reservation. I just feel we can’t go all the way explicit if we need polymorphism though.

As an extra step I would recommend you just make a separate configuration file and just use that in order not to have too much routing logic hardcoded in a module (where other team members might have trouble finding it or it will become hard to maintain as you pointed out).

If you opt for that you should definitely put that string_action->module map that @peerreynders suggested into that config.

I understand the nagging feeling of “it just doesn’t feel right” but don’t get stuck into paralysis analysis. Truth is, functional languages are not ideal when polymorphism is involved and that’s okay because it’s not their main focus. But behaviours and map routers get you close enough, and it’s not like the OOP languages don’t do the same anyway!

IMO just go for the minimal maintenance approach and move on. Even if the code is a bit more. Important part is to be able to add/remove consumers in a minute.

peerreynders

peerreynders

In some contexts “passing a function” can be seen as the functional equivalent of OO’s strategy pattern.

Where Next? Top

Trending in Questions Top

itzmidinesh
Hi all, I’m working on a Phoenix project where I need to make external HTTP requests and I’m trying to choose between Req and HTTPoison....
New
yarrichar
I’m very new to Elixir, so sorry if this is a silly question: I am wondering what happens to the mailbox of a process when the machine i...
New
pragdave
How do I tell Hologram not to escape HTML when substituting into templates? {Textile.render(@product.long_description)} escapes the HTM...
New
Fl4m3Ph03n1x
Background When trying to execute mix release on a Windows 11 machine for a Phoenix project I get the following error: * assembling mark...
New
rlipscombe
I’d like to create a file with 600 permissions (rw-------), but there’s a race condition if I create it and then change the permissions. ...
New
saleyn
Is there a way to import record definitions in the iex shell for pretty printing (similar to the rr command in the erl shell)? This woul...
New
rlipscombe
When I use Mix.install with a git dependency, using git@github.com:... how do I change the SSH identity used? I’ve got two Github accoun...
New

Other Trending Topics Top

dominicletz
Hey there, I haven’t seen a list of useful Elixir specific skills for the popular AI tools yet so I’m starting one here with my first. ...
New
raa-ziq
Hi everyone :waving_hand: I wanted to share something I’ve been building — NowBlind, a random chat app designed to help people discover ...
New
mudasobwa
I fully migrated to my own harness from Anthropic/Gemini and I think it’s time to share it. Welcome DSH, the DeepSeek Harness, fully writ...
New
KristerV
Hey. Is there anyone here who creates agents in their apps? Not talking about using agents, but creating them. I’m finding it pretty diff...
New
OndrejValenta
(I just needed to vent somewhere and LinkedIn is full of hope, or hype, I’m not sure which exactly) AI dream has many faces, but general...
New
kip
Localize is the next generation localisation library for Elixir. Think of it as ex_cldr version 3.0. The first version will be released ...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews