Repository: jeregrine/jsonapi (elixir)

Issue:
Should Filter Params Be Transformed?
Maybe I'm writing by tests wrong, but it seems like filter params aren't transformed. E.g. given...

```elixir
  plug JSONAPI.QueryParser,
    filter: ~w[dog-breed],
    view: MyApp.DogView
```

```
GET /api/foo?filter[dog-breed]="corgi"
```

Results in `%{"filter" => %{"dog-breed" => "corgi"}}`.

It seems to me that we should apply the underscore transformation a la attribute params. This would mean that we neither need to apply the transformation manually, nor does the client need KNOW how to write the params (e.g. if we're dasherized, we're always dasherized).

I know the filter portion of the spec is skimpy, but I figured I'd raise this. Thoughts?

Pull request description:
Add Plug to Transform Parameters
This, optional, Plug can be used to transform incoming dasherized
parameters to underscored ones that are more amenable to usage in your
application.

Resolves #145

Patch:
diff --git a/README.md b/README.md
index 173eed1..507ce4e 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,9 @@ 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/)
+of the JSON:API spec.
+
 - [x] Basic [JSONAPI Document](http://jsonapi.org/format/#document-top-level) encoding
 - [x] Basic support for [compound documents](http://jsonapi.org/format/#document-compound-documents)
 - [x] [Links](http://jsonapi.org/format/#document-links)
@@ -19,7 +22,8 @@ A project that will render your data models into [JSONAPI Documents](http://json
 
 ## Documentation
 
-[Full docs here](https://hexdocs.pm/jsonapi/api-reference.html)
+* [Full docs here](https://hexdocs.pm/jsonapi)
+* [JSON API Spec (v1.0)](https://jsonapi.org/format/1.0/)
 
 ## How to use with Phoenix
 
@@ -80,6 +84,30 @@ 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
+
+JSONAPI now recommends the use of dashes (`-`) in place of underscore (`_`) as a
+word separator. Handling these fields requires two steps:
+
+1. Dasherizing *outgoing* fields requires you to set the `underscore_to_dash`
+   configuration option. This is not enabled by default. Example:
+
+    ```elixir
+    config :jsonapi,
+      underscore_to_dash: true,
+    ```
+
+2. Underscoring *incoming* params (both query and body) requires you add the
+   `JSONAPI.UnderscoreParameters` Plug to your API's pipeline. Your pipeline in a
+   Phoenix app might look something like this:
+
+   ```elixir
+   pipeline :api do
+     plug(JSONAPI.EnsureSpec)
+     plug(JSONAPI.UnderscoreParameters)
+   end
+   ```
+
 ## Spec Enforcement
 
 We include a set of Plugs to make enforcing the JSONAPI spec for requests easy. To add spec enforcement to your application, add `JSONAPI.EnsureSpec` to your pipeline:
@@ -98,8 +126,6 @@ Under-the-hood `JSONAPI.EnsureSpec` relies on three individual plugs:
 
 ## Configuration
 
-By default host and scheme are pulled from the provided conn but can be overridden via configuration like so:
-
 ```elixir
 config :jsonapi,
   host: "www.someotherhost.com",
@@ -109,13 +135,12 @@ config :jsonapi,
   json_library: Jason
 ```
 
-- **underscore_to_dash**
-
-Additionally, JSONAPI now recommends the use of dashes (`-`) in place of underscore (`_`) as a word separator. Enabling this change is easy with the `underscore_to_dash` option, which handles the conversion for you. Defaults to `false`.
-
-- **remove_links**
-
-`links` data can optionally be removed from the payload via setting the configuration above to `true`. Defaults to `false`.
+- **host**, **scheme**. By default these are pulled from the `conn`, but may be
+  overridden.
+- **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).
+- **underscore_to_dash**. See "Dasherizing Fields".
 
 ## Other
 
diff --git a/lib/jsonapi.ex b/lib/jsonapi.ex
index 6dbe1be..fdc8e65 100644
--- a/lib/jsonapi.ex
+++ b/lib/jsonapi.ex
@@ -24,4 +24,13 @@ defmodule JSONAPI do
       """)
     end
   end
+
+  @mime_type "application/vnd.api+json"
+
+  @doc """
+  This returns the MIME type for JSONAPIs
+  """
+  def mime_type() do
+    @mime_type
+  end
 end
diff --git a/lib/jsonapi/deprecation.ex b/lib/jsonapi/deprecation.ex
index 4975344..4d8b6d2 100644
--- a/lib/jsonapi/deprecation.ex
+++ b/lib/jsonapi/deprecation.ex
@@ -20,4 +20,11 @@ defmodule JSONAPI.Deprecation do
       Macro.Env.stacktrace(__ENV__)
     )
   end
+
+  def warn(:query_parser_dash) do
+    IO.warn(
+      "`JSONAPI.QueryParser` will no longer automatically dasherize incoming parameters. Please include `JSONAPI.UnderscoreParameters` in your pipeline. See https://github.com/jeregrine/jsonapi/pull/149",
+      Macro.Env.stacktrace(__ENV__)
+    )
+  end
 end
diff --git a/lib/jsonapi/error_view.ex b/lib/jsonapi/error_view.ex
index eb54337..26f5024 100644
--- a/lib/jsonapi/error_view.ex
+++ b/lib/jsonapi/error_view.ex
@@ -69,7 +69,7 @@ defmodule JSONAPI.ErrorView do
 
   def send_error(conn, status, error) do
     conn
-    |> put_resp_content_type("application/vnd.api+json")
+    |> put_resp_content_type(JSONAPI.mime_type())
     |> send_resp(status, error)
     |> halt
   end
diff --git a/lib/jsonapi/plugs/content_type_negotiation.ex b/lib/jsonapi/plugs/content_type_negotiation.ex
index ee83ced..09a6e33 100644
--- a/lib/jsonapi/plugs/content_type_negotiation.ex
+++ b/lib/jsonapi/plugs/content_type_negotiation.ex
@@ -17,8 +17,6 @@ defmodule JSONAPI.ContentTypeNegotiation do
 
   import Plug.Conn
 
-  @jsonapi "application/vnd.api+json"
-
   def init(opts), do: opts
 
   def call(%{method: method} = conn, _opts) when method in ["DELETE", "GET", "HEAD"], do: conn
@@ -62,14 +60,22 @@ defmodule JSONAPI.ContentTypeNegotiation do
   end
 
   defp validate_header(string) when is_binary(string) do
-    string |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.member?(@jsonapi)
+    string
+    |> String.split(",")
+    |> Enum.map(&String.trim/1)
+    |> Enum.member?(JSONAPI.mime_type())
   end
 
   defp validate_header(nil), do: true
 
   defp add_header_to_resp(conn) do
     register_before_send(conn, fn conn ->
-      update_resp_header(conn, "content-type", @jsonapi, & &1)
+      update_resp_header(
+        conn,
+        "content-type",
+        JSONAPI.mime_type(),
+        & &1
+      )
     end)
 
     conn
diff --git a/lib/jsonapi/plugs/query_parser.ex b/lib/jsonapi/plugs/query_parser.ex
index e075c92..f77249d 100644
--- a/lib/jsonapi/plugs/query_parser.ex
+++ b/lib/jsonapi/plugs/query_parser.ex
@@ -58,6 +58,14 @@ defmodule JSONAPI.QueryParser do
     * `:view` - The JSONAPI View which is the basis for this plug.
     * `:sort` - List of atoms which define which fields can be sorted on.
     * `:filter` - List of atoms which define which fields can be filtered on.
+
+  ## Dasherized Fields
+
+  Note that if your API is returning dasherized params (e.g. `"dog-breed": "Corgi"`)
+  we recommend that you include the `JSONAPI.UnderscoreParameters` Plug in your
+  API's pipeline. This will underscore fields for easier operations in your code.
+
+  For more details please see `JSONAPI.UnderscoreParameters`.
   """
 
   def init(opts) do
@@ -209,6 +217,7 @@ defmodule JSONAPI.QueryParser do
 
   def dash(data) do
     if Underscore.underscore?() do
+      Deprecation.warn(:query_parser_dash)
       Underscore.dash(data)
     else
       data
diff --git a/lib/jsonapi/plugs/response_content_type.ex b/lib/jsonapi/plugs/response_content_type.ex
index 89b3e99..9b93a5a 100644
--- a/lib/jsonapi/plugs/response_content_type.ex
+++ b/lib/jsonapi/plugs/response_content_type.ex
@@ -17,7 +17,7 @@ defmodule JSONAPI.ResponseContentType do
       if conn.assigns[:override_jsonapi] do
         conn
       else
-        put_resp_content_type(conn, "application/vnd.api+json")
+        put_resp_content_type(conn, JSONAPI.mime_type())
       end
     end)
   end
diff --git a/lib/jsonapi/plugs/underscore_parameters.ex b/lib/jsonapi/plugs/underscore_parameters.ex
new file mode 100644
index 0000000..c0ba299
--- /dev/null
+++ b/lib/jsonapi/plugs/underscore_parameters.ex
@@ -0,0 +1,54 @@
+defmodule JSONAPI.UnderscoreParameters do
+  @moduledoc """
+  Takes dasherized JSON:API params and turns them into underscored params. Add
+  this to your API's pipeline to aid in dealing with incoming parameters.
+
+  Note that this Plug will only underscore parameters when the request's content
+  type is for a JSON:API request (i.e. "application/vnd.api+json"). All other
+  content types will be ignored.
+
+  ## Example
+
+  Given a request like:
+
+      GET /example?filters[dog-breed]=Corgi
+
+  **Without** this Plug your index action would look like:
+
+      def index(conn, %{"filters" => %{"dog-breed" => "Corgi"}})
+
+  And **with** this Plug:
+
+      def index(conn, %{"filters" => %{"dog_breed" => "Corgi"}})
+
+  Your API's pipeline might look something like this:
+
+      # e.g. a Phoenix app
+
+      pipeline :api do
+        plug(JSONAPI.EnforceSpec)
+        plug(JSONAPI.UnderscoreParameters)
+      end
+  """
+
+  import Plug.Conn
+
+  import JSONAPI.Utils.Underscore, only: [dash: 1]
+
+  @doc false
+  def init(_opts) do
+  end
+
+  @doc false
+  def call(%Plug.Conn{params: params} = conn, _opts) do
+    content_type = get_req_header(conn, "content-type")
+
+    if JSONAPI.mime_type() in content_type do
+      new_params = dash(params)
+
+      Map.put(conn, :params, new_params)
+    else
+      conn
+    end
+  end
+end
diff --git a/lib/jsonapi/utils/underscore.ex b/lib/jsonapi/utils/underscore.ex
index 952179e..75ff757 100644
--- a/lib/jsonapi/utils/underscore.ex
+++ b/lib/jsonapi/utils/underscore.ex
@@ -20,11 +20,52 @@ defmodule JSONAPI.Utils.Underscore do
 
       iex> dash("-top--posts-")
       "-top--posts-"
+
+      iex> dash(%{"foo-bar" => "baz"})
+      %{"foo_bar" => "baz"}
+
+      iex> dash({"foo-bar", "dollar-sol"})
+      {"foo_bar", "dollar-sol"}
+
+      iex> dash({"foo-bar", %{"a-d" => "z-8"}})
+      {"foo_bar", %{"a_d" => "z-8"}}
+
+      iex> dash(%{"f-b" => %{"a-d" => "z"}, "c-d" => "e"})
+      %{"f_b" => %{"a_d" => "z"}, "c_d" => "e"}
+
+      iex> dash(:"foo-bar")
+      :foo_bar
+
+      iex> dash(%{"f-b" => "a-d"})
+      %{"f_b" => "a-d"}
   """
   def dash(value) when is_binary(value) do
     String.replace(value, ~r/([a-zA-Z0-9])-([a-zA-Z0-9])/, "\\1_\\2")
   end
 
+  def dash(map) when is_map(map) do
+    Enum.into(map, %{}, &dash/1)
+  end
+
+  def dash({key, value}) when is_map(value) do
+    {dash(key), dash(value)}
+  end
+
+  def dash({key, value}) do
+    {dash(key), value}
+  end
+
+  def dash(value) when is_atom(value) do
+    value
+    |> to_string()
+    |> dash()
+    |> String.to_atom()
+  end
+
+  def dash(value) do
+    value
+  end
+
   @doc """
   Replace underscores between words in `value` with dashes
 
diff --git a/mix.exs b/mix.exs
index 01179cf..c2d03ab 100644
--- a/mix.exs
+++ b/mix.exs
@@ -13,7 +13,13 @@ defmodule JSONAPI.Mixfile do
       build_embedded: Mix.env() == :prod,
       start_permanent: Mix.env() == :prod,
       source_url: "https://github.com/jeregrine/jsonapi",
-      deps: deps()
+      deps: deps(),
+      docs: [
+        extras: [
+          "README.md"
+        ],
+        main: "readme"
+      ]
     ]
   end
 


Test patch:
diff --git a/test/jsonapi/plugs/content_type_negotiation_test.exs b/test/jsonapi/plugs/content_type_negotiation_test.exs
index bb34251..26e965b 100644
--- a/test/jsonapi/plugs/content_type_negotiation_test.exs
+++ b/test/jsonapi/plugs/content_type_negotiation_test.exs
@@ -4,12 +4,14 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
 
   alias JSONAPI.ContentTypeNegotiation
 
+  import JSONAPI, only: [mime_type: 0]
+
   test "passes request through" do
     conn =
       :post
       |> conn("/example", "")
-      |> Plug.Conn.put_req_header("content-type", "application/vnd.api+json")
-      |> Plug.Conn.put_req_header("accept", "application/vnd.api+json")
+      |> Plug.Conn.put_req_header("content-type", mime_type())
+      |> Plug.Conn.put_req_header("accept", mime_type())
       |> ContentTypeNegotiation.call([])
 
     refute conn.halted
@@ -28,7 +30,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
     conn =
       :post
       |> conn("/example", "")
-      |> Plug.Conn.put_req_header("content-type", "application/vnd.api+json")
+      |> Plug.Conn.put_req_header("content-type", mime_type())
       |> ContentTypeNegotiation.call([])
 
     refute conn.halted
@@ -38,7 +40,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
     conn =
       :post
       |> conn("/example", "")
-      |> Plug.Conn.put_req_header("accept", "application/vnd.api+json")
+      |> Plug.Conn.put_req_header("accept", mime_type())
       |> ContentTypeNegotiation.call([])
 
     refute conn.halted
@@ -50,7 +52,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
       |> conn("/example", "")
       |> Plug.Conn.put_req_header(
         "accept",
-        "application/vnd.api+json, application/vnd.api+json; version=1.0"
+        "#{mime_type()}, #{mime_type()}; version=1.0"
       )
       |> ContentTypeNegotiation.call([])
 
@@ -63,7 +65,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
       |> conn("/example", "")
       |> Plug.Conn.put_req_header(
         "content-type",
-        "application/vnd.api+json; version=1.0, application/vnd.api+json"
+        "#{mime_type()}, #{mime_type()}; version=1.0"
       )
       |> ContentTypeNegotiation.call([])
 
@@ -76,7 +78,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
       |> conn("/example", "")
       |> Plug.Conn.put_req_header(
         "accept",
-        "application/vnd.api+json; version=1.0, application/vnd.api+json"
+        "#{mime_type()}, #{mime_type()}; version=1.0"
       )
       |> ContentTypeNegotiation.call([])
 
@@ -98,7 +100,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
     conn =
       :post
       |> conn("/example", "")
-      |> Plug.Conn.put_req_header("content-type", "application/vnd.api+json; version=1.0")
+      |> Plug.Conn.put_req_header("content-type", "#{mime_type()}; version=1.0")
       |> ContentTypeNegotiation.call([])
 
     assert conn.halted
@@ -111,7 +113,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
       |> conn("/example", "")
       |> Plug.Conn.put_req_header(
         "content-type",
-        "application/vnd.api+json; version=1.0, application/vnd.api+json; version=1.0"
+        "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0"
       )
       |> ContentTypeNegotiation.call([])
 
@@ -123,8 +125,8 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
     conn =
       :post
       |> conn("/example", "")
-      |> Plug.Conn.put_req_header("content-type", "application/vnd.api+json; version=1.0")
-      |> Plug.Conn.put_req_header("accept", "application/vnd.api+json")
+      |> Plug.Conn.put_req_header("content-type", "#{mime_type()}; version=1.0")
+      |> Plug.Conn.put_req_header("accept", "#{mime_type()}")
       |> ContentTypeNegotiation.call([])
 
     assert conn.halted
@@ -135,8 +137,8 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
     conn =
       :post
       |> conn("/example", "")
-      |> Plug.Conn.put_req_header("content-type", "application/vnd.api+json")
-      |> Plug.Conn.put_req_header("accept", "application/vnd.api+json charset=utf-8")
+      |> Plug.Conn.put_req_header("content-type", mime_type())
+      |> Plug.Conn.put_req_header("accept", "#{mime_type()} charset=utf-8")
       |> ContentTypeNegotiation.call([])
 
     assert conn.halted
@@ -149,7 +151,7 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
       |> conn("/example", "")
       |> Plug.Conn.put_req_header(
         "accept",
-        "application/vnd.api+json; version=1.0, application/vnd.api+json; version=1.0"
+        "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0"
       )
       |> ContentTypeNegotiation.call([])
 
@@ -161,10 +163,10 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
     conn =
       :post
       |> conn("/example", "")
-      |> Plug.Conn.put_req_header("content-type", "application/vnd.api+json")
+      |> Plug.Conn.put_req_header("content-type", mime_type())
       |> Plug.Conn.put_req_header(
         "accept",
-        "application/vnd.api+json; version=1.0, application/vnd.api+json; version=1.0"
+        "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0"
       )
       |> ContentTypeNegotiation.call([])
 
@@ -178,14 +180,14 @@ defmodule JSONAPI.ContentTypeNegotiationTest do
       |> conn("/example", "")
       |> Plug.Conn.put_req_header(
         "accept",
-        "application/vnd.api+json; version=1.0, application/vnd.api+json; version=1.0"
+        "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0"
       )
       |> ContentTypeNegotiation.call([])
 
     assert conn.halted
 
     assert Plug.Conn.get_resp_header(conn, "content-type") == [
-             "application/vnd.api+json; charset=utf-8"
+             "#{mime_type()}; charset=utf-8"
            ]
   end
 end
diff --git a/test/jsonapi/plugs/response_content_type_test.exs b/test/jsonapi/plugs/response_content_type_test.exs
index 591d55e..7b69b10 100644
--- a/test/jsonapi/plugs/response_content_type_test.exs
+++ b/test/jsonapi/plugs/response_content_type_test.exs
@@ -11,7 +11,7 @@ defmodule JSONAPI.ResponseContentTypeTest do
       |> ResponseContentType.call([])
       |> send_resp(200, "done")
 
-    assert get_resp_header(conn, "content-type") == ["application/vnd.api+json; charset=utf-8"]
+    assert get_resp_header(conn, "content-type") == ["#{JSONAPI.mime_type()}; charset=utf-8"]
   end
 
   test "can be overridden when in play" do
@@ -22,6 +22,6 @@ defmodule JSONAPI.ResponseContentTypeTest do
       |> ResponseContentType.call([])
       |> send_resp(200, "done")
 
-    refute get_resp_header(conn, "content-type") == ["application/vnd.api+json; charset=utf-8"]
+    refute get_resp_header(conn, "content-type") == ["#{JSONAPI.mime_type()}; charset=utf-8"]
   end
 end
diff --git a/test/jsonapi/plugs/underscore_parameters_test.exs b/test/jsonapi/plugs/underscore_parameters_test.exs
new file mode 100644
index 0000000..1b64edf
--- /dev/null
+++ b/test/jsonapi/plugs/underscore_parameters_test.exs
@@ -0,0 +1,92 @@
+defmodule JSONAPI.UnderscoreParametersTest do
+  use ExUnit.Case, async: true
+  use Plug.Test
+
+  alias JSONAPI.UnderscoreParameters
+
+  test "underscores dasherized data parameters" do
+    params = %{
+      "data" => %{
+        "attributes" => %{
+          "first-name" => "John",
+          "last-name" => "Cleese",
+          "stats" => %{
+            "age" => 45,
+            "dog-name" => "Pedro"
+          }
+        }
+      },
+      "filter" => %{
+        "dog-breed" => "Corgi"
+      }
+    }
+
+    conn =
+      :get
+      |> conn("/hello", params)
+      |> put_req_header("content-type", JSONAPI.mime_type())
+
+    assert %Plug.Conn{
+             params: %{
+               "data" => %{
+                 "attributes" => %{
+                   "first_name" => "John",
+                   "last_name" => "Cleese",
+                   "stats" => %{
+                     "age" => 45,
+                     "dog_name" => "Pedro"
+                   }
+                 }
+               },
+               "filter" => %{
+                 "dog_breed" => "Corgi"
+               }
+             }
+           } = UnderscoreParameters.call(conn, [])
+
+    params = %{
+      "data" => %{
+        "attributes" => %{
+          "math-problem" => "1-1=2"
+        }
+      }
+    }
+
+    conn =
+      :get
+      |> conn("/example", params)
+      |> put_req_header("content-type", JSONAPI.mime_type())
+
+    assert %Plug.Conn{
+             params: %{
+               "data" => %{
+                 "attributes" => %{
+                   "math_problem" => "1-1=2"
+                 }
+               }
+             }
+           } = UnderscoreParameters.call(conn, [])
+  end
+
+  test "does not transform when the content type is not for json:api" do
+    params = %{
+      "data" => %{
+        "attributes" => %{
+          "dog-breed" => "Corgi"
+        }
+      }
+    }
+
+    conn = conn(:get, "/hello", params)
+
+    assert %Plug.Conn{
+             params: %{
+               "data" => %{
+                 "attributes" => %{
+                   "dog-breed" => "Corgi"
+                 }
+               }
+             }
+           } = UnderscoreParameters.call(conn, [])
+  end
+end