|
| 1 | +# Cross-port REST API contract |
| 2 | + |
| 3 | +The browser-side TypeScript client (`@metaobjectsdev/runtime-web` + |
| 4 | +`@metaobjectsdev/react` + `@metaobjectsdev/tanstack`) is **universal**: it |
| 5 | +ships with the React + TanStack runtime and the generated query hooks + |
| 6 | +column defs, but it makes **no assumption about which language wrote the |
| 7 | +backend**. Any HTTP server — TypeScript Fastify, Java Spring, Kotlin Ktor, |
| 8 | +C# ASP.NET, Python FastAPI — that speaks the URL grammar and JSON wire |
| 9 | +format on this page can serve the same React app, with the same generated |
| 10 | +hooks and grids. |
| 11 | + |
| 12 | +This page is the contract. Implementations that pass it interoperate with |
| 13 | +the TS client; implementations that don't, don't. |
| 14 | + |
| 15 | +Throughout the doc the worked example is an `Author` entity in the |
| 16 | +`acme::blog` package. |
| 17 | + |
| 18 | +## What this is |
| 19 | + |
| 20 | +The contract has two halves: the **URL grammar** (paths + query-string |
| 21 | +shape) and the **wire format** (JSON request / response bodies). Both |
| 22 | +halves are language-agnostic and stable across the four shipped |
| 23 | +language ports. Routes can be **generated** (where the port ships a |
| 24 | +route generator) or **hand-written** (where it doesn't) — the wire |
| 25 | +behavior is identical either way. |
| 26 | + |
| 27 | +## The `EntityFetcher` contract |
| 28 | + |
| 29 | +The browser client never calls `fetch` directly. Every generated hook |
| 30 | +delegates to a single `EntityFetcher` function pulled from React |
| 31 | +context: |
| 32 | + |
| 33 | +```ts |
| 34 | +// from @metaobjectsdev/runtime-web |
| 35 | +export type EntityFetcher = <T>(path: string, init?: RequestInit) => Promise<T>; |
| 36 | +``` |
| 37 | + |
| 38 | +Responsibilities of the fetcher (supplied by the consumer's app, not |
| 39 | +generated): |
| 40 | + |
| 41 | +- Resolve the `path` argument (always starts with `apiPrefix`, e.g. |
| 42 | + `/api/author?...`) to a fully-qualified URL. |
| 43 | +- Attach auth (cookies / bearer token / API key) per the app's policy. |
| 44 | +- Parse the JSON response and return it typed as `T`. |
| 45 | +- Surface non-2xx as a thrown `Error` (hooks rely on this for React Query's |
| 46 | + `error` state). |
| 47 | + |
| 48 | +The fetcher is supplied once via `<EntityFetcherProvider value={fetcher}>` |
| 49 | +at the React tree root; every generated hook reads it via |
| 50 | +`useEntityFetcher()` from `@metaobjectsdev/tanstack`. |
| 51 | + |
| 52 | +## URL grammar |
| 53 | + |
| 54 | +These are the routes a generated TanStack hook calls. The `apiPrefix` |
| 55 | +prefix (default `/api`) is set in `metaobjects.config.ts` and is |
| 56 | +**baked into the generated entity-constants file** as `$apiPrefix`, so the |
| 57 | +client and server agree on it without runtime configuration. |
| 58 | + |
| 59 | +### Routes per entity |
| 60 | + |
| 61 | +| Verb | Path | Purpose | |
| 62 | +|---|---|---| |
| 63 | +| `GET` | `/<apiPrefix>/<entity>?filter[...][...]=...&sort=...&limit=N&offset=N&withCount=1` | List (with filter / sort / pagination) | |
| 64 | +| `GET` | `/<apiPrefix>/<entity>/:id` | Get by id | |
| 65 | +| `POST` | `/<apiPrefix>/<entity>` | Create | |
| 66 | +| `PATCH` | `/<apiPrefix>/<entity>/:id` | Update (partial) | |
| 67 | +| `PUT` | `/<apiPrefix>/<entity>/:id` | Update (replace) — optional; same body shape as `PATCH` | |
| 68 | +| `DELETE` | `/<apiPrefix>/<entity>/:id` | Delete | |
| 69 | + |
| 70 | +`<entity>` is lowercased + pluralized per the codegen's pluralization |
| 71 | +helper (so `Author` → `authors`). Generated TS hooks read `$path` from |
| 72 | +the entity-constants file, so the client and the server agree on the |
| 73 | +path segment without hand-coordination. |
| 74 | + |
| 75 | +### Filter operators (8) |
| 76 | + |
| 77 | +Filters use a **bracketed qs** shape: `filter[<field>][<op>]=<value>`. |
| 78 | +A bare value (`filter[<field>]=<value>`) is sugar for `eq`. Multiple |
| 79 | +filters AND together; the optional `filter[or]=[...]` / `filter[and]=[...]` |
| 80 | +keys nest disjunctions / conjunctions. |
| 81 | + |
| 82 | +| Operator | Strings | Numbers / Dates | Booleans | |
| 83 | +|---|---|---|---| |
| 84 | +| `eq`, `ne`, `isNull` | yes | yes | yes (eq + isNull only) | |
| 85 | +| `in`, `like` | yes | `in` only | – | |
| 86 | +| `gt`, `gte`, `lt`, `lte` | – | yes | – | |
| 87 | + |
| 88 | +The operator set is gated by field subtype in the generated |
| 89 | +`<Entity>FilterAllowlist`. A request with an operator that the field |
| 90 | +subtype doesn't support → HTTP 400. The operator list is a Tier 1 |
| 91 | +cross-port invariant — every port's parser must implement these eight |
| 92 | +and only these eight. |
| 93 | + |
| 94 | +### Sort + pagination |
| 95 | + |
| 96 | +- `sort=<field>:asc|desc` — single sort key (multi-sort not in the |
| 97 | + default contract). Field must appear in `<Entity>SortAllowlist`. |
| 98 | +- `limit=N` — page size. |
| 99 | +- `offset=N` — page offset. |
| 100 | +- `withCount=1` — opt-in flag that switches the list response from |
| 101 | + `[<row>...]` to `{ rows: [<row>...], total: <N> }` (needed for grid |
| 102 | + pagination). The grid hook always sends `withCount=1`. |
| 103 | + |
| 104 | +### `apiPrefix` policy |
| 105 | + |
| 106 | +`apiPrefix` in `metaobjects.config.ts` flows through codegen to **both** |
| 107 | +sides: the generated server routes mount under it, and the generated |
| 108 | +client hooks bake it into their fetch URLs via `$apiPrefix` on the |
| 109 | +entity-constants file. |
| 110 | + |
| 111 | +```ts |
| 112 | +export default defineConfig({ |
| 113 | + apiPrefix: "/api", // server mounts /api/author; hooks call /api/author |
| 114 | +}); |
| 115 | +``` |
| 116 | + |
| 117 | +## Wire format |
| 118 | + |
| 119 | +### Encoding |
| 120 | + |
| 121 | +- **JSON** for all request and response bodies (`application/json; |
| 122 | + charset=utf-8`). |
| 123 | +- No envelope on single-row responses (`GET /:id`, `POST`, `PATCH`, `PUT`) |
| 124 | + — the body is the row. |
| 125 | +- List responses are either `[<row>...]` (default) or |
| 126 | + `{ rows, total }` (when `withCount=1`). |
| 127 | + |
| 128 | +### Type encodings (Tier 1 invariant) |
| 129 | + |
| 130 | +| Metadata field type | JSON type | Notes | |
| 131 | +|---|---|---| |
| 132 | +| `field.string`, `field.uuid`, `field.enum` | string | UUID is canonical hex (`8-4-4-4-12`). | |
| 133 | +| `field.int`, `field.long`, `field.double` | number | `long` MAY be string on overflow; defer to per-port docs. | |
| 134 | +| `field.boolean` | boolean | – | |
| 135 | +| `field.date` | string | ISO 8601 calendar date (`YYYY-MM-DD`). | |
| 136 | +| `field.timestamp` | string | ISO 8601 with timezone (`YYYY-MM-DDTHH:mm:ss.sssZ`). | |
| 137 | +| `field.currency` | **integer minor units** | Cents for USD, yen for JPY. Float arithmetic is forbidden. Server never formats. | |
| 138 | +| `field.object` (`@storage: jsonb`) | object | Nested per the sub-object schema. | |
| 139 | +| `field.object` (`@storage: flattened`) | object | Same JSON shape — only the storage differs. | |
| 140 | + |
| 141 | +The currency invariant is the load-bearing one: every port emits and |
| 142 | +expects integer minor units on the wire, and the |
| 143 | +[`features/field-types.md`](field-types.md) reference enforces this for |
| 144 | +each port's codegen output. Float arithmetic for money has bitten |
| 145 | +every language at least once. |
| 146 | + |
| 147 | +### Error response |
| 148 | + |
| 149 | +Non-2xx responses MUST return: |
| 150 | + |
| 151 | +```json |
| 152 | +{ "error": "<short_code>", "message": "<optional human string>" } |
| 153 | +``` |
| 154 | + |
| 155 | +- HTTP 400 — validation / filter-parser errors (`{ "error": "validation", "issues": [...] }` |
| 156 | + in TS; `{ "error": "FILTER_UNKNOWN_FIELD", ... }` for filter |
| 157 | + errors). |
| 158 | +- HTTP 404 — `{ "error": "not_found" }`. |
| 159 | +- HTTP 5xx — implementation-defined. |
| 160 | + |
| 161 | +The exact `error` code vocabulary is not yet a hard cross-port |
| 162 | +invariant; consumers should treat any 4xx as user-facing and any 5xx as |
| 163 | +retryable / log-only. |
| 164 | + |
| 165 | +## Per-port route codegen status |
| 166 | + |
| 167 | +| Port | Route codegen | Notes | |
| 168 | +|---|---|---| |
| 169 | +| TypeScript | shipped — `@metaobjectsdev/codegen-ts` `routesFile()` → Fastify (`@metaobjectsdev/runtime-ts/drizzle-fastify`) | Reference implementation; full filter/sort + `withCount` support. | |
| 170 | +| C# | shipped — `MetaObjects.Codegen` `RoutesGenerator` → ASP.NET Minimal API | `MapGet` / `MapPost` / `MapPut` / `MapDelete` mounted under `apiPrefix`; full CRUD. | |
| 171 | +| Java | planned | Hand-write a Spring `@RestController` (or any HTTP-server framework) matching the URL grammar. OMDB persistence + the Maven plugin ship; only the controller layer is missing. | |
| 172 | +| Kotlin | planned (deferred per [codegen-kotlin](../superpowers/specs/2026-05-25-codegen-kotlin-design.md) §9) | Hand-write a Spring-Kotlin `@RestController` or Ktor route handler. | |
| 173 | +| Python | planned | Hand-write a FastAPI router matching the URL grammar. Entity-model codegen ships; persistence + migration in progress. | |
| 174 | + |
| 175 | +## Hand-writing a conforming controller |
| 176 | + |
| 177 | +While the planned route-codegen work matures, here is the minimum |
| 178 | +controller needed to make `useAuthors`, `useAuthor`, etc. work against |
| 179 | +each non-TS / non-C# backend. The shape is the same in every language — |
| 180 | +mount five (or six, if you want PUT) routes under `apiPrefix` that |
| 181 | +match the URL grammar above. |
| 182 | + |
| 183 | +### Java — Spring `@RestController` |
| 184 | + |
| 185 | +```java |
| 186 | +// AuthorController.java |
| 187 | +import org.springframework.http.ResponseEntity; |
| 188 | +import org.springframework.web.bind.annotation.*; |
| 189 | +import java.util.List; |
| 190 | +import java.util.Map; |
| 191 | + |
| 192 | +@RestController |
| 193 | +@RequestMapping("/api/authors") |
| 194 | +public class AuthorController { |
| 195 | + private final AuthorRepository repo; |
| 196 | + |
| 197 | + public AuthorController(AuthorRepository repo) { this.repo = repo; } |
| 198 | + |
| 199 | + @GetMapping |
| 200 | + public Object list(@RequestParam Map<String, String> qs) { |
| 201 | + var page = repo.find(FilterParser.parse(qs, AuthorFilterAllowlist.INSTANCE)); |
| 202 | + return qs.containsKey("withCount") |
| 203 | + ? Map.of("rows", page.rows(), "total", page.total()) |
| 204 | + : page.rows(); |
| 205 | + } |
| 206 | + |
| 207 | + @GetMapping("/{id}") public Author get(@PathVariable long id) { return repo.findById(id).orElseThrow(() -> new NotFound()); } |
| 208 | + @PostMapping public ResponseEntity<Author> create(@RequestBody AuthorInsert in) { return ResponseEntity.status(201).body(repo.insert(in)); } |
| 209 | + @PatchMapping("/{id}") public Author update(@PathVariable long id, @RequestBody AuthorUpdate in) { return repo.update(id, in); } |
| 210 | + @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable long id) { repo.delete(id); return ResponseEntity.noContent().build(); } |
| 211 | +} |
| 212 | +``` |
| 213 | + |
| 214 | +### Kotlin — Spring `@RestController` |
| 215 | + |
| 216 | +```kotlin |
| 217 | +// AuthorController.kt |
| 218 | +import org.springframework.http.ResponseEntity |
| 219 | +import org.springframework.web.bind.annotation.* |
| 220 | + |
| 221 | +@RestController |
| 222 | +@RequestMapping("/api/authors") |
| 223 | +class AuthorController(private val repo: AuthorRepository) { |
| 224 | + |
| 225 | + @GetMapping |
| 226 | + fun list(@RequestParam qs: Map<String, String>): Any { |
| 227 | + val page = repo.find(FilterParser.parse(qs, AuthorFilterAllowlist)) |
| 228 | + return if ("withCount" in qs) mapOf("rows" to page.rows, "total" to page.total) else page.rows |
| 229 | + } |
| 230 | + |
| 231 | + @GetMapping("/{id}") fun get(@PathVariable id: Long): Author = repo.findById(id) ?: throw NotFound() |
| 232 | + @PostMapping fun create(@RequestBody input: AuthorInsert): ResponseEntity<Author> = ResponseEntity.status(201).body(repo.insert(input)) |
| 233 | + @PatchMapping("/{id}") fun update(@PathVariable id: Long, @RequestBody input: AuthorUpdate): Author = repo.update(id, input) |
| 234 | + @DeleteMapping("/{id}") fun delete(@PathVariable id: Long): ResponseEntity<Void> { repo.delete(id); return ResponseEntity.noContent().build() } |
| 235 | +} |
| 236 | +``` |
| 237 | + |
| 238 | +### Python — FastAPI router |
| 239 | + |
| 240 | +```python |
| 241 | +# author_router.py |
| 242 | +from fastapi import APIRouter, HTTPException, Request, status |
| 243 | +from .author import Author, AuthorInsert, AuthorUpdate, AuthorFilterAllowlist |
| 244 | +from .repo import AuthorRepository |
| 245 | +from .filter_parser import parse_filter_qs |
| 246 | + |
| 247 | +router = APIRouter(prefix="/api/authors") |
| 248 | +repo = AuthorRepository() |
| 249 | + |
| 250 | +@router.get("") |
| 251 | +async def list_authors(request: Request): |
| 252 | + qs = dict(request.query_params) |
| 253 | + page = repo.find(parse_filter_qs(qs, AuthorFilterAllowlist)) |
| 254 | + return {"rows": page.rows, "total": page.total} if "withCount" in qs else page.rows |
| 255 | + |
| 256 | +@router.get("/{id}") |
| 257 | +async def get_author(id: int) -> Author: |
| 258 | + row = repo.find_by_id(id) |
| 259 | + if row is None: raise HTTPException(status_code=404, detail={"error": "not_found"}) |
| 260 | + return row |
| 261 | + |
| 262 | +@router.post("", status_code=status.HTTP_201_CREATED) |
| 263 | +async def create_author(input: AuthorInsert) -> Author: |
| 264 | + return repo.insert(input) |
| 265 | + |
| 266 | +@router.patch("/{id}") |
| 267 | +async def update_author(id: int, input: AuthorUpdate) -> Author: |
| 268 | + return repo.update(id, input) |
| 269 | + |
| 270 | +@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) |
| 271 | +async def delete_author(id: int) -> None: |
| 272 | + repo.delete(id) |
| 273 | +``` |
| 274 | + |
| 275 | +The filter-parser implementation is the bulk of the work; the route |
| 276 | +shapes themselves are trivial. The TS `parseFilterParams` (in |
| 277 | +`@metaobjectsdev/runtime-ts/drizzle-fastify`) is the reference — port it |
| 278 | +into your framework's idiomatic query-builder, gated by the generated |
| 279 | +`<Entity>FilterAllowlist`. |
| 280 | + |
| 281 | +## Future direction |
| 282 | + |
| 283 | +Cross-port route codegen — for Java (Spring), Kotlin (Spring-Kotlin / |
| 284 | +Ktor), Python (FastAPI), and a browser-side Angular client — is planned |
| 285 | +but not yet specced. The planned FR will track: |
| 286 | + |
| 287 | +- A shared route-shape oracle (analogous to the persistence-conformance |
| 288 | + corpus) covering `list` / `get` / `create` / `update` / `delete` + |
| 289 | + filter / sort / `withCount` + the error-response shape. |
| 290 | +- Per-port route generators emitting idiomatic controllers / routers |
| 291 | + against that oracle. |
| 292 | +- An Angular client (browser-side) that consumes the same generated |
| 293 | + hook surface as the React client. |
| 294 | + |
| 295 | +Until then, hand-written controllers per the templates above are |
| 296 | +expected, and they remain the conformance gate. |
| 297 | + |
| 298 | +## Verified by |
| 299 | + |
| 300 | +The query semantics behind these routes — filter operators, sort, |
| 301 | +`withCount`, identity-by-id, projection read-only-ness — are exercised |
| 302 | +by the shared corpus at |
| 303 | +[`fixtures/persistence-conformance/queries/`](../../fixtures/persistence-conformance/queries/), |
| 304 | +which every port runs against an ephemeral Postgres container via |
| 305 | +`scripts/integration-test.sh`. Identical normalized results across every |
| 306 | +port is the contract; deviation is a port bug. |
| 307 | + |
| 308 | +The URL-grammar half (qs parsing, route mounting, error shape) is |
| 309 | +covered by per-port HTTP-layer tests today; a shared |
| 310 | +`fixtures/api-conformance/` corpus is planned as part of the future |
| 311 | +route-codegen FR. |
| 312 | + |
| 313 | +## See also |
| 314 | + |
| 315 | +- [`docs/features/loaders.md`](loaders.md) — how metadata maps to the |
| 316 | + entity shapes referenced by these routes |
| 317 | +- [`docs/features/field-types.md`](field-types.md) — wire-format |
| 318 | + encoding per field subtype (currency minor units, date / timestamp, |
| 319 | + enum) |
| 320 | +- [`docs/ports/typescript-client.md`](../ports/typescript-client.md) — |
| 321 | + the universal browser client that consumes this contract |
| 322 | +- [`docs/ports/typescript.md`](../ports/typescript.md) — server-side TS |
| 323 | + reference implementation of route codegen |
| 324 | +- [`docs/ports/csharp.md`](../ports/csharp.md) — C# reference |
| 325 | + implementation of route codegen |
0 commit comments