defmodule Nestru do @external_resource "README.md" @moduledoc "README.md" |> File.read!() |> String.split("\n") |> Enum.at(1) |> String.trim("\n") @doc """ Decodes a map into the given struct. The first argument is a map having key-value pairs. Supports both string and atom keys in the map. The second argument is a struct's module atom. The third argument is a context value to be passed to implemented functions of `Nestru.PreDecoder` and `Nestru.Decoder` protocols. To give a hint on how to decode nested struct values or a list of such values for the given field, implement `Nestru.Decoder` protocol for the struct. Function calls `struct/2` to build the struct's value. Keys in the map that don't exist in the struct are automatically discarded. """ def decode_from_map(map, struct_module, context \\ []) def decode_from_map(%{} = map, struct_module, context) do case prepare_map(:warn, map, struct_module, context) do {:ok, nil} -> {:ok, nil} {:ok, map} -> {:ok, struct(struct_module, map)} {:error, %{} = map} -> {:error, format_paths(map)} {:invalid_hint_shape, %{message: {struct_module, value}} = error_map} -> {:error, %{error_map | message: invalid_hint_shape(struct_module, value)}} {:invalid_gather_fields_shape, struct_module, value} -> {:error, %{message: invalid_gather_fields_shape(struct_module, value)}} {:unexpected_item_value, key, value} -> {:error, %{message: invalid_item_value(struct_module, key, value)}} {:unexpected_item_function_return, key, fun, value} -> {:error, %{message: invalid_item_function_return_value(struct_module, key, fun, value)}} {:unexpected_atom_for_item_with_list, key, value} -> {:error, %{message: invalid_atom_for_item_with_list(struct_module, key, value)}} end end def decode_from_map(value, struct_module, _context) do {:error, %{message: invalid_map_input(struct_module, value)}} end @doc """ Similar to `decode_from_map/3` but checks if enforced struct's fields keys exist in the given map. Returns a struct or raises an error. """ def decode_from_map!(map, struct_module, context \\ []) def decode_from_map!(%{} = map, struct_module, context) do case prepare_map(:raise, map, struct_module, context) do {:ok, nil} -> nil {:ok, map} -> struct!(struct_module, map) {:error, %{} = error_map} -> raise format_raise_message("map", error_map) {:invalid_hint_shape, %{message: {struct_module, value}}} -> raise invalid_hint_shape(struct_module, value) {:invalid_gather_fields_shape, struct_module, value} -> raise invalid_gather_fields_shape(struct_module, value) {:unexpected_item_value, key, value} -> raise invalid_item_value(struct_module, key, value) {:unexpected_item_function_return, key, fun, value} -> raise invalid_item_function_return_value(struct_module, key, fun, value) {:unexpected_atom_for_item_with_list, key, value} -> raise invalid_atom_for_item_with_list(struct_module, key, value) end end def decode_from_map!(value, struct_module, _context) do raise invalid_map_input(struct_module, value) end defp prepare_map(error_mode, map, struct_module, context) do struct_value = struct_module.__struct__() struct_info = {struct_value, struct_module} with {:ok, map} <- gather_fields_from_map(struct_info, map, context), {:ok, decode_hint} <- get_decode_hint(struct_info, map, context), {:ok, _shaped_fields} = ok <- shape_fields(error_mode, struct_info, decode_hint, map) do ok end end defp gather_fields_from_map(struct_info, map, context) do {struct_value, struct_module} = struct_info struct_value |> Nestru.PreDecoder.gather_fields_from_map(context, map) |> validate_fields_map(struct_module) end defp validate_fields_map({:ok, %{}} = ok, _struct_module), do: ok defp validate_fields_map({:error, message}, _struct_module), do: {:error, %{message: message}} defp validate_fields_map(value, struct_module), do: {:invalid_gather_fields_shape, struct_module, value} defp get_decode_hint(struct_info, map, context) do {struct_value, struct_module} = struct_info struct_value |> Nestru.Decoder.from_map_hint(context, map) |> validate_hint(struct_module) end defp validate_hint({:ok, hint} = ok, _struct_module) when is_nil(hint) or is_map(hint), do: ok defp validate_hint({:error, %{message: _}} = error, _struct_module), do: error defp validate_hint({:error, message}, _struct_module), do: {:error, %{message: message}} defp validate_hint(value, struct_module), do: {:invalid_hint_shape, %{message: {struct_module, value}}} defp shape_fields(_error_mode, _struct_info, nil = _decode_hint, _map) do {:ok, nil} end defp shape_fields(error_mode, struct_info, decode_hint, map) do {struct_value, struct_module} = struct_info struct_keys = struct_value |> Map.keys() |> List.delete(:__struct__) inform_unknown_keys(error_mode, decode_hint, struct_module, struct_keys) decode_hint = Map.take(decode_hint, struct_keys) kvi = decode_hint |> :maps.iterator() |> :maps.next() with {:ok, acc} <- shape_fields_recursively(error_mode, kvi, map) do as_is_keys = struct_keys -- Map.keys(decode_hint) fields = Enum.reduce(as_is_keys, %{}, fn key, taken_map -> if has_key?(map, key) do value = get(map, key) Map.put(taken_map, key, value) else taken_map end end) {:ok, Map.merge(fields, acc)} end end defp inform_unknown_keys(error_mode, map, struct_module, struct_keys) do if extra_key = List.first(Map.keys(map) -- struct_keys) do message = """ The decoding hint value for key #{inspect(extra_key)} received from Nestru.Decoder.from_map_hint/3 \ implemented for #{inspect(struct_module)} is unexpected because the struct hasn't a field with such key name.\ """ if error_mode == :raise do raise message else IO.warn(message) end end :ok end defp shape_fields_recursively(error_mode, kvi, map, acc \\ %{}) defp shape_fields_recursively(_error_mode, :none = _kvi, _map, target_map) do {:ok, target_map} end defp shape_fields_recursively(error_mode, {key, [module], iterator}, map, target_map) when is_atom(module) do shape_fields_recursively( error_mode, {key, &__MODULE__.decode_from_list_of_maps(&1, module), iterator}, map, target_map ) end defp shape_fields_recursively(error_mode, {key, fun, iterator}, map, target_map) when is_function(fun) do map_value = get(map, key) case fun.(map_value) do {:ok, updated_value} -> target_map = Map.put(target_map, key, updated_value) shape_fields_recursively(error_mode, :maps.next(iterator), map, target_map) {:error, %{message: _, path: path} = error_map} = error -> validate_path!(path, error, fun) {:error, insert_to_path(error_map, key, map)} {:error, message} -> {:error, insert_to_path(%{message: message}, key, map)} value -> {:unexpected_item_function_return, key, fun, value} end end defp shape_fields_recursively(error_mode, {key, module, iterator}, map, target_map) when is_atom(module) do if Code.ensure_loaded?(module) and function_exported?(module, :__struct__, 0) do result = case get(map, key) do [_ | _] -> {:unexpected_atom_for_item_with_list, key, module} nil -> {:ok, nil} map_value -> shape_nested_struct(error_mode, map, key, map_value, module) end case result do {:ok, shaped_value} -> target_map = Map.put(target_map, key, shaped_value) shape_fields_recursively(error_mode, :maps.next(iterator), map, target_map) error -> error end else {:unexpected_item_value, key, module} end end defp shape_fields_recursively(_error_mode, kvi, _map, _acc) do {key, value, _iterator} = kvi {:unexpected_item_value, key, value} end defp shape_nested_struct(error_mode, map, key, map_value, module) do shaped_value = if error_mode == :raise do decode_from_map!(map_value, module) else decode_from_map(map_value, module) end case shaped_value do struct when error_mode == :raise -> {:ok, struct} {:ok, _struct} = ok -> ok {:error, error_map} -> {:error, insert_to_path(error_map, key, map)} end end defp validate_path!(path, error, fun) do unless Enum.all?(path, &(not is_nil(&1) and (is_atom(&1) or is_binary(&1) or is_number(&1)))) do raise """ Error path can contain only not nil atoms, binaries or integers. \ Error is #{inspect(error)}, received from function #{inspect(fun)}.\ """ end end defp insert_to_path(error_map, key, map_value) do key = resolve_key(map_value, key) insert_to_path(error_map, key) end defp insert_to_path(error_map, key_or_idx) do path = Enum.concat([ List.wrap(key_or_idx), Map.get(error_map, :path, []) ]) Map.put(error_map, :path, path) end defp resolve_key(map, key) do existing_key(map, key) || (is_binary(key) && apply_atom_key(key, nil, &existing_key(map, &1))) || (is_atom(key) && existing_key(map, to_string(key))) || key end defp apply_atom_key(string, default, fun) do try do fun.(String.to_existing_atom(string)) rescue ArgumentError -> default end end defp existing_key(map, key) do if Map.has_key?(map, key), do: key end defp invalid_map_input(struct_module, value) do """ Expected a map value received #{inspect(value)} instead. \ Can't convert it to a #{inspect(struct_module)} struct.\ """ end defp invalid_gather_fields_shape(struct_module, value) do """ Expected a {:ok, map} | {:error, term} value from Nestru.PreDecoder.gather_fields_from_map/3 \ function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\ """ end defp invalid_hint_shape(struct_module, value) do """ Expected a {:ok, nil | map} | {:error, term} value from Nestru.Decoder.from_map_hint/3 \ function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\ """ end defp invalid_item_function_return_value(struct_module, key, fun, value) do """ Expected {:ok, term}, {:error, %{message: term, path: list}}, or %{:error, term} \ return value from the anonymous function for the key defined in the following \ {:ok, %{#{inspect(key)} => #{inspect(fun)}}} tuple returned from Nestru.Decoder.from_map_hint/3 \ function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\ """ end defp invalid_item_value(struct_module, key, value) do """ Expected a struct's module atom, [struct_module_atom], or a function value for #{inspect(key)} key received \ from Nestru.Decoder.from_map_hint/3 function implemented for #{inspect(struct_module)}, \ received #{inspect(value)} instead.\ """ end defp invalid_atom_for_item_with_list(struct_module, key, value) do """ Unexpected #{inspect(value)} value received for #{inspect(key)} key \ from Nestru.Decoder.from_map_hint/3 function implemented for #{inspect(struct_module)}. \ You can return &Nestru.decode_from_list_of_maps(&1, #{inspect(value)}) as a hint \ for list decoding.\ """ end @doc """ Returns whether the given key exists in the given map as a binary or as an atom. """ def has_key?(map, key) when is_binary(key) do Map.has_key?(map, key) or apply_atom_key(key, false, &Map.has_key?(map, &1)) end def has_key?(map, key) when is_atom(key) do Map.has_key?(map, key) or Map.has_key?(map, to_string(key)) end @doc """ Gets the value for a specific key in map. Lookups a binary then an atom key. If key is present in map then its value value is returned. Otherwise, default is returned. If default is not provided, nil is used. """ def get(map, key, default \\ nil) def get(map, key, default) when is_binary(key) do Map.get(map, key, apply_atom_key(key, default, &Map.get(map, &1, default))) end def get(map, key, default) when is_atom(key) do Map.get(map, key, Map.get(map, to_string(key), default)) end @doc """ Encodes the given struct into a map. The first argument is a struct value to be encoded into map. Encodes each field's value recursively when it is a struct or a list of structs. The second argument is a context to be passed to `Nestru.Encoder` protocol function. To insert additional fields or rename or drop existing ones before encoding into map, implement `Nestru.Encoder` protocol for the struct. That can be used to keep additional type information for the field that can have a value of various value types. """ def encode_to_map(struct, context \\ nil) def encode_to_map(%_{} = struct, context) do case cast_to_map(struct, context) do {:invalid_hint_shape, %{message: {struct_module, value}} = error_map} -> {:error, %{error_map | message: invalid_to_map_value_message(struct_module, value)}} {:ok, _value} = ok -> ok {:error, map} -> {:error, format_paths(map)} end end def encode_to_map(value, _context) do raise expected_struct_value("encode_to_map/1", value, "encode_to_list_of_maps/1") end @doc """ Similar to `encode_to_map/1`. Returns a map or raises an error. """ def encode_to_map!(struct, context \\ nil) def encode_to_map!(%_{} = struct, context) do case cast_to_map(struct, context) do {:ok, map} -> map {:invalid_hint_shape, %{message: {struct_module, value}}} -> raise invalid_to_map_value_message(struct_module, value) {:error, error_map} -> raise format_raise_message("struct", error_map) end end def encode_to_map!(value, _context) do raise expected_struct_value("encode_to_map!/1", value, "encode_to_list_of_maps!/1") end defp cast_to_map(struct, context, kvi \\ nil, acc \\ {[], %{}}) defp cast_to_map(%module{} = struct, context, _kvi, {path, _target_map} = acc) do case struct |> Nestru.Encoder.gather_fields_from_struct(context) |> validate_hint(module) do {:ok, map} -> cast_to_map(map, context, nil, acc) {tag, %{} = map} -> {tag, Map.put(map, :path, path)} end end defp cast_to_map([_ | _] = list, context, _kvi, {path, _target_map} = _acc) do list |> reduce_via_cast_to_map(context, path) |> maybe_ok_reverse() end defp cast_to_map(value, _context, _kvi, _acc) when not is_map(value) do {:ok, value} end defp cast_to_map(map, context, nil, acc) do kvi = map |> :maps.iterator() |> :maps.next() cast_to_map(map, context, kvi, acc) end defp cast_to_map(_map, _context, :none, {_path, target_map} = _acc) do {:ok, target_map} end defp cast_to_map(map, context, {key, value, iterator}, {path, target_map}) do with {:ok, casted_value} <- cast_to_map(value, context, nil, {[key | path], %{}}) do target_map = Map.put(target_map, key, casted_value) kvi = :maps.next(iterator) cast_to_map(map, context, kvi, {path, target_map}) end end defp reduce_via_cast_to_map(list, context, path) do list |> Enum.with_index() |> Enum.reduce_while([], fn {item, idx}, acc -> case cast_to_map(item, context, nil, {[], %{}}) do {:ok, casted_item} -> {:cont, [casted_item | acc]} {:error, error_map} -> keys_list = path |> Enum.reverse() |> Enum.concat([idx]) {:halt, {:error, insert_to_path(error_map, keys_list)}} end end) end defp maybe_ok_reverse([_ | _] = list), do: {:ok, Enum.reverse(list)} defp maybe_ok_reverse([]), do: {:ok, []} defp maybe_ok_reverse({:error, _map} = error), do: error defp invalid_to_map_value_message(struct_module, value) do """ Expected a {:ok, nil | map} | {:error, term} value from Nestru.Encoder.gather_fields_from_struct/2 \ function implemented for #{inspect(struct_module)}, received #{inspect(value)} instead.\ """ end defp expected_struct_value(fun_name, value, list_fun_name) do """ #{fun_name} expects a struct as input value, received #{inspect(value)} instead. Use #{list_fun_name} to encode a list of structs to list of maps.\ """ end @doc """ Encodes the given list of structs into a list of maps. Calls `encode_to_map/2` for each struct in the list. The `Nestru.Encoder` protocol should be implemented for each struct module. The function returns a list of maps or the first error from `encode_to_map/2` function. """ def encode_to_list_of_maps(list, context \\ nil) do return_value = list |> Enum.with_index() |> Enum.reduce_while([], fn {struct, idx}, acc -> case encode_to_map(struct, context) do {:ok, map} -> {:cont, [map | acc]} {:error, map} -> {:halt, {:error, format_paths(insert_to_path(map, idx))}} end end) if is_list(return_value) do {:ok, Enum.reverse(return_value)} else return_value end end @doc """ Similar to `encode_to_list_of_maps/2` Returns list of maps or raises an error. """ def encode_to_list_of_maps!(list, context \\ nil) do case encode_to_list_of_maps(list, context) do {:ok, list_of_maps} -> list_of_maps {:invalid_hint_shape, %{message: {struct_module, value}}} -> raise invalid_to_map_value_message(struct_module, value) {:error, error_map} -> raise format_raise_message("list", error_map) end end defp format_paths(map) do keys = map |> Map.get(:path, []) |> Enum.map(&to_access_fun/1) Map.put(map, :get_in_keys, keys) end defp to_access_fun(key) when is_atom(key) or is_binary(key), do: Access.key!(key) defp to_access_fun(key) when is_integer(key), do: Access.at!(key) defp format_raise_message(object, map) do keys = map |> Map.get(:path, []) |> Enum.map_join(", ", &to_access_string/1) """ #{stringify(map.message)} See details by calling get_in/2 with the #{object} and the following keys: [#{keys}]\ """ end defp to_access_string(key) when is_atom(key) or is_binary(key), do: "Access.key!(#{inspect(key)})" defp to_access_string(key) when is_integer(key), do: "Access.at!(#{key})" defp stringify(value) when is_binary(value), do: value defp stringify(value), do: inspect(value) @doc """ Decodes a list of maps into the list of the given struct values. The first argument is a list of maps. If the second argument is a struct's module atom, then the function calls the `decode_from_map/3` on each input list item. If the second argument is a list of struct module atoms, the function calls the `decode_from_map/3` function on each input list item with the module atom taken at the same index of the second list. In this case, both arguments should be of equal length. The third argument is a context value to be passed to implemented functions of `Nestru.PreDecoder` and `Nestru.Decoder` protocols. The function returns a list of structs or the first error from `decode_from_map/3` function. """ def decode_from_list_of_maps(list, struct_atoms, context \\ []) def decode_from_list_of_maps([_ | _] = list, struct_atoms, context) do list |> reduce_via_from_map(struct_atoms, context) |> maybe_ok_reverse() end def decode_from_list_of_maps(list, _struct_atoms, _context) do {:error, %{message: expected_list_value(list)}} end @doc """ Similar to `decode_from_list_of_maps/2` but checks if enforced struct's fields keys exist in the given maps. Returns a struct or raises an error. """ def decode_from_list_of_maps!(list, struct_atoms, context \\ []) def decode_from_list_of_maps!([_ | _] = list, struct_atoms, context) do case list |> reduce_via_from_map(struct_atoms, context) |> maybe_ok_reverse() do {:ok, list} -> list {:error, %{message: message}} -> raise message end end def decode_from_list_of_maps!(list, _struct_atoms, _context) do raise expected_list_value(list) end defp reduce_via_from_map(list, [_ | _] = struct_atoms, context) when length(list) == length(struct_atoms) do list |> Enum.with_index() |> Enum.reduce_while([], fn {item, idx}, acc -> struct_module = Enum.at(struct_atoms, idx) case decode_from_map(item, struct_module, context) do {:ok, casted_item} -> {:cont, [casted_item | acc]} {:error, map} -> {:halt, {:error, insert_to_path(map, idx)}} end end) end defp reduce_via_from_map(list, struct_atoms, context) when is_atom(struct_atoms) do list |> Enum.with_index() |> Enum.reduce_while([], fn {item, idx}, acc -> case decode_from_map(item, struct_atoms, context) do {:ok, casted_item} -> {:cont, [casted_item | acc]} {:error, map} -> {:halt, {:error, insert_to_path(map, idx)}} end end) end defp reduce_via_from_map(list, struct_atoms, _context) do {:error, %{ message: """ The map's list length (#{length(list)}) is expected to be equal to \ the struct module atoms list length (#{length(struct_atoms)}).\ """ }} end defp expected_list_value(value) do """ The first argument should be a list. Got #{inspect(value)} instead.\ """ end end