How to decode a JSON into a struct safely?

It is in the Ecto.Enum documentation.

2 Likes

OK, now I understood Ecto.Enum - turns out it does exactly what I want:

defmodule Person do
  use Ecto.Schema

  @primary_key false
  embedded_schema do
    field(:id, :integer)
    field(:name, :string, null: false)
    field(:age, :integer)
    field(:job, Ecto.Enum, values: Job.ids())
    field(:hobbies, {:array, Ecto.Enum}, values: Hobby.ids())
    field(:friends, {:array, :integer})
  end
end

defmodule Hobby do
  @hobbies [:hobby_painting, :hobby_freeclimbing, :hobby_stampcollecting]

  def ids(), do: @hobbies
end
iex(1)> data = %{id: 1, name: "Bob", age: "18", job: "job_programmer", friends: [2, 4711], hobbies: ["hobby_freeclimbing", "hobby_painting"]}
iex(2)> p = Ecto.Changeset.cast(%Person{}, data, Map.keys(data)) |> Ecto.Changeset.apply_changes()
%Person{
  age: 18,
  friends: [2, 4711],
  hobbies: [:hobby_freeclimbing, :hobby_painting],
  id: 1,
  job: :job_programmer,
  name: "Bob"
}
1 Like

Are you using typed_embedded_schema here?

One alternative valid approach is to do the serialization with Nestru like the following:

defmodule Person do
  defstruct [:id, :name, :age, :job, :hobbies, :friends]

  defimpl Nestru.Decoder do
    def from_map_hint(_value, _context, _map) do
      {:ok, %{hobbies: &safe_to_list_of_atoms(&1), job: &safe_to_atom(&1)}}
    end

    defp safe_to_list_of_atoms(list) do
      atoms =
        Enum.reduce_while(list, [], fn item, acc ->
          case safe_to_atom(item) do
            {:ok, atom} -> {:cont, [atom | acc]}
            {:error, _message} = error -> {:halt, error}
          end
        end)
      
      if match?({:error, _message}, atoms) do
        atoms
      else
        {:ok, Enum.reverse(atoms)}
      end
    end

    defp safe_to_atom(binary) do
      try do
        {:ok, String.to_existing_atom(binary)}
      rescue 
        ArgumentError -> {:error, "No atom exists for the value #{inspect(binary)}."}
      end
    end
  end
end

So for the data with binaries for existing atoms, it returns the struct like that:

iex(1)> [:hobby_painting, :hobby_freeclimbing, :hobby_stampcollecting]
iex(2)> :job_programmer

iex(3)> data = %{id: 1, name: "Bob", age: "18", job: "job_programmer", friends: [2, 4711], hobbies: ["hobby_freeclimbing", "hobby_painting"]}
iex(4)> Nestru.from_map(data, Person)
{:ok,
 %Person{
   age: "18",
   friends: [2, 4711],
   hobbies: [:hobby_freeclimbing, :hobby_painting],
   id: 1,
   job: :job_programmer,
   name: "Bob"
 }}

And for the data with binary having no equivalent atom, it will fail like the following:

iex(5)> data = %{id: 1, name: "Bob", age: "18", job: "job_programmer", friends: [2, 4711], hobbies: ["hobby_freeclimbing", "hobby_gardening"]}
iex(6)> {:error, error} = Nestru.from_map(data, Person)
{:error,
 %{
   get_in_keys: [#Function<8.5372299/3 in Access.key!/1>],
   message: "No atom exists for the value \"hobby_gardening\".",
   path: [:hobbies]
 }}

And it’s even possible to get the failed piece of the map like that :slightly_smiling_face::

iex(7)> get_in(data, error.get_in_keys)
["hobby_freeclimbing", "hobby_gardening"]
1 Like

back to the original question…
my idea:

  1. decode to a map with atoms as keys
  2. merge it with a new struct
  @doc ~S"""                                                                                                                                              
  Decode to Struct.                                                                                                                                       
                                                                                                                                                          
  ## Examples                                                                                                                                             
                                                                                                                                                          
      iex> Test.decode_struct("{\"age\":123,\"name\":\"foo\"}", keys: :atoms)                                                                             
      %User{age: 123, name: "foo"}                                                                                                                        
                                                                                                                                                          
  """
  def decode_struct(json_str, opts \\ %{}) do
    %User{} |> Map.merge(Jason.decode!(json_str, opts))
  end

You could use struct!/2 to cast a struct and not allow unknown keys to be added to the struct :wink:

%User{} |> Map.merge(%{unknown: "works"}) will yield %User{unknown: "works"}

But struct!(User, %{unknown: "works"}) will raise a KeyError.

thanks!