Repository: jeregrine/jsonapi
Pull request #158 (2019-01-15 04:53:02)

Provide camelized option according to v1.1 spec
https://jsonapi.org/format/1.1/

Profile links to follow in another PR.

snewcomer: @doomspork @jherdman Lmk what you think about this PR to merge into this base branch.  Essentially introduces `expand_fields/2` where the second arg is the appropriate string method.  There still exists Sean's idea to tokenize the string - which I like a lot and can happen here or another PR.

https://github.com/snewcomer/jsonapi/blob/5519920245c80af336027a9dfaafc526ec7cb8f7/lib/jsonapi/utils/string.ex

https://github.com/snewcomer/jsonapi/pull/1/files
doomspork: @snewcomer after we merge do you want me to open a PR to refactor the String utils?
snewcomer: @doomspork Yeah that would work!  

Just looking for some feedback on this [PR](https://github.com/snewcomer/jsonapi/pull/1/files) before tying this one up.  Basically consolidates duplicated logic to get at the value to transform.
doomspork: Oh I missed that PR, let me look
snewcomer: @jherdman Did you want 0.9 released before this PR goes in?
jherdman: Yeah, let's do that. I can't seem to find any issues that need to be addressed before a 0.9 release.
snewcomer: Alrighty up to date with master and passing tests!  All good to merge?

Patch:
diff --git a/README.md b/README.md
index 8ec82a2..fb390fb 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ A project that will render your data models into [JSONAPI Documents](http://json
 
 ## JSONAPI Support
 
-This library implements [version 1.0](https://jsonapi.org/format/1.0/)
+This library implements [version 1.1](https://jsonapi.org/format/1.1/)
 of the JSON:API spec.
 
 - [x] Basic [JSONAPI Document](http://jsonapi.org/format/#document-top-level) encoding
@@ -23,7 +23,7 @@ of the JSON:API spec.
 ## Documentation
 
 * [Full docs here](https://hexdocs.pm/jsonapi)
-* [JSON API Spec (v1.0)](https://jsonapi.org/format/1.0/)
+* [JSON API Spec (v1.1)](https://jsonapi.org/format/1.1/)
 
 ## How to use with Phoenix
 
@@ -84,17 +84,20 @@ when Ecto gets more complex field selection support we will go further to only q
 
 You will need to handle filtering yourself, the filter is just a map with key=value.
 
-## Dasherized Fields
+## Camelized or Dasherized Fields
 
-JSONAPI now recommends the use of dashes (`-`) in place of underscore (`_`) as a
-word separator. Handling these fields requires two steps:
+JSONAPI has recommended in the past the use of dashes (`-`) in place of underscore (`_`) as a
+word separator for document member keys. However, as of [JSON API Spec (v1.1)](https://jsonapi.org/format/1.1/), it is now recommended that member names
+are camelCased. This library provides various configuration options for maximum flexibility.
 
-1. Dasherizing *outgoing* fields requires you to set the `:field_transformation`
+Transforming fields requires two steps:
+
+1. camelCase *outgoing* fields requires you to set the `:field_transformation`
    configuration option. Example:
 
    ```elixir
    config :jsonapi,
-     field_transformation: :dasherize
+     field_transformation: :camelize # or dasherize
    ```
 
 2. Underscoring *incoming* params (both query and body) requires you add the
@@ -142,7 +145,9 @@ config :jsonapi,
   setting the configuration above to `true`. Defaults to `false`.
 - **json_library**. Defaults to [Jason](https://hex.pm/packages/jason).
 - **field_transformation**. This option describes how your API's fields word
-  boundaries are marked. JSON:API v1 recommends using a dash (e.g.
+  boundaries are marked. [JSON API Spec (v1.1)](https://jsonapi.org/format/1.1/) recommends using camelCase (e.g.
+  `"favoriteColor": blue`). If your API uses camelCase fields, set this value to
+  `: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`.
diff --git a/lib/jsonapi/plugs/query_parser.ex b/lib/jsonapi/plugs/query_parser.ex
index 5e27b8b..a915302 100644
--- a/lib/jsonapi/plugs/query_parser.ex
+++ b/lib/jsonapi/plugs/query_parser.ex
@@ -253,7 +253,7 @@ defmodule JSONAPI.QueryParser do
     if JString.field_transformation() == :underscore do
       fields
     else
-      JString.underscore(fields)
+      JString.expand_fields(fields, &JString.underscore/1)
     end
   end
 end
diff --git a/lib/jsonapi/plugs/underscore_parameters.ex b/lib/jsonapi/plugs/underscore_parameters.ex
index 83c926f..7e685b2 100644
--- a/lib/jsonapi/plugs/underscore_parameters.ex
+++ b/lib/jsonapi/plugs/underscore_parameters.ex
@@ -33,7 +33,7 @@ defmodule JSONAPI.UnderscoreParameters do
 
   import Plug.Conn
 
-  import JSONAPI.Utils.String, only: [underscore: 1]
+  alias JSONAPI.Utils.String, as: JString
 
   @doc false
   def init(_opts) do
@@ -44,7 +44,7 @@ defmodule JSONAPI.UnderscoreParameters do
     content_type = get_req_header(conn, "content-type")
 
     if JSONAPI.mime_type() in content_type do
-      new_params = underscore(params)
+      new_params = JString.expand_fields(params, &JString.underscore/1)
 
       Map.put(conn, :params, new_params)
     else
diff --git a/lib/jsonapi/serializer.ex b/lib/jsonapi/serializer.ex
index 3be2597..4c92d1d 100644
--- a/lib/jsonapi/serializer.ex
+++ b/lib/jsonapi/serializer.ex
@@ -230,10 +230,10 @@ defmodule JSONAPI.Serializer do
   defp with_pagination?, do: Application.get_env(:jsonapi, :with_pagination, false)
 
   defp transform_fields(fields) do
-    if JString.field_transformation() == :dasherize do
-      JString.dasherize(fields)
-    else
-      fields
+    case JString.field_transformation() do
+      :camelize -> JString.expand_fields(fields, &JString.camelize/1)
+      :dasherize -> JString.expand_fields(fields, &JString.dasherize/1)
+      _ -> fields
     end
   end
 end
diff --git a/lib/jsonapi/utils/string.ex b/lib/jsonapi/utils/string.ex
index e399ccb..55ade06 100644
--- a/lib/jsonapi/utils/string.ex
+++ b/lib/jsonapi/utils/string.ex
@@ -5,7 +5,7 @@ defmodule JSONAPI.Utils.String do
 
   alias JSONAPI.Deprecation
 
-  @allowed_transformations [:dasherize, :underscore]
+  @allowed_transformations [:camelize, :dasherize, :underscore]
 
   @doc """
   Replace dashes between words in `value` with underscores
@@ -17,55 +17,26 @@ defmodule JSONAPI.Utils.String do
       iex> underscore("top-posts")
       "top_posts"
 
+      iex> underscore(:top_posts)
+      "top_posts"
+
       iex> underscore("-top-posts")
       "-top_posts"
 
       iex> underscore("-top--posts-")
       "-top--posts-"
 
-      iex> underscore(%{"foo-bar" => "baz"})
-      %{"foo_bar" => "baz"}
-
-      iex> underscore({"foo-bar", "dollar-sol"})
-      {"foo_bar", "dollar-sol"}
-
-      iex> underscore({"foo-bar", %{"a-d" => "z-8"}})
-      {"foo_bar", %{"a_d" => "z-8"}}
-
-      iex> underscore(%{"f-b" => %{"a-d" => "z"}, "c-d" => "e"})
-      %{"f_b" => %{"a_d" => "z"}, "c_d" => "e"}
-
-      iex> underscore(:"foo-bar")
-      :foo_bar
-
-      iex> underscore(%{"f-b" => "a-d"})
-      %{"f_b" => "a-d"}
   """
+  @spec underscore(String.t()) :: String.t()
   def underscore(value) when is_binary(value) do
     String.replace(value, ~r/([a-zA-Z0-9])-([a-zA-Z0-9])/, "\\1_\\2")
   end
 
-  def underscore(map) when is_map(map) do
-    Enum.into(map, %{}, &underscore/1)
-  end
-
-  def underscore({key, value}) when is_map(value) do
-    {underscore(key), underscore(value)}
-  end
-
-  def underscore({key, value}) do
-    {underscore(key), value}
-  end
-
+  @spec underscore(atom) :: String.t()
   def underscore(value) when is_atom(value) do
     value
     |> to_string()
     |> underscore()
-    |> String.to_atom()
-  end
-
-  def underscore(value) do
-    value
   end
 
   @doc """
@@ -83,31 +54,129 @@ defmodule JSONAPI.Utils.String do
 
       iex> dasherize("_top__posts_")
       "_top__posts_"
+
   """
+  @spec dasherize(atom) :: String.t()
   def dasherize(value) when is_atom(value) do
     value
     |> to_string()
     |> dasherize()
   end
 
+  @spec dasherize(String.t()) :: String.t()
   def dasherize(value) when is_binary(value) do
     String.replace(value, ~r/([a-zA-Z0-9])_([a-zA-Z0-9])/, "\\1-\\2")
   end
 
-  def dasherize(%{__struct__: _} = value) when is_map(value) do
+  @doc """
+  Replace underscores or dashes between words in `value` with camelCasing
+
+  Ignores underscores or dashes that are not between letters/numbers
+
+  ## Examples
+
+      iex> camelize("top_posts")
+      "topPosts"
+
+      iex> camelize(:top_posts)
+      "topPosts"
+
+      iex> camelize("_top_posts")
+      "_topPosts"
+
+      iex> camelize("_top__posts_")
+      "_top__posts_"
+
+  """
+  @spec camelize(atom) :: String.t()
+  def camelize(value) when is_atom(value) do
     value
+    |> to_string()
+    |> camelize()
   end
 
-  def dasherize(value) when is_map(value) do
-    Enum.into(value, %{}, &dasherize/1)
+  @spec camelize(String.t()) :: String.t()
+  def camelize(value) when is_binary(value) do
+    with words <-
+           Regex.split(
+             ~r{(?<=[a-zA-Z0-9])[-_](?=[a-zA-Z0-9])},
+             to_string(value)
+           ) do
+      [h | t] = words |> Enum.filter(&(&1 != ""))
+
+      [String.downcase(h) | camelize_list(t)]
+      |> Enum.join()
+    end
   end
 
-  def dasherize({key, value}) do
-    if is_map(value) do
-      {dasherize(key), dasherize(value)}
-    else
-      {dasherize(key), value}
-    end
+  defp camelize_list([]), do: []
+
+  defp camelize_list([h | t]) do
+    [String.capitalize(h)] ++ camelize_list(t)
+  end
+
+  @doc """
+
+  ## Examples
+
+      iex> expand_fields(%{"foo-bar" => "baz"}, &underscore/1)
+      %{"foo_bar" => "baz"}
+
+      iex> expand_fields(%{"foo_bar" => "baz"}, &dasherize/1)
+      %{"foo-bar" => "baz"}
+
+      iex> expand_fields(%{"foo-bar" => "baz"}, &camelize/1)
+      %{"fooBar" => "baz"}
+
+      iex> expand_fields({"foo-bar", "dollar-sol"}, &underscore/1)
+      {"foo_bar", "dollar-sol"}
+
+      iex> expand_fields({"foo-bar", %{"a-d" => "z-8"}}, &underscore/1)
+      {"foo_bar", %{"a_d" => "z-8"}}
+
+      iex> expand_fields(%{"f-b" => %{"a-d" => "z"}, "c-d" => "e"}, &underscore/1)
+      %{"f_b" => %{"a_d" => "z"}, "c_d" => "e"}
+
+      iex> expand_fields(%{"f-b" => %{"a-d" => %{"z-w" => "z"}}, "c-d" => "e"}, &underscore/1)
+      %{"f_b" => %{"a_d" => %{"z_w" => "z"}}, "c_d" => "e"}
+
+      iex> expand_fields(:"foo-bar", &underscore/1)
+      "foo_bar"
+
+      iex> expand_fields(:foo_bar, &dasherize/1)
+      "foo-bar"
+
+      iex> expand_fields(:"foo-bar", &camelize/1)
+      "fooBar"
+
+      iex> expand_fields(%{"f-b" => "a-d"}, &underscore/1)
+      %{"f_b" => "a-d"}
+
+      iex> expand_fields(%{"inserted-at" => ~N[2019-01-17 03:27:24.776957]}, &underscore/1)
+      %{"inserted_at" => ~N[2019-01-17 03:27:24.776957]}
+
+  """
+  @spec expand_fields(map, function) :: map
+  def expand_fields(%{__struct__: _} = value, _fun), do: value
+
+  @spec expand_fields(map, function) :: map
+  def expand_fields(map, fun) when is_map(map) do
+    Enum.into(map, %{}, &expand_fields(&1, fun))
+  end
+
+  @spec expand_fields(tuple, function) :: tuple
+  def expand_fields({key, value}, fun) when is_map(value) do
+    {fun.(key), expand_fields(value, fun)}
+  end
+
+  @spec expand_fields(tuple, function) :: tuple
+  def expand_fields({key, value}, fun) do
+    {fun.(key), value}
+  end
+
+  @spec expand_fields(any, function) :: any
+  def expand_fields(value, fun) do
+    fun.(value)
   end
 
   defp normalized_underscore_to_dash_config(value) when is_boolean(value) do
@@ -123,13 +192,20 @@ defmodule JSONAPI.Utils.String do
   defp normalized_underscore_to_dash_config(value) when is_nil(value), do: value
 
   @doc """
-  The configured transformation for the API's fields. JSON:API v1 recommends
-  using dashed fields (e.g. "good-dog", versus "good_dog").
+  The configured transformation for the API's fields. JSON:API v1.1 recommends
+  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 dashed and underscored fields.
+  This library currently supports camelized, dashed and underscored fields.
 
   ## Configuration examples
 
+  camelCase fields:
+
+  ```
+  config :jsonapi, field_transformation: :camelize
+  ```
+
   Dashed fields:
 
   ```


Test patch:
diff --git a/test/serializer_test.exs b/test/serializer_test.exs
index 792dd42..165e5fe 100644
--- a/test/serializer_test.exs
+++ b/test/serializer_test.exs
@@ -368,6 +368,58 @@ defmodule JSONAPISerializerTest do
     assert Enum.count(encoded.included) == 4
   end
 
+  describe "when configured to camelize fields" do
+    setup do
+      Application.put_env(:jsonapi, :field_transformation, :camelize)
+
+      on_exit(fn ->
+        Application.delete_env(:jsonapi, :field_transformation)
+      end)
+
+      {:ok, []}
+    end
+
+    test "serialize properly camelizes both attributes and relationships" do
+      data = %{
+        id: 1,
+        text: "Hello",
+        inserted_at: NaiveDateTime.utc_now(),
+        body: "Hello world",
+        full_description: "This_is_my_description",
+        author: %{id: 2, username: "jbonds", first_name: "jerry", last_name: "bonds"},
+        best_comments: [
+          %{
+            id: 5,
+            text: "greatest comment ever",
+            user: %{id: 4, username: "jack", last_name: "bronds"}
+          }
+        ]
+      }
+
+      encoded = Serializer.serialize(PostView, data, nil)
+
+      attributes = encoded[:data][:attributes]
+      relationships = encoded[:data][:relationships]
+      included = encoded[:included]
+
+      assert attributes["fullDescription"] == data[:full_description]
+      assert attributes["insertedAt"] == data[:inserted_at]
+
+      assert Enum.find(included, fn i -> i[:type] == "user" && i[:id] == "2" end)[:attributes][
+               "lastName"
+             ] == "bonds"
+
+      assert Enum.find(included, fn i -> i[:type] == "user" && i[:id] == "4" end)[:attributes][
+               "lastName"
+             ] == "bronds"
+
+      assert List.first(relationships["bestComments"][:data])[:id] == "5"
+
+      assert relationships["bestComments"][:links][:self] ==
+               "/mytype/1/relationships/bestComments"
+    end
+  end
+
   describe "when configured to dasherize fields" do
     setup do
       Application.put_env(:jsonapi, :field_transformation, :dasherize)