Skip to main content

lib/json_codec.ex

defmodule JSONCodec do
  @moduledoc """
  Compile-time generated codecs for JSON-shaped Elixir structs.

  `JSONCodec` is not a JSON parser. It uses `Jason` for JSON parsing and focuses on
  the part application code usually repeats by hand: turning decoded string-keyed maps
  into nested structs with defaults, aliases, computed fields, explicit atom policy, and
  schema export.
  """

  alias JSONCodec.Error

  @missing :__json_codec_missing__

  defmacro __using__(opts \\ []) do
    opts = Macro.expand(opts, __CALLER__)

    quote bind_quoted: [opts: opts] do
      import Kernel, except: [defstruct: 1]
      import JSONCodec, only: [defstruct: 1, codec: 2, computed: 2]

      Module.register_attribute(__MODULE__, :json_codec_struct_fields, accumulate: false)
      Module.register_attribute(__MODULE__, :json_codec_options, accumulate: false)
      Module.register_attribute(__MODULE__, :json_codec_field_options, accumulate: true)
      Module.register_attribute(__MODULE__, :json_codec_computed, accumulate: true)

      @json_codec_options opts
      @before_compile JSONCodec
    end
  end

  defmacro defstruct(fields) do
    quote do
      @json_codec_struct_fields unquote(fields)
      Kernel.defstruct(unquote(fields))
    end
  end

  defmacro codec(name, opts) when is_atom(name) do
    caller = __CALLER__
    {opts, _binding} = Code.eval_quoted(opts, [], caller)

    quote bind_quoted: [name: name, opts: Macro.escape(opts)] do
      @json_codec_field_options {name, opts}
    end
  end

  defmacro computed(name, fun_ast) when is_atom(name) do
    escaped_fun = Macro.escape(fun_ast)

    quote bind_quoted: [name: name, fun_ast: escaped_fun] do
      @json_codec_computed {name, fun_ast}
    end
  end

  defmacro __before_compile__(env) do
    env
    |> before_compile_context()
    |> generated_codec_ast()
  end

  defp before_compile_context(env) do
    module = env.module
    codec_options = Module.get_attribute(module, :json_codec_options) || []
    struct_fields = Module.get_attribute(module, :json_codec_struct_fields) || []
    field_options = field_options(module)
    computed = computed_fields(module)
    type_fields = parse_type_fields(module, env)
    fields = build_fields(module, struct_fields, type_fields, field_options, codec_options, env)

    strict? = Keyword.get(codec_options, :strict, false)

    %{
      fields: fields,
      build_pairs: Enum.map(fields, &field_pair_ast(&1, generic_raw_strategy(strict?))),
      fast_build_pairs: fast_path_field_pairs(fields, codec_options),
      fast_pattern: fast_path_pattern(fields, codec_options),
      computed_result: computed_result_ast(computed),
      strict?: strict?
    }
  end

  defp field_options(module) do
    module
    |> Module.get_attribute(:json_codec_field_options)
    |> Enum.reverse()
    |> Map.new()
  end

  defp computed_fields(module) do
    module
    |> Module.get_attribute(:json_codec_computed)
    |> Enum.reverse()
  end

  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
  defp generated_codec_ast(%{
         fields: fields,
         build_pairs: build_pairs,
         fast_build_pairs: fast_build_pairs,
         fast_pattern: fast_pattern,
         computed_result: computed_result,
         strict?: strict?
       }) do
    escaped_fields = Macro.escape(fields)
    strict_check = strict_check_ast(strict?)

    fast_from_map =
      fast_from_map_ast(fast_pattern, fast_build_pairs, computed_result, strict_check)

    quote do
      @doc false
      def __json_codec_fields__, do: unquote(escaped_fields)

      @doc "Decodes a JSON string into this struct."
      def decode(json) when is_binary(json) do
        JSONCodec.decode(json, __MODULE__)
      end

      @doc "Decodes a JSON string into this struct, raising on failure."
      def decode!(json) when is_binary(json) do
        JSONCodec.decode!(json, __MODULE__)
      end

      @doc "Builds this struct from a decoded JSON map."
      def from_map(map) when is_map(map) do
        JSONCodec.from_map(map, __MODULE__)
      end

      @doc "Builds this struct from a decoded JSON map, raising on failure."
      unquote(fast_from_map)

      def from_map!(map) when is_map(map) do
        unquote(strict_check)
        struct = %__MODULE__{unquote_splicing(build_pairs)}
        unquote(computed_result)
      end

      @doc "Converts this struct into a JSON-shaped map."
      def to_map(%__MODULE__{} = struct) do
        JSONCodec.to_map(struct)
      end

      @doc "Dumps this struct into JSON-shaped data, respecting JSON field names."
      def dump(%__MODULE__{} = struct) do
        JSONCodec.dump(struct)
      end

      @doc "Returns a JSON Schema-compatible schema map."
      def schema do
        JSONCodec.Schema.object(__MODULE__)
      end

      @doc "Returns a JSON Schema-compatible schema map."
      def json_schema, do: schema()
    end
  end

  @doc "Decodes a JSON string into `module`."
  def decode(json, module) when is_binary(json) and is_atom(module) do
    with {:ok, map} <- Jason.decode(json) do
      from_map(map, module)
    end
  end

  @doc "Decodes a JSON string into `module`, raising on failure."
  def decode!(json, module) when is_binary(json) and is_atom(module) do
    json
    |> Jason.decode!()
    |> from_map!(module)
  end

  @doc "Builds `module` from a decoded JSON map."
  def from_map(map, module) when is_map(map) and is_atom(module) do
    {:ok, from_map!(map, module)}
  rescue
    error in [Error] -> {:error, error}
  end

  @doc "Builds `module` from a decoded JSON map, raising on failure."
  def from_map!(map, module) when is_map(map) and is_atom(module), do: module.from_map!(map)

  @doc "Converts a struct or value into JSON-shaped Elixir data."
  def to_map(value)
  def to_map(%_{} = struct), do: struct |> Map.from_struct() |> to_map()
  def to_map(%{} = map), do: Map.new(map, fn {key, value} -> {encode_key(key), to_map(value)} end)
  def to_map(values) when is_list(values), do: Enum.map(values, &to_map/1)
  def to_map(value) when is_boolean(value), do: value
  def to_map(value) when is_atom(value) and not is_nil(value), do: Atom.to_string(value)
  def to_map(value), do: value

  @doc "Dumps a value into JSON-shaped Elixir data, respecting JSONCodec field names."
  def dump(value)

  def dump(%module{} = struct) do
    if function_exported?(module, :__json_codec_fields__, 0) do
      dump_json_codec(struct, module.__json_codec_fields__())
    else
      struct |> Map.from_struct() |> dump()
    end
  end

  def dump(%{} = map), do: Map.new(map, fn {key, value} -> {encode_key(key), dump(value)} end)
  def dump(values) when is_list(values), do: Enum.map(values, &dump/1)
  def dump(value) when is_boolean(value), do: value
  def dump(nil), do: nil
  def dump(value) when is_atom(value), do: Atom.to_string(value)
  def dump(value), do: value

  @doc "Returns a JSON Schema-compatible schema map for a JSONCodec module."
  def schema(module), do: JSONCodec.Schema.object(module)

  @doc "Returns a JSON Schema-compatible schema map for a JSONCodec module."
  def json_schema(module), do: schema(module)

  defp build_fields(module, struct_fields, type_fields, field_options, codec_options, env) do
    defaults = struct_defaults(struct_fields)
    field_names = Map.keys(defaults)

    Enum.map(field_names, fn name ->
      opts = Map.get(field_options, name, []) |> normalize_callbacks(module, name, env)
      type = Map.get(type_fields, name, :any)
      default = Map.fetch!(defaults, name)
      default? = default != @missing
      nullable? = nullable_type?(type)

      %{
        name: name,
        json: Keyword.get(opts, :as, json_key(name, codec_options)),
        type: type,
        required: not default? and not nullable?,
        default?: default?,
        default: if(default?, do: default, else: nil),
        opts: opts,
        module: module
      }
    end)
  end

  defp struct_defaults(fields) when is_list(fields) do
    Map.new(fields, fn
      {name, default} when is_atom(name) -> {name, default}
      name when is_atom(name) -> {name, @missing}
    end)
  end

  defp normalize_callbacks(opts, module, field, env) do
    opts
    |> normalize_callback(:cast, 1, module, field, env)
    |> normalize_callback(:transform, 1, module, field, env)
    |> normalize_callback(:values, 3, module, field, env)
    |> normalize_callback(:decode_values, 3, module, field, env)
    |> normalize_callback(:values_source, 1, module, field, env)
  end

  defp normalize_callback(opts, key, arity, module, field, env) do
    case Keyword.fetch(opts, key) do
      :error ->
        opts

      {:ok, fun} when is_atom(fun) ->
        Keyword.put(opts, key, {:local, module, fun, arity})

      {:ok, fun} when is_function(fun, arity) ->
        opts

      {:ok, other} ->
        raise CompileError,
          file: env.file,
          line: env.line,
          description:
            "invalid JSONCodec option #{inspect(key)} for #{inspect(field)}. " <>
              "Expected a local function name atom or remote capture with arity #{arity}, got: #{inspect(other)}"
    end
  end

  defp parse_type_fields(module, env) do
    env.module
    |> Module.get_attribute(:type)
    |> Enum.find_value(%{}, fn
      {:type, {:"::", _, [{:t, _, _}, type_ast]}, _} -> parse_struct_type(type_ast, module, env)
      _other -> nil
    end)
  end

  defp parse_struct_type({:%, _, [_module_ast, {:%{}, _, fields}]}, _module, env) do
    Map.new(fields, fn {name, type_ast} -> {name, normalize_type(type_ast, env)} end)
  end

  defp parse_struct_type(_type_ast, _module, _env), do: %{}

  defp normalize_type({:|, _, _} = union, env) do
    values = union |> collect_union() |> Enum.map(&normalize_type(&1, env))
    non_nil = Enum.reject(values, &is_nil/1)

    type =
      case non_nil do
        [single] ->
          single

        values ->
          if enum_values?(values), do: {:enum, flatten_enum(values)}, else: {:one_of, values}
      end

    if Enum.any?(values, &is_nil/1), do: {:nullable, type}, else: type
  end

  defp normalize_type([type], env), do: {:list, normalize_type(type, env)}

  defp normalize_type({:%{}, _, [{:"=>", _, [key_type, value_type]}]}, env) do
    {:map, normalize_type(key_type, env), normalize_type(value_type, env)}
  end

  defp normalize_type({:%{}, _, [{key_type, value_type}]}, env) do
    {:map, normalize_type(key_type, env), normalize_type(value_type, env)}
  end

  defp normalize_type({name, _, []}, _env)
       when name in [
              :integer,
              :non_neg_integer,
              :pos_integer,
              :float,
              :number,
              :boolean,
              :atom,
              :any,
              :term
            ],
       do: name

  defp normalize_type({{:., _, [{:__aliases__, _, [:String]}, :t]}, _, []}, _env), do: :string

  defp normalize_type({{:., _, [module_ast, :t]}, _, []}, env) do
    Macro.expand(module_ast, env)
  end

  defp normalize_type(nil, _env), do: nil
  defp normalize_type(atom, _env) when is_atom(atom), do: atom
  defp normalize_type(_other, _env), do: :any

  defp collect_union(union), do: union |> collect_union([]) |> Enum.reverse()

  defp collect_union({:|, _, [left, right]}, acc) do
    collect_union(right, collect_union(left, acc))
  end

  defp collect_union(other, acc), do: [other | acc]

  defp enum_values?(values), do: Enum.all?(values, &is_atom/1) and nil not in values

  defp flatten_enum(values) do
    values
    |> Enum.flat_map(fn
      {:enum, nested} -> nested
      value -> [value]
    end)
    |> Enum.uniq()
  end

  defp nullable_type?({:nullable, _type}), do: true
  defp nullable_type?(_type), do: false

  defp json_key(name, opts) do
    case Keyword.get(opts, :case, :snake) do
      :snake -> Atom.to_string(name)
      :camel -> camelize(name)
    end
  end

  defp camelize(name) do
    [first | rest] = name |> Atom.to_string() |> String.split("_")
    first <> Enum.map_join(rest, &String.capitalize/1)
  end

  defp generic_raw_strategy(true), do: :json
  defp generic_raw_strategy(false), do: :generic

  defp fast_path_field_pairs(fields, opts) do
    required = required_fields(fields)

    if Keyword.get(opts, :fast_path) == :json and required != [] do
      required_vars = Map.new(required, &{&1.name, Macro.var(&1.name, nil)})
      Enum.map(fields, &field_pair_ast(&1, fast_path_raw(&1, required_vars)))
    end
  end

  defp fast_path_pattern(fields, opts) do
    required = required_fields(fields)

    if Keyword.get(opts, :fast_path) == :json and required != [] do
      {:%{}, [], Enum.map(required, &{&1.json, Macro.var(&1.name, nil)})}
    end
  end

  defp fast_path_raw(field, required_vars) do
    case Map.fetch(required_vars, field.name) do
      {:ok, var} ->
        {:raw, var}

      :error ->
        {:json, field.json}
    end
  end

  defp required_fields(fields), do: Enum.filter(fields, & &1.required)

  defp fast_from_map_ast(nil, _build_pairs, _computed_result, _strict_check), do: nil

  defp fast_from_map_ast(pattern, build_pairs, computed_result, strict_check) do
    quote do
      def from_map!(unquote(pattern) = map) do
        unquote(strict_check)
        struct = %__MODULE__{unquote_splicing(build_pairs)}
        unquote(computed_result)
      end
    end
  end

  defp field_pair_ast(field, :json) do
    {field.name, field_value_ast(field, {:json, field.json})}
  end

  defp field_pair_ast(field, raw_strategy) do
    {field.name, field_value_ast(field, raw_strategy)}
  end

  defp field_value_ast(field, raw_strategy) do
    decoder = quote(do: JSONCodec.Decoder)
    type = Macro.escape(field.type)
    path = [field.name]

    raw = raw_value_ast(raw_strategy, decoder, field.name, field.json)
    present = present_field_ast(raw, raw_strategy, field, decoder, path, type)
    defaulted = defaulted_field_ast(present, field, decoder)
    casted = cast_ast(defaulted, Keyword.get(field.opts, :cast), field.required)
    decoded = decoded_field_ast(casted, field, path)

    transform_ast(decoded, Keyword.get(field.opts, :transform))
  end

  defp present_field_ast(raw, raw_strategy, field, decoder, path, type) do
    cond do
      match?({:raw, _}, raw_strategy) ->
        raw

      field.required ->
        quote do
          unquote(decoder).required!(unquote(raw), unquote(path), unquote(type))
        end

      true ->
        raw
    end
  end

  defp defaulted_field_ast(present, %{default?: true, default: default}, decoder) do
    quote do
      unquote(decoder).default(unquote(present), unquote(Macro.escape(default)))
    end
  end

  defp defaulted_field_ast(present, _field, _decoder), do: present

  defp decoded_field_ast(defaulted, %{required: true} = field, path) do
    decode_value_ast(defaulted, field.type, path, field.opts, quote(do: map))
  end

  defp decoded_field_ast(defaulted, field, path) do
    decode_type = non_nil_type(field.type)

    quote do
      case unquote(defaulted) do
        :__json_codec_missing__ ->
          nil

        nil ->
          nil

        value ->
          unquote(
            decode_value_ast(quote(do: value), decode_type, path, field.opts, quote(do: map))
          )
      end
    end
  end

  defp strict_check_ast(false), do: nil

  defp strict_check_ast(true) do
    quote do
      unless Enum.all?(Map.keys(map), &is_binary/1) do
        raise JSONCodec.Error,
          path: [],
          expected: :json_object,
          got: map,
          reason: :non_string_key
      end
    end
  end

  defp raw_value_ast({:raw, value}, _decoder, _atom, _json), do: value

  defp raw_value_ast({:json, json}, _decoder, _atom, _json) do
    quote do
      :maps.get(unquote(json), map, :__json_codec_missing__)
    end
  end

  defp raw_value_ast(:generic, decoder, atom, json) do
    quote do
      unquote(decoder).fetch_field(map, unquote(atom), unquote(json))
    end
  end

  defp non_nil_type({:nullable, type}), do: type
  defp non_nil_type(type), do: type

  defp decode_value_ast(value, :string, path, _opts, _source) do
    quote do
      case unquote(value) do
        string when is_binary(string) -> string
        other -> JSONCodec.Decoder.type_error!(unquote(path), :string, other)
      end
    end
  end

  defp decode_value_ast(value, :integer, path, _opts, _source) do
    quote do
      case unquote(value) do
        integer when is_integer(integer) -> integer
        other -> JSONCodec.Decoder.type_error!(unquote(path), :integer, other)
      end
    end
  end

  defp decode_value_ast(value, :non_neg_integer, path, _opts, _source) do
    quote do
      case unquote(value) do
        integer when is_integer(integer) and integer >= 0 -> integer
        other -> JSONCodec.Decoder.type_error!(unquote(path), :non_neg_integer, other)
      end
    end
  end

  defp decode_value_ast(value, :pos_integer, path, _opts, _source) do
    quote do
      case unquote(value) do
        integer when is_integer(integer) and integer > 0 -> integer
        other -> JSONCodec.Decoder.type_error!(unquote(path), :pos_integer, other)
      end
    end
  end

  defp decode_value_ast(value, :boolean, path, _opts, _source) do
    quote do
      case unquote(value) do
        boolean when is_boolean(boolean) -> boolean
        other -> JSONCodec.Decoder.type_error!(unquote(path), :boolean, other)
      end
    end
  end

  defp decode_value_ast(value, {:enum, values} = type, path, _opts, _source) do
    fallback = Macro.var(:other, nil)

    clauses =
      values
      |> Enum.flat_map(fn atom ->
        [
          {:->, [], [[atom], atom]},
          {:->, [], [[Atom.to_string(atom)], atom]}
        ]
      end)
      |> Kernel.++([
        {:->, [],
         [
           [fallback],
           quote(
             do:
               JSONCodec.Decoder.type_error!(
                 unquote(path),
                 unquote(Macro.escape(type)),
                 unquote(fallback)
               )
           )
         ]}
      ])

    {:case, [], [value, [do: clauses]]}
  end

  defp decode_value_ast(value, {:map, :string, module} = type, path, opts, source)
       when is_atom(module) do
    if primitive_type?(module),
      do: generic_decode_ast(value, type, path, opts, source),
      else: map_module_decode_ast(value, module, type, path, opts, source)
  end

  defp decode_value_ast(value, {:list, module} = type, path, _opts, _source)
       when is_atom(module) do
    if primitive_type?(module),
      do: generic_decode_ast(value, type, path, [], quote(do: map)),
      else: list_module_decode_ast(value, module, type, path)
  end

  defp decode_value_ast(value, {:nullable, type}, path, opts, source) do
    quote do
      case unquote(value) do
        nil -> nil
        value -> unquote(decode_value_ast(quote(do: value), type, path, opts, source))
      end
    end
  end

  defp decode_value_ast(value, module, path, opts, source) when is_atom(module) do
    cond do
      primitive_type?(module) ->
        generic_decode_ast(value, module, path, opts, source)

      Keyword.has_key?(opts, :cast) ->
        value

      json_codec_module?(module) ->
        json_codec_module_decode_ast(value, module, path)

      true ->
        struct_module_decode_ast(value, module, path)
    end
  end

  defp decode_value_ast(value, type, path, opts, source) do
    generic_decode_ast(value, type, path, opts, source)
  end

  defp json_codec_module_decode_ast(value, module, path) do
    quote do
      case unquote(value) do
        %unquote(module){} = struct ->
          struct

        map when is_map(map) ->
          unquote(module).from_map!(map)

        other ->
          JSONCodec.Decoder.type_error!(unquote(path), unquote(module), other)
      end
    end
  end

  defp struct_module_decode_ast(value, module, path) do
    quote do
      case unquote(value) do
        %unquote(module){} = struct -> struct
        other -> JSONCodec.Decoder.type_error!(unquote(path), unquote(module), other)
      end
    end
  end

  defp list_module_decode_ast(value, module, type, path) do
    escaped_type = Macro.escape(type)

    quote do
      case unquote(value) do
        values when is_list(values) ->
          Enum.map(values, fn
            map when is_map(map) ->
              unquote(module).from_map!(map)

            other ->
              JSONCodec.Decoder.type_error!(unquote(path), unquote(escaped_type), other)
          end)

        other ->
          JSONCodec.Decoder.type_error!(unquote(path), unquote(escaped_type), other)
      end
    end
  end

  defp map_module_decode_ast(value, module, type, path, opts, source) do
    escaped_type = Macro.escape(type)

    quote do
      case unquote(value) do
        entries when is_map(entries) ->
          values_source = unquote(values_source_ast(source, opts))

          unquote(map_entries_decode_ast(module, opts, path, escaped_type))

        other ->
          JSONCodec.Decoder.type_error!(unquote(path), unquote(escaped_type), other)
      end
    end
  end

  defp map_entries_decode_ast(module, opts, path, escaped_type) do
    if Keyword.has_key?(opts, :decode_values),
      do: maps_map_entries_decode_ast(module, opts, path, escaped_type),
      else: new_map_entries_decode_ast(module, opts, path, escaped_type)
  end

  defp maps_map_entries_decode_ast(module, opts, path, escaped_type) do
    quote do
      :maps.map(
        fn key, item ->
          if is_binary(key) do
            unquote(
              map_decoded_value_ast(
                quote(do: item),
                quote(do: key),
                quote(do: values_source),
                module,
                opts
              )
            )
          else
            JSONCodec.Decoder.type_error!(unquote(path), unquote(escaped_type), key)
          end
        end,
        entries
      )
    end
  end

  defp new_map_entries_decode_ast(module, opts, path, escaped_type) do
    quote do
      Map.new(entries, fn
        {key, item} when is_binary(key) ->
          {key,
           unquote(
             map_decoded_value_ast(
               quote(do: item),
               quote(do: key),
               quote(do: values_source),
               module,
               opts
             )
           )}

        {key, _item} ->
          JSONCodec.Decoder.type_error!(unquote(path), unquote(escaped_type), key)
      end)
    end
  end

  defp values_source_ast(source, opts) do
    case Keyword.get(opts, :values_source) do
      nil ->
        source

      {:local, module, fun, 1} ->
        quote do
          unquote(module).unquote(fun)(unquote(source))
        end

      transform ->
        quote do
          unquote(Macro.escape(transform)).(unquote(source))
        end
    end
  end

  defp map_decoded_value_ast(item, key, source, module, opts) do
    case Keyword.get(opts, :decode_values) do
      nil ->
        item = map_value_ast(item, key, source, opts)

        quote do
          unquote(module).from_map!(unquote(item))
        end

      {:local, callback_module, fun, 3} ->
        quote do
          unquote(callback_module).unquote(fun)(unquote(key), unquote(item), unquote(source))
        end

      decoder ->
        quote do
          unquote(Macro.escape(decoder)).(unquote(key), unquote(item), unquote(source))
        end
    end
  end

  defp map_value_ast(item, key, source, opts) do
    case Keyword.get(opts, :values) do
      nil ->
        item

      {:local, module, fun, 3} ->
        quote do
          unquote(module).unquote(fun)(unquote(key), unquote(item), unquote(source))
        end

      transform ->
        quote do
          unquote(Macro.escape(transform)).(unquote(key), unquote(item), unquote(source))
        end
    end
  end

  defp generic_decode_ast(value, type, path, opts, source) do
    quote do
      JSONCodec.Decoder.decode(
        unquote(value),
        unquote(Macro.escape(type)),
        unquote(path),
        unquote(Macro.escape(opts)),
        unquote(source)
      )
    end
  end

  defp cast_ast(value, nil, _required?), do: value
  defp cast_ast(value, cast, true), do: apply_callback_ast(value, cast)

  defp cast_ast(value, cast, false) do
    casted = apply_callback_ast(value, cast)

    quote do
      case unquote(value) do
        :__json_codec_missing__ -> :__json_codec_missing__
        _ -> unquote(casted)
      end
    end
  end

  defp json_codec_module?(module) do
    match?({:module, ^module}, Code.ensure_compiled(module)) and
      function_exported?(module, :from_map!, 1)
  end

  defp primitive_type?(type) do
    type in [
      :any,
      :term,
      :string,
      :integer,
      :non_neg_integer,
      :pos_integer,
      :float,
      :number,
      :boolean,
      :atom
    ]
  end

  defp transform_ast(decoded, nil), do: decoded
  defp transform_ast(decoded, transform), do: apply_callback_ast(decoded, transform)

  defp apply_callback_ast(value, {:local, module, fun, _arity}) do
    quote do
      unquote(module).unquote(fun)(unquote(value))
    end
  end

  defp apply_callback_ast(value, callback) do
    callback = Macro.escape(callback)

    quote do
      unquote(callback).(unquote(value))
    end
  end

  defp computed_result_ast(computed) do
    Enum.reduce(computed, quote(do: struct), fn {name, fun_ast}, acc ->
      quote do
        value = unquote(acc)
        %{value | unquote(name) => unquote(fun_ast).(value)}
      end
    end)
  end

  defp dump_json_codec(struct, fields) do
    Map.new(fields, fn %{name: name, json: json} -> {json, dump(Map.get(struct, name))} end)
  end

  defp encode_key(key) when is_atom(key), do: Atom.to_string(key)
  defp encode_key(key), do: key
end