Phoenix API Versions v0.1.2 PhoenixApiVersions behaviour View Source
PhoenixApiVersions
PhoenixApiVersions helps Phoenix applications support multiple JSON API versions while minimizing maintenance overhead.
Documentation
API documentation is available at https://hexdocs.pm/phoenix_api_versions
Getting Started
Adding PhoenixApiVersions To Your Project
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
def render("404.json", _) do
%{error: "not_found"}
end
# ...
end
end
Creating an ApiVersions Module
Create a configuration 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
.
# 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
Creating 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:
- Stripe’s API versioning scheme revealed in this blog.
- This Hacker News comment by bringtheaction which references an idea from a Rich Hickey talk about “maintaining old versions not by backporting bug fixes but instead by rewriting the old version to be a thin layer that gives you the interface of the old version upon the code of the new version.”
License
This software is licensed under the MIT license.
Link to this section Summary
Functions
Given a conn and a list of Version structs
Callbacks
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 versions in the JSON REST API
Link to this section Functions
changes_to_apply(Plug.Conn.t()) :: [module()] | {:error, :no_matching_version_found}
Given a conn and a list of Version structs:
- 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)
- Traverse the Change modules of the remaining Versions, filtering out Change modules that don’t match the current route
- 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 section Callbacks
Generates the version name from the Conn
.
Applications may choose to allow API consumers to specify the API version in a number of ways:
- Via a URL segment, such as
/api/v3/profile
- Via a request header, such as
X-Api-Version: v3
- Via the
Accept
header, such asAccept: 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:
- Provide a default fallback version
- Return
nil
or any other value that isn’t thename
of aVersion
.
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.
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("application/vnd.github.v3.json")
|> case do
%{"version" => v} -> v
nil -> nil
end
end
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 versions in the JSON REST API