Phoenix API Versions v1.1.0 PhoenixApiVersions behaviour View Source

PhoenixApiVersions

Move your API forward. Support legacy versions with ease.

PhoenixApiVersions helps Phoenix JSON API apps support legacy versions while minimizing maintenance overhead.

Master Hex.pm Version Coverage Status

Documentation

API documentation is available at https://hexdocs.pm/phoenix_api_versions

How Does It Work?

It’s A JSON Translation Layer

PhoenixApiVersions simply does the following:

  1. Modifies incoming JSON before it reaches the controller
  2. Modifies outgoing JSON right before sending the response

Versions Are Defined In Layers

-------------
|           |
|    v3     |
| (current) |
|           |
|     ▲     |
------|------  <-- v2/v3 translation layer
|     ▼     |
|           |
|    v2     |
|           |
|     ▲     |
------|------  <-- v1/v2 translation layer
|     ▼     |
|           |
|    v1     |
|           |
-------------

Each legacy version is responsible for transforming JSON to and from the shape expected/returned by the next version. Apart from bug fixes, developers will only have to maintain middleware from the last version.

Assume an API whose current version is v3.

  • v1 middleware transforms incoming JSON to the shape that v2 expects.
  • v2 middleware transforms incoming JSON to the shape that v3 expects.

The request reaches the controller in the shape of the current version. The controller and view respond with “v3 JSON”.

  • v2 middleware transforms outgoing v3 JSON to the shape that v2 should return.
  • v1 middleware then transforms the v2 JSON to the shape that v1 should return.

Once v4 comes out, developers will simply build the transformation layer for v3-to-v4 (and back).

Supports Any Versioning Mechanism

The version can be specified in any way:

  • URL (/api/v1/...)
  • Accept header (Accept: application/vnd.github.v3.json)
  • Custom header (X-Api-Version: 2016-01-20)
  • Anything else in conn

Benefits

✅ Limits Legacy Code

PhoenixApiVersions only allows developers to define old versions by transforming JSON.

It assumes that these JSON-transforming middleware functions will not perform database calls or heavy computation. (Although this is not completely prohibited.)

✅ Flexible

If your application has one or two legacy API endpoints that simply need to be handled differently, that’s completely posslble.

✅ Ensure Consistent Business Rules Across API Versions

Every version of a given API endpoint will reach the same controller function, making it much less likely that subtle differences between business rules will crystallize over time.

Installation

Add PhoenixApiVersions To web.ex

In the Phoenix web.ex file for your JSON API, add the plug to the controller section, and use the PhoenixApiVersions view macro in the view section.

Optionally, you may want to add a render("404.json", _) function in the view section, which can be used later if you don’t already have a mechanism for handling 404’s.

# web.ex

def controller do
  quote do

    plug PhoenixApiVersions.Plug

  end
end

def view do
  quote do

    use PhoenixApiVersions.View


    # Optional; recommended if you have no other way to handle 404's yet
    def render("404.json", _) do
      %{error: "not_found"}
    end

  end
end

Create an ApiVersions Module

We suggest calling this ApiVersions, namespaced inside your phoenix application’s main namespace. (e.g. MyApp.ApiVersions) Make sure to use PhoenixApiVersions in this module.

The module must implement the PhoenixApiVersions behaviour, which includes version_not_found/1, version_name/1, and versions/0.

Example

# lib/my_app_web/api_versions/api_versions.ex

defmodule MyApp.ApiVersions do
  use PhoenixApiVersions

  alias PhoenixApiVersions.Version
  alias MyApp.ApiVersions.V1
  alias Plug.Conn
  alias Phoenix.Controller

  def version_not_found(conn) do
    conn
    |> Conn.put_status(:not_found)
    |> Controller.render("404.json", %{})
  end

  def version_name(conn) do
    Map.get(conn.path_params, "api_version")
  end

  def versions do
    [
      %Version{
        name: "v1",
        changes: [
          V1.ChangeNameToDescription,
          V1.AnotherChange
        ]
      },
      %Version{
        name: "v2",
        changes: []
      }
    ]
  end
end

Add ApiVersions Module in config.exs

Reference this module in your Phoenix application’s config.exs as such:

config :phoenix_api_versions, versions: MyApp.ApiVersions

Add Change Modules

Change modules are only used when the current route is found in routes/1.

Example

Assume your project has a concept of devices, each with a name property. In version v2, you want to change name to description.

Simply change all your code (and the database field) to description. Then, implement a change like this:

# lib/my_app_web/api_versions/v1/change_name_to_description.ex

defmodule MyApp.ApiVersions.V1.ChangeNameToDescription do
  use PhoenixApiVersions.Change

  alias MyApp.Api.DeviceController

  def routes do
    [
      {DeviceController, :show},
      {DeviceController, :create},
      {DeviceController, :update},
      {DeviceController, :index}
    ]
  end

  def transform_request_body_params(%{"name" => _} = params, DeviceController, action)
      when action in [:create, :update] do
    params
    |> Map.put("description", params["name"])
    |> Map.drop(["name"])
  end

  def transform_response(%{data: device} = output, DeviceController, action)
      when action in [:create, :update, :show] do
    output
    |> Map.put(:data, device_output_to_v1(device))
  end

  def transform_response(%{data: devices} = output, DeviceController, :index) do
    devices = Enum.map(devices, &device_output_to_v1/1)

    output
    |> Map.put(:data, devices)
  end

  defp device_output_to_v1(device) do
    device
    |> Map.put(:name, device.description)
    |> Map.drop([:description])
  end
end

As a result, v1 API endpoints will accept and return the field as name, while v2 API endpoints will accept and return is as description.

Credits

The inspiration for this library came from two sources:

License

This software is licensed under the MIT license.

Link to this section Summary

Callbacks

Return whether or not any changes should be applied at all for the given request

Generates the version name from the Conn

Processes the Conn whenever the consumer makes a request that cannot be mapped to a version

Generates the list of valid versions and the changes defined by each version

Link to this section Functions

Link to this function apply_changes_for_request?(conn) View Source
apply_changes_for_request?(Plug.Conn.t()) :: boolean()

Return whether or not any changes should be applied at all for the given request.

(Used as an escape hatch for routes that shouldn’t be governed by PhoenixApiVersions.)

Link to this function changes_to_apply(conn) View Source
changes_to_apply(Plug.Conn.t()) ::
  [module()] | {:error, :no_matching_version_found}

Given a conn and a list of Version structs:

  1. Traverse the list of Versions until one is found with a name that matches the current API version name (and discard the initial ones that didn’t match)
  2. Traverse the Change modules of the remaining Versions, filtering out Change modules that don’t match the current route
  3. Return all remaining Change modules (those that match the current route)

Note that the order of both the Versions and Changes matter. All Versions listed before the match will be discarded. The resulting changes will be applied in the order listed.

Link to this function handle_invalid_version(conn) View Source
Link to this function private_process_output_key() View Source
Link to this function transform_response(output, assigns) View Source

Link to this section Callbacks

Link to this callback apply_changes_for_request?(arg0) View Source
apply_changes_for_request?(Plug.Conn.t()) :: boolean()

Return whether or not any changes should be applied at all for the given request.

This should be used as an escape hatch for routes that shouldn’t be governed by PhoenixApiVersions.

For example, most route definitions might begin with /api/:api_version, and there might be hard-coded route definitions above overriding a single version, so that they start with /api/v1. In this case, apply_changes_for_request? might be configured to return false if api_version is missing from params.

Link to this callback version_name(arg0) View Source
version_name(Plug.Conn.t()) :: any()

Generates the version name from the Conn.

Applications may choose to allow API consumers to specify the API version in a number of ways:

  1. Via a URL segment, such as /api/v3/profile
  2. Via a request header, such as X-Api-Version: v3
  3. Via the Accept header, such as Accept: application/vnd.github.v3.json

Rather than enforcing a specific method, PhoenixApiVersions provides this callback so that any method can be used.

If the callback is unable to discover a version, applications can choose to do one of the following:

  1. Provide a default fallback version
  2. Return nil or any other value that isn’t the name of a Version.

Examples

# Get the version from a URL segment.
# Assumes all API urls have `/:api_version/` in them.
def version_name(%{path_params: %{"api_version" => v}}), do: v

# Get the version from `X-Api-Version` header.
# Return the latest version as a fallback if none is provided.
def version_name(conn) do
  conn
  |> Plug.Conn.get_req_header("x-api-version")
  |> List.first()
  |> case do
    nil -> "v3"
    v -> v
  end
end

# Get the version from `Accept` header.
# Return `nil` if none is provided so that the "not found" response is displayed.
#
# Assumes a format like this:
#   "application/vnd.github.v3.json"
def version_name(conn) do
  accept_header =
    conn
    |> Plug.Conn.get_req_header("accept")
    |> List.first()

  ~r/application/vnd.github.(?<version>.+).json/
  |> Regex.named_captures(accept_header)
  |> case do
    %{"version" => v} -> v
    nil -> nil
  end
end
Link to this callback version_not_found(arg0) View Source
version_not_found(Plug.Conn.t()) :: Plug.Conn.t()

Processes the Conn whenever the consumer makes a request that cannot be mapped to a version.

(Example: The app defines v1 and v2 but the consumer visits API version v3 or hippopotamus.)

This callback does not need to call Conn.halt(); the library does so immediately after this callback returns.

Example

def version_not_found(conn) do
  conn
  |> Conn.put_status(:not_found)
  |> Controller.render("404.json", %{})
end

Note that in this example, a render/1 function matching "404.json" must exist in the View. (Presumably through a project-wide macro such as the Web module’s view macro. This is a great hooking point for application-level abstractions.)

The PhoenixApiVersions library intentionally refrains from assuming anything about the application, and leaves this work up to library consumers.

Generates the list of valid versions and the changes defined by each version.

Example

alias PhoenixApiVersions.Version

def versions do
  [
    %Version{
      name: "v1",
      changes: [
        V1.AccountTypes,
        V1.CollapseEventRequest,
        V1.EventAccountToUserID
      ]
    },
    %Version{
      name: "v2",
      changes: [
        V1.LegacyTransfers
      ]
    },
    %Version{
      name: "v3",
      changes: [
        V1.AutoexpandChargeDispute,
        V1.AutoexpandChargeRule
      ]
    }
  ]
end