PhoenixApiVersions helps Phoenix JSON API apps support legacy versions while minimizing maintenance overhead.
API documentation is available at https://hexdocs.pm/phoenix_api_versions
PhoenixApiVersions simply does the following:
- Modifies incoming JSON before it reaches the controller
- Modifies outgoing JSON right before sending the response
-------------
| |
| 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).
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
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.)
If your application has one or two legacy API endpoints that simply need to be handled differently, that's completely posslble.
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.
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
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
Reference this module in your Phoenix application's config.exs
as such:
config :phoenix_api_versions, versions: MyApp.ApiVersions
Change modules are only used when the current route is found in routes/1
.
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
.
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."
This software is licensed under the MIT license.