Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide camelized option according to v1.1 spec #158

Merged
merged 10 commits into from
Jan 20, 2019
21 changes: 13 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe this is strictly true, so it may be a bit misleading.

I'll start open a project board that has issues for new JSON:API v1.1 features.

of the JSON:API spec.

- [x] Basic [JSONAPI Document](http://jsonapi.org/format/#document-top-level) encoding
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion lib/jsonapi/plugs/query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions lib/jsonapi/plugs/underscore_parameters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions lib/jsonapi/serializer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
170 changes: 123 additions & 47 deletions lib/jsonapi/utils/string.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 """
Expand All @@ -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
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ought this to be the default going forward, or do you think that explicit configuration is more ideal?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of goes back to this discussion but I agree it probably should be the default going forward. Other libraries like ja_serializer also assume the default according to the jsonapi spec. I'll wait for @doomspork's thoughts as well.

#149 (comment)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the JSONAPI default, we should follow suit 👌

```

Dashed fields:

```
Expand Down
52 changes: 52 additions & 0 deletions test/serializer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down