Skip to content

Commit f876e06

Browse files
committed
Merge docs A — TS client reference + cross-port API contract
NEW docs/ports/typescript-client.md (508 lines) — self-contained TS client reference. Covers all 3 client/web/packages/* runtimes + the 2 codegen-ts-* packages + currency + filter URL grammar + per-target output + cell-renderer overrides + layout.dataGrid metadata. NEW docs/features/api-contract.md (325 lines) — cross-port REST API contract. EntityFetcher type, URL grammar + filter operators + apiPrefix policy, wire format (currency minor-units, ISO dates, pagination), honest per-port route- codegen status table (TS ✓ / C# ✓ / Java + Kotlin + Python = hand-write today), worked example hand-written controllers for Java Spring / Kotlin Spring / Python FastAPI matching the URL grammar for one entity. Light updates: docs/README.md indexes both new docs; docs/ports/typescript.md points at typescript-client.md instead of duplicating; root README capability matrix gains React/UI + REST routes rows. Source-of-truth discoveries: all CLAUDE.md-described TS-client features are actually shipped (formFile / tanstackQuery / tanstackGrid / useEntityForm / CurrencyInput / EntityGrid / CellRendererProvider / buildFilterQs all confirmed in source). Runtime supports both PATCH + PUT verbs and a withCount=1 envelope — both documented.
2 parents be3b3ec + dcc1b22 commit f876e06

5 files changed

Lines changed: 858 additions & 0 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,21 @@ working code.
4545
| Template-drift verify | Yes | Yes (`Renderer.verify`) | Yes (via Java) | Yes (`meta verify`) | Yes (`metaobjects.render.verify`) |
4646
| YAML authoring (sigil-free → JSON) | Yes | Yes | Yes (via Java) | Yes | Yes |
4747
| Runtime metadata (ObjectManager-style) | Yes (`runtime-ts`) | Yes (OMDB) | Yes (via Java OMDB + Exposed) | Roadmap | Roadmap |
48+
| React / UI client (browser) | Yes (`@metaobjectsdev/react` + `@metaobjectsdev/tanstack`; codegen + runtime) | Consumes TS client via REST | Consumes TS client via REST | Consumes TS client via REST | Consumes TS client via REST |
49+
| Cross-port REST routes for the client | Generated (`routesFile()` → Fastify) | Hand-write Spring controller per contract | Hand-write Spring-Kotlin / Ktor per contract | Generated (`RoutesGenerator` → ASP.NET Minimal API) | Hand-write FastAPI router per contract |
4850

4951
A "Yes" means the feature is covered by the shared conformance corpora at
5052
[`fixtures/`](fixtures/) for that port, or by a port-local test of equivalent
5153
scope. A "partial" means the loader recognizes the metamodel feature but the
5254
codegen / runtime tier doesn't fully exercise it yet.
5355

56+
The React / UI client is TypeScript-only by construction (the browser is
57+
TS-native) but is **universal** — see
58+
[`docs/features/api-contract.md`](docs/features/api-contract.md) for the
59+
URL grammar + wire format the client speaks, and
60+
[`docs/ports/typescript-client.md`](docs/ports/typescript-client.md) for
61+
the consumer-side wiring.
62+
5463
## Four pillars
5564

5665
Equal-weight. The first three ship per-language today; the fourth lands in

docs/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ docs/
2424
│ ├── templates-and-payloads.md
2525
│ ├── loaders.md
2626
│ ├── migrations-and-drift.md
27+
│ ├── api-contract.md # cross-port REST contract (the universal browser client speaks it)
2728
│ └── yaml-authoring.md
2829
└── ports/ # one file per language/framework port
2930
├── typescript.md
31+
├── typescript-client.md # browser-side TS (React, TanStack, codegen pairs)
3032
├── java.md
3133
├── kotlin.md
3234
├── csharp.md
@@ -42,6 +44,7 @@ docs/
4244
| Compare what TS vs Java vs Kotlin vs C# vs Python emit for the same metadata | any [`features/*.md`](features/) — every feature shows all five ports side-by-side |
4345
| Author metadata in YAML instead of JSON | [`features/yaml-authoring.md`](features/yaml-authoring.md) |
4446
| Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) |
47+
| Wire the universal browser client (React + TanStack) to any backend | [`ports/typescript-client.md`](ports/typescript-client.md) + [`features/api-contract.md`](features/api-contract.md) |
4548
| Know which feature is supported in which port today | the capability matrix in the root [`README.md`](../README.md) or the per-port "Capability snapshot" table |
4649
| See which conformance fixtures gate each feature (and which port passes which) | [`CONFORMANCE.md`](CONFORMANCE.md) |
4750
| Read the canonical spec (target-agnostic) | [`../spec/`](../spec/) |

docs/features/api-contract.md

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
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

Comments
 (0)