9.2 KiB
API Design Guidelines
Document Version: v2
Note on Versioning:
- This document version is independent — reused across projects
- Project version source of truth: the project manifest (
pyproject.toml,Cargo.toml)- The URL version (
/v0,/v1) is a separate axis and moves independently — see section 1CHANGELOG.mduses the project version, never the URL version
Related Documents
- README.md — Project overview, tool descriptions, build instructions
- AGENTS.md — Rules for AI assistants
- PROJECT.md — Project goals and current state
- CHANGELOG.md — Version history
- DESIGN_DOCUMENT_LANGUAGE.md — everything language-specific (
DESIGN_DOCUMENT_PYTHON.md, …)
This document applies to any project that exposes an HTTP API, in addition to the design document for the language it is written in. Where the two disagree, the language document wins on language matters and this one wins on API surface.
1. Two Version Axes
An API carries two versions that must never be conflated.
| Axis | Where it lives | What it means | How often it moves |
|---|---|---|---|
| URL version | the path — /v0/... |
the contract with the consumer | almost never |
| Product version | project manifest, job manifest | the release of the software | every release |
The product version follows semantic versioning and the rules in AGENTS.md. It is what the
OpenAPI/Swagger version field reports, and what CHANGELOG.md is organised by.
The URL version is not semantic. It is a single integer identifying a frozen contract. A
consumer pinned to /v0 keeps working across every product release until /v0 is formally retired.
1.1 When the URL version is bumped
Bump /v0 to /v1 only on a breaking change to an existing endpoint:
- a field is removed or renamed
- a field changes type or meaning
- a default changes in a way that alters the response
- an endpoint is removed or its path changes
- a previously optional parameter becomes required
The following are not breaking and never justify a bump:
- adding a new endpoint
- adding a new optional query parameter
- adding a new field to a response
- performance, caching or refresh-strategy changes that leave the payload identical
1.2 Coexistence and retirement
When the version is bumped, the old version keeps serving until every consumer has moved:
- both versions are routed at the same time; the old one is not silently redirected
- the old version is marked
deprecatedin the OpenAPI schema - retirement is announced in
CHANGELOG.mdat least one release before removal - removal is a MAJOR bump of the product version
A new URL version is a cost paid by every consumer. Prefer an additive change, a new endpoint, or an opt-in query parameter over a bump.
2. Path Naming
/v<n>/<CONSUMER>/<resource>
| Segment | Rule | Example |
|---|---|---|
v<n> |
lowercase v + integer, no dots |
/v0, /v1 |
<CONSUMER> |
the consuming system, as its own abbreviation | WEXT, PDH, SALESOFFER |
<resource> |
lowercase kebab-case |
insert-relation, attribute-defs |
- Collections are plural, single lookups append the identifier:
/v0/WEXT/productsand/v0/WEXT/products/{productnr} - Sub-resources nest, they are not hyphenated into the parent:
/v0/WEXT/assets/general, not/v0/WEXT/assets-general - Never PascalCase or SCREAMING_CASE a resource. The consumer segment is the only place an uppercase abbreviation is allowed, because it is a proper name rather than a word.
- Spell resources out.
number-of-insertsovernoi— the URL is read by people who do not know the internal acronym.
2.1 Endpoints outside the versioned space
Two different kinds of endpoint carry no version prefix, and the same rule does not cover both.
Operational endpoints — /health, /cache/stats, /cache/reset — are called by operators and
monitoring, never by a consumer. They change with the product version, no ceremony.
Unversioned consumer endpoints — the token endpoints (/subscribe) and any feed that predates
the versioning scheme — carry no prefix but are still contracts, and the token endpoints are the
widest of them: every delta consumer holds a token one of them issued. A breaking change to any of
these follows section 1.1 exactly. Having no version to bump is not permission to break. The
endpoint is either extended additively, or it enters the versioned space as a new path while the
old one keeps serving and is marked deprecated.
Unversioned is not unprotected. What decides is who calls the endpoint, not whether the path has a number in it.
3. Parameter Naming
| Kind | Convention | Example |
|---|---|---|
| Path parameter | snake_case |
/products/{productnr} |
| Query parameter | snake_case |
?web_only=true, ?dict_id=42 |
| Header | X- prefix, Kebab-Case |
X-Token |
Reserved query parameter names, to be used for nothing else:
| Name | Meaning |
|---|---|
limit |
rows per page |
offset |
row offset |
since |
ISO-8601 low-water mark for a delta pull |
format |
response format override, where an endpoint serves more than one |
4. Code Naming
The path is the contract; the code that serves it mirrors the path mechanically, so the two cannot drift apart.
| Element | Convention | Example |
|---|---|---|
| Router variable | <consumer>_router |
wext_router |
| Handler function | <consumer>_<resource>, snake_case |
wext_attribute_defs |
| Single-item handler | <consumer>_<resource>, singular |
wext_product |
| OpenAPI tag | <Consumer> feed |
"WEXT feed" |
One router per consumer, declared with its own prefix and tag and included on the application in a single place. A handler never hardcodes its own prefix.
5. Responses
5.1 Envelope
A list endpoint returns its rows inside an envelope carrying the paging state, never a bare array:
{"table": "wext-products", "since": null, "count": 3,
"limit": 5000, "offset": 0, "has_more": false, "rows": []}
For XML, the same metadata goes on the root element as attributes (limit, offset, returned,
hasMore).
Where the response body is a message defined by the consumer rather than by us, the paging metadata stays outside that message — the body must remain exactly what the consumer schema expects.
5.2 Paging
Every list endpoint is paged. An endpoint with no cap is a denial of service waiting for the first client that omits a filter.
limithas a documented default and a hard maximum, both configurable by environment variable- the maximum is chosen by measurement — response size, and the time a request holds any shared lock — not by guess
has_moreis reported so the client knows to continue- deep offsets over a very large table are not the intended access path; document the alternative
(walking
sincewindows) instead
5.3 Delta feeds
An endpoint whose source has a reliable change column may support incremental pulls:
?since=<ISO-8601>returns only rows changed strictly after that instant- a subscription token in
X-Tokensupplies, and then advances, a server-tracked watermark - an explicit
?sincealways wins over the token value - a malformed
?sinceis rejected with 422 before it reaches the query
A snapshot message is never a delta. If a response states a complete set as of a timestamp, answering with only the changed rows tells the consumer that the set shrank to those rows. Such an endpoint is a full snapshot and says so in its description.
5.4 Status codes
| Code | When |
|---|---|
200 |
success, including a legitimately empty collection |
302 |
redirect to an external asset |
400 |
the request is missing something it needs |
403 |
unknown or revoked token |
404 |
a named single entity does not exist |
422 |
a parameter is present but malformed |
500 |
internal failure |
An empty collection is 200 with an empty list, not 404. 404 means this identifier does not
exist, which only a single-entity lookup can determine.
Error bodies carry a short, generic detail. Internal detail — driver strings, connection
parameters, SQL, stack traces — goes to the log only, never to the client.
6. Documentation
- Every endpoint declares a
summaryand adescription. The description states where the data comes from, whether the endpoint is a delta or a snapshot, and any non-obvious default. - The OpenAPI
versionfield is read from the project manifest, never hand-maintained. - The interactive schema (
/docs,/openapi.json) is the reference for consumers. Prose that only restates it belongs nowhere. - A new consumer feed is announced in
CHANGELOG.mdunder the product version that ships it.
7. Testing
In addition to the testing rules in the language design document:
- every endpoint has at least one test asserting the shape of its response
- the query an endpoint builds is asserted, so a changed filter cannot pass unnoticed
- tests stub the data-access layer; they never require a live database
- the empty-result path is tested explicitly — it is the one most often wrong