Skip to content

Commit fc5b670

Browse files
committed
Merge FR-008 §2.6 — API contract conformance corpus + TS + Kotlin reference runners (10/10 each)
# Conflicts: # bun.lock
2 parents a971644 + 1aa2470 commit fc5b670

27 files changed

Lines changed: 1558 additions & 63 deletions

bun.lock

Lines changed: 101 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/CONFORMANCE.md

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Conformance coverage
22

3-
The MetaObjects standard has 5 cross-language conformance corpora plus a deferred
4-
6th (`codegen-conformance/`, tracked as FR-007). Every port runs every applicable
3+
The MetaObjects standard has 6 cross-language conformance corpora plus a deferred
4+
7th (`codegen-conformance/`, tracked as FR-007). Every port runs every applicable
55
corpus and asserts the same expected behavior against the same fixtures. **This
66
page is the inverse index**: fixture → feature doc + per-port pass status.
77

@@ -19,17 +19,18 @@ human-readable explanation somewhere, look it up in the
1919
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | 31 / 31 | 31 / 31 | inherits via Java | 31 / 31 | 31 / 31 |
2020
| [`fixtures/render-conformance/`](../fixtures/render-conformance/) | 4 | 4 / 4 | 4 / 4 | inherits via Java | 4 / 4 | 4 / 4 |
2121
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 12 (9 query + 3 migration) | 12 / 12 | 12 / 12 | 12 / 12 (via Exposed) | 12 / 12 | 12 / 12 |
22+
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 10 | 10 / 10 (Fastify reference runner) | not yet wired | 10 / 10 (embedded HTTP + Exposed reference runner) | not yet wired | not yet wired |
2223
| `fixtures/codegen-conformance/` (FR-007 — DROPPED in favor of `persistence-conformance` participation) | 0 | n/a | n/a | n/a | n/a | n/a |
2324

2425
Per-port runners + commands:
2526

26-
| Port | Metamodel + YAML + render + verify | Persistence |
27-
|---|---|---|
28-
| TypeScript | `cd server/typescript && bun test` (per-package, `~3s`) | `scripts/integration-test.sh ts` (needs Docker) |
29-
| Java | `mvn -pl metaobjects-conformance test` (and per-tier `-pl render`, etc.) | `scripts/integration-test.sh java` (needs Docker) |
30-
| Kotlin | `mvn -pl codegen-kotlin test` (snapshot suite) | `mvn -pl integration-tests-kotlin test` (needs Docker) |
31-
| C# | `dotnet test` (per project) | `scripts/integration-test.sh csharp` (needs Docker) |
32-
| Python | `pytest` (per package) | `scripts/integration-test.sh python` (needs Docker) |
27+
| Port | Metamodel + YAML + render + verify | Persistence | API contract |
28+
|---|---|---|---|
29+
| TypeScript | `cd server/typescript && bun test` (per-package, `~3s`) | `scripts/integration-test.sh ts` (needs Docker) | `cd server/typescript/packages/integration-tests && bun test test/api-contract.test.ts` (needs Docker) |
30+
| Java | `mvn -pl metaobjects-conformance test` (and per-tier `-pl render`, etc.) | `scripts/integration-test.sh java` (needs Docker) | not yet wired |
31+
| Kotlin | `mvn -pl codegen-kotlin test` (snapshot suite) | `mvn -pl integration-tests-kotlin test` (needs Docker) | `mvn -f server/java/integration-tests-kotlin/pom.xml test -Dtest=ApiContractConformanceTest` (needs Docker) |
32+
| C# | `dotnet test` (per project) | `scripts/integration-test.sh csharp` (needs Docker) | not yet wired |
33+
| Python | `pytest` (per package) | `scripts/integration-test.sh python` (needs Docker) | not yet wired |
3334

3435
The `persistence-conformance` corpus is intentionally **on-demand** — none of the
3536
unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
@@ -78,9 +79,20 @@ All 31 fixtures → [features/migrations-and-drift.md](features/migrations-and-d
7879
- `migrations/*` (3) → [features/migrations-and-drift.md](features/migrations-and-drift.md) (schema migration section)
7980
- `queries/*` (9) → [features/source-kinds.md](features/source-kinds.md) (query semantics against `source.rdb`)
8081

82+
### `fixtures/api-contract-conformance/` (10)
83+
84+
All 10 scenarios → [features/api-contract.md](features/api-contract.md) (cross-port
85+
REST API URL grammar + JSON wire format). Verifies every backend's emitted CRUD
86+
routes answer identically over HTTP — list / get / create / patch+put / delete,
87+
plus pagination (`limit`/`offset`), sort (`sort=field:dir`), the `withCount=1`
88+
envelope, the `not_found` / `invalid_sort` error envelopes, and the 201 / 204
89+
status codes. Filter operators (`eq`/`ne`/...) deferred — backends defer these
90+
per their `KNOWN_GAPS.md`; conformance for filter operators arrives when the
91+
operators ship per port.
92+
8193
## Orphaned fixtures (tested but not yet documented)
8294

83-
All 138 fixtures across the 5 active corpora map to a feature doc. None are
95+
All 148 fixtures across the 6 active corpora map to a feature doc. None are
8496
orphaned today.
8597

8698
If you add a new fixture and don't see a clear home for it, either:

docs/features/api-contract.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,10 +305,24 @@ which every port runs against an ephemeral Postgres container via
305305
`scripts/integration-test.sh`. Identical normalized results across every
306306
port is the contract; deviation is a port bug.
307307

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.
308+
The URL-grammar half (qs parsing, route mounting, status codes, JSON
309+
envelope shape) is exercised by the cross-port corpus at
310+
[`fixtures/api-contract-conformance/`](../../fixtures/api-contract-conformance/)
311+
(10 scenarios — `list-empty`, `list-with-pagination`, `list-with-withcount`,
312+
`sort-asc-desc`, `get-by-id`, `get-by-id-not-found`, `create-201`,
313+
`update-patch-and-put`, `delete-204-and-404`, `invalid-sort-400`). Each
314+
port's runner spins up a real HTTP server hosting its emitted routes for the
315+
canonical `Author` entity, walks the scenarios, and asserts byte-shape
316+
identical responses against the cross-port `expect.body.*` vocabulary.
317+
TypeScript + Kotlin runners ship; Java / C# / Python runners are planned
318+
follow-ups. See [`docs/CONFORMANCE.md`](../CONFORMANCE.md) for per-port pass
319+
status.
320+
321+
Filter operator coverage (`eq` / `ne` / `gt` / `like` / `in` / `isNull`) is
322+
deferred from the API-contract corpus on purpose — backends defer those per
323+
their `KNOWN_GAPS.md`, so a scenario for `filter[name][like]=...` would fail
324+
on every port today. Filter-operator scenarios land alongside the per-port
325+
operator implementations.
312326

313327
## See also
314328

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# `fixtures/api-contract-conformance/` — cross-port REST API contract corpus
2+
3+
Verifies that every backend's emitted CRUD routes — TS Fastify, Kotlin
4+
Spring controllers, Java Spring controllers, ASP.NET Minimal API, FastAPI
5+
routers — answer identically when driven over HTTP. The corpus is the
6+
language-agnostic contract; per-port runners spin up a real HTTP server,
7+
walk the scenarios, and assert each response matches.
8+
9+
Scope: the **URL grammar + wire format** half of the contract from
10+
[`docs/features/api-contract.md`](../../docs/features/api-contract.md).
11+
The persistence-layer half (filter operators, projections) is exercised
12+
separately by `fixtures/persistence-conformance/`.
13+
14+
## Shape
15+
16+
```
17+
fixtures/api-contract-conformance/
18+
├── README.md # this file
19+
├── meta.json # shared canonical metadata (Author entity)
20+
├── seed.json # 5 seed Author rows applied before each scenario
21+
└── scenarios/
22+
├── list-empty.yaml
23+
├── list-with-pagination.yaml
24+
├── list-with-withcount.yaml
25+
├── sort-asc-desc.yaml
26+
├── get-by-id.yaml
27+
├── get-by-id-not-found.yaml
28+
├── create-201.yaml
29+
├── update-patch-and-put.yaml
30+
├── delete-204-and-404.yaml
31+
└── invalid-sort-400.yaml
32+
```
33+
34+
`meta.json` declares a single canonical `Author` entity in the `acme::blog`
35+
package:
36+
37+
| Field | Type | Notes |
38+
|--------------|------------------|--------------------------------------|
39+
| `id` | `field.long` | `identity.primary @generation=increment` |
40+
| `name` | `field.string` | `@required` + `@maxLength 100` |
41+
| `bio` | `field.string` | nullable + `@maxLength 1000` |
42+
| `createdAt` | `field.timestamp`| `@required` |
43+
44+
`source.rdb @table="authors"` — the URL segment per the cross-port grammar
45+
is therefore `/api/authors` (lowercased + pluralized).
46+
47+
`seed.json` is applied fresh before every scenario (truncate-then-insert).
48+
Scenarios that need an empty table opt in via `setup: { truncate: true }`.
49+
50+
## Scenario YAML shape
51+
52+
```yaml
53+
name: <kebab-case-scenario-name>
54+
description: >
55+
Free-text describing what behavior the scenario verifies.
56+
setup: # optional
57+
truncate: true # opt-in: empty the table before this scenario's requests
58+
requests:
59+
- id: r1 # stable id per request (referenced in test logs)
60+
method: GET # GET | POST | PATCH | PUT | DELETE
61+
path: /api/authors?... # path + query string; runner prepends the base URL
62+
body: # optional; JSON-shaped; omitted for GET/DELETE
63+
name: "..."
64+
expect:
65+
status: 200 # exact HTTP status to assert
66+
body: # one of: equals | row | rows | length | envelope | error | empty | hasId | ids | names
67+
...
68+
```
69+
70+
### Supported `body.*` assertions
71+
72+
| Key | Meaning |
73+
|--------------|------------------------------------------------------------------------|
74+
| `equals` | deep-equal the response body to the literal value |
75+
| `length` | the response body is an array of this length |
76+
| `ids` | the response body is an array; assert the `id` field of each, in order |
77+
| `names` | the response body is an array; assert the `name` field of each |
78+
| `row` | the response body is an object; assert the listed keys match |
79+
| `hasId` | the response body is an object containing a numeric `id` |
80+
| `envelope` | the response body is `{ rows, total }` (set `rowsLength` + `total`) |
81+
| `error` | the response body has `error: "<value>"` |
82+
| `empty` | the response body is empty / null (204 No Content) |
83+
84+
Runners are responsible for normalizing `createdAt` (and any other
85+
non-deterministic fields) before comparison. The keys listed above are
86+
the only ones a runner must understand to be conformant.
87+
88+
## How a port's runner works
89+
90+
Every per-port runner:
91+
92+
1. Loads `meta.json` and applies it as a fresh schema (via the port's
93+
migrate engine or hand-rolled DDL for ports that lack migrate).
94+
2. Starts an HTTP server hosting the port's generated CRUD routes for
95+
`Author` mounted under `/api`.
96+
3. For each scenario file in `scenarios/`:
97+
- Truncates + re-seeds `authors` from `seed.json` (or empties it when
98+
`setup.truncate: true`).
99+
- Walks `requests[]` in order, issuing each over HTTP via the port's
100+
standard test-client (Fastify inject, ASP.NET `WebApplicationFactory`,
101+
Spring `MockMvc`, FastAPI `TestClient`, or a raw HTTP client against
102+
a local-bound port).
103+
- Asserts the response status + body matches `expect`.
104+
4. Tears down (postgres testcontainer, in-memory DB, etc.).
105+
106+
The runner's job is to **map** the cross-port assertion vocabulary
107+
(`row` / `rows` / `envelope` / `error` / `empty`) onto its own port's
108+
test-assertion idioms.
109+
110+
## Adding a scenario
111+
112+
1. Drop a new `<name>.yaml` in `scenarios/`.
113+
2. Make sure each per-port runner's allowed-scenarios list (or auto-discovery
114+
glob) picks it up.
115+
3. If the scenario needs a new assertion shape beyond the table above, add it
116+
to **every** runner in lockstep — the corpus is the contract.
117+
118+
## Pass status per port
119+
120+
See [`docs/CONFORMANCE.md`](../../docs/CONFORMANCE.md) for the current
121+
per-port pass count against this corpus.
122+
123+
## Why this is separate from `persistence-conformance/`
124+
125+
`persistence-conformance/` exercises the runtime metadata pillar end-to-end
126+
through the persistence layer (filter operators, projections, view DDL).
127+
`api-contract-conformance/` exercises the URL grammar + HTTP wire shape
128+
above the persistence layer. A port can ship one without the other
129+
(e.g. a port might land routes-only first), so they live in separate
130+
corpora and are gated independently.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::blog",
4+
"children": [
5+
{ "object.entity": {
6+
"name": "Author",
7+
"children": [
8+
{ "source.rdb": { "@table": "authors" } },
9+
{ "field.long": { "name": "id" } },
10+
{ "field.string": { "name": "name", "@required": true, "@maxLength": 100 } },
11+
{ "field.string": { "name": "bio", "@maxLength": 1000 } },
12+
{ "field.timestamp": { "name": "createdAt", "@required": true } },
13+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
14+
]
15+
}}
16+
]
17+
}
18+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: create-201
2+
description: >
3+
POST /api/authors with a valid body returns HTTP 201 and a row with the
4+
server-assigned id. Verifies the create verb returns the persisted entity.
5+
requests:
6+
- id: r1
7+
method: POST
8+
path: /api/authors
9+
body:
10+
name: "Brian Kernighan"
11+
bio: "Co-author of The C Programming Language"
12+
createdAt: "2026-02-01T10:00:00"
13+
expect:
14+
status: 201
15+
body:
16+
row:
17+
name: "Brian Kernighan"
18+
bio: "Co-author of The C Programming Language"
19+
# The exact id depends on autoincrement state, but it MUST be present.
20+
hasId: true
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: delete-204-and-404
2+
description: >
3+
DELETE /api/authors/2 returns HTTP 204 (no body) on first call. The second
4+
DELETE /api/authors/2 returns HTTP 404 with the cross-port error envelope
5+
because the row no longer exists. Verifies idempotency-safe error shape.
6+
requests:
7+
- id: r1
8+
method: DELETE
9+
path: /api/authors/2
10+
expect:
11+
status: 204
12+
body:
13+
empty: true
14+
- id: r2
15+
method: DELETE
16+
path: /api/authors/2
17+
expect:
18+
status: 404
19+
body:
20+
error: "not_found"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
name: get-by-id-not-found
2+
description: >
3+
GET /api/authors/999 (no such row) returns HTTP 404 with the cross-port
4+
error envelope { "error": "not_found" }.
5+
requests:
6+
- id: r1
7+
method: GET
8+
path: /api/authors/999
9+
expect:
10+
status: 404
11+
body:
12+
error: "not_found"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: get-by-id
2+
description: >
3+
GET /api/authors/3 returns the single row with id=3 as a bare JSON object
4+
(no envelope). Verifies the single-resource endpoint shape.
5+
requests:
6+
- id: r1
7+
method: GET
8+
path: /api/authors/3
9+
expect:
10+
status: 200
11+
body:
12+
row:
13+
id: 3
14+
name: "Grace Hopper"
15+
bio: "Invented the compiler"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: invalid-sort-400
2+
description: >
3+
GET /api/authors?sort=unknownfield:asc — the field is not on the entity's
4+
sort allowlist — returns HTTP 400 with the cross-port invalid-sort error
5+
envelope. Verifies the server-side sort allowlist gate.
6+
requests:
7+
- id: r1
8+
method: GET
9+
path: /api/authors?sort=unknownfield:asc
10+
expect:
11+
status: 400
12+
body:
13+
error: "invalid_sort"

0 commit comments

Comments
 (0)