Reusing atomized JSON binary keys

This proof of concept periodically polls data from a specific API before decoding the JSON. It should create a list of atoms from the binary keys it receives the first time it queries the API for reuse with each subsequent poll.

defmodule ProofOfConcept do
  def naive_string_to_atom(raw_json) do
    {:ok, decoded_api_response} = Jason.decode(raw_json)

    root_keys = 
    decoded_api_response
    |> List.first()
    |> Map.keys()

    nested_keys =
      Enum.flat_map(
        root_keys,
        fn key ->
          decoded_api_response
          |> Enum.map(fn map_element ->
            foo = Map.get(map_element, key)

            case is_map(foo) do
              true ->
                Map.keys(foo)

              false ->
                :not_a_key
            end
          end)
          |> Enum.reject(&(&1 == :not_a_key))
        end
      )
      |> Enum.flat_map(& &1)
      |> Enum.uniq()

    
      extracted_keys = 
      (nested_keys ++ root_keys)
      |> Enum.map(&String.to_atom(&1))

      IO.inspect(extracted_keys, limit: :infinity)
  end

  def decode(raw_json) do
    try do
      Jason.decode(raw_json, keys: :atoms!)
    rescue
      _ ->
        IO.puts("Converting JSON keys to atoms . . . ")
        naive_string_to_atom(raw_json)
        main()
    end
  end

  def main() do
    api_url = "https://wavescap.com/api/assets.json"
    {:ok, api_response} = HTTPoison.get(api_url)

    decode(api_response.body)
  end
end

The problem:
Apparently the atomized keys are never retained in memory, requiring them to be recreated each time updated data is retrieved from the API.

iex(1)> ProofOfConcept.main
Converting JSON keys to atoms . . . 
[:firstPrice_brln, :firstPrice_btc, :firstPrice_cnyn, :firstPrice_eth,
 :firstPrice_eurn, :firstPrice_gbpn, :firstPrice_jpyn, :firstPrice_ngnn,
 :firstPrice_rubn, :firstPrice_tryn, :firstPrice_uahn, :"firstPrice_usd-n",
 :firstPrice_waves, :lastPrice_brln, :lastPrice_btc, :lastPrice_cnyn,
 :lastPrice_eth, :lastPrice_eurn, :lastPrice_gbpn, :lastPrice_jpyn,
 :lastPrice_ngnn, :lastPrice_rubn, :lastPrice_tryn, :lastPrice_uahn,
 :"lastPrice_usd-n", :lastPrice_waves, :"24h_vol_brln", :"24h_vol_btc",
 :"24h_vol_cnyn", :"24h_vol_eth", :"24h_vol_eurn", :"24h_vol_gbpn",
 :"24h_vol_jpyn", :"24h_vol_ngnn", :"24h_vol_rubn", :"24h_vol_tryn",
 :"24h_vol_uahn", :"24h_vol_usd-n", :"24h_vol_waves", :circulating, :data,
 :excludedFromCirculating, :id, :name, :precision, :sender, :shortcode, :start,
 :totalSupply, :trades, :website]

Converting JSON keys to atoms . . . 
[:firstPrice_brln, :firstPrice_btc, :firstPrice_cnyn, :firstPrice_eth,
 :firstPrice_eurn, :firstPrice_gbpn, :firstPrice_jpyn, :firstPrice_ngnn,
 :firstPrice_rubn, :firstPrice_tryn, :firstPrice_uahn, :"firstPrice_usd-n",
 :firstPrice_waves, :lastPrice_brln, :lastPrice_btc, :lastPrice_cnyn,
 :lastPrice_eth, :lastPrice_eurn, :lastPrice_gbpn, :lastPrice_jpyn,
 :lastPrice_ngnn, :lastPrice_rubn, :lastPrice_tryn, :lastPrice_uahn,
 :"lastPrice_usd-n", :lastPrice_waves, :"24h_vol_brln", :"24h_vol_btc",
 :"24h_vol_cnyn", :"24h_vol_eth", :"24h_vol_eurn", :"24h_vol_gbpn",
 :"24h_vol_jpyn", :"24h_vol_ngnn", :"24h_vol_rubn", :"24h_vol_tryn",
 :"24h_vol_uahn", :"24h_vol_usd-n", :"24h_vol_waves", :circulating, :data,
 :excludedFromCirculating, :id, :name, :precision, :sender, :shortcode, :start,
 :totalSupply, :trades, :website]

Converting JSON keys to atoms . . . 
[:firstPrice_brln, :firstPrice_btc, :firstPrice_cnyn, :firstPrice_eth,
 :firstPrice_eurn, :firstPrice_gbpn, :firstPrice_jpyn, :firstPrice_ngnn,
 :firstPrice_rubn, :firstPrice_tryn, :firstPrice_uahn, :"firstPrice_usd-n",
 :firstPrice_waves, :lastPrice_brln, :lastPrice_btc, :lastPrice_cnyn,
 :lastPrice_eth, :lastPrice_eurn, :lastPrice_gbpn, :lastPrice_jpyn,
 :lastPrice_ngnn, :lastPrice_rubn, :lastPrice_tryn, :lastPrice_uahn,
 :"lastPrice_usd-n", :lastPrice_waves, :"24h_vol_brln", :"24h_vol_btc",
 :"24h_vol_cnyn", :"24h_vol_eth", :"24h_vol_eurn", :"24h_vol_gbpn",
 :"24h_vol_jpyn", :"24h_vol_ngnn", :"24h_vol_rubn", :"24h_vol_tryn",
 :"24h_vol_uahn", :"24h_vol_usd-n", :"24h_vol_waves", :circulating, :data,
 :excludedFromCirculating, :id, :name, :precision, :sender, :shortcode, :start,
 :totalSupply, :trades, :website]

Converting JSON keys to atoms . . . 
[:firstPrice_brln, :firstPrice_btc, :firstPrice_cnyn, :firstPrice_eth,
 :firstPrice_eurn, :firstPrice_gbpn, :firstPrice_jpyn, :firstPrice_ngnn,
 :firstPrice_rubn, :firstPrice_tryn, :firstPrice_uahn, :"firstPrice_usd-n",
 :firstPrice_waves, :lastPrice_brln, :lastPrice_btc, :lastPrice_cnyn,
 :lastPrice_eth, :lastPrice_eurn, :lastPrice_gbpn, :lastPrice_jpyn,
 :lastPrice_ngnn, :lastPrice_rubn, :lastPrice_tryn, :lastPrice_uahn,
 :"lastPrice_usd-n", :lastPrice_waves, :"24h_vol_brln", :"24h_vol_btc",
 :"24h_vol_cnyn", :"24h_vol_eth", :"24h_vol_eurn", :"24h_vol_gbpn",
 :"24h_vol_jpyn", :"24h_vol_ngnn", :"24h_vol_rubn", :"24h_vol_tryn",
 :"24h_vol_uahn", :"24h_vol_usd-n", :"24h_vol_waves", :circulating, :data,
 :excludedFromCirculating, :id, :name, :precision, :sender, :shortcode, :start,
 :totalSupply, :trades, :website]

Converting JSON keys to atoms . . . 
[:firstPrice_brln, :firstPrice_btc, :firstPrice_cnyn, :firstPrice_eth,
 :firstPrice_eurn, :firstPrice_gbpn, :firstPrice_jpyn, :firstPrice_ngnn,
 :firstPrice_rubn, :firstPrice_tryn, :firstPrice_uahn, :"firstPrice_usd-n",
 :firstPrice_waves, :lastPrice_brln, :lastPrice_btc, :lastPrice_cnyn,
 :lastPrice_eth, :lastPrice_eurn, :lastPrice_gbpn, :lastPrice_jpyn,
 :lastPrice_ngnn, :lastPrice_rubn, :lastPrice_tryn, :lastPrice_uahn,
 :"lastPrice_usd-n", :lastPrice_waves, :"24h_vol_brln", :"24h_vol_btc",
 :"24h_vol_cnyn", :"24h_vol_eth", :"24h_vol_eurn", :"24h_vol_gbpn",
 :"24h_vol_jpyn", :"24h_vol_ngnn", :"24h_vol_rubn", :"24h_vol_tryn",
 :"24h_vol_uahn", :"24h_vol_usd-n", :"24h_vol_waves", :circulating, :data,
 :excludedFromCirculating, :id, :name, :precision, :sender, :shortcode, :start,
 :totalSupply, :trades, :website]

After each new API poll, why aren’t the atoms that were previously generated being reused?

If one of the elements of decoded_api_response has additional keys not present in the first one, naive_string_to_atom will not see them and the bug you’re seeing would happen.

The error message here should be helpful - on OTP 25, it is explicit about what string failed:

iex(1)> Jason.decode("{\"24h\":0}", keys: :atoms!)
** (ArgumentError) errors were found at the given arguments:

  * 1st argument: not an already existing atom

    :erlang.binary_to_existing_atom("24h", :utf8)
    (elixir 1.13.4) lib/string.ex:2456: String.to_existing_atom/1
    (jason 1.3.0) lib/decoder.ex:322: Jason.Decoder.object/6
    (jason 1.3.0) lib/decoder.ex:55: Jason.Decoder.parse/2
1 Like

Per your suggestion I made the following update:


  def decode(raw_json) do
    try do
      Jason.decode(raw_json, keys: :atoms!)
    rescue
      error ->
        IO.inspect(error, limit: :infinity)
        IO.puts("Converting JSON keys to atoms . . . ")
        naive_string_to_atom(raw_json)
        main()
    end
  end

Outcome:

Erlang/OTP 25 [erts-13.0.3] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [jit:ns]
Interactive Elixir (1.13.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> ProofOfConcept.main

%ArgumentError{
  message: "errors were found at the given arguments:\n\n  * 1st argument: not an already existing atom\n"
}

Converting JSON keys to atoms . . . 
[:firstPrice_brln, :firstPrice_btc, :firstPrice_cnyn, :firstPrice_eth,
 :firstPrice_eurn, :firstPrice_gbpn, :firstPrice_jpyn, :firstPrice_ngnn,
 :firstPrice_rubn, :firstPrice_tryn, :firstPrice_uahn, :"firstPrice_usd-n",
 :firstPrice_waves, :lastPrice_brln, :lastPrice_btc, :lastPrice_cnyn,
 :lastPrice_eth, :lastPrice_eurn, :lastPrice_gbpn, :lastPrice_jpyn,
 :lastPrice_ngnn, :lastPrice_rubn, :lastPrice_tryn, :lastPrice_uahn,
 :"lastPrice_usd-n", :lastPrice_waves, :"24h_vol_brln", :"24h_vol_btc",
 :"24h_vol_cnyn", :"24h_vol_eth", :"24h_vol_eurn", :"24h_vol_gbpn",
 :"24h_vol_jpyn", :"24h_vol_ngnn", :"24h_vol_rubn", :"24h_vol_tryn",
 :"24h_vol_uahn", :"24h_vol_usd-n", :"24h_vol_waves", :circulating, :data,
 :excludedFromCirculating, :id, :name, :precision, :sender, :shortcode, :start,
 :totalSupply, :trades, :website]

It seems it’s trying to atomize new lines? Am I reading this right?

The message in the ArgumentError is only the top half of the output from triggering that error in iex - the other half is from __STACKTRACE__:

iex(4)> try do                                      
...(4)>   Jason.decode("{\"24h\":0}", keys: :atoms!)
...(4)> rescue                                      
...(4)>   error ->                                  
...(4)>     __STACKTRACE__
...(4)> end
warning: variable "error" is unused (if the variable is not meant to be used, prefix it with an underscore)
  iex:7

[
  {:erlang, :binary_to_existing_atom, ["24h", :utf8],
   [error_info: %{module: :erl_erts_errors}]},
  {String, :to_existing_atom, 1, [file: 'lib/string.ex', line: 2456]},
  {Jason.Decoder, :object, 6, [file: 'lib/decoder.ex', line: 322]},
  {Jason.Decoder, :parse, 2, [file: 'lib/decoder.ex', line: 55]},
  {:erl_eval, :do_apply, 7, [file: 'erl_eval.erl', line: 744]},
  {:erl_eval, :try_clauses, 10, [file: 'erl_eval.erl', line: 987]},
  {:elixir, :recur_eval, 3, [file: 'src/elixir.erl', line: 296]},
  {:elixir, :eval_forms, 3, [file: 'src/elixir.erl', line: 274]}
]
1 Like

The API is not universally constructed, as it turns out, so some records have keys that are absent from others. I was able to deduce what was going wrong and rewrite the code to accommodate for it thanks to being able to examine the error stacktrace. Thanks for the tip!

Btw, please elaborate on a few of the points you made here when you have a moment to spare, thanks again!