What's the most idiomatic way to go from JSON body -> struct in Phoenix?

If I have a struct (with maybe some nested structs), and it has atom keys, what’s the most idiomatic way to get from user input in the form of a JSON request body to that struct? In my day job I use Scala + Play Framework and this is really easy to do, but I think in Elixir I’m getting caught up on the fact that user input -> atom is a DoS vector.

So Phoenix presents my JSON body to my controller function in conn.body_params, nicely parsed into a map - with string keys. I can’t parse this to a struct using Kernel.struct because the string keys are ignored - and I can’t convert them to atoms without a potential DoS.

The only thing I can think of that would probably work is converting that map back into JSON, and then decoding it using Poison’s as parameter. But that seems really wasteful because Plug already converted it from JSON -> map, then I’m doing map -> JSON -> struct.

I guess I’m wondering if there’s anything more idiomatic/automated/safe than just doing something like this in the controller:

body_params = conn.body_params

user = %User{
  name: body_params["name"],
  age: body_params["age"],
  occupation: %Occupation{
    job_title: body_params["occupation"]["job_title"]
  }
}

Which seems quite error prone and tedious particularly where there’s a lot of nesting, JSON lists, etc.

3 Likes

Converting data from the outside is usually done with changesets in Elixir, with or without database.

Something like

{:ok, user} = %User{}
|> User.changeset(body_params)
|> Ecto.Changeset.apply_action(:insert)
12 Likes

Structs are actually just maps with a additional key __struct__, so %User{a: 1} is simply a map %{__struct__: User, a: 1} underneath. You can hack the map you have, by converting keys from strings to atoms, and then adding the proper __struct__ key.

Or maybe just simply use struct function, here are examples: https://hexdocs.pm/elixir/Kernel.html#struct/2-examples but you will have to convert keys from that map to atoms, and then that map to keyword list.

From my experience, it’s just easier and more readable, when you have specialized functions in your module that declares that struct. and invoke them whenever you have need to create that struct from params map, You can add there proper validation etc with Ecto embeded schemas etc.

1 Like

Thanks - I had left Ecto out when I started the Phoenix app because I’m not using a database but it looks like changeset is still the best approach here.

If you are still thinking about going completely without ecto you could try something like this

defmodule StructMap.User do
  defstruct [:name, :age]

  def map_to_struct(map) do
    #convert string keys to atoms
    map = Map.new(map, fn {k, v} -> {String.to_atom(k), v} end)

    # return struct from map
    struct(__MODULE__, map)
  end
end

iex(1)> StructMap.User.map_to_struct(%{"name" => "mike", "age" => 12})
%StructMap.User{age: 12, name: "mike"}
1 Like

Hello and welcome,

This could lead to potential :atom overflow,

2 Likes

Hello and thank you,

Thank you for mentioning :atom overflow I haven’t thought about that. Atoms are not garbage-collected.

1 Like

I think half the elixir community is waiting for the ecto team to break off changesets like Phoenix did with pubsub.

2 Likes

Spliting ecto and ecto_sql was already nice :slight_smile:

Nice thing about changesets is that you can still define an embedded schema and get casts and validations.

2 Likes

One doesn’t even need schemas. There are schemaless changesets.

1 Like

An alternative way can be to use Nestru which converts the JSON map to a nested struct like the following:

defmodule User do
  @derive {Nestru.Decoder, %{occupation: Occupation}}
  defstruct [:name, :age, :occupation]
end

defmodule Occupation do
  @derive Nestru.Decoder
  defstruct [:job_title]
end

body_params = %{
  "name" => "John",
  "age" => 25,
  "occupation" => %{
    "job_title" => "plumber"
  }
}

iex> Nestru.from_map(body_params, User)
{:ok, %User{age: 25, name: "John", occupation: %Occupation{job_title: "plumber"}}}
1 Like