The API
Two routes, not two per entity. Adding an entity adds two endpoints with no rebuild.
GET /api/v1/{module}/{entity} list
POST /api/v1/{module}/{entity} create
GET /api/v1/{module}/{entity}/{id} fetch one
PATCH /api/v1/{module}/{entity}/{id} change
DELETE /api/v1/{module}/{entity}/{id} remove
POST /api/v1/{module}/{entity}/{id}/{flow} run a flow
GET /api-doc/openapi.json what *this* token can do
A browsable reference at /api-doc
$ open http://localhost:8080/api-doc
Paste a bearer token into the bar at the top and the page re-renders as that principal. Try it with two different tokens — watching entities and fields appear and disappear is the quickest way to understand what your role can do, and it is something a normal API reference cannot show you.
“Try it out” sends requests as the same token, so what you read and what you call are the same principal.
The viewer ships inside the runtime, so it works with no internet access and makes no request to anyone else. The page and its script are the only two things the runtime serves without a token — a browser cannot send one until the page has asked you for it. The document itself is always authenticated.
The document describes you
GET /api-doc/openapi.json is generated against your token. Entities you cannot
read are absent; fields you cannot read are absent from the schemas.
The usual single document is true about the system and false about you — you read it, write a client against a field, and find out at runtime that your role cannot see it. This one answers the question you actually had.
Pagination is a cursor
$ curl "localhost:8080/api/v1/sales/Customer?limit=50"
{"data":[…],"next":"01a0…"}
Pass next as ?after= for the following page, and loop until it is absent.
There is no page number and no total: offset pagination over a filtered set
skips rows when one is inserted underneath it, and a total means a second query
nobody asked for.
limit is clamped. Asking for more than the maximum is not an error.
Following an association
?expand= attaches a referenced row to every row of a page:
$ curl "localhost:8080/api/v1/sales/Order?expand=Customer"
{"data":[{"order_number":"ORD-1","customer_id":"019f…",
"Customer":{"company_name":"Ours Ltd","tier":"Gold"}}]}
Fifty orders and their customers is two queries, not fifty-one. The identifiers are collected from the page and fetched in one go, so the cost depends on how many associations you asked for and not at all on how many rows came back.
Comma-separate for several, and use a dot to go deeper:
$ curl "localhost:8080/api/v1/sales/OrderLine?expand=Order.Customer"
At most three deep, and twelve in total. Each one is a query, so the cap is what stops a long query string being an expensive request.
The expanded row appears under the association’s name, beside the
<name>_id field rather than replacing it — so a client that only wanted the
identifier keeps working.
It follows the same permissions you have
An expansion is a read of a second entity, and being allowed to see an order says nothing about being allowed to see the customer behind it. So the expansion is checked on its own account, with your credentials, and two things follow:
- An entity you may not read at all is a
400, naming it. Your own API document already lists what you may read, so this tells you nothing new and saves you debugging a field that is quietly missing. - A row a constraint hides comes back as
null, exactly as an unset reference does. It has to: distinguishing “hidden” from “absent” would let somebody confirm which rows exist by watching which shape came back.
Hidden fields are hidden inside an expansion too. If your role cannot read
creditLimit on a customer, it is absent from the nested object for the same
reason it is absent from a direct read.
What it does not do
Many-to-many. A referenceSet is refused by name. It is not readable at
all yet — not even as a list of identifiers — so there is nothing to expand.
The reverse direction. Order declares Customer, so ?expand=Customer
has a name to use. Customer declares nothing pointing back at its orders, so
there is no name to ask for, and ?expand=Orders is not a thing you can write.
Fetch them the other way round: list orders and filter by the customer.
Writes need a precondition
PATCH and DELETE without an If-Match are refused with 428:
$ curl localhost:8080/api/v1/sales/Customer/$ID # read it
ETag: "1"
$ curl -X PATCH -H 'If-Match: "1"' … $ID # 200, new ETag "2"
$ curl -X PATCH -H 'If-Match: "1"' … $ID # 409, somebody was first
Two people editing the same record is not a race condition, it is a Tuesday, and
last-write-wins loses one of the two changes with no error at all. If-Match: *
overwrites deliberately.
There is no PUT. A role that cannot read every field cannot send a
complete representation of a row, so the only honest full-replacement semantics
would be “and blank everything I cannot see”. PATCH with a partial body is the
only shape that composes with per-field access control.
Errors are problem documents
RFC 9457, with a stable type to
match on and a detail to read. A rejected write names every bad field at once:
{
"type": "https://…/problems/invalid-fields",
"status": 422,
"detail": "the request names fields that cannot be used; see `errors`",
"errors": [
{
"field": "tier",
"detail": "must be one of `Bronze`, `Silver`, `Gold`, not `Platinum`"
},
{
"field": "total",
"detail": "is not writable by this role; it can be read but not changed"
}
]
}
Match on type. The detail may be reworded; the type will not.
| Status | When |
|---|---|
401 | No token, or one that did not validate |
403 | Authenticated, not permitted |
404 | No such row — or one you may not see. Deliberately the same |
409 | A stale If-Match, or something is in the way |
422 | Understood, cannot be done. Field problems are here |
428 | A write with no If-Match |
Vary: Authorization
On every response, because rows, fields and the whole OpenAPI document differ per token. If you put a shared cache in front of this, it must respect that header — otherwise it hands one customer’s rows to another.
Money
decimal is a string in JSON, in both directions:
{ "credit_limit": "1000.00" }
Sending 1000.00 as a number is a 422 explaining why: by the time it reaches
the runtime it has already been through a binary float, and 0.10 is not
representable in one. Your client should keep it a string all the way to
whatever does the arithmetic.