Packages

Plug that allows to add version compatibility layers via API request/response gateways.

Current section

Files

Jump to
multiverse lib multiverse.ex
Raw

lib/multiverse.ex

defmodule Multiverse do
@moduledoc """
This plug helps to manage multiple API versions based on request and response gateways.
This is an awesome practice to hide your backward compatibility.
It allows to have your code in a latest possible version, without duplicating controllers or models.
For more information see [README.md](https://github.com/Nebo15/multiverse/).
"""
@behaviour Plug
@type config :: %{
adapter: module,
version_header: String.t(),
gates: Multiverse.Adapter.gates()
}
defmodule VersionSchema do
@moduledoc """
This module defines schema that is assigned to `conn.private[:multiverse_version_schema]`
and can be used by third-party libraries to store or process API version and changes data.
## Available attributes
* `adapter` - adapter which were used to handle connection;
* `version` - version which is assigned for consumer connection;
* `changes` - ordered list of changes that were applied to the connection.
"""
@type t :: %__MODULE__{adapter: module, version: any, changes: [module]}
defstruct [:adapter, :version, :changes]
end
@default_version_header "x-api-version"
@default_adapter Multiverse.Adapters.ISODate
@doc """
Initializes Multiverse plug.
Raises at compile time when adapter or change is not loaded.
Available options:
* `:endpoint` - endpoint which is used to fetch configuration from application environment;
* `:adapter` - module which implements `Multiverse.Adapter` behaviour;
* `:version_header` - header which is used to fetch consumer version;
* `:gates` - list of gates (and changes) that are available for consumers.
"""
@spec init(opts :: Keyword.t()) :: config
def init(opts) do
endpoint = Keyword.get(opts, :endpoint)
opts =
if endpoint do
Keyword.merge(opts, Application.get_env(:multiverse, endpoint, []))
else
opts
end
adapter = Keyword.get(opts, :adapter, @default_adapter)
config = Multiverse.Adapter.compile_config!(adapter, opts)
version_header = Keyword.get(opts, :version_header, @default_version_header)
gates =
config
|> Keyword.get(:gates, [])
|> enshure_changes_loaded!()
|> sort_gates(adapter)
%{adapter: adapter, version_header: version_header, gates: gates}
end
defp enshure_changes_loaded!(gates) do
# credo:disable-for-lines:3
Enum.map(gates, fn {_version, changes} ->
Enum.map(changes, &enshure_change_loaded/1)
end)
gates
end
defp enshure_change_loaded(change_mod) do
unless Code.ensure_loaded?(change_mod) do
raise ArgumentError,
"change module #{inspect(change_mod)} was not compiled, " <>
"ensure it is correct and it is included as a project dependency"
end
end
defp sort_gates(gates, adapter) do
gates
|> Enum.sort_by(fn {gate_version, _gate_changes} -> gate_version end, &adapter.version_comparator/2)
|> Enum.into(%{})
end
@spec call(conn :: Plug.Conn.t(), config :: config) :: Plug.Conn.t()
def call(conn, config) do
%{adapter: adapter, version_header: version_header, gates: gates} = config
{:ok, consumer_api_version, conn} = fetch_consumer_api_version(adapter, conn, version_header)
version_changes = changes_for_version(adapter, consumer_api_version, gates)
version_schema =
%Multiverse.VersionSchema{
adapter: adapter,
version: consumer_api_version,
changes: version_changes
}
conn
|> apply_request_changes(version_schema)
|> apply_response_changes(version_schema)
|> Plug.Conn.put_private(:multiverse_version_schema, version_schema)
end
defp fetch_consumer_api_version(adapter, conn, version_header) do
case Plug.Conn.get_req_header(conn, version_header) do
[] -> adapter.fetch_default_version(conn)
["" | _] -> adapter.fetch_default_version(conn)
[version_or_channel | _] -> adapter.resolve_version_or_channel(conn, version_or_channel)
end
end
defp changes_for_version(adapter, consumer_version, gates) do
Enum.reduce(gates, [], fn {gate_version, gate_changes}, changes ->
if adapter.version_comparator(consumer_version, gate_version) do
changes ++ gate_changes
else
changes
end
end)
end
defp apply_request_changes(conn, %{changes: []}), do: conn
defp apply_request_changes(conn, %{changes: changes}) do
Enum.reduce(changes, conn, fn change_mod, conn ->
change_mod.handle_request(conn)
end)
end
defp apply_response_changes(conn, %{changes: []}), do: conn
defp apply_response_changes(conn, %{changes: changes}) do
Enum.reduce(changes, conn, fn change_mod, conn ->
Plug.Conn.register_before_send(conn, fn conn ->
change_mod.handle_response(conn)
end)
end)
end
end