Put the id your caller already holds in the URI. For an integration API that’s the external id the upstream system sent, as long as it’s unique, stable and safe in a path segment. If it can change, put the primary key in the URI and keep the external id for ingestion.
Two ids for one order#
Say you’re filling out the CRUD (create, read, update, delete) endpoints on an
integration API. Upstream systems push daily snapshots into it, and each record
carries an external id: the upstream system’s own id, with its name in front,
like crm:ORD-4821. Your database also gives the row a bigint primary key.
So which one goes in /v1/orders/:id?
Ingestion already chose#
The ingestion endpoint uses the external id:
PUT /v1/orders/crm:ORD-4821PUT fits because it’s
idempotent:
sending the same request twice has the same intended effect as sending it
once, and a
daily snapshot sends the same record over and over. The upstream system picks
the URI, and it only knows the external id.
That leaves the id in the GET, PATCH and DELETE paths, and in the
Location header that POST returns. There are three options:
- The primary key everywhere, with
PUTas the one exception. - The external id everywhere.
- Both, with a second path like
/v1/orders/by-external-id/:external_id.
Use the caller’s id#
A URI identifies a resource, and nothing in HTTP says the id in it has to be your primary key. What matters is which id the caller already holds.
For an integration API the caller is the upstream system, and it holds the
external id. With the primary key in the GET, PATCH and DELETE paths, a
caller has to get the primary key from a lookup or an earlier response, and
store it next to its own id. That’s state the upstream system keeps only
because of your URI choice.
scope "/v1", MyAppWeb.API do
pipe_through :api
post "/orders", OrderController, :create
get "/orders/:external_id", OrderController, :show
put "/orders/:external_id", OrderController, :upsert
patch "/orders/:external_id", OrderController, :update
delete "/orders/:external_id", OrderController, :delete
endThe primary key is still useful to you in logs, support and joins, so return both ids in the body:
# OrderController
def create(conn, %{"order" => params}) do
{:ok, order} = Orders.create_order(params)
conn
|> put_status(:created)
|> put_resp_header("location", "/v1/orders/#{order.external_id}")
|> render(:show, order: order)
end
# OrderJSON
def show(%{order: order}) do
%{id: order.id, external_id: order.external_id}
endThree tests for the id#
The external id has to be:
- Unique. The source prefix keeps two sources apart, so
crm:ORD-4821andbilling:ORD-4821are different orders, but each source still has to keep its own ids unique. Drop the prefix and you have a collision the day a second system sends its ownORD-4821. - Stable. When an id changes, every client holding the old URI breaks, unless you keep the old one working.
- Safe in a path segment.
RFC 3986 allows the
colon in
/v1/orders/crm:ORD-4821. A slash isn’t allowed: the caller has to send%2F, and some servers, like Apache, reject that by default.
An id that isn’t unique needs a better prefix, whatever goes in the URI. One
that isn’t stable means option 1: the primary key goes in the URI, and the
external id stays in the ingestion path only. One that isn’t safe in a path
can’t go in any path, so ingestion takes it in the body of a POST instead.
The case against both#
Option 3 gives one order two URIs, /v1/orders/42 and
/v1/orders/by-external-id/crm:ORD-4821. That’s two cache keys for the same
row, so every change has to clear both. If you need both, pick one as the
address you document and return in Location, and make the other a GET that
redirects to it.
