Iteraptor library was initially conceived as a helper to iterate/map/reduce deeply nested enumerables, like maps, keywords and lists.

Today I occasionally discovered a new—initially undesired—application for it. I was introducing property-based testing for Camarero. The latter serves JSON, so the integration test looked like

defmacrop key_value, do: quote(do: map_of(key(), map_or_leaf()))

test "properly handles nested terms" do
  check all term <- key_value(), max_runs: 25 do
    Enum.each(term, fn {k, v} ->
      Camarero.Carta.Heartbeat.plato_put(k, v)
      conn =
        :get
        |> conn("/api/v1/heartbeat/#{k}")
        |> Camarero.Handler.call(@opts)

      assert conn.state == :sent
      assert conn.status == 200
      assert conn.resp_body |> Jason.decode!() |> Map.get("value") == v
    end)
  end
end

Here is the thing: the above fails because atoms are jsonified (jsonificated?) as binaries / strings. When we need to check if the real data is sent as JSON properly, it might quickly become cumbersome to check the outcome. Also, there would be issues to produce JSON out of keywords because keywords are nothing but lists of 2-elements tuples and tuples are not JSON-serializable.

Luckily enough, I had already the library to modify deeply nested terms, so I decided to add the new function to it. Here is it.

@spec jsonify(Access.t(), opts :: list()) :: %{required(binary()) => any()}

def jsonify(input, opts \\ [])

def jsonify([{_, _} | _] = input, opts),
  do: input |> Enum.into(%{}) |> jsonify(opts)

def jsonify(input, opts) when is_list(input),
  do: Enum.map(input, &jsonify(&1, opts))

def jsonify(input, opts) when not is_map(input) and not is_list(input),
  do: if(opts[:values] && is_atom(input), do: to_string(input), else: input)

def jsonify(input, opts) do
  Iteraptor.map(
    input,
    fn {k, v} when is_list(k) ->
      {k |> List.last() |> to_string(), jsonify(v, opts)}
    end,
    yield: :all
  )
end

It converts keywords to maps and atom keys to strings, and—optionally (if values: true is passed as the second parameter)—converts atoms to binaries in values.

I am posting this code here mostly to promote Iteraptor itself to show how easy it would be to transform any nested term with it.

Turning back to the original intent, to test the JSON response for deeply nested list/map/term one would tell mix to include Iteraptor package for :test environment only:

  {:iteraptor, "~> 1.0", only: :test}

and use somewhat like the code below to deep-compare the JSON against original data:

  term = Iteraptor.jsonify(term, values: true)

  conn =
    :get
    |> conn("/api/v1/my_term")
    |> MyJsonEndpoint.call(@opts)

  assert conn.resp_body |> Jason.decode!() == term
end

Whether one needs to be able to transparently serve terms that might include keywords, Iteraptor should be included as dependency in all environments and the call to Iteraptor.jsonify(term) should be done before passing the term to JSON encoder. values: true parameter is not needed in this scenario since JSON serializers are able to take care of atoms.

Happy testing!