|
| 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. |
0 commit comments