Phoenix API versioning: Accept Header

In my previous post about this topic (Phoenix API versioning: URL), I wrote about API versioning using the URL. In this post, I cover how to implement API versioning using the Accept HTTP header.

If you haven’t read the previous post, I strongly encourage you to do so, because this post will use the context described there.

The use of the Accept header

I personally prefer to use the Accept header to describe how I want the data. As described on RFC2616, the Accept header “can be used to specify certain media types which are acceptable for the response”.

It means that an URL (e.g http://api.app.tld/users) can be used to identify a resource and the Accept header defines how the client wants the data format. It also means that the same URL is used for different versions (or data formats). Examples:

The API

I am using the same examples of the previous post, with the difference that now we don’t specify the version in the URL. Our API looks like:

First version (v1) including only one “favorite_sport”:

$ curl -H "Accept: application/vnd.app.v1+json" http://api.app.tld/users/971

{
  "id": 971,
  "name": "John Doe",
  "email": "john@doe.com",
  "favorite_sport": "Soccer"
}

Second version (v2) including a list of “favorite_sports”:

$ curl -H "Accept: application/vnd.app.v2+json" http://api.app.tld/users/971

{
  "id": 971,
  "name": "John Doe",
  "email": "john@doe.com",
  "favorite_sports": [
    "Soccer",
    "Tennis"
  ]
}

Mime types

To support our custom Accept headers, we need to let Plug know about them. Add the following lines to your config.exs file:

config :plug, :mimes, %{
  "application/vnd.app.v1+json" => [:v1],
  "application/vnd.app.v2+json" => [:v2]
}

Routing

The previous routing specified different entry-points for the different versions we wanted to handle. Since now we handling the versions using the Accept header, our routing can be simplified:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
defmodule ActivityTracker.Router do
  use ActivityTracker.Web, :router
  alias ActivityTracker.APIVersion

  pipeline :api do
    plug :accepts, [:v1, :v2]
    plug APIVersion
  end

  scope "/", ActivityTracker do
    pipe_through :api
    resources "/users", UserController
  end
end

On line 6 we say the API accepts :v1 and :v2 versions (following the description we added to the config.exs file). On line 7 we use (again) the APIVersion custom plug. The plug name is the same, but it has changed a bit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
defmodule ActivityTracker.APIVersion do
  @versions Application.get_env(:plug, :mimes)
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    [accept] = get_req_header(conn, "accept")
    version = Map.fetch(@versions, accept)
    _call(conn, version)
  end

  defp _call(conn, {:ok, [version]}) do
    assign(conn, :version, version)
  end
  defp _call(conn, _) do
    conn
    |> send_resp(404, "Not Found")
    |> halt()
  end
end

On the previous version of this file, we were manually setting the version value. On this version we use the content of the Accept header that was sent by the client. Based on its content, we set the version value inside the connection assigns.

It means that:

If you try performing requests using those headers right now, it probably won’t work. This error will be shown:

[info] Running ActivityTracker.Endpoint with Cowboy using http://localhost:4000
Interactive Elixir (1.3.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> [info] GET /users/1
[info] Sent 406 in 39ms
[debug] ** (Phoenix.NotAcceptableError) no supported media type in accept header, expected one of ["v1", "v2"]
    (phoenix) lib/phoenix/controller.ex:959: Phoenix.Controller.refuse/2
    (activity_tracker) web/router.ex:10: ActivityTracker.Router.api/2
    (activity_tracker) web/router.ex:1: ActivityTracker.Router.match_route/4
    (activity_tracker) web/router.ex:1: ActivityTracker.Router.do_call/2
    (activity_tracker) lib/activity_tracker/endpoint.ex:1: ActivityTracker.Endpoint.phoenix_pipeline/1
    (activity_tracker) lib/plug/debugger.ex:93: ActivityTracker.Endpoint."call (overridable 3)"/2
    (activity_tracker) lib/activity_tracker/endpoint.ex:1: ActivityTracker.Endpoint.call/2
    (plug) lib/plug/adapters/cowboy/handler.ex:15: Plug.Adapters.Cowboy.Handler.upgrade/4
    (cowboy) src/cowboy_protocol.erl:442: :cowboy_protocol.execute/4

That’s OK. To solve that, you can simple build plug again:

$ mix deps.clean plug --build && mix deps.get

After that, everything should work fine.

Controller and View

In the previous post, I showed how we rely on pattern-matching to handle different versions. The thing is: with the change we performed on router and the APIVersion plug, we are still setting the exactly same values (:v1 and :v2) in the connection’s assigns, therefore the Controller and View don’t need to be changed.

If you want to take a look on how the Controller and View look like, here they are:

Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
defmodule ActivityTracker.UserController do
  use ActivityTracker.Web, :controller
  alias ActivityTracker.User

  def show(%{assigns: %{version: :v1}}=conn, %{"id" => id}) do
    user = Repo.get(User, id)
    render(conn, "show.v1.json", user: user)
  end
  def show(%{assigns: %{version: :v2}}=conn, %{"id" => id}) do
    user = Repo.get(User, id)
    render(conn, "show.v2.json", user: user)
  end
end

View

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
defmodule ActivityTracker.UserView do
  use ActivityTracker.Web, :view

  def render("show.v1.json", %{user: user}) do
    %{
      id: user.id,
      name: user.name,
      email: user.email,
      favorite_sport: "Soccer"
    }
  end
  def render("show.v2.json", %{user: user}) do
    %{
      id: user.id,
      name: user.name,
      email: user.email,
      favorite_sports: [
        "Soccer",
        "Tennis"
      ]
    }
  end
end

Conclusion

I hope you liked this series of post about versioning APIs with Phoenix. You can see how Elixir and Phoenix are powerful, allowing us to completely change the way we are versioning our API with a simple change to the router. As a matter of fact, it wouldn’t be complicated to support both URL and Accept header versioning at the same time.

You might also like

Free book

Today I released "Versioned APIs with Phoenix" free book. It covers three different strategies on API versioning with Phoenix...

Phoenix API versioning: URL

API versioning allows you to response with different content, based on the information sent by the client. Let's see how to do that with Phoenix...

Deploying Elixir releases

I have seen great posts about Elixir release deployments lately and I would like to share my experience deploying Elixir releases to production...

Download free e-book

Learn different strategies on API versioning with "Versioned APIs with Phoenix" free e-book