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.
- server_decorator_gen_openapi-2.0.19/.gitignore +57 -0
- server_decorator_gen_openapi-2.0.19/PKG-INFO +22 -0
- server_decorator_gen_openapi-2.0.19/README.md +8 -0
- server_decorator_gen_openapi-2.0.19/pyproject.toml +39 -0
- server_decorator_gen_openapi-2.0.19/scripts/check.sh +26 -0
- server_decorator_gen_openapi-2.0.19/scripts/emit_openapi.py +12 -0
- server_decorator_gen_openapi-2.0.19/src/server_decorator_gen_openapi/__init__.py +371 -0
- server_decorator_gen_openapi-2.0.19/src/server_decorator_gen_openapi/py.typed +0 -0
- server_decorator_gen_openapi-2.0.19/tests/unit/test_cases.py +38 -0
- server_decorator_gen_openapi-2.0.19/tests/unit/test_document.py +48 -0
|
@@ -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
|
|
File without changes
|
|
@@ -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())
|