228 lines
9.2 KiB
Markdown
228 lines
9.2 KiB
Markdown
# 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 1
|
|
> - `CHANGELOG.md` uses 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 `deprecated` in the OpenAPI schema
|
|
- retirement is announced in `CHANGELOG.md` at 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/products` and `/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-inserts` over `noi` — 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:
|
|
|
|
```json
|
|
{"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.
|
|
|
|
- `limit` has 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_more` is reported so the client knows to continue
|
|
- deep offsets over a very large table are not the intended access path; document the alternative
|
|
(walking `since` windows) 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-Token` supplies, and then advances, a server-tracked watermark
|
|
- an explicit `?since` always wins over the token value
|
|
- a malformed `?since` is 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 `summary` and a `description`. The description states where the data
|
|
comes from, whether the endpoint is a delta or a snapshot, and any non-obvious default.
|
|
- The OpenAPI `version` field 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.md` under 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
|