server-decorator-gen-openapi 2.0.19__py3-none-any.whl

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,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,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,5 @@
1
+ server_decorator_gen_openapi/__init__.py,sha256=PM8ABN9KgFAbPI2e4XNgWHvfgekGjuXbParO0fPoUG0,13149
2
+ server_decorator_gen_openapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ server_decorator_gen_openapi-2.0.19.dist-info/METADATA,sha256=uDb1N1ixrapd6iBfJSWCU1ufFV3UQZY2anY1rMtK7uI,923
4
+ server_decorator_gen_openapi-2.0.19.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
5
+ server_decorator_gen_openapi-2.0.19.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any