JSON:API Setup

12/11/2018


Foundations

Overview

JSON:API is a standard for REST API’s. The cool kids are using GraphQL these days, and, it’s definitely something worth looking into, later.

For now, our goal is to build an api that works on convention out of the box. I don’t want to have to shave any yaks or paint any bike sheds. I’m too old, I’d rather stand on the shoulders of giants who have already figured this shit out.

Whether its JSON:API of GraphQL,  the purpose is to separate our business logic from our presentation. We want to future proof our application, this way we can easily build a mobile, tv, car, or refrigerator application with the same API that we are using for our web application.

JaSerializer

Phoenix out of the box is a traditional web server that renders html. Luckily, you can configure it to render JSON instead using the –no-html flag when you mix phx.new.

The problem is, it’s raw JSON, no convention or standard. We’re going to tame this beast using JaSerializer.

# mix.exs
defp deps do
  [
    # ...
    {:ja_serializer, "~> 0.13"}
  ]
end

After updating our mix.exs we need to install our dependencies docker-compose run --rm server mix deps.get. If you’re wondering where docker-compose came from, check out the getting started article.

With JaSerializer installed, we need to tell Phoenix to listen and respond to JSON:API vis the application/vnd.api+json request header in stead of the standard application/json.

# config/config.exs
config :get_social, GetSocialWeb.Endpoint,
  # ...
  render_errors: [view: GetSocialWeb.ErrorView, accepts: ~w(json json-api)]
  # ...

config :mime, :types, %{
  "application/vnd.api+json" => ["json-api"]
}

config :phoenix, :format_encoders, "json-api": Jason

Since we changed out mime config, we need to rebuild Phoenix with docker-compose run --rm server mix deps.clean mime --build

Finally, we can restart our server docker-compose restart server.

Request Deserialization

Now we need to configure Phoenix to send/receive the JSON:API payload. To do this, we will create a new plug server/src/lib/get_social_web/plugs/json_api.ex:

# server/src/lib/get_social_web/plugs/json_api.ex
defmodule GetSocialWeb.Plugs.JSONAPI do
  use Plug.Builder

  alias Plug.Conn

  plug JaSerializer.ContentTypeNegotiation
  plug JaSerializer.Deserializer
  plug :data_to_attributes

  @spec data_to_attributes(Conn, map) :: map
  defp data_to_attributes(%Conn{params: %{} = params} = conn, _opts) do
    params = params
      |> Map.put("data", parse_data(params))

    conn
    |> Map.put(:params, params)
  end

  @spec parse_data(map) :: map
  defp parse_data(%{"data" => data}), do: data |> JaSerializer.Params.to_attributes
  defp parse_data(%{}), do: %{}
end

This new plug adds the JaSerializer.ContentTypeNegotiation and JaSerializer.Deserializer plugs to normalize the incoming JSON:API payload by converting dasherized keys to underscored keys. This will also handle converting the JSON:API payload to an easier to use attributes map.

For instance:

{
  "data": {
    "attributes": {
      "about_me": "test",
      "display_name": "test",
      "status": "test",
      "username": "test"
    },
    "relationships": {
      "assets": {
        "data": [
          {
            "id": "90ea2e25-6166-40db-8991-bfe043741d3f",
            "type": "assets"
          }
        ]
      }
    },
    "type": "profiles"
  }
}

to

{
  "data": {
    "about_me": "test",
    "assets_ids": [
      "90ea2e25-6166-40db-8991-bfe043741d3f"
    ],
    "display_name": "test",
    "id": null,
    "status": "test",
    "type": "profiles",
    "username": "test"
  }
}

Without this new plug, we would have expand our controller actions to match the nested JSON:API format. This also add additional complexity when dealing with relationships.

defmodule Web.Controller do
  use Web, :controller

  def create(conn, %{ "data" => data = %{ "type" => "asset", "attributes" => asset_params } }) do
    # ...
  end
end

Instead, we can just match on “data”:

defmodule Web.Controller do
  use Web, :controller

  def create(conn, %{ "data" => asset_params }) do
    # ...
  end
end

We now need to add the new GetSocialWeb.Plugs.JSONAPI plug to our router.ex and change our :accepts to json-api.

# lib/get_social_web/router.ex

pipeline :api do
  plug :accepts, ["json", "json-api"]
  plug GetSocialWeb.Plugs.JSONAPI
end

Now that we have our incoming data sorted, we need to wire up our views so they leverage JaSerializer to generate JSON:API payloads.

# server/src/lib/get_social_web.ex

defmodule GetSocialWeb do
  def view do
    quote do
      use Phoenix.View, root: "lib/get_social_web/templates",
                        namespace: GetSocialWeb
      use JaSerializer.PhoenixView

      # ...
    end
  end
end

Error Serialization

The last thing we need to do is refactor our server/src/lib/get_social_web/views/error_view.ex to send a JSON:API formatted error. To do this we will refactor our template_not_found method by borrowing the logic from Phoenix.Controller.status_message_from_template to generate our status code in addition to our title and detail messages.

# server/src/lib/get_social_web/views/error_view.ex
defmodule GetSocialWeb.ErrorView do
  # ...

  def template_not_found(template, _assigns) do
    # extract the status code
    status = template
      |> String.split(".")
      |> hd()
      |> String.to_integer()

    # convert the template name into a status message
    status_message = template
      |> Phoenix.Controller.status_message_from_template()

    JaSerializer.ErrorSerializer.format %{
      title: status_message,
      detail: status_message,
      status: status
    }
  end
end

And fix our tests:

# test/sample_one_web/views/error_view_test.exs
defmodule GetSocialWeb.ErrorViewTest do
  use GetSocialWeb.ConnCase, async: true

  # Bring render/3 and render_to_string/3 for testing custom views
  import Phoenix.View

  test "renders 404.json-api" do
    assert render(GetSocialWeb.ErrorView, "404.json-api", []) == %{
      "errors" => [
        %{
          status: 404,
          detail: "Not Found",
          title: "Not Found"
        }
      ],
      "jsonapi" => %{
        "version" => "1.0"
      }
    }
  end

  test "renders 500.json-api" do
    assert render(GetSocialWeb.ErrorView, "500.json-api", []) == %{
      "errors" => [
        %{
          status: 500,
          detail: "Internal Server Error",
          title: "Internal Server Error"
        }
      ],
      "jsonapi" => %{
        "version" => "1.0"
      }
    }
  end
end
Next Level
Test Suite Setup

© 2020 ThinkAddict.com. All rights reserved.