The browser-side TypeScript client (@metaobjectsdev/runtime-web +
@metaobjectsdev/react + @metaobjectsdev/tanstack) is universal: it
ships with the React + TanStack runtime and the generated query hooks +
column defs, but it makes no assumption about which language wrote the
backend. Any HTTP server — TypeScript Fastify, Java Spring, Kotlin Spring,
C# ASP.NET, Python FastAPI — that speaks the URL grammar and JSON wire
format on this page can serve the same React app, with the same generated
hooks and grids.
This page is the contract. Implementations that pass it interoperate with the TS client; implementations that don't, don't.
Throughout the doc the worked example is an Author entity in the
acme::blog package.
The contract has two halves: the URL grammar (paths + query-string shape) and the wire format (JSON request / response bodies). Both halves are language-agnostic and stable across all five shipped language ports. Every port ships a generated route implementation today (see "Per-port route codegen status" below). That route code is a reference helper: it implements this contract on the reference fixtures, and you eject it and own the copy when you need to change it. The contract is what stays fixed.
The browser client never calls fetch directly. Every generated hook
delegates to a single EntityFetcher function pulled from React
context:
// from @metaobjectsdev/runtime-web
export type EntityFetcher = <T>(path: string, init?: RequestInit) => Promise<T>;Responsibilities of the fetcher (supplied by the consumer's app, not generated):
- Resolve the
pathargument (always starts withapiPrefix, e.g./api/author?...) to a fully-qualified URL. - Attach auth (cookies / bearer token / API key) per the app's policy.
- Parse the JSON response and return it typed as
T. - Surface non-2xx as a thrown
Error(hooks rely on this for React Query'serrorstate).
The fetcher is supplied once via <EntityFetcherProvider fetcher={fetcher} baseUrl="/api">
at the React tree root; every generated hook reads it via
useEntityPathFetcher() from @metaobjectsdev/tanstack, which prepends baseUrl
to the ENTITY-relative path the hook passes it. (useEntityFetcher() is a
deprecated alias — it rewrites paths too, which its name did not say, so handing it
an application-absolute path produces <baseUrl> + that path and a silent 404.)
These are the routes a generated TanStack hook calls, relative to the
client's base URL. apiPrefix in metaobjects.config.ts mounts the
generated server routes; the client base is supplied once at runtime as
baseUrl on the fetcher provider. The two must agree, and saying so is the
app's job — see the policy section below.
| Verb | Path | Purpose |
|---|---|---|
GET |
/<apiPrefix>/<entity>?filter[...][...]=...&sort=...&limit=N&offset=N&withCount=1 |
List (with filter / sort / pagination) |
GET |
/<apiPrefix>/<entity>/:id |
Get by id |
POST |
/<apiPrefix>/<entity> |
Create |
PATCH |
/<apiPrefix>/<entity>/:id |
Update (partial) |
PUT |
/<apiPrefix>/<entity>/:id |
An alias of PATCH — same handler, same partial semantics, in every port (see below) |
PUT is not a full replace. Every port routes PUT to the same update handler as
PATCH, so a field the body omits keeps its stored value under either verb, and an
explicit null clears it under either verb. The contract has always meant it that way
(the update-patch-and-put scenario gates that both verbs reach the update path); only
this table's wording used to say "replace". A client that wants replace semantics sends
every field. The TS mounts can restrict the update to one verb with
updateMethod: "patch" | "put".
| DELETE | /<apiPrefix>/<entity>/:id | Delete |
| Verb | Path | Purpose |
|---|---|---|
GET |
/<apiPrefix>/_meta |
Serves the loaded model's shape (entities, fields, types, validators, layouts) as JSON — a separate contract, see docs/features/metadata-api.md |
<entity> is the entity name snake_cased and then pluralized. One rule, the
same in all five ports, and derived from the NAME — never from the physical
@table:
| Name | Segment | Why |
|---|---|---|
Author |
authors |
a single regular word takes s |
PostCategory |
post_categories |
multi-word: the capitals carry the word boundary |
Address |
addresses |
ending s/x/z/ch/sh takes es |
Category |
categories |
consonant + y becomes ies |
Day |
days |
a VOWEL before the y does not |
HTTPServer |
http_servers |
a run of capitals stays together until the final one that begins a word |
Stats |
stats |
already-plural (a final s NOT preceded by s/u/i/a) is left unchanged |
The same rule serves an object.projection, so OrderSummary is at
/order_summaries whether it is an entity or a projection.
Already-plural names don't double. An entity whose name is already a plural noun —
Stats,Settings,Series— used to be served at/statses,/settingses,/serieses. The pluralizer now treats a finalsas already-plural unless the character before it is one of s/u/i/a (which still keepsStatus→/statuses,Address→/addresses,Alias→/aliasespluralizing normally — those endings are a genuinely singular word, not an already-plural one). This is the API/code-surface rule only: a sourceless entity's DEFAULT PHYSICAL table name is a separate, intentionally unchanged derivation, so an adopter's existing database never sees a migration propose a rename for an already-plural entity it didn't touch.fixtures/naming-conformance/gates both rules, for the same input set, in every port.
Generated TS hooks read $path from the entity-constants file, so the client and
the server agree on the path segment without hand-coordination.
This changed. Each port used to spell this differently, and they only agreed on single regular words like
Author— which is every collection base the corpus had, so every lane was green whileOrderSummarywas served at four different URLs:/order_summaries(TS entity),/order-summaries(TS projection),/ordersummaries(C#) and/ordersummarys(Java, Kotlin, Python). If your entity names are all single regular words, nothing moves. If any is multi-word or takes an irregular plural, its collection URL changes and clients must follow.fixtures/api-contract-conformance/m2m/'sPostCategorynow gates it in every port, on both lanes.
Filters use a bracketed qs shape: filter[<field>][<op>]=<value>.
A bare value (filter[<field>]=<value>) is sugar for eq. Multiple
filters AND together.
filter[or]/filter[and]nesting is a TS-only extension, not part of the cross-port contract — see "TS-only filter extensions" below.
| Operator | Strings | Numbers / Dates | Booleans |
|---|---|---|---|
eq, ne, isNull |
yes | yes | yes (eq + isNull only) |
in, like |
yes | in only |
– |
gt, gte, lt, lte |
– | yes | – |
The operator set is gated by field subtype in the generated
<Entity>FilterAllowlist. A request with an operator that the field
subtype doesn't support → HTTP 400. The operator list is a Tier 1
cross-port invariant — every port's parser must implement these nine
and only these nine.
like is case-SENSITIVE SQL LIKE
(ADR-0049):
the author-supplied pattern binds verbatim — % (any run) and _ (any
single character) are the only wildcards, there is no %-wrapping and no
case folding — identically on every port and every engine. Engines whose
native LIKE is not case-sensitive are lowered around, not deferred to: on
SQLite/D1 (whose built-in LIKE folds ASCII case) the TS runtime lowers
like to GLOB with an exactly-translated pattern. Case-insensitive
matching is deliberately NOT in the operator vocabulary (FR-009 §7 /
ADR-0036 reserve ilike as a future additive operator, gated on real
consumer demand); until then, normalize case in the data or the pattern —
or use the TS-only ?search extension, which IS case-insensitive.
The TypeScript runtime parser ships seven filter behaviors beyond the nine operators. They are NOT part of the cross-port REST contract — the other ports (Java, Kotlin, Python, C#) do not implement them, and a relying adopter must not assume them on a non-TS backend. They are deliberately deferred until real consumer demand (none touch the metamodel vocabulary, so any of them can be added cross-port later as a purely additive, non-breaking change):
| Extension | What it does |
|---|---|
?search=<term> |
ORs a case-INSENSITIVE substring match (%term%) across the entity's @filterable string fields — deliberately looser than the case-sensitive like operator (it is a human search box; ILIKE on Postgres, native LIKE on SQLite) |
filter[or][N] / filter[and][N] |
boolean combinators (recursive nesting) |
| leading-wildcard gating | a like pattern starting with % → HTTP 400 (filter.leading_wildcard_disallowed) |
| filter nesting-depth cap | rejects deeply-nested or/and (tied to the combinators) |
| bare field parameter | ?priority=low where priority is in the allowlist → HTTP 400 { "error": "filter.bare_field", "field": "priority", "expected": "filter[priority][eq]=low" } instead of silently returning every row. A bare parameter named after a field of the entity that is NOT filterable (a column of the table or view the mount reads, off the allowlist) → HTTP 400 { "error": "filter.bare_field", "field": "kind", "filterable": false, "expected": "a filterable field — \"kind\" is not @filterable", "allowed": [<the filterable fields>] } — declare @filterable: true on the field to filter by it. Any other unknown parameter (a cache-buster, a tracking tag) is still ignored, and the reserved list parameters (filter, sort, limit, offset, search, withCount) are never claimed |
| filter-value format check | a comparison value (eq/ne/gt/gte/lt/lte, and every element of an in list) that cannot be the field's type → HTTP 400 { "error": "invalid_filter_value", "field": "publishedOn", "op": "gte", "expected": "date (YYYY-MM-DD)" } instead of reaching SQL, where SQLite compared the text and silently returned [] and Postgres failed the cast. Checked per field: field.date (a real calendar day), field.time (HH:MM[:SS[.fff]]), field.timestamp (a date, optionally with a time and a Z/offset), field.uuid (8-4-4-4-12 hex), field.enum (a declared member — the response adds allowed), numbers (an empty value is not 0; on field.int / field.long / field.currency a fractional value such as 40.5 → "expected": "integer (a whole number)") and booleans. A timestamp value whose offset arrived as a space — …T00:00:00+00:00 sent unencoded, where + decodes to a space — adds "hint" saying to URL-encode the + as %2B or to use Z. A comparison value on a field.timestamp that is not @localTime is compared as an instant in the stored UTC spelling: a zoned value at its instant, a zoneless one read as UTC and a date alone as midnight UTC — the same rule the generated insert/update schemas store such a field by ("2026-10-06" is stored as 2026-10-06T00:00:00Z: the canonical wire spelling, no fraction when it is zero; a value already spelled YYYY-MM-DDTHH:MM:SS[.f]Z is kept byte-for-byte), because SQLite and D1 compare the text; an @localTime value is kept as sent on both paths. The generated <Entity>FilterAllowlist carries the format / enumValues / instant / integer this needs; an allowlist generated before them still has a temporal value checked against all three temporal formats and an enum checked against the Drizzle column's own members. The envelope is the cross-port one; what is TS-only is refusing a malformed comparison value — the other ports pass it through to the database and only the isNull value is corpus-gated |
Leading-wildcard gating is fail-closed with no metadata opt-in. The
generated <Entity>FilterAllowlist hardcodes leadingWildcard: false on every
field — an unanchored LIKE ("%@example.com") cannot use a btree index, so the
generated TS routes reject it by default. The runtime honors
leadingWildcard: true per field, but codegen never emits it: to opt a field
in, hand-edit that field's entry in the generated allowlist (hand edits inside
generated files survive regeneration via the three-way merge). There is
deliberately no @-attribute for this (ADR-0037/ADR-0023: the gate is a
TS-only generated-code behavior, not cross-port metamodel semantics — the other
ports accept leading wildcards, which is exactly why it is listed here as a
TS-only extension).
The in-list size cap (reject an in list longer than 100 → HTTP 400) is a
safety limit, not a feature — TS enforces it, the other ports currently do not.
Unifying that cap cross-port is the one item here worth doing regardless of
feature demand (it is a consistency/safety divergence, not a capability).
The TypeScript mount helpers (@metaobjectsdev/runtime-ts/drizzle-fastify,
/fastify and /hono) pin five responses the contract leaves open — HTTP 5xx is
implementation-defined below, and no corpus scenario sends a malformed body, a write the database rejects, a
malformed page bound, or a value only the generated schema's format check refuses. All five use the contract's
{ "error": "<code>" } envelope, and all five are scoped to the
routes the helpers mount: an adopter's own routes, and a Fastify setErrorHandler
or Hono onError the adopter installed, answer exactly as they did before.
| Response | When |
|---|---|
| malformed JSON body | a POST/PATCH/PUT body that does not parse as JSON (an empty body sent as application/json included) → HTTP 400 { "error": "invalid_json" }. An EMPTY body on a method that takes none (GET, DELETE, HEAD) is accepted even when the request declares content-type: application/json, as many clients do on every request; a non-empty DELETE body is still parsed. Before, Fastify answered its own { "statusCode": 400, "code": "FST_ERR_CTP_INVALID_JSON_BODY", … } and Hono a Zod validation error about a missing object |
| malformed page bound | a ?limit= or ?offset= that is not a non-negative integer (abc, -5, 1.5, an empty value) → HTTP 400 { "error": "pagination.invalid_value", "param": "limit", "value": "abc", "expected": "a non-negative integer (0 or more)" }. Before, a non-numeric bound was dropped (every row came back) and a negative or fractional one reached SQL. There is no page-size maximum on an entity or a projection with declared columns; an opaque view (no declared columns, served by raw SQL) pages to at most 1000 rows, so there a limit above 1000 is refused too, with "expected": "an integer from 0 to 1000" (it used to be clamped silently). The same answer comes from the Drizzle mounts and the ObjectManager Fastify mount. No corpus scenario sends a malformed bound; the other ports answer it as their framework does |
| database constraint violation | a write the database itself rejects → { "error": "constraint_violation", "constraint": "<kind>" }, HTTP 409 for unique and foreign_key (a conflict with another row) and HTTP 400 for check and not_null (the request's own value), per the status rule under "Error response". The body names the constraint KIND only — never the SQL, the table or the bound values. A CHECK the model derives (an enum, a validator.numeric/length/regex range) is normally refused earlier by the route's own validation, as the corpus's { "error": "validation" } 400; this row is the database's backstop |
| schema validation failure | a body that fails the insert/update schema → HTTP 400 { "error": "validation", "issues": [...] }, each issue as Zod reports it minus pattern: Zod 4 attaches a failed .regex() check's source there, which for a generated field.date / field.time / field.timestamp check is a ~300-character calendar regex (and an authored validator.regex pattern is the server's to keep). The generated temporal checks carry a short message naming the expected format instead ("must be an ISO date (YYYY-MM-DD)") |
| unexpected server error | anything that is not a filter, validation, not-found or constraint answer — a query against a column the database no longer has, a driver failure → HTTP 500 { "error": "internal" }, the code the cross-port reference servers already use. The body names no SQL, table, column or bound parameter; the full error goes to the server log (console.error). Before, Fastify's default handler echoed the driver message, which for Drizzle is the query text and its parameter values |
How each framework scopes it: on Fastify, the helpers pass a route-level
errorHandler in the options of each route they register (Fastify applies it to
that route only). A deliberate 4xx raised on such a route — an auth preHandler's
401, schema validation, 413, 415 — is rethrown to the enclosing scope's handler
untouched, and an errorHandler you pass in routeOptions replaces the helpers'
own. Hono has no per-route handler (app.onError is app-wide), so the helpers wrap
each handler they register instead; an HTTPException is rethrown to your
onError.
sort=<field>:asc|desc— single sort key (multi-sort not in the default contract). Field must appear in<Entity>SortAllowlist.sort=<field>— the:asc|deschalf is OPTIONAL, and when omitted the direction comes from that field's@sortableDefaultOrder, defaulting toascfor a field that declares none. An order the caller DOES supply always wins: the declaration fills in a missing direction, it never overrides a present one. Applies per named field, so?sort=nameon a model wherecreatedAtdeclaresdescstill sortsnameascending. Guarded cross-port by thesort-default-orderscenario (fixtures/api-contract-conformance/scenarios/), whose three arms cover the declared field, an explicit order beating it, and an undeclared field falling back toasc.- Known limit — enums sort by stored value, not declared order. A
string-backed
field.enumsorts alphabetically (high<low<medium<urgent), not in@valuesorder; an int-backed (@intValueMap) enum sorts by its mapped integers. To sort by declared order, back the enum with an@intValueMapwhose integers follow that order. limit=N— page size.offset=N— page offset. Both are non-negative integers; the TS mounts refuse anything else with a 400 (see "TS-only error responses").- No page-size maximum, and the default is per port. The contract pins what
an explicit
limit/offsetreturns (list-with-pagination); it does not pin a default or a ceiling, and the ports differ. Withlimitomitted, the TypeScript mounts and the C# routes return every matching row, while the Java, Kotlin and Python controllers return the first 50. No port caps an explicitlimiton an entity —limit=100000is honoured as asked — except the TS opaque-view route, which refuses alimitabove 1000 (see "TS-only error responses"). A list over a large table should therefore always send alimit. A default page size is deliberately not introduced in a patch release: it would silently truncate the lists of every existing client that relies on the unpaged answer. withCount=1— opt-in flag that switches the list response from[<row>...]to{ rows: [<row>...], total: <N> }(needed for grid pagination). The grid hook always sendswithCount=1.
The two sides are configured separately, and deliberately so.
apiPrefix in metaobjects.config.ts is a server setting: the generated
routes mount under it, baked in as a literal, because the code that registers
/api/authors is the server.
export default defineConfig({
apiPrefix: "/api", // server mounts /api/authors
});The client's base is a deployment fact, so it is supplied at runtime rather
than frozen at meta gen time. Generated hooks and Angular services emit
entity-relative paths (${Author.$path}/${id}), and the provider prepends the
base:
<EntityFetcherProvider fetcher={fetcher} baseUrl="/api">// Angular
provideEntityFetcher({ fetcher, baseUrl: "/api" })baseUrl is optional and defaults to "" — correct for an app served from the
same origin with routes at the root, which is what meta init scaffolds
(apiPrefix: ""). Because it is runtime configuration, one client bundle can be
served against a dev proxy, a preview environment, or a separate API host
(baseUrl: "https://api.example.com/v1") without regenerating.
Before 0.25.0 the prefix was baked into every generated entity descriptor. See the migration guide for the one-line change.
- JSON for all request and response bodies (
application/json; charset=utf-8). - No envelope on single-row responses (
GET /:id,POST,PATCH,PUT) — the body is the row. - List responses are either
[<row>...](default) or{ rows, total }(whenwithCount=1).
| Metadata field type | JSON type | Notes |
|---|---|---|
field.string, field.uuid, field.enum |
string | UUID is canonical hex (8-4-4-4-12). |
field.int, field.long, field.double |
number | long MAY be string on overflow; defer to per-port docs. |
field.boolean |
boolean | – |
field.date |
string | ISO 8601 calendar date (YYYY-MM-DD). Asserted literally by the report/ sub-corpus (a time dimension's bucket), the first corpus to do so. |
field.decimal |
per port | Not pinned. Each port sends its own decimal form (TypeScript a string, since the driver reads numeric as one), and no scenario asserts a decimal's spelling: precision is the engine's. A client reading decimals from more than one backend must accept a string or a number. |
field.time |
string | HH:MM:SS[.fff]. |
field.timestamp |
string | Instant, tz-aware by default (ADR-0036 Wave 2) — YYYY-MM-DDTHH:MM:SS[.fff]Z, always UTC. |
field.timestamp (@localTime: true) |
string | Naive wall clock — YYYY-MM-DDTHH:MM:SS[.fff], no Z. |
field.currency |
integer minor units | Cents for USD, yen for JPY. Float arithmetic is forbidden. Server never formats. |
field.object (@storage: jsonb) |
object | Nested per the sub-object schema. |
field.object (@storage: flattened) |
object | Same JSON shape — only the storage differs. |
The currency invariant is the load-bearing one: every port emits and
expects integer minor units on the wire, and the
features/field-types.md reference enforces this for
each port's codegen output. Float arithmetic for money has bitten
every language at least once.
The temporal spellings above are not this document's to define — they are
the cross-port wire forms pinned by
fixtures/persistence-conformance/normalization.md,
which every port byte-matches. Two properties of that rule are easy to get
wrong and are restated here only because they decide whether a response
compares equal:
- The
Zdiscriminates the two timestamp kinds. Never elide it for a default (tz-aware)field.timestamp, and never add it for an@localTime: trueone. The suffix is what tellsTIMESTAMPTZfromTIMESTAMP. - Sub-seconds are millisecond resolution, truncated, with no trailing
zeros — and the fractional component and its
.are omitted entirely when zero. A whole-second instant is2026-05-25T14:30:00Z, never2026-05-25T14:30:00.000Z.
Non-2xx responses MUST return:
{ "error": "<short_code>", "message": "<optional human string>" }- HTTP 400 — validation and filter/sort-parser errors.
- HTTP 404 —
{ "error": "not_found" }. - HTTP 409 — a declared constraint conflicting with existing state (a uniqueness or referential violation); a constraint rejecting the request's own value — a
CHECKorNOT NULLviolation — stays a 400, because resending the same body can never succeed while a 409 can clear once the other row changes (the TS body is under "TS-only error responses"). - HTTP 5xx — implementation-defined (the TS mount helpers answer
{ "error": "internal" }and nothing more — see "TS-only error responses").
These four codes are a hard cross-port invariant, and each one MUST carry
field — the filter or sort field the request was rejected for:
error |
Raised when | Example |
|---|---|---|
invalid_filter_field |
the field is not on the entity's filter allowlist | { "error": "invalid_filter_field", "field": "unknown" } |
invalid_filter_op |
the operator is not valid for that field's subtype | { "error": "invalid_filter_op", "field": "name" } |
invalid_filter_value |
the value will not coerce for that field and operator | { "error": "invalid_filter_value", "field": "bio" } |
invalid_sort |
the sort field is off the sort allowlist, or the order is not asc/desc |
{ "error": "invalid_sort", "field": "unknownfield" } |
field is REQUIRED, not optional. An optional member is un-gateable — the
corpus can neither require nor forbid it — which is exactly how one port came
to send it while four did not, invisibly, for several releases. A code that is
not about a particular field (an implementation-specific guard such as filter
nesting depth or in-list size) carries no field, and those guards are outside
this contract: a port may emit them under its own code.
Responses may carry additional diagnostic members beyond field (TS adds op,
expected and the allowlist — an invalid_sort answer carries
"expected": "sort=<field>:asc|desc" and allowed, the sortable fields, so a
?sort=-createdAt names the spelling that works); a consumer must tolerate them. Gated by
fixtures/api-contract-conformance/scenarios/filter-invalid-*.yaml and
invalid-sort-400.yaml in both the reference and generated lanes of every port.
Outside those four, treat any 4xx as user-facing and any 5xx as retryable / log-only.
| Port | Route codegen | Notes |
|---|---|---|
| TypeScript | shipped — @metaobjectsdev/codegen-ts routesFile() → Fastify (@metaobjectsdev/runtime-ts/drizzle-fastify) AND routesFileHono() → Hono (@metaobjectsdev/runtime-ts/hono) |
Reference implementation; full filter/sort + withCount support. Both flavors emit byte-identical on-the-wire responses for the same metadata (same envelopes, same status codes, same filter operator parser), so consumers can pick the server framework that matches their runtime (Fastify for long-lived Node, Hono for Workers / Bun / edge). |
| C# | shipped — MetaObjects.Codegen RoutesGenerator → ASP.NET Minimal API |
MapGet / MapPost / MapPut / MapDelete mounted under apiPrefix; full CRUD. |
| Java | shipped — metaobjects-codegen-spring SpringControllerGenerator + SpringDtoGenerator + SpringRepositoryGenerator → Spring @RestController (Spring Boot 3.x / Spring Web MVC) |
One controller per writable entity (source.rdb @kind="table") plus a READ-ONLY one per view-kind object.projection (F22, see "Read-only projections" below); 5 CRUD endpoints (GET list / GET by id / POST / PATCH + PUT / DELETE); ?sort, ?limit/?offset, ?withCount=1 envelope, 404 + 400 envelopes per the contract. Java 21 record DTOs for request/response; a stubbed <Entity>Repository interface the consumer implements against their persistence layer (JPA / jOOQ / JDBC). Filter operators (eq/ne/gt/gte/lt/lte/in/like/isNull) ship via the generated <Entity>FilterAllowlist (SpringFilterAllowlistGenerator) + the runtime FilterParser, wired directly into the list handler. |
| Kotlin | shipped — metaobjects-codegen-kotlin KotlinSpringControllerGenerator → Spring @RestController |
One controller per writable entity (source.rdb @kind="table") plus a READ-ONLY one per view-kind object.projection (F22, see "Read-only projections" below); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); ?sort, ?limit/?offset, ?withCount=1 envelope, 404 + 400 envelopes per the contract. Filter operators ship via the generated <Entity>FilterAllowlist (KotlinFilterAllowlistGenerator) + an inline parse<Entity>Filter helper emitted in the controller. |
| Python | shipped — metaobjects.codegen.generators.router_generator → FastAPI APIRouter |
One router per writable entity (source.rdb @kind="table") plus a READ-ONLY one per view-kind object.projection (F22, see "Read-only projections" below); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); ?sort, ?limit/?offset, ?withCount=1 envelope, 404 + 400 envelopes per the contract. Consumer wires the repository via FastAPI app.dependency_overrides; the generator emits a Protocol interface so the persistence layer (SQLAlchemy / asyncpg / etc.) is the consumer's choice. Filter operators ship via the generated <entity>_filter_allowlist.py (filter_allowlist_generator.py) + the shared filter_parser helper, wired into the list handler. |
All five ports serve an object.projection whose only source is a read-only
view. This used to be a 2-vs-3 split — TypeScript and C# mounted routes for one,
Java, Kotlin and Python emitted nothing — and the split was documented rather than
decided, because no corpus scenario ever asked. F22 ruled it: a projection's routes
are derivable from declared metadata exactly as a table entity's are, so they are
codegen, and scoping them out would have dropped a capability two ports shipped.
The surface:
GET /<plural>is mounted, andGET /<plural>/{id}when the projection can be addressed by key (the keyless rule below), through whatever identity it declares or inherits.?filter[...]and?sort=apply, against allowlists generated from the projection's own declared field set — not the base entity's.- Every write verb answers
405with{"error": "method_not_allowed"}—POSTon the collection,PATCH/PUT/DELETEon the item. 405 rather than 404 because the resource plainly exists: the same path answersGET.messageis free prose and is not part of the contract. - A keyless projection, one that declares and inherits no
identity.primary, mounts no/{id}route of any verb in any port, so it refuses only the collection verb — refusing an item verb would advertise an address the port never serves. A field that happens to be namedidis a convention, not a key: a projection with anidfield and no declared identity is keyless.GET /<plural>/1on one answers the framework's own404, never a row. A projection with a declared identity addresses a row by the field that identity names, which need not be calledidand which the view's column must match:GET /<plural>/{key}answers that row, or the404 {"error": "not_found"}envelope. One difference remains, and the corpus does not gate it: C#, Java and Kotlin mount item routes only for a single-column identity, while TypeScript and Python bind a composite one to its first field. - Filters apply to a projection's
field.decimalandfield.floatfields as they do to an entity's. A decimal's wire spelling is port-specific and is not asserted. - The api docs list the same routes. A projection's api page documents
GET <path>, andGET <path>/{id}only when the item route exists. The405refusals are not operations a caller can use, so no write verb is documented. Pinned in every port byprojection/docs-routes.json.
Every port mounts those refusals explicitly. Left to the framework, ASP.NET and
Spring each answer an unmatched method on a matched path with an empty-bodied 405 and
FastAPI with {"detail": ...} — three more body shapes on a wire the contract spells
one way.
Gated by fixtures/api-contract-conformance/projection/,
which runs the generated lane only, on all five ports: what is under test is
whether a port's generator emits the routes, and a hand-rolled reference server would
answer every scenario by construction.
All five ports serve a view-backed object.report (a concrete report whose read source
is a source.rdb of @kind: view) exactly as a keyless read-only projection is served. A
report with no source, an abstract one, and one over a materializedView, storedProc or
tableFunction mount nothing. <segment> is the object rule above: InvoicesByMonth is at
/invoices_by_months.
| Request | Answer |
|---|---|
GET /<apiPrefix>/<segment> |
200, a JSON array of rows, one per distinct dimension tuple. ?filter[...], ?sort=, limit and offset apply as on any list route; withCount=1 answers { "rows": [...], "total": N }, N being the number of groups after filtering. |
POST /<apiPrefix>/<segment> |
405 {"error": "method_not_allowed"}. message is free prose. |
any verb on /<apiPrefix>/<segment>/{id} |
Not mounted, in every port: a report has no identity. The framework's own 404; its body is outside the contract. |
| a filter or sort error | The field-naming envelopes above, unchanged. |
Every derived field whose type has filter operators is filterable and sortable, dimension
and measure alike; the allowlists are the report's own derived fields, never the @from
entity's. No request parameter picks dimensions, measures or a grain. No port generates a
client hook, grid or form for a report yet.
Gated by fixtures/api-contract-conformance/report/
(13 scenarios), generated lane only, on all five ports, for the reason projection/
gives. The columns, their types and the per-port generated files are in
reporting.md.
Every port ships a route generator today (see the status table above) —
you do not need to hand-write a controller to get a conforming API. This
section is for the rare case where you need a shape the generator
doesn't cover (a custom auth scheme, a framework the generator doesn't
target, etc.) and want a hand-rolled controller that still speaks the
contract. The shape is the same in every language — mount five (or six,
if you want PUT) routes under apiPrefix that match the URL grammar
above.
// AuthorController.java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/authors")
public class AuthorController {
private final AuthorRepository repo;
public AuthorController(AuthorRepository repo) { this.repo = repo; }
@GetMapping
public Object list(@RequestParam Map<String, String> qs) {
var page = repo.find(FilterParser.parse(qs, AuthorFilterAllowlist.INSTANCE));
return qs.containsKey("withCount")
? Map.of("rows", page.rows(), "total", page.total())
: page.rows();
}
@GetMapping("/{id}") public Author get(@PathVariable long id) { return repo.findById(id).orElseThrow(() -> new NotFound()); }
@PostMapping public ResponseEntity<Author> create(@RequestBody AuthorInsert in) { return ResponseEntity.status(201).body(repo.insert(in)); }
@PatchMapping("/{id}") public Author update(@PathVariable long id, @RequestBody AuthorUpdate in) { return repo.update(id, in); }
@DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable long id) { repo.delete(id); return ResponseEntity.noContent().build(); }
}// AuthorController.kt
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/authors")
class AuthorController(private val repo: AuthorRepository) {
@GetMapping
fun list(@RequestParam qs: Map<String, String>): Any {
val page = repo.find(FilterParser.parse(qs, AuthorFilterAllowlist))
return if ("withCount" in qs) mapOf("rows" to page.rows, "total" to page.total) else page.rows
}
@GetMapping("/{id}") fun get(@PathVariable id: Long): Author = repo.findById(id) ?: throw NotFound()
@PostMapping fun create(@RequestBody input: AuthorInsert): ResponseEntity<Author> = ResponseEntity.status(201).body(repo.insert(input))
@PatchMapping("/{id}") fun update(@PathVariable id: Long, @RequestBody input: AuthorUpdate): Author = repo.update(id, input)
@DeleteMapping("/{id}") fun delete(@PathVariable id: Long): ResponseEntity<Void> { repo.delete(id); return ResponseEntity.noContent().build() }
}# author_router.py
from fastapi import APIRouter, HTTPException, Request, status
from .author import Author, AuthorInsert, AuthorUpdate, AuthorFilterAllowlist
from .repo import AuthorRepository
from .filter_parser import parse_filter_qs
router = APIRouter(prefix="/api/authors")
repo = AuthorRepository()
@router.get("")
async def list_authors(request: Request):
qs = dict(request.query_params)
page = repo.find(parse_filter_qs(qs, AuthorFilterAllowlist))
return {"rows": page.rows, "total": page.total} if "withCount" in qs else page.rows
@router.get("/{id}")
async def get_author(id: int) -> Author:
row = repo.find_by_id(id)
if row is None: raise HTTPException(status_code=404, detail={"error": "not_found"})
return row
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_author(input: AuthorInsert) -> Author:
return repo.insert(input)
@router.patch("/{id}")
async def update_author(id: int, input: AuthorUpdate) -> Author:
return repo.update(id, input)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_author(id: int) -> None:
repo.delete(id)The filter-parser implementation is the bulk of the work; the route
shapes themselves are trivial. The TS parseFilterParams (in
@metaobjectsdev/runtime-ts/drizzle-fastify) is the reference — port it
into your framework's idiomatic query-builder, gated by the generated
<Entity>FilterAllowlist.
Cross-port route + filter codegen (Java, Kotlin, Python), the shared
route-shape oracle (fixtures/api-contract-conformance/), and a
browser-side Angular client have all shipped — see "Per-port route
codegen status" above and "Verified by" below, and
docs/ports/typescript-client.md#angular-18.
What's still genuinely open:
- The seven TS-only filter extensions (
?search=,filter[or]/filter[and]nesting, leading-wildcard gating, the nesting-depth cap, thein-list size cap, the bare filterable-field 400, and the filter-value format check) — see "TS-only filter extensions" above. None touch the metamodel vocabulary, so any of them can be promoted cross-port later as a purely additive, non-breaking change if real consumer demand shows up. - The
errorcode vocabulary is not yet a hard cross-port invariant beyondnot_foundand the filter-parser error codes.
Neither item blocks adoption on any port today.
The query semantics behind these routes — filter operators, sort,
withCount, identity-by-id, projection read-only-ness — are exercised
by the shared corpus at
fixtures/persistence-conformance/queries/,
which every port runs against an ephemeral Postgres container via
scripts/integration-test.sh. Identical normalized results across every
port is the contract; deviation is a port bug.
The URL-grammar half (qs parsing, route mounting, status codes, JSON
envelope shape) is exercised by the cross-port corpus at
fixtures/api-contract-conformance/
The scenario count is deliberately not restated here — it was stale in this
sentence the last time it was written, and ls fixtures/api-contract-conformance/scenarios/ is always right. Representative
cases: list-empty, list-with-pagination, list-with-withcount,
sort-asc-desc, sort-default-order, get-by-id, create-201,
update-patch-and-put, delete-204-and-404, invalid-sort-400, and the
filter-* operator family. Each
port's runner spins up a real HTTP server hosting its emitted routes for the
canonical Author entity, walks the scenarios, and asserts byte-shape
identical responses against the cross-port expect.body.* vocabulary.
All five ports ship a runner — TypeScript, Java, Kotlin, C#, and Python
each spin up their generated routes and pass every scenario. See
docs/CONFORMANCE.md for per-port pass status.
Filter operator coverage (eq / ne / gt / gte / lt / lte / in /
like / isNull) is part of the corpus: filter scenarios run on top of
the base CRUD scenarios, and all five ports satisfy the full
api-contract-conformance suite today (see
docs/CONFORMANCE.md).
docs/features/metadata-api.md— theGET /_metamodel-shape contract this page's row-data contract complementsdocs/features/loaders.md— how metadata maps to the entity shapes referenced by these routesdocs/features/field-types.md— wire-format encoding per field subtype (currency minor units, date / timestamp, enum)docs/ports/typescript-client.md— the universal browser client that consumes this contractdocs/ports/typescript.md— server-side TS reference implementation of route codegendocs/ports/csharp.md— C# reference implementation of route codegen