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,,
|