server-decorator-gen-openapi 2.0.19__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ .npmrc
2
+ dist
3
+ node_modules
4
+ .astro
5
+
6
+
7
+ # GitHub Actions
8
+ .env
9
+ *.log
10
+
11
+ # Package managers
12
+ npm-debug.log*
13
+ yarn-debug.log*
14
+ yarn-error.log*
15
+ package-lock.json # Use pnpm-lock.yaml instead
16
+ yarn.lock # Use pnpm-lock.yaml instead
17
+ # uv is dev-only; do not commit the lockfile
18
+ python/uv.lock
19
+
20
+ # iCloud-style duplicate files (D-D)
21
+ * 2
22
+ * 2.*
23
+ * 3
24
+ * 3.*
25
+
26
+ # Python (added during polyglot migration)
27
+ __pycache__/
28
+ *.py[cod]
29
+ .venv/
30
+ *.egg-info/
31
+ .pytest_cache/
32
+ .mypy_cache/
33
+ .ruff_cache/
34
+ # ps-release-workflow (gitignored state)
35
+ .claude/state/
36
+ .claude/worktrees/
37
+
38
+ # Rendered design HTML — local view artifacts only (global rule)
39
+ docs/superpowers/**/*.html
40
+ research/**/*.html
41
+
42
+ # Nim (F-006): `nim c` drops binaries beside sources; `nim r`/`nimble test` do not, but be safe.
43
+ nimcache/
44
+ nim/tests/t_*
45
+ !nim/tests/t_*.nim
46
+ nim/examples/emit_*
47
+ !nim/examples/emit_*.nim
48
+ nim/examples/user
49
+ nim/nimble.lock
50
+ # ^ the package has zero dependencies (plan constraint) — this lockfile must never be committed.
51
+
52
+ # graft's local graph cache — regenerable, not committed (run `graft build`).
53
+ graft/
54
+ # Rust build artifacts
55
+ target/
56
+ rust/target/
57
+
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.5
2
+ Name: server-decorator-gen-openapi
3
+ Version: 2.0.19
4
+ Summary: IR-derived OpenAPI 3.1 document (contracts/rules/rest-conventions.md). Polyglot sibling of @montionugera/entity-spec-gen-openapi.
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: server-decorator-entity==2.0.19
8
+ Provides-Extra: dev
9
+ Requires-Dist: mypy>=1.10; extra == 'dev'
10
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Requires-Dist: ruff>=0.5; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # server-decorator-gen-openapi
16
+
17
+ Compiles the EntitySpec IR into an OpenAPI 3.1 document, following
18
+ `contracts/rules/rest-conventions.md`. Pure functions — no HTTP framework, no
19
+ dependency on `server-decorator` or on `server-decorator-gen-pydantic`.
20
+
21
+ Polyglot sibling of `@montionugera/entity-spec-gen-openapi`; both emit a
22
+ byte-identical document, enforced by `scripts/check-openapi-parity.sh`.
@@ -0,0 +1,8 @@
1
+ # server-decorator-gen-openapi
2
+
3
+ Compiles the EntitySpec IR into an OpenAPI 3.1 document, following
4
+ `contracts/rules/rest-conventions.md`. Pure functions — no HTTP framework, no
5
+ dependency on `server-decorator` or on `server-decorator-gen-pydantic`.
6
+
7
+ Polyglot sibling of `@montionugera/entity-spec-gen-openapi`; both emit a
8
+ byte-identical document, enforced by `scripts/check-openapi-parity.sh`.
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "server-decorator-gen-openapi"
3
+ version = "2.0.19"
4
+ description = "IR-derived OpenAPI 3.1 document (contracts/rules/rest-conventions.md). Polyglot sibling of @montionugera/entity-spec-gen-openapi."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "MIT"}
8
+ dependencies = ["server-decorator-entity==2.0.19"]
9
+
10
+ [tool.uv.sources]
11
+ server-decorator-entity = { workspace = true }
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.5", "mypy>=1.10"]
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/server_decorator_gen_openapi"]
22
+
23
+ [tool.ruff]
24
+ line-length = 100
25
+ target-version = "py310"
26
+
27
+ [tool.ruff.lint]
28
+ select = ["E", "F", "I", "B", "UP", "N", "ASYNC", "RUF"]
29
+
30
+ [tool.ruff.lint.per-file-ignores]
31
+ "tests/**/*.py" = ["S101"]
32
+
33
+ [tool.mypy]
34
+ python_version = "3.10"
35
+ strict = true
36
+ files = ["src/server_decorator_gen_openapi"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # python/packages/gen_openapi/scripts/check.sh — lint + type-check + unit tests.
3
+ # Assumes `uv` is installed; `pip install uv` if not.
4
+ set -euo pipefail
5
+
6
+ cd "$(dirname "$0")/.."
7
+
8
+ if command -v uv >/dev/null 2>&1; then
9
+ uv sync --extra dev >/dev/null
10
+ RUN="uv run"
11
+ else
12
+ echo "warning: uv not found; falling back to plain python+pytest. Install uv for the full dev experience." >&2
13
+ RUN=""
14
+ fi
15
+
16
+ $RUN ruff check src tests
17
+ $RUN ruff format --check src tests
18
+ $RUN mypy src
19
+
20
+ # Coverage gate (unit tests only; integration-marked tests are excluded).
21
+ # COV_MIN is a ratchet: achieved line+branch coverage (--cov-branch) rounded down,
22
+ # measured on Python 3.10, 3.12 and 3.14. Raise it, never lower it.
23
+ COV_MIN=${COV_MIN:-94}
24
+ export COVERAGE_FILE="${COVERAGE_FILE:-${TMPDIR:-/tmp}/server-decorator-gen_openapi.coverage}"
25
+ $RUN pytest -q tests/unit -m "not integration" \
26
+ --cov=src --cov-branch --cov-report=term-missing:skip-covered --cov-fail-under="$COV_MIN"
@@ -0,0 +1,12 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ from server_decorator_entity import entity_spec
5
+ from server_decorator_entity.canonical import canonical_json
6
+ from server_decorator_entity.testing.user_fixture import User
7
+
8
+ from server_decorator_gen_openapi import openapi_document
9
+
10
+ # emit_openapi.py -> scripts -> gen_openapi -> packages -> python -> repo root
11
+ version = (Path(__file__).resolve().parents[4] / "contracts/VERSION").read_text().strip()
12
+ sys.stdout.write(canonical_json(openapi_document([entity_spec(User)], version)))
@@ -0,0 +1,371 @@
1
+ """IR-derived OpenAPI 3.1 document — see contracts/rules/rest-conventions.md."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ # Copied from contracts/rules/entity-spec-validation.md, not imported: this package must
7
+ # not depend on gen_pydantic (layering rule). Keep byte-identical to gen-openapi's patterns.ts.
8
+ EMAIL_PATTERN = (
9
+ r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
10
+ r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
11
+ )
12
+ UUID_PATTERN = r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
13
+ URI_PATTERN = r"^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s]+$"
14
+
15
+ _BASE_TYPE = {
16
+ "str": "string",
17
+ "ref": "string",
18
+ "int": "integer",
19
+ "float": "number",
20
+ "bool": "boolean",
21
+ "datetime": "string",
22
+ "enum": "string",
23
+ "list": "array",
24
+ "obj": "object",
25
+ }
26
+ _FORMAT_PATTERN = {"email": EMAIL_PATTERN, "uuid": UUID_PATTERN, "uri": URI_PATTERN}
27
+
28
+ Include = Callable[[dict[str, Any]], bool]
29
+
30
+
31
+ def json_schema_for_field(f: dict[str, Any], include: Include | None = None) -> dict[str, Any]:
32
+ """Map one IR field to JSON Schema.
33
+
34
+ ``include`` threads through EVERY level of nesting — obj members and list items alike —
35
+ because contracts/rules/rest-conventions.md filters nested obj and list members by the same
36
+ rule as their parent, and gen_pydantic's model builder already does exactly this. Without it
37
+ a create DTO would keep a readonly member that lives inside a list of objects.
38
+ """
39
+ keep: Include = (lambda _f: True) if include is None else include
40
+ t = f["type"]
41
+ base = _BASE_TYPE[t]
42
+ s: dict[str, Any] = {"type": [base, "null"] if f.get("nullable") else base}
43
+ if t == "datetime":
44
+ s["format"] = "date-time"
45
+ if t in ("str", "ref"):
46
+ fmt = f.get("format")
47
+ if fmt:
48
+ s["format"] = fmt
49
+ s["pattern"] = _FORMAT_PATTERN[fmt]
50
+ if "pattern" in f:
51
+ s["pattern"] = f["pattern"]
52
+ if "min" in f:
53
+ s["minLength"] = f["min"]
54
+ if "max" in f:
55
+ s["maxLength"] = f["max"]
56
+ if t in ("int", "float"):
57
+ if "min" in f:
58
+ s["minimum"] = f["min"]
59
+ if "max" in f:
60
+ s["maximum"] = f["max"]
61
+ if t == "enum":
62
+ s["enum"] = list(f["enum"])
63
+ if t == "list":
64
+ s["items"] = json_schema_for_field(f["item"], keep) if f.get("item") else {}
65
+ if "min" in f:
66
+ s["minItems"] = f["min"]
67
+ if "max" in f:
68
+ s["maxItems"] = f["max"]
69
+ if t == "obj":
70
+ kept = [n for n in f.get("fields", []) if keep(n)]
71
+ s["properties"] = object_properties(kept, keep)
72
+ # Filter `required` by `include` too: an excluded member left in `required` would make
73
+ # the schema impossible to satisfy, since its property is not emitted at all.
74
+ required = [n["name"] for n in kept if not n.get("optional")]
75
+ if required:
76
+ s["required"] = required
77
+ s["additionalProperties"] = False
78
+ if "description" in f:
79
+ s["description"] = f["description"]
80
+ return s
81
+
82
+
83
+ def object_properties(fields: list[dict[str, Any]], include: Include) -> dict[str, Any]:
84
+ out: dict[str, Any] = {}
85
+ for f in [x for x in fields if include(x)]:
86
+ out[f["name"]] = json_schema_for_field(f, include)
87
+ return out
88
+
89
+
90
+ def _strict_object(fields: list[dict[str, Any]], include: Include) -> dict[str, Any]:
91
+ kept = [f for f in fields if include(f)]
92
+ s: dict[str, Any] = {"type": "object", "properties": object_properties(kept, include)}
93
+ required = [f["name"] for f in kept if not f.get("optional")]
94
+ if required:
95
+ s["required"] = required
96
+ s["additionalProperties"] = False
97
+ return s
98
+
99
+
100
+ def _list_envelope(entity: str, pagination: str) -> dict[str, Any]:
101
+ items = {"type": "array", "items": {"$ref": f"#/components/schemas/{entity}Read"}}
102
+ limit = {"type": "integer", "minimum": 1, "maximum": 100}
103
+ if pagination == "cursor":
104
+ return {
105
+ "type": "object",
106
+ "properties": {"items": items, "nextCursor": {"type": ["string", "null"]}},
107
+ "required": ["items", "nextCursor"],
108
+ "additionalProperties": False,
109
+ }
110
+ return {
111
+ "type": "object",
112
+ "properties": {
113
+ "items": items,
114
+ "total": {"type": "integer", "minimum": 0},
115
+ "limit": limit,
116
+ "offset": {"type": "integer", "minimum": 0},
117
+ },
118
+ "required": ["items", "total", "limit", "offset"],
119
+ "additionalProperties": False,
120
+ }
121
+
122
+
123
+ _PROBLEM_PROPS: dict[str, Any] = {
124
+ "type": {"type": "string", "format": "uri"},
125
+ "title": {"type": "string"},
126
+ "status": {"type": "integer"},
127
+ "detail": {"type": "string"},
128
+ "instance": {"type": "string", "format": "uri"},
129
+ }
130
+
131
+ PROBLEM_SCHEMAS: dict[str, Any] = {
132
+ "Problem": {
133
+ "type": "object",
134
+ "properties": dict(_PROBLEM_PROPS),
135
+ "required": ["type", "title", "status"],
136
+ "additionalProperties": False,
137
+ },
138
+ "ValidationProblem": {
139
+ "type": "object",
140
+ "properties": {**_PROBLEM_PROPS, "fields": {"type": "array", "items": {"type": "string"}}},
141
+ "required": ["type", "title", "status", "fields"],
142
+ "additionalProperties": False,
143
+ },
144
+ }
145
+
146
+
147
+ def _scheme_object(s: dict[str, Any]) -> dict[str, Any]:
148
+ if s["type"] == "apiKey":
149
+ return {"type": "apiKey", "in": s["in"], "name": s["paramName"]}
150
+ out: dict[str, Any] = {"type": "http", "scheme": s["scheme"]}
151
+ if "bearerFormat" in s:
152
+ out["bearerFormat"] = s["bearerFormat"]
153
+ return out
154
+
155
+
156
+ def openapi_components(spec: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
157
+ e = spec["entity"]
158
+ create = _strict_object(spec["fields"], lambda f: not f.get("readonly"))
159
+ update = {k: v for k, v in create.items() if k != "required"}
160
+ schemas: dict[str, Any] = {
161
+ f"{e}Create": create,
162
+ f"{e}Update": update,
163
+ f"{e}Read": _strict_object(spec["fields"], lambda f: not f.get("writeonly")),
164
+ f"{e}ListResponse": _list_envelope(e, spec["pagination"]),
165
+ }
166
+ for a in spec.get("actions", []):
167
+ name = a["name"][0].upper() + a["name"][1:]
168
+ schemas[f"{e}{name}Input"] = _strict_object(a.get("input", []), lambda _f: True)
169
+ security_schemes = {
170
+ s["name"]: _scheme_object(s) for s in spec.get("security", {}).get("schemes", [])
171
+ }
172
+ return schemas, security_schemes
173
+
174
+
175
+ def _problem(status: str, description: str, component: str = "Problem") -> dict[str, Any]:
176
+ return {
177
+ status: {
178
+ "description": description,
179
+ "content": {
180
+ "application/problem+json": {
181
+ "schema": {"$ref": f"#/components/schemas/{component}"}
182
+ }
183
+ },
184
+ }
185
+ }
186
+
187
+
188
+ def _json(ref: str) -> dict[str, Any]:
189
+ return {"content": {"application/json": {"schema": {"$ref": f"#/components/schemas/{ref}"}}}}
190
+
191
+
192
+ def _security_for(spec: dict[str, Any], key: str, kind: str) -> dict[str, Any]:
193
+ security = spec.get("security")
194
+ if security is None or key not in security.get(kind, {}):
195
+ return {}
196
+ schemes = security.get("schemes", [])
197
+ if not schemes:
198
+ return {}
199
+ return {"security": [{schemes[0]["name"]: security[kind][key]}]}
200
+
201
+
202
+ def _filter_params(spec: dict[str, Any]) -> list[dict[str, Any]]:
203
+ out = []
204
+ for f in spec["fields"]:
205
+ if not f.get("filterable"):
206
+ continue
207
+ bare = {k: v for k, v in f.items() if k not in ("optional", "nullable")}
208
+ out.append(
209
+ {
210
+ "name": f["name"],
211
+ "in": "query",
212
+ "required": False,
213
+ "schema": json_schema_for_field(bare),
214
+ }
215
+ )
216
+ return out
217
+
218
+
219
+ def _pagination_params(spec: dict[str, Any]) -> list[dict[str, Any]]:
220
+ limit = {
221
+ "name": "limit",
222
+ "in": "query",
223
+ "required": False,
224
+ "schema": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
225
+ }
226
+ if spec["pagination"] == "cursor":
227
+ return [
228
+ limit,
229
+ {"name": "cursor", "in": "query", "required": False, "schema": {"type": "string"}},
230
+ ]
231
+ return [
232
+ limit,
233
+ {
234
+ "name": "offset",
235
+ "in": "query",
236
+ "required": False,
237
+ "schema": {"type": "integer", "minimum": 0, "default": 0},
238
+ },
239
+ ]
240
+
241
+
242
+ def openapi_paths(spec: dict[str, Any]) -> dict[str, Any]:
243
+ e = spec["entity"]
244
+ base = "/" + spec["path"]
245
+ id_field = next((f for f in spec["fields"] if f.get("role") == "id"), None)
246
+ if id_field is None:
247
+ raise ValueError(f'{e}: no field with role "id"')
248
+ id_bare = {k: v for k, v in id_field.items() if k != "role"}
249
+ id_param = {
250
+ "name": id_field["name"],
251
+ "in": "path",
252
+ "required": True,
253
+ "schema": json_schema_for_field(id_bare),
254
+ }
255
+ tags = {"tags": spec["tags"]} if "tags" in spec else {}
256
+ ops = spec["ops"]
257
+ out: dict[str, Any] = {}
258
+ collection: dict[str, Any] = {}
259
+ item: dict[str, Any] = {}
260
+ filters = _filter_params(spec)
261
+
262
+ if "list" in ops:
263
+ params = sorted(_pagination_params(spec) + filters, key=lambda p: str(p["name"]))
264
+ responses = {"200": {"description": "OK", **_json(f"{e}ListResponse")}}
265
+ if filters:
266
+ responses.update(_problem("400", "Bad Request", "ValidationProblem"))
267
+ collection["get"] = {
268
+ **tags,
269
+ "operationId": f"list{e}",
270
+ "parameters": params,
271
+ "responses": responses,
272
+ **_security_for(spec, "list", "ops"),
273
+ }
274
+ if "create" in ops:
275
+ collection["post"] = {
276
+ **tags,
277
+ "operationId": f"create{e}",
278
+ "requestBody": {"required": True, **_json(f"{e}Create")},
279
+ "responses": {
280
+ "201": {"description": "Created", **_json(f"{e}Read")},
281
+ **_problem("400", "Bad Request", "ValidationProblem"),
282
+ },
283
+ **_security_for(spec, "create", "ops"),
284
+ }
285
+ if "get" in ops:
286
+ item["get"] = {
287
+ **tags,
288
+ "operationId": f"get{e}",
289
+ "parameters": [id_param],
290
+ "responses": {
291
+ "200": {"description": "OK", **_json(f"{e}Read")},
292
+ **_problem("404", "Not Found"),
293
+ },
294
+ **_security_for(spec, "get", "ops"),
295
+ }
296
+ if "update" in ops:
297
+ item["patch"] = {
298
+ **tags,
299
+ "operationId": f"update{e}",
300
+ "parameters": [id_param],
301
+ "requestBody": {"required": True, **_json(f"{e}Update")},
302
+ "responses": {
303
+ "200": {"description": "OK", **_json(f"{e}Read")},
304
+ **_problem("400", "Bad Request", "ValidationProblem"),
305
+ **_problem("404", "Not Found"),
306
+ },
307
+ **_security_for(spec, "update", "ops"),
308
+ }
309
+ if "delete" in ops:
310
+ item["delete"] = {
311
+ **tags,
312
+ "operationId": f"delete{e}",
313
+ "parameters": [id_param],
314
+ "responses": {
315
+ "204": {"description": "No Content"},
316
+ **_problem("404", "Not Found"),
317
+ },
318
+ **_security_for(spec, "delete", "ops"),
319
+ }
320
+ if collection:
321
+ out[base] = collection
322
+ if item:
323
+ out[f"{base}/{{{id_field['name']}}}"] = item
324
+
325
+ for a in spec.get("actions", []):
326
+ name = a["name"][0].upper() + a["name"][1:]
327
+ responses = {
328
+ "200": {"description": "OK", **_json(f"{e}Read")},
329
+ **_problem("400", "Bad Request", "ValidationProblem"),
330
+ **_problem("404", "Not Found"),
331
+ }
332
+ if "from" in a:
333
+ responses.update(_problem("409", "Conflict"))
334
+ post: dict[str, Any] = {
335
+ **tags,
336
+ "operationId": f"{a['name']}{e}",
337
+ "parameters": [id_param],
338
+ }
339
+ if "description" in a:
340
+ post["description"] = a["description"]
341
+ post["requestBody"] = {"required": True, **_json(f"{e}{name}Input")}
342
+ post["responses"] = responses
343
+ post.update(_security_for(spec, a["name"], "actions"))
344
+ out[f"{base}/{{{id_field['name']}}}/actions/{a['name']}"] = {"post": post}
345
+ return out
346
+
347
+
348
+ def openapi_document(specs: list[dict[str, Any]], version: str) -> dict[str, Any]:
349
+ schemas: dict[str, Any] = dict(PROBLEM_SCHEMAS)
350
+ security_schemes: dict[str, Any] = {}
351
+ paths: dict[str, Any] = {}
352
+ tag_names: set[str] = set()
353
+
354
+ for spec in specs:
355
+ s, sec = openapi_components(spec)
356
+ schemas.update(s)
357
+ security_schemes.update(sec)
358
+ paths.update(openapi_paths(spec))
359
+ tag_names.update(spec.get("tags", []))
360
+
361
+ doc: dict[str, Any] = {
362
+ "openapi": "3.1.0",
363
+ "info": {"title": "server-decorator API", "version": version},
364
+ "paths": paths,
365
+ "components": {"schemas": schemas},
366
+ }
367
+ if tag_names:
368
+ doc["tags"] = [{"name": n} for n in sorted(tag_names)]
369
+ if security_schemes:
370
+ doc["components"]["securitySchemes"] = security_schemes
371
+ return doc
@@ -0,0 +1,38 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ import pytest
6
+
7
+ from server_decorator_gen_openapi import openapi_document
8
+
9
+ # test_cases.py -> unit -> tests -> gen_openapi -> packages -> python -> repo root
10
+ _CASES_FILE = Path(__file__).resolve().parents[5] / "contracts/fixtures/entity-spec.cases.json"
11
+ CASES: list[dict[str, Any]] = json.loads(_CASES_FILE.read_text()).get("openapi", [])
12
+
13
+
14
+ def _resolve(doc: Any, pointer: str) -> Any:
15
+ """RFC 6901 JSON Pointer.
16
+
17
+ Path keys in an OpenAPI document contain "/" ("/users/{id}"), so a naive split on "/"
18
+ cannot address them — segments escape "/" as "~1" and "~" as "~0". gen-openapi's Node
19
+ runner resolves pointers identically; keep the two in step.
20
+ """
21
+ node: Any = doc
22
+ for raw in pointer.split("/")[1:]:
23
+ if node is None:
24
+ return None
25
+ key = raw.replace("~1", "/").replace("~0", "~")
26
+ node = node[int(key)] if isinstance(node, list) else node.get(key)
27
+ return node
28
+
29
+
30
+ def test_at_least_one_case() -> None:
31
+ assert len(CASES) > 0
32
+
33
+
34
+ @pytest.mark.parametrize("case", CASES, ids=lambda c: str(c["name"]))
35
+ def test_case(case: dict[str, Any]) -> None:
36
+ doc = openapi_document([case["spec"]], "1.2.0")
37
+ for pointer, expected in case["expect"].items():
38
+ assert _resolve(doc, pointer) == expected, pointer
@@ -0,0 +1,48 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from server_decorator_entity import entity_spec
5
+ from server_decorator_entity.testing.user_fixture import User
6
+
7
+ from server_decorator_gen_openapi import openapi_document
8
+
9
+ # test_document.py -> unit -> tests -> gen_openapi -> packages -> python -> repo root
10
+ _CONTRACTS = Path(__file__).resolve().parents[5] / "contracts"
11
+ # The golden document is emitted with contracts/VERSION (scripts/emit_openapi.py), so the
12
+ # test must read the same file: a hardcoded literal goes stale on every contract bump.
13
+ _VERSION_FILE = _CONTRACTS / "VERSION"
14
+ VERSION = _VERSION_FILE.read_text().strip() if _VERSION_FILE.exists() else "0.0.0"
15
+
16
+ DOC = openapi_document([entity_spec(User)], VERSION)
17
+
18
+
19
+ def test_declares_openapi_31() -> None:
20
+ assert DOC["openapi"] == "3.1.0"
21
+ assert DOC["info"] == {"title": "server-decorator API", "version": VERSION}
22
+
23
+
24
+ def test_version_argument_is_passed_through_verbatim() -> None:
25
+ doc = openapi_document([entity_spec(User)], "9.8.7-rc.1")
26
+ assert doc["info"] == {"title": "server-decorator API", "version": "9.8.7-rc.1"}
27
+
28
+
29
+ def test_routes_match_conventions() -> None:
30
+ assert sorted(DOC["paths"]) == [
31
+ "/users",
32
+ "/users/{id}",
33
+ "/users/{id}/actions/activate",
34
+ "/users/{id}/actions/ping",
35
+ ]
36
+ assert "put" not in DOC["paths"]["/users/{id}"]
37
+ assert "patch" in DOC["paths"]["/users/{id}"]
38
+
39
+
40
+ def test_security_only_on_declared_ops() -> None:
41
+ assert DOC["paths"]["/users"]["post"]["security"] == [{"bearerAuth": ["user:write"]}]
42
+ assert "security" not in DOC["paths"]["/users"]["get"]
43
+
44
+
45
+ def test_matches_golden_document() -> None:
46
+ golden = _CONTRACTS / "fixtures/openapi.valid-user.json"
47
+ if golden.exists():
48
+ assert DOC == json.loads(golden.read_text())