Skip to content

Commit ce9f0e4

Browse files
dmealingclaude
andauthored
test(api-contract): cross-port gate for jsonb open-bag parsed-value contract (#98) (#110)
New isolated `jsonb/` sub-corpus under fixtures/api-contract-conformance/ (peer of m2m/ and tph/): a single `Document` entity with a `field.string @dbColumnType:jsonb` open bag. The scenario POSTs records whose jsonb field holds a JSON object (and an object whose value is an array), reads them back, and asserts the field is the parsed object/array — NOT a JSON-encoded string. Locks in the API-boundary half of the #98 contract that was only gated per-port before (the persistence corpus gates the runtime wire form, not the generated DTO/validator accepting a posted object). Wired + run green in BOTH lanes for: - TypeScript: hand-rolled reference (ObjectManager) + GENERATED routes (runGen → emitted Document.routes.ts) over Postgres testcontainers. - Python: hand-rolled reference (FastAPI + pg8000) over a Postgres testcontainer + GENERATED router (render_router) with an in-memory seam. Real finding the gate surfaced (runtime-ts): ObjectManager.create rejected an object written to a `field.string @dbColumnType:jsonb` open bag — the write-path validator type-checked it as a string, contradicting the write-side coercer (serializeJsonbColumns), which already expects an object and JSON.stringifies it. The #98 fix had covered codegen (Zod `unknown`) and the read-side coercer but missed the runtime write-path validator. Fixed minimally: validator-runner skips the string type/length checks for an open-bag jsonb-columned string field (required-ness still applies). The generated API lane was already green; the runtime reference lane caught the gap. Deferred (corpus is ready, follow-up tracked in #98): Java, Kotlin, C#. Their api-contract harnesses bind the per-entity DTO shape statically (the JVM generated harness reflects a fixed Author constructor arity + ships a hand-written in-memory repo source; the C# generated lane is full-stack EF + Kestrel), so adding the Document entity means a new reference server + generated harness per port (mirroring their m2m/ and tph/ harnesses) rather than a focused fixture edit. The sub-corpus is isolated, so those ports' runners do not discover jsonb/ and stay green. Claude-Session: https://claude.ai/code/session_01LuZWKnWzYGVnESijL7uuky Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b97bda6 commit ce9f0e4

12 files changed

Lines changed: 962 additions & 0 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# `api-contract-conformance/jsonb/` — jsonb open-bag parsed-value corpus
2+
3+
Cross-port REST contract for the **`field.string @dbColumnType:jsonb` open
4+
JSON bag** at the API boundary. The field is a bare jsonb column that may hold
5+
*any* JSON value; the contract is that a client POSTs a JSON **object** to it
6+
and reads back the same **object** — never a JSON-encoded **string**
7+
(`"{\"k\":\"v\"}"`).
8+
9+
This closes a real gap: the persistence-conformance corpus
10+
(`queries/asset-uuid-roundtrip.yaml`) gates the runtime/ORM **wire** form of a
11+
jsonb column, but nothing gated that each port's **generated DTO/validator +
12+
controller** accept a posted object and surface a parsed value over HTTP. Each
13+
port already carries the fix on `main` (TS `z.unknown()`, Python `Any`,
14+
Java `Object`, Kotlin `JsonElement`, C# `JsonDocument`); this corpus locks it
15+
in cross-port.
16+
17+
The single `Document` entity (`acme::store`, `@table="documents"`) carries one
18+
open-bag field:
19+
20+
| Field | Type | Notes |
21+
|-----------|-----------------------------------|-----------------------------|
22+
| `id` | `field.long` | `identity.primary @generation=increment` |
23+
| `title` | `field.string` | `@required` + `@maxLength 200` |
24+
| `payload` | `field.string @dbColumnType=jsonb`| open JSON bag — parsed value over the wire |
25+
26+
`source.rdb @table="documents"` → the URL segment is `/api/documents`
27+
(lowercased + pluralized), per the cross-port grammar.
28+
29+
## Files
30+
31+
```
32+
jsonb/
33+
├── README.md # this file
34+
├── meta.json # the Document entity
35+
├── seed.json # 2 seed Documents (ids 1, 2), each with a jsonb object
36+
└── scenarios/
37+
└── jsonb-open-bag-roundtrip.yaml
38+
```
39+
40+
`seed.json` is applied fresh before the scenario; the runner advances the id
41+
sequence past `max(id)` so the next implicit-id POST lands at id 3 (then 4),
42+
mirroring the single-entity `create-201` contract.
43+
44+
## Scenario
45+
46+
`jsonb-open-bag-roundtrip.yaml` exercises both read and write of the open bag:
47+
48+
| Request | What it pins |
49+
|---|---|
50+
| `r1` GET `/api/documents/1` | a **seeded** jsonb object reads back as an object |
51+
| `r2` POST `/api/documents` | the generated validator accepts a posted nested object; the create echo is an object |
52+
| `r3` GET `/api/documents/3` | the persisted value reads back as the same object |
53+
| `r4` POST `/api/documents` | jsonb holds any JSON — an object whose values include an **array** |
54+
| `r5` GET `/api/documents/4` | the array-bearing structure reads back parsed (never a string) |
55+
56+
The `body.row` assertion deep-equals each listed key with **sorted-key**
57+
normalization (the corpus's existing canonical form), so the gate is
58+
object-vs-string and structure-preserving — it does not pin jsonb key order.
59+
A regressed port that returned `"{\"k\":\"v\"}"` (a string) instead of
60+
`{"k":"v"}` (an object) fails here.
61+
62+
## Per-port wiring status
63+
64+
Both lanes (hand-rolled reference server + the GENERATED routes/controller
65+
booted over HTTP) are wired and green for:
66+
67+
- **TypeScript** — reference (`ObjectManager`) + generated (`runGen`
68+
emitted `Document.routes.ts` over Postgres testcontainer).
69+
- **Python** — reference (FastAPI + pg8000 over Postgres testcontainer) +
70+
generated (`render_router` emitted router + in-memory seam).
71+
72+
Deferred (corpus is ready; follow-up): **Java**, **Kotlin**, **C#**. Their
73+
api-contract harnesses bind the per-entity DTO shape statically (the JVM
74+
generated harness reflects a fixed `Author` constructor arity + ships a
75+
hand-written in-memory repo source; the C# generated lane is full-stack EF +
76+
Kestrel), so adding the `Document` entity means a new reference server +
77+
generated harness per port (mirroring their `m2m/` and `tph/` sub-corpus
78+
harnesses) rather than a focused fixture edit. Tracked in #98.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::store",
4+
"children": [
5+
{ "object.entity": {
6+
"name": "Document",
7+
"children": [
8+
{ "source.rdb": { "@table": "documents" } },
9+
{ "field.long": { "name": "id", "@filterable": true, "@sortable": true } },
10+
{ "field.string": { "name": "title", "@required": true, "@maxLength": 200, "@filterable": true, "@sortable": true } },
11+
{ "field.string": { "name": "payload", "@dbColumnType": "jsonb" } },
12+
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }
13+
]
14+
}}
15+
]
16+
}
17+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: jsonb-open-bag-roundtrip
2+
description: >
3+
The `field.string @dbColumnType:jsonb` open-JSON-bag contract over HTTP: a
4+
client POSTs a record whose jsonb field holds a JSON OBJECT and reads it back
5+
as the same OBJECT — NOT a JSON-encoded string. This pins the API-boundary
6+
half of the contract that the per-port fixes establish (TS `z.unknown()`,
7+
Python `Any`, Java `Object`, Kotlin `JsonElement`, C# `JsonDocument`): the
8+
generated DTO/validator must accept a posted object and the read path must
9+
surface a parsed value, not a stringified `"{\"k\":\"v\"}"`.
10+
11+
Two seed rows (ids 1, 2) carry jsonb objects (read path); the next implicit-id
12+
POST lands at id 3, the one after at id 4 (the seed runner advances the
13+
sequence past max(id), mirroring the create-201 contract). Coverage:
14+
- r1: GET a SEEDED row → jsonb object read back as an object.
15+
- r2/r3: POST a nested object + GET it back → object on both write-echo and
16+
persisted read.
17+
- r4/r5: POST an object whose values include an ARRAY (jsonb holds any JSON)
18+
+ GET it back → structure preserved, parsed (never a string).
19+
requests:
20+
- id: r1
21+
method: GET
22+
path: /api/documents/1
23+
expect:
24+
status: 200
25+
body:
26+
row:
27+
id: 1
28+
title: "alpha"
29+
payload: { seeded: true, n: 1 }
30+
31+
- id: r2
32+
method: POST
33+
path: /api/documents
34+
body:
35+
title: "gamma"
36+
payload: { k: "v", n: 1, nested: { deep: true }, list: [1, 2, 3] }
37+
expect:
38+
status: 201
39+
body:
40+
hasId: true
41+
row:
42+
title: "gamma"
43+
payload: { k: "v", n: 1, nested: { deep: true }, list: [1, 2, 3] }
44+
45+
- id: r3
46+
method: GET
47+
path: /api/documents/3
48+
expect:
49+
status: 200
50+
body:
51+
row:
52+
id: 3
53+
title: "gamma"
54+
payload: { k: "v", n: 1, nested: { deep: true }, list: [1, 2, 3] }
55+
56+
- id: r4
57+
method: POST
58+
path: /api/documents
59+
body:
60+
title: "delta"
61+
payload: { items: [{ x: 1 }, { y: 2 }], flag: false }
62+
expect:
63+
status: 201
64+
body:
65+
hasId: true
66+
row:
67+
title: "delta"
68+
payload: { items: [{ x: 1 }, { y: 2 }], flag: false }
69+
70+
- id: r5
71+
method: GET
72+
path: /api/documents/4
73+
expect:
74+
status: 200
75+
body:
76+
row:
77+
id: 4
78+
title: "delta"
79+
payload: { items: [{ x: 1 }, { y: 2 }], flag: false }
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"rows": [
3+
{ "id": 1, "title": "alpha", "payload": { "seeded": true, "n": 1 } },
4+
{ "id": 2, "title": "beta", "payload": { "k": "v" } }
5+
]
6+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Hand-rolled reference FastAPI app for the ``Document`` entity from the
2+
``api-contract-conformance/jsonb/`` corpus.
3+
4+
The contract under test is the ``field.string @dbColumnType:jsonb`` open bag:
5+
a posted JSON object must round-trip as an object (never a JSON-encoded
6+
string). Pared down from ``api_contract_server.py`` (the Author reference) —
7+
the jsonb scenario only POSTs + GETs by id, so this server implements just
8+
those verbs.
9+
10+
Backend: pg8000 (pure-python DB-API; same driver as ``api_contract_server.py``).
11+
``payload`` is a bare ``JSONB`` column — the open bag holds any JSON value.
12+
pg8000 auto-decodes jsonb to a native Python object on read; on write the
13+
value is ``json.dumps``-serialized and bound with a ``::jsonb`` cast.
14+
"""
15+
from __future__ import annotations
16+
17+
import json
18+
from contextlib import closing
19+
from typing import Any
20+
21+
import pg8000.dbapi as pg8000
22+
from fastapi import FastAPI, Request, status
23+
from fastapi.responses import JSONResponse
24+
25+
from .postgres_container import PostgresInfo
26+
27+
28+
class DocumentRepository:
29+
"""Direct-JDBC-equivalent repo against the test Postgres instance."""
30+
31+
def __init__(self, info: PostgresInfo) -> None:
32+
self._info = info
33+
34+
def create_schema(self) -> None:
35+
self._exec(
36+
'CREATE TABLE IF NOT EXISTS "documents" ('
37+
" id BIGSERIAL PRIMARY KEY,"
38+
" title VARCHAR(200) NOT NULL,"
39+
" payload JSONB"
40+
")"
41+
)
42+
43+
def truncate(self) -> None:
44+
self._exec('TRUNCATE TABLE "documents" RESTART IDENTITY')
45+
46+
def apply_seed(self, rows: list[dict[str, Any]]) -> None:
47+
"""Insert seed rows with explicit ids, then bump the sequence so the
48+
next implicit-id POST lands at max(id) + 1 (the GET-after-POST
49+
contract relies on a deterministic id)."""
50+
self.truncate()
51+
with closing(self._connect()) as conn:
52+
cur = conn.cursor()
53+
try:
54+
for r in rows:
55+
cur.execute(
56+
'INSERT INTO "documents" (id, title, payload) '
57+
"VALUES (%s, %s, %s::jsonb)",
58+
(int(r["id"]), r["title"], _dump_payload(r.get("payload"))),
59+
)
60+
cur.execute(
61+
"SELECT setval(pg_get_serial_sequence('documents', 'id'), "
62+
"COALESCE((SELECT MAX(id) FROM documents), 1))"
63+
)
64+
conn.commit()
65+
finally:
66+
cur.close()
67+
68+
def find_by_id(self, ident: int) -> dict[str, Any] | None:
69+
with closing(self._connect()) as conn:
70+
cur = conn.cursor()
71+
try:
72+
cur.execute(
73+
'SELECT id, title, payload FROM "documents" WHERE id = %s',
74+
(int(ident),),
75+
)
76+
row = cur.fetchone()
77+
return _row_to_dict(row) if row is not None else None
78+
finally:
79+
cur.close()
80+
81+
def create(self, dto: dict[str, Any]) -> dict[str, Any]:
82+
with closing(self._connect()) as conn:
83+
cur = conn.cursor()
84+
try:
85+
cur.execute(
86+
'INSERT INTO "documents" (title, payload) '
87+
"VALUES (%s, %s::jsonb) RETURNING id",
88+
(dto.get("title"), _dump_payload(dto.get("payload"))),
89+
)
90+
new_id = int(cur.fetchone()[0])
91+
conn.commit()
92+
finally:
93+
cur.close()
94+
created = self.find_by_id(new_id)
95+
if created is None:
96+
raise RuntimeError("create: row vanished between INSERT and SELECT")
97+
return created
98+
99+
def _connect(self) -> Any:
100+
return pg8000.connect(
101+
host=self._info.host,
102+
port=self._info.port,
103+
user=self._info.user,
104+
password=self._info.password,
105+
database=self._info.database,
106+
)
107+
108+
def _exec(self, sql: str) -> None:
109+
with closing(self._connect()) as conn:
110+
cur = conn.cursor()
111+
try:
112+
cur.execute(sql)
113+
conn.commit()
114+
finally:
115+
cur.close()
116+
117+
118+
def make_jsonb_app(repo: DocumentRepository) -> FastAPI:
119+
app = FastAPI()
120+
121+
@app.exception_handler(404)
122+
async def _default_404(_req: Request, _exc: Any) -> JSONResponse:
123+
return JSONResponse(status_code=404, content={"error": "not_found"})
124+
125+
@app.get("/api/documents/{document_id}")
126+
def get_document(document_id: int) -> Any:
127+
row = repo.find_by_id(document_id)
128+
if row is None:
129+
return JSONResponse(status_code=404, content={"error": "not_found"})
130+
return row
131+
132+
@app.post("/api/documents", status_code=status.HTTP_201_CREATED)
133+
def create_document(dto: dict[str, Any]) -> Any:
134+
return repo.create(dto)
135+
136+
return app
137+
138+
139+
def _dump_payload(value: Any) -> Any:
140+
"""jsonb bind: a dict/list is ``json.dumps``-serialized for the ``::jsonb``
141+
cast; ``None`` stays ``None`` (SQL NULL); a pre-serialized string passes
142+
through."""
143+
if value is None:
144+
return None
145+
if isinstance(value, str):
146+
return value
147+
return json.dumps(value)
148+
149+
150+
def _row_to_dict(row: Any) -> dict[str, Any]:
151+
ident, title, payload = row
152+
# pg8000 auto-decodes jsonb → native Python object; if a driver returned raw
153+
# text we'd parse it, but the open-bag contract is "parsed value", so a str
154+
# that is JSON is decoded here defensively.
155+
if isinstance(payload, str):
156+
try:
157+
payload = json.loads(payload)
158+
except json.JSONDecodeError:
159+
pass
160+
return {"id": int(ident), "title": title, "payload": payload}

0 commit comments

Comments
 (0)