diff --git a/lib/json_serde/atom_key.ex b/lib/json_serde/atom_key.ex new file mode 100644 index 0000000..5a380b2 --- /dev/null +++ b/lib/json_serde/atom_key.ex @@ -0,0 +1,68 @@ +defmodule JsonSerde.AtomKey do + @moduledoc """ + Handles encoding and decoding atom keys to support serializing and deserializing maps with atom keys. + + This would be a breaking change, so it is disabled by default. + To enable it, add the following to your config.exs: + + config :json_serde, :encode_atom_keys, true + """ + + @atom_key "__is_atom__" + @key_length String.length(@atom_key) + 1 + + @doc """ + Encodes a key if it an atom, otherwise returns the key unaltered. + + If the key is an atom, it will be converted to a string and appended with "#{@atom_key}". + + Examples: + + iex> JsonSerde.AtomKey.encode(:foo) + "foo__is_atom__" + + iex> JsonSerde.AtomKey.encode("foo") + "foo" + + Note: Encoding atom keys is disabled by defualt and must be enabled in your config.exs. + """ + @spec encode(key :: any(), boolean()) :: any() + def encode(key, is_struct) do + if enabled?() && !is_struct && is_atom(key) do + key + |> Atom.to_string() + |> Kernel.<>(@atom_key) + else + key + end + end + + @doc """ + Decodes a key if it ends with "__is_atom__", otherwise returns the key unaltered. + + If the key ends with "__is_atom__", it will be converted to an atom. + + Examples: + + iex> JsonSerde.AtomKey.decode("foo__is_atom__") + :foo + + iex> JsonSerde.AtomKey.decode("foo") + "foo" + + Note: Decoding atom keys is disabled by defualt and must be enabled in your config.exs. + """ + def decode(key) do + if enabled?() && String.ends_with?(key, @atom_key) do + key + |> String.slice(0..-@key_length//1) + |> String.to_atom() + else + key + end + end + + defp enabled? do + Application.get_env(:json_serde, :encode_atom_keys, false) + end +end diff --git a/lib/json_serde/impls/map.ex b/lib/json_serde/impls/map.ex index bfa00e4..e5a2c58 100644 --- a/lib/json_serde/impls/map.ex +++ b/lib/json_serde/impls/map.ex @@ -2,11 +2,17 @@ defimpl JsonSerde.Serializer, for: Map do import Brex.Result.Base, only: [fmap: 2] import Brex.Result.Mappers + require JsonSerde + def serialize(map) do + is_struct = Map.has_key?(map, JsonSerde.data_type_key()) + map |> map_while_success(fn {key, value} -> + k = JsonSerde.AtomKey.encode(key, is_struct) + JsonSerde.Serializer.serialize(value) - |> fmap(fn v -> {key, v} end) + |> fmap(fn v -> {k, v} end) end) |> fmap(&Map.new/1) end @@ -39,8 +45,10 @@ defimpl JsonSerde.Deserializer, for: Map do def deserialize(_, map) do map |> map_while_success(fn {key, value} -> + k = JsonSerde.AtomKey.decode(key) + JsonSerde.Deserializer.deserialize(value, value) - |> fmap(fn v -> {key, v} end) + |> fmap(fn v -> {k, v} end) end) |> fmap(&Map.new/1) end diff --git a/test/atom_keys_test.exs b/test/atom_keys_test.exs new file mode 100644 index 0000000..919be78 --- /dev/null +++ b/test/atom_keys_test.exs @@ -0,0 +1,92 @@ +defmodule AtomKeysTest do + use ExUnit.Case + + defmodule AtomSimpleStruct do + use JsonSerde, alias: "atom_simple" + + defstruct [:name, :age, :birthdate] + end + + defmodule AtomNestedStruct do + use JsonSerde, alias: "atom_nested" + + defstruct [:tag, :simple] + end + + setup do + Application.put_env(:json_serde, :encode_atom_keys, true) + :ok + end + + test "map with mixed keys" do + input = %{ + "a" => "1", + b: 2 + } + + {:ok, serialized_term} = JsonSerde.serialize(input) + + assert serialized_term == + Jason.encode!(%{ + "a" => "1", + "b__is_atom__" => 2 + }) + + assert {:ok, input} == JsonSerde.deserialize(serialized_term) + end + + test "nested map with mixed keys" do + input = %{ + "a" => "1", + b: 2, + c: %{ + "d" => "3", + e: 4 + } + } + + {:ok, serialized_term} = JsonSerde.serialize(input) + + assert serialized_term == + Jason.encode!(%{ + "a" => "1", + "b__is_atom__" => 2, + "c__is_atom__" => %{ + "d" => "3", + "e__is_atom__" => 4 + } + }) + + assert {:ok, input} == JsonSerde.deserialize(serialized_term) + end + + describe "do not atomize structs" do + test "test with nested struct" do + input = %AtomNestedStruct{ + tag: "dev", + simple: %AtomSimpleStruct{name: "brian", age: 21, birthdate: Date.utc_today()} + } + + {:ok, serialized_value} = JsonSerde.serialize(input) + + assert {:ok, input} == JsonSerde.deserialize(serialized_value) + end + + test "test with simple struct" do + input = %AtomSimpleStruct{name: "brian", age: 21, birthdate: Date.utc_today()} + + {:ok, serialized_value} = JsonSerde.serialize(input) + + iso = Date.to_iso8601(input.birthdate) + + assert Jason.decode!(serialized_value) == %{ + "__data_type__" => "atom_simple", + "name" => "brian", + "age" => 21, + "birthdate" => %{"__data_type__" => "date", "value" => iso} + } + + assert {:ok, input} == JsonSerde.deserialize(serialized_value) + end + end +end