Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

Data API conventions

The routes, query grammar, paging, headers, statuses and problem documents every generated Data API endpoint follows.

Every entity in an applied descriptor gets the same ten routes, the same query grammar and the same headers. This page lists them. The guides show them in use: Read data, Write data safely and Handle errors.

Every descriptor generates its own OpenAPI document. The standalone host serves it at GET /openapi/v1.json, with an API browser at /scalar, unless Alvo:Docs:Enabled is false; both are readable without a key. Data API: example is the document for examples/vehicle-registry. An embedded host that calls ASP.NET Core’s AddOpenApi() and MapOpenApi() gets Alvo’s routes and schemas in its own document; Alvo does not add an OpenAPI document on its own.

The routes sit under AlvoApiOptions.RoutePrefix (configuration key Alvo:Api:RoutePrefix), default /api; an empty prefix mounts them at the root. {entity} is the entity’s name as the descriptor declares it, and {id} is a row’s UUID.

Method and pathDoesGated by the ruleSuccess
GET {prefix}/{entity}List rows, filtered, sorted and pagedlist200, a page
POST {prefix}/{entity}/queryThe same list, with the query as a JSON bodylist200, a page
GET {prefix}/{entity}/{id}Read one rowget200, the row; 304 on a matching If-None-Match
POST {prefix}/{entity}Create a row with a generated idcreate201, Location and the row
PUT {prefix}/{entity}/{id}Create the row under this id, or replace itcreate and update201 on create, 200 on replace
PATCH {prefix}/{entity}/{id}Merge the body into the rowupdate200, the row
DELETE {prefix}/{entity}/{id}Delete the rowdelete204, no body
POST {prefix}/{entity}/batchCreate many rows in one transactioncreate200, items and affected
PATCH {prefix}/{entity}/batchUpdate many rows in one transactionupdate200, items and affected
DELETE {prefix}/{entity}/batchDelete many rows in one transactiondelete200, empty items and affected

An entity the descriptor does not declare has no route, so it answers the router’s 404 with no problem document. A write answers with the row as a GET by the same caller would show it, or with its id alone when the caller may not read the row. Every response from these routes carries Cache-Control: no-store.

PostgREST’s syntax, so that a client or an agent recognises it. One parameter per term; every term must hold.

FormMeans
{field}={op}.{value}A filter: year=gte.2020. The parameter name is a field; an unknown name is refused, never ignored.
not.{field}={op}.{value}, not.or=(…), not.and=(…)Negates the term or the group. One not. at most.
or=({term},{term},…)Any term holds. A term inside a group is {field}.{op}.{value}, optionally prefixed with not..
and=({term},{term},…)Every term holds. A group nests with = inside it: or=(a.eq.1,and=(b.eq.2,c.eq.3)).
order={field}[.asc|.desc][.nullsfirst|.nullslast],…Sort keys, comma-separated. Ascending and nulls last unless stated.
select={field},{alias}:{field},…The fields each row carries, optionally renamed.
limit={n}Page size, 1 to 200; default 50.
after={cursor}Continue from the page whose next this is.
offset={n}Skip n rows; not together with after.

Reserved, so no descriptor may declare a field with these names: order, limit, offset, after, select, or, and, not.

Deviation from PostgREST: a nested group is written and=(…) inside the outer group, where PostgREST writes and(…), and negation is a prefix on the parameter name (not.make=eq.Skoda) rather than on the value (make=not.eq.Skoda, which is refused with unknown-operator; #350).

OperatorMatchesAllowed on
eq, neqequal, not equalevery field type
inone of a list, in.(a,b,c)every field type
gt, gte, lt, lteorderingstring, text, enum, integer, decimal, date, datetime
like, ilikea pattern with % and _; ilike ignores casestring, text, enum
isnull on any field; true or falsenull: every type; true, false: boolean

An operator is matched exactly: EQ is not an operator. In a URL, a literal % in a pattern is written %25.

POST {prefix}/{entity}/query takes a JSON object whose members are the parameters above, each value written as it would follow = in the URL: {"make": "in.(Skoda,Renault)", "order": "year", "limit": 100}. Values are decoded, so % is literal. An array repeats a parameter. Member names compare case-insensitively, and a name that appears twice is refused. {} is the empty query. It is a read gated by list, honours Prefer: count, and ignores If-Match, If-None-Match and Idempotency-Key.

A list always answers one page, as an object with exactly these members:

MemberHolds
itemsThe rows of this page.
nextThe cursor for the next page, or null on the last one. Opaque, at most 512 characters.
countThe number of rows the whole query matches when the request sent Prefer: count=exact, otherwise null.

The cursor carries no data; a stale, forged or foreign cursor returns an empty page. count covers only the rows the caller’s rules admit, and is computed in a second statement, so a concurrent write can make it differ from the rows by one. Prefer: count=planned and count=estimated return the exact count; an unrecognised preference is ignored. Sizes and budgets are in Limits and budgets.

HeaderDirectionMeaning
X-Alvo-Api-KeyrequestThe API key, <keyId>.<secret>. The name is Alvo:Auth:HeaderName; Cookie is refused.
X-Alvo-TenantrequestThe tenant the caller asks to act in. The name is Alvo:Auth:TenantHeaderName; Cookie is refused.
Content-Typerequestapplication/json or any application/*+json, on every request with a body.
If-MatchrequestThe ETag a PATCH, PUT or DELETE expects the row to have. * asks only that the row exist.
If-None-MatchrequestOn a read of one row, the ETag you hold; 304 when it is current. Refused on a write.
Idempotency-KeyrequestMakes any write (POST, PUT, PATCH, DELETE, or a batch) safe to retry; at most 255 UTF-8 bytes.
Preferrequestcount=exact fills the page’s count.
ETagresponseThe row’s version, on an entity with audit; strong, opaque.
LocationresponseOn a 201, the new row’s URL, including any path base or route-group prefix.
Preference-AppliedresponseWhich preference was honoured, such as count=exact.
Accept-Post, Accept-PatchresponseOn a 415, the media types the route accepts.
Cache-Controlresponseno-store, on every response from a generated route.
WWW-AuthenticateresponseOn a 401, the scheme and the header name the key must be sent in.
SituationAnswer
If-Match equals the row’s ETagThe write proceeds.
If-Match names an older version412 precondition-failed
A weak tag, several tags, or a tag on an entity without audit412 precondition-failed
If-Match: *Accepted on every entity; asks only that the row exist.
If-None-Match on a write, or any precondition on a create412 precondition-failed
No If-MatchThe write proceeds without a check.
POST …/batchPATCH …/batchDELETE …/batch
Body{"rows": [ {fields}, … ]}{"rows": [ {"id": …, fields}, … ]}{"rows": [ "id", … ]}
Answer200, items and affected200, items and affected200, empty items and affected

Every row is checked before any row is written, and one transaction writes all of them or none. Up to 1000 rows; a row named twice is refused; no If-Match. A refusal lists every bad row: a 422 whose pointers start with /rows/<index>/, or a 403 whose violations name each row a rule refused. A 409 conflict names the field and no row index.

SituationAnswer
A new keyThe request runs, and the key is stored with the ids of the rows it wrote, scoped to the caller.
The same key and the same request againNo second write; the original status, with the row read again under the caller’s current rules. A replayed PUT answers 200 without Location.
The same key with a different request409 idempotency-conflict
A key from a caller without an API key422 malformed-query
A key over 255 bytes422 malformed-query
StatusWhen
200A row, a page, or a batch result.
201Created, with Location.
204Deleted.
304If-None-Match matches the current version (a read of one audited row).
401A key was presented and cannot be used. A request without a key is judged by the rules instead.
403A rule or a before-hook refused (forbidden), or the key’s scopes do not cover it (out-of-scope).
404The row does not exist or the caller’s rules exclude it; or, without a problem document, the entity does not exist.
405A verb the route does not have, such as GET …/query; from routing, without a problem document.
409A unique or restrict conflict (conflict), or a reused Idempotency-Key (idempotency-conflict).
412A precondition that does not hold or cannot be evaluated.
415A body not declared as JSON.
422A malformed body (validation) or query (malformed-query).
413, 408, 400The web server refused the request before Alvo read it (unreadable-request).
500internal or function-failed.

Every refusal is an RFC 9457 problem document, application/problem+json, with type, title, status, detail and, when there are reasons to itemise, violations: each with pointer, code, message and fixSuggestion. Branch on the slug at the end of type. Every slug, its causes and its fix are in Problem types; unreadable-request, internal and function-failed come only from a host that called AddAlvoProblemDetails(), which the standalone host does.

Design notes: docs/architecture/data-api.md.