From 262b9cb336fc8c0fe08ffd183f0ca9213f242c7e Mon Sep 17 00:00:00 2001 From: Zack Michener Date: Mon, 12 Feb 2024 10:52:59 -0800 Subject: [PATCH] Add options to transform field keys non-recursively (#132) --- README.md | 5 +- lib/jsonapi/plugs/query_parser.ex | 6 +-- lib/jsonapi/serializer.ex | 2 + lib/jsonapi/utils/data_to_params.ex | 2 + lib/jsonapi/utils/string.ex | 40 +++++++++++++++- mix.exs | 1 - test/jsonapi/serializer_test.exs | 73 +++++++++++++++++++++++++---- 7 files changed, 113 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8a3392e9..d0d7503d 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Transforming fields requires two steps: ```elixir config :jsonapi, - field_transformation: :camelize # or dasherize + field_transformation: :camelize # or :dasherize, :camelize_shallow, or :dasherize_shallow ``` 2. Underscoring _incoming_ params (both query and body) requires you add the @@ -215,7 +215,8 @@ config :jsonapi, `:camelize`. JSON:API v1.0 recommended using a dash (e.g. `"favorite-color": blue`). If your API uses dashed fields, set this value to `:dasherize`. If your API uses underscores (e.g. `"favorite_color": "red"`) - set to `:underscore`. + set to `:underscore`. To transform only the top-level field keys, use + `:camelize_shallow` or `:dasherize_shallow`. - **remove_links**. `links` data can optionally be removed from the payload via setting the configuration above to `true`. Defaults to `false`. - **json_library**. Defaults to [Jason](https://hex.pm/packages/jason). diff --git a/lib/jsonapi/plugs/query_parser.ex b/lib/jsonapi/plugs/query_parser.ex index d849c2ad..ab12ccb2 100644 --- a/lib/jsonapi/plugs/query_parser.ex +++ b/lib/jsonapi/plugs/query_parser.ex @@ -282,15 +282,15 @@ defmodule JSONAPI.QueryParser do @spec get_view_for_type(module(), String.t()) :: module() | no_return() def get_view_for_type(view, type) do case Enum.find(view.relationships(), fn relationship -> - is_field_valid_for_relationship(relationship, type) + field_valid_for_relationship?(relationship, type) end) do {_, view} -> view nil -> raise_invalid_field_names(type, view.type()) end end - @spec is_field_valid_for_relationship({atom(), module()}, String.t()) :: boolean() - defp is_field_valid_for_relationship({key, view}, expected_type) do + @spec field_valid_for_relationship?({atom(), module()}, String.t()) :: boolean() + defp field_valid_for_relationship?({key, view}, expected_type) do cond do view.type == expected_type -> true diff --git a/lib/jsonapi/serializer.ex b/lib/jsonapi/serializer.ex index 3c744a7f..0ea777e8 100644 --- a/lib/jsonapi/serializer.ex +++ b/lib/jsonapi/serializer.ex @@ -302,6 +302,8 @@ defmodule JSONAPI.Serializer do case Utils.String.field_transformation() do :camelize -> Utils.String.expand_fields(fields, &Utils.String.camelize/1) :dasherize -> Utils.String.expand_fields(fields, &Utils.String.dasherize/1) + :camelize_shallow -> Utils.String.expand_root_keys(fields, &Utils.String.camelize/1) + :dasherize_shallow -> Utils.String.expand_root_keys(fields, &Utils.String.dasherize/1) _ -> fields end end diff --git a/lib/jsonapi/utils/data_to_params.ex b/lib/jsonapi/utils/data_to_params.ex index 651b417f..c442b773 100644 --- a/lib/jsonapi/utils/data_to_params.ex +++ b/lib/jsonapi/utils/data_to_params.ex @@ -110,7 +110,9 @@ defmodule JSONAPI.Utils.DataToParams do defp transform_fields(fields) do case JString.field_transformation() do :camelize -> JString.expand_fields(fields, &JString.camelize/1) + :camelize_shallow -> JString.expand_fields(fields, &JString.camelize/1) :dasherize -> JString.expand_fields(fields, &JString.dasherize/1) + :dasherize_shallow -> JString.expand_fields(fields, &JString.dasherize/1) _ -> fields end end diff --git a/lib/jsonapi/utils/string.ex b/lib/jsonapi/utils/string.ex index 0be48824..bbe308df 100644 --- a/lib/jsonapi/utils/string.ex +++ b/lib/jsonapi/utils/string.ex @@ -3,7 +3,13 @@ defmodule JSONAPI.Utils.String do String manipulation helpers. """ - @allowed_transformations [:camelize, :dasherize, :underscore] + @allowed_transformations [ + :camelize, + :dasherize, + :underscore, + :camelize_shallow, + :dasherize_shallow + ] @doc """ Replace dashes between words in `value` with underscores @@ -236,6 +242,27 @@ defmodule JSONAPI.Utils.String do value end + @doc """ + Like `JSONAPI.Utils.String.expand_fields/2`, but only uses the given function to transform the + keys of a top-level map. Other values are transformed with `to_string/1`. + + ## Examples + + iex> expand_root_keys(%{"foo-bar" => %{"bar-baz" => "x"}}, &underscore/1) + %{"foo_bar" => %{"bar-baz" => "x"}} + + iex> expand_root_keys(%{"foo-bar" => [:x, %{"bar-baz" => "y"}]}, &underscore/1) + %{"foo_bar" => ["x", %{"bar-baz" => "y"}]} + + """ + def expand_root_keys(map, fun) when is_map(map) do + Enum.into(map, %{}, fn {key, value} -> + {fun.(key), expand_fields(value, &to_string/1)} + end) + end + + def expand_root_keys(value, _fun), do: expand_fields(value, &to_string/1) + defp maybe_expand_fields(values, fun) when is_list(values) do Enum.map(values, fn string when is_binary(string) -> string @@ -248,7 +275,8 @@ defmodule JSONAPI.Utils.String do using camlized fields (e.g. "goodDog", versus "good_dog"). However, we don't hold a strong opinion, so feel free to customize it how you would like (e.g. "good-dog", versus "good_dog"). - This library currently supports camelized, dashed and underscored fields. + This library currently supports camelized, dashed and underscored fields. Shallow variants + exist that only transform top-level field keys. ## Configuration examples @@ -258,12 +286,20 @@ defmodule JSONAPI.Utils.String do config :jsonapi, field_transformation: :camelize ``` + ``` + config :jsonapi, field_transformation: :camelize_shallow + ``` + Dashed fields: ``` config :jsonapi, field_transformation: :dasherize ``` + ``` + config :jsonapi, field_transformation: :dasherize_shallow + ``` + Underscored fields: ``` diff --git a/mix.exs b/mix.exs index 6a9f36df..d97e5374 100644 --- a/mix.exs +++ b/mix.exs @@ -25,7 +25,6 @@ defmodule JSONAPI.Mixfile do end # Use Phoenix compiler depending on environment. - defp compilers(:test), do: [:phoenix] ++ Mix.compilers() defp compilers(_), do: Mix.compilers() # Specifies which paths to compile per environment. diff --git a/test/jsonapi/serializer_test.exs b/test/jsonapi/serializer_test.exs index ba6a986f..65543c3f 100644 --- a/test/jsonapi/serializer_test.exs +++ b/test/jsonapi/serializer_test.exs @@ -588,9 +588,14 @@ defmodule JSONAPI.SerializerTest do id: 1, text: "Hello", inserted_at: NaiveDateTime.utc_now(), - body: "Hello world", + body: %{data: "Hello world", data_attr: "foo"}, full_description: "This_is_my_description", - author: %{id: 2, username: "jbonds", first_name: "jerry", last_name: "bonds"}, + author: %{ + id: 2, + username: "jbonds", + first_name: %{data: "jerry", data_attr: "foo"}, + last_name: "bonds" + }, best_comments: [ %{ id: 5, @@ -607,15 +612,17 @@ defmodule JSONAPI.SerializerTest do included = encoded[:included] assert attributes["full-description"] == data[:full_description] + assert attributes["body"]["data-attr"] == "foo" assert attributes["inserted-at"] == data[:inserted_at] - assert Enum.find(included, fn i -> i[:type] == "user" && i[:id] == "2" end)[:attributes][ - "last-name" - ] == "bonds" + author2 = Enum.find(included, fn i -> i[:type] == "user" && i[:id] == "2" end) + assert author2 != nil + assert author2[:attributes]["first-name"]["data-attr"] == "foo" + assert author2[:attributes]["last-name"] == "bonds" - assert Enum.find(included, fn i -> i[:type] == "user" && i[:id] == "4" end)[:attributes][ - "last-name" - ] == "bronds" + author4 = Enum.find(included, fn i -> i[:type] == "user" && i[:id] == "4" end) + assert author4 != nil + assert author4[:attributes]["last-name"] == "bronds" assert List.first(relationships["best-comments"][:data])[:id] == "5" @@ -624,6 +631,56 @@ defmodule JSONAPI.SerializerTest do end end + describe "when configured to dasherize fields non-recursively" do + setup do + Application.put_env(:jsonapi, :field_transformation, :dasherize_shallow) + + on_exit(fn -> + Application.delete_env(:jsonapi, :field_transformation) + end) + + {:ok, []} + end + + test "serialize properly dasherizes attribute and relationship keys only" do + data = %{ + id: 1, + text: "Hello", + inserted_at: NaiveDateTime.utc_now(), + body: %{data: "Some data", data_attr: "foo"}, + full_description: "This_is_my_description", + author: %{ + id: 2, + username: "jbonds", + first_name: %{data: "jerry", data_attr: "foo"}, + last_name: "bonds" + }, + best_comments: [ + %{ + id: 5, + text: %{data: "greatest comment ever", data_attr: "foo"}, + user: %{id: 4, username: "jack", last_name: "bronds"} + } + ] + } + + encoded = Serializer.serialize(PostView, data, nil) + + attributes = encoded[:data][:attributes] + included = encoded[:included] + + assert attributes["full-description"] == data[:full_description] + assert attributes["body"]["data_attr"] == "foo" + assert attributes["inserted-at"] == data[:inserted_at] + + author = Enum.find(included, &(&1[:type] == "user" && &1[:id] == "2")) + assert author != nil + assert author[:attributes]["last-name"] == "bonds" + assert author[:attributes]["first-name"]["data"] == "jerry" + assert author[:attributes]["first-name"]["data_attr"] == "foo" + end + end + test "serialize does not merge `included` if not configured" do data = %{ id: 1,