07 · API Design & Versioning¶
An API is a contract: once external clients (a mobile app, another team's service, a third-party integration) depend on a response shape, changing that shape breaks them silently, often long after you've forgotten the endpoint exists. Good API design front-loads decisions that make it possible to evolve the API without breaking existing consumers — versioning is the main tool for that.
Resource-oriented URLs¶
A REST API models data as resources, addressed by URL, manipulated via HTTP verbs:
GET /users list users
GET /users/:id fetch one user
POST /users create a user
PATCH /users/:id partially update a user
PUT /users/:id replace a user
DELETE /users/:id delete a user
The verb carries the action; the URL only ever names the resource. A URL
like POST /users/1/delete mixes an action into the path and duplicates
what DELETE /users/1 already expresses — a sign the API is drifting
from REST conventions into RPC-style "call this function" URLs.
URL-path versioning¶
The simplest, most visible versioning strategy: put the version directly in the path.
class VersionedAPI < Sinatra::Base
before { content_type :json }
get "/api/v1/users/:id" do
{ id: params[:id].to_i, name: "Ada Lovelace" }.to_json
end
get "/api/v2/users/:id" do
{ id: params[:id].to_i, full_name: "Ada Lovelace", email: "ada@example.com" }.to_json
end
end
$ curl /api/v1/users/1
{"id":1,"name":"Ada Lovelace"}
$ curl /api/v2/users/1
{"id":1,"full_name":"Ada Lovelace","email":"ada@example.com"}
v2 renamed name to full_name and added email — a breaking
change for any client parsing name. Because it lives at a different
URL, existing v1 clients keep working, untouched, indefinitely (or
until you formally deprecate and remove v1 on a published timeline).
Header-based versioning¶
An alternative keeps one URL and negotiates the version via the
Accept header — more RESTful in spirit (a URL should identify a
resource, not a version of a resource), at the cost of being less
discoverable/testable by just pasting a URL into a browser:
get '/api/users/:id' do
version = request.env['HTTP_ACCEPT'].to_s[/version=(\d+)/, 1] || "1"
if version == "2"
{ id: params[:id].to_i, full_name: "Ada Lovelace" }.to_json
else
{ id: params[:id].to_i, name: "Ada Lovelace" }.to_json
end
end
$ curl -H "Accept: application/vnd.myapi.v2+json;version=2" /api/users/1
{"id":1,"full_name":"Ada Lovelace"}
Captured output from running all three requests via rack-test:
{"id":1,"name":"Ada Lovelace"}
{"id":1,"full_name":"Ada Lovelace","email":"ada@example.com"}
{"id":1,"full_name":"Ada Lovelace"}
What counts as a breaking change¶
- Breaking: removing a field, renaming a field, changing a field's type (string → number), changing status codes for existing scenarios, making an optional request parameter required.
- Non-breaking (usually safe without a version bump): adding a new optional field to a response, adding a new endpoint, adding a new optional request parameter with a sensible default.
The rule of thumb: if an existing client's code, written against the current contract, would start behaving incorrectly (crash, misinterpret data, silently drop information) without any code changes on their end, it's breaking.
Consistent error responses¶
A well-designed API has one predictable error shape across every endpoint, so clients write one error-handling path instead of one per endpoint:
error 404 do
content_type :json
{ error: { code: "not_found", message: "Resource not found" } }.to_json
end
error 422 do
content_type :json
{ error: { code: "validation_failed", message: env['sinatra.error']&.message } }.to_json
end
Every error response sharing the same { error: { code:, message: } }
envelope means a client can write one generic error handler instead of
parsing a different shape per status code or per endpoint.
API-design-specific traps¶
- Versioning too late. Shipping
v1with no versioning scheme at all, then needing a breaking change, forces an awkward retrofit (redirect old clients, guess at a version from other signals) instead of a cleanv1→v2path that was planned from day one. - Maintaining "just one more" version forever. Every live API
version is code you have to keep working and testing — publish a
deprecation timeline for old versions (a
Sunsetheader, a changelog entry, direct notice to known consumers) rather than letting versions accumulate silently forever. - Inconsistent pluralization/casing across endpoints. Mixing
/api/v1/user(singular) with/api/v1/orders(plural), orsnake_casefields in one response andcamelCasein another, forces every client integration to special-case your API's own inconsistency. - Returning
200 OKfor a request that actually failed. A response body containing{"success": false, "error": "..."}with an HTTP200status defeats HTTP-layer tooling (load balancers, monitoring, generic HTTP client error handling) that correctly expects a non-2xx status to signal failure. - Silent behavior changes with no version bump at all — the worst case: mutating a field's meaning or type in place on the same version, breaking every consumer with zero warning and no rollback path other than reverting the deploy.
How It Actually Works¶
URL-based API versioning (/v1/users) and header-based versioning
(Accept: application/vnd.myapp.v1+json) both resolve to the same
underlying mechanism in a Rack-based app: the router inspects some part of
the request (env["PATH_INFO"] or env["HTTP_ACCEPT"]) and dispatches to
a different controller/handler class based on it — there's no
framework-level concept of "a version," just conditional routing on request
data, exactly like the route-matching walk in Sinatra's routing table.
Serializer objects (turning a model into a version-specific JSON shape)
work by calling #to_json or building a hash explicitly rather than
relying on ActiveRecord's default as_json, which is precisely why adding
a new API version rarely requires touching the model — the model's
dynamically-generated attribute methods (from Level 3) stay constant while
the serialization layer that reads them changes per version. Deprecation
headers are just ordinary response headers your controller code sets
before returning — there's no separate "deprecation system," it's the same
[status, headers, body] Rack triple every response is built from.
Cheat sheet¶
| Task | Convention |
|---|---|
| List resources | GET /things |
| Fetch one | GET /things/:id |
| Create | POST /things |
| Full replace | PUT /things/:id |
| Partial update | PATCH /things/:id |
| Delete | DELETE /things/:id |
| Version in URL | /api/v1/things |
| Version via header | Accept: application/vnd.app.v2+json |
| Consistent error shape | { error: { code:, message: } } |
| Success status for creation | 201 Created |
| Success status for deletion | 204 No Content |
Exercise¶
- Design and implement
v1andv2of aGET /api/vN/products/:idendpoint wherev2renamesprice(a plain number) toprice_cents(an integer) — demonstrating that this is exactly the kind of change that requires a version bump rather than an in-place field addition. - Add a consistent Sinatra
error 404/error 422handler pair using the shared{ error: { code:, message: } }envelope, and write a request spec confirming both error responses share the same top-level shape. - Write a short
CHANGELOG.md-style entry documenting thev1→v2breaking change from step 1, including a "sunset" date forv1and a one-line migration note for API consumers.