fastapi-streams 0.0.1__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.
- fastapi_streams/__init__.py +4 -0
- fastapi_streams/codegen/__init__.py +0 -0
- fastapi_streams/codegen/cli.py +120 -0
- fastapi_streams/codegen/enums.py +172 -0
- fastapi_streams/codegen/ir.py +524 -0
- fastapi_streams/codegen/names.py +88 -0
- fastapi_streams/codegen/openapi.py +154 -0
- fastapi_streams/codegen/python.py +473 -0
- fastapi_streams/codegen/schemas/LICENSE +201 -0
- fastapi_streams/codegen/schemas/README.md +8 -0
- fastapi_streams/codegen/schemas/openapi-3.2.json +1729 -0
- fastapi_streams/codegen/templates/errors.ts +26 -0
- fastapi_streams/codegen/templates/python/api.py.j2 +52 -0
- fastapi_streams/codegen/templates/python/client.py.j2 +40 -0
- fastapi_streams/codegen/templates/python/runtime.py +217 -0
- fastapi_streams/codegen/templates/sse.ts +264 -0
- fastapi_streams/codegen/templates/typescript/api.ts.j2 +52 -0
- fastapi_streams/codegen/templates/typescript/client.ts.j2 +15 -0
- fastapi_streams/codegen/templating.py +19 -0
- fastapi_streams/codegen/typescript.py +220 -0
- fastapi_streams/codegen/writer.py +64 -0
- fastapi_streams/py.typed +0 -0
- fastapi_streams/router.py +133 -0
- fastapi_streams-0.0.1.dist-info/METADATA +238 -0
- fastapi_streams-0.0.1.dist-info/RECORD +28 -0
- fastapi_streams-0.0.1.dist-info/WHEEL +4 -0
- fastapi_streams-0.0.1.dist-info/entry_points.txt +2 -0
- fastapi_streams-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Literal, cast
|
|
6
|
+
|
|
7
|
+
from fastapi_streams.codegen.names import identifier, slug
|
|
8
|
+
from fastapi_streams.codegen.openapi import (
|
|
9
|
+
ContractError,
|
|
10
|
+
Document,
|
|
11
|
+
resolve_pointer,
|
|
12
|
+
resolve_ref,
|
|
13
|
+
validate_contract,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_REF_METADATA = {
|
|
17
|
+
"title",
|
|
18
|
+
"description",
|
|
19
|
+
"default",
|
|
20
|
+
"examples",
|
|
21
|
+
"example",
|
|
22
|
+
"deprecated",
|
|
23
|
+
"readOnly",
|
|
24
|
+
"writeOnly",
|
|
25
|
+
"$comment",
|
|
26
|
+
"externalDocs",
|
|
27
|
+
"xml",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Scalar:
|
|
33
|
+
name: Literal[
|
|
34
|
+
"string", "integer", "number", "boolean", "null", "never", "JsonValue"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LiteralType:
|
|
40
|
+
value: str | int | float | bool | None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class EnumMember:
|
|
45
|
+
name: str
|
|
46
|
+
value: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class StringEnum:
|
|
51
|
+
members: tuple[EnumMember, ...]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class EnumAlias:
|
|
56
|
+
target: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class EnumValue:
|
|
61
|
+
enum: str
|
|
62
|
+
member: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Reference:
|
|
67
|
+
name: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Array:
|
|
72
|
+
item: Type
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Field:
|
|
77
|
+
name: str
|
|
78
|
+
type: Type
|
|
79
|
+
required: bool
|
|
80
|
+
default: LiteralType | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Object:
|
|
85
|
+
fields: tuple[Field, ...]
|
|
86
|
+
additional: Type | None = None
|
|
87
|
+
additional_forbidden: bool = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Union:
|
|
92
|
+
members: tuple[Type, ...]
|
|
93
|
+
discriminator: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Intersection:
|
|
98
|
+
members: tuple[Type, ...]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
type Type = (
|
|
102
|
+
Scalar
|
|
103
|
+
| LiteralType
|
|
104
|
+
| Reference
|
|
105
|
+
| Array
|
|
106
|
+
| Object
|
|
107
|
+
| Union
|
|
108
|
+
| Intersection
|
|
109
|
+
| StringEnum
|
|
110
|
+
| EnumAlias
|
|
111
|
+
| EnumValue
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Declaration:
|
|
117
|
+
name: str
|
|
118
|
+
type: Type
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Parameter:
|
|
123
|
+
name: str
|
|
124
|
+
argument: str
|
|
125
|
+
location: Literal["path", "query", "header"]
|
|
126
|
+
type: Type
|
|
127
|
+
required: bool
|
|
128
|
+
explode: bool = True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class Body:
|
|
133
|
+
type: Type
|
|
134
|
+
required: bool
|
|
135
|
+
media_type: str = "application/json"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class ErrorResponse:
|
|
140
|
+
status: str
|
|
141
|
+
type: Type
|
|
142
|
+
media_type: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class SecurityScheme:
|
|
147
|
+
name: str
|
|
148
|
+
kind: str
|
|
149
|
+
location: str | None
|
|
150
|
+
parameter: str | None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class Operation:
|
|
155
|
+
operation_id: str
|
|
156
|
+
name: str
|
|
157
|
+
group: str
|
|
158
|
+
directory: str
|
|
159
|
+
path: str
|
|
160
|
+
method: str
|
|
161
|
+
parameters: tuple[Parameter, ...]
|
|
162
|
+
body: Body | None
|
|
163
|
+
event: Type
|
|
164
|
+
response_status: str
|
|
165
|
+
errors: tuple[ErrorResponse, ...]
|
|
166
|
+
security: tuple[dict[str, list[str]], ...]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class Contract:
|
|
171
|
+
operations: tuple[Operation, ...]
|
|
172
|
+
declarations: tuple[Declaration, ...]
|
|
173
|
+
security_schemes: tuple[SecurityScheme, ...]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_ir(document: Document) -> Contract:
|
|
177
|
+
validate_contract(document)
|
|
178
|
+
types = _Types(document)
|
|
179
|
+
operations: list[Operation] = []
|
|
180
|
+
ids: set[str] = set()
|
|
181
|
+
public_names: set[str] = set()
|
|
182
|
+
names: set[tuple[str, str]] = set()
|
|
183
|
+
groups: dict[str, str] = {}
|
|
184
|
+
directories: dict[str, str] = {}
|
|
185
|
+
paths: Document = document.get("paths", {})
|
|
186
|
+
for path, raw_path in sorted(paths.items()):
|
|
187
|
+
path_item = resolve_ref(document, raw_path)
|
|
188
|
+
for method in (
|
|
189
|
+
"get",
|
|
190
|
+
"post",
|
|
191
|
+
"put",
|
|
192
|
+
"patch",
|
|
193
|
+
"delete",
|
|
194
|
+
"options",
|
|
195
|
+
"head",
|
|
196
|
+
"trace",
|
|
197
|
+
):
|
|
198
|
+
if method not in path_item:
|
|
199
|
+
continue
|
|
200
|
+
raw: Document = path_item[method]
|
|
201
|
+
responses: Document = raw.get("responses", {})
|
|
202
|
+
streams: list[tuple[str, Document]] = []
|
|
203
|
+
errors: list[ErrorResponse] = []
|
|
204
|
+
for status, value in sorted(responses.items()):
|
|
205
|
+
response = resolve_ref(document, value)
|
|
206
|
+
content: Document = response.get("content", {})
|
|
207
|
+
if status.startswith("2") and "text/event-stream" in content:
|
|
208
|
+
streams.append((status, content["text/event-stream"]))
|
|
209
|
+
if not streams:
|
|
210
|
+
continue
|
|
211
|
+
if len(streams) != 1 or method not in {"get", "post"}:
|
|
212
|
+
raise ContractError(
|
|
213
|
+
f"Expected one GET/POST SSE success response: {method} {path}"
|
|
214
|
+
)
|
|
215
|
+
status, media = streams[0]
|
|
216
|
+
envelope = resolve_ref(document, media.get("itemSchema", {}))
|
|
217
|
+
data = resolve_ref(document, envelope.get("properties", {}).get("data", {}))
|
|
218
|
+
if (
|
|
219
|
+
data.get("contentMediaType") != "application/json"
|
|
220
|
+
or "contentSchema" not in data
|
|
221
|
+
or data.get("type") != "string"
|
|
222
|
+
or "data" not in envelope.get("required", [])
|
|
223
|
+
or envelope.get("type") != "object"
|
|
224
|
+
):
|
|
225
|
+
raise ContractError(
|
|
226
|
+
f"SSE data needs application/json contentSchema: {path}"
|
|
227
|
+
)
|
|
228
|
+
operation_id: str = raw.get("operationId", "")
|
|
229
|
+
if not operation_id or operation_id in ids:
|
|
230
|
+
raise ContractError(
|
|
231
|
+
f"Missing or duplicate streaming operationId: {operation_id!r}"
|
|
232
|
+
)
|
|
233
|
+
ids.add(operation_id)
|
|
234
|
+
public_name = identifier(operation_id, upper=True)
|
|
235
|
+
if public_name in public_names:
|
|
236
|
+
raise ContractError(f"Operation identifier collision: {public_name}")
|
|
237
|
+
public_names.add(public_name)
|
|
238
|
+
tag: str = (raw.get("tags") or ["streams"])[0]
|
|
239
|
+
group, directory = identifier(tag), slug(tag)
|
|
240
|
+
if (group in groups and groups[group] != tag) or (
|
|
241
|
+
directory in directories and directories[directory] != tag
|
|
242
|
+
):
|
|
243
|
+
raise ContractError(f"Tag name collision: {tag}")
|
|
244
|
+
if group in {"transport", "types", "client", "index"}:
|
|
245
|
+
raise ContractError(f"Reserved stream tag: {tag}")
|
|
246
|
+
groups[group], directories[directory] = tag, tag
|
|
247
|
+
name = identifier(operation_id)
|
|
248
|
+
if (group, name) in names:
|
|
249
|
+
raise ContractError(f"Operation name collision: {group}.{name}")
|
|
250
|
+
names.add((group, name))
|
|
251
|
+
merged: dict[tuple[str, str], Document] = {}
|
|
252
|
+
for value in [*path_item.get("parameters", []), *raw.get("parameters", [])]:
|
|
253
|
+
param = resolve_ref(document, value)
|
|
254
|
+
merged[(param["in"], param["name"])] = param
|
|
255
|
+
params: list[Parameter] = []
|
|
256
|
+
arguments: set[str] = set()
|
|
257
|
+
for (location, param_name), param in sorted(merged.items()):
|
|
258
|
+
if location not in {"path", "query", "header"}:
|
|
259
|
+
raise ContractError(f"Unsupported parameter location: {location}")
|
|
260
|
+
if "content" in param:
|
|
261
|
+
raise ContractError("Parameters with content are not supported")
|
|
262
|
+
style = "form" if location == "query" else "simple"
|
|
263
|
+
if param.get("style", style) != style or param.get(
|
|
264
|
+
"allowReserved", False
|
|
265
|
+
):
|
|
266
|
+
raise ContractError(
|
|
267
|
+
f"Unsupported serialization for parameter {param_name}"
|
|
268
|
+
)
|
|
269
|
+
param_type = types.parse(param.get("schema", {}))
|
|
270
|
+
if not _parameter_type(param_type, types, arrays=location == "query"):
|
|
271
|
+
supported = "scalar/array" if location == "query" else "scalar"
|
|
272
|
+
raise ContractError(
|
|
273
|
+
f"Only {supported} {location} parameters are supported: "
|
|
274
|
+
f"{param_name}"
|
|
275
|
+
)
|
|
276
|
+
argument = identifier(param_name)
|
|
277
|
+
if argument in arguments:
|
|
278
|
+
raise ContractError(f"Parameter name collision: {argument}")
|
|
279
|
+
arguments.add(argument)
|
|
280
|
+
params.append(
|
|
281
|
+
Parameter(
|
|
282
|
+
param_name,
|
|
283
|
+
argument,
|
|
284
|
+
cast(Any, location),
|
|
285
|
+
param_type,
|
|
286
|
+
param.get("required", False),
|
|
287
|
+
param.get("explode", location == "query"),
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
expected = set(re.findall(r"\{([^{}]+)\}", path))
|
|
291
|
+
actual = {param.name for param in params if param.location == "path"}
|
|
292
|
+
if expected != actual:
|
|
293
|
+
raise ContractError(
|
|
294
|
+
f"Path placeholders do not match parameters: {path}"
|
|
295
|
+
)
|
|
296
|
+
body: Body | None = None
|
|
297
|
+
if "requestBody" in raw:
|
|
298
|
+
request = resolve_ref(document, raw["requestBody"])
|
|
299
|
+
content = request.get("content", {})
|
|
300
|
+
if method != "post" or set(content) != {"application/json"}:
|
|
301
|
+
raise ContractError(
|
|
302
|
+
"Streaming request bodies must be POST application/json"
|
|
303
|
+
)
|
|
304
|
+
if "body" in arguments:
|
|
305
|
+
raise ContractError(
|
|
306
|
+
"Parameter 'body' conflicts with the request body"
|
|
307
|
+
)
|
|
308
|
+
body = Body(
|
|
309
|
+
types.parse(content["application/json"].get("schema", {})),
|
|
310
|
+
request.get("required", False),
|
|
311
|
+
)
|
|
312
|
+
for code, value in sorted(responses.items()):
|
|
313
|
+
if code.startswith("2"):
|
|
314
|
+
continue
|
|
315
|
+
content = resolve_ref(document, value).get("content", {})
|
|
316
|
+
for media_type, error_media in sorted(content.items()):
|
|
317
|
+
if "schema" in error_media:
|
|
318
|
+
errors.append(
|
|
319
|
+
ErrorResponse(
|
|
320
|
+
code, types.parse(error_media["schema"]), media_type
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
operations.append(
|
|
324
|
+
Operation(
|
|
325
|
+
operation_id,
|
|
326
|
+
name,
|
|
327
|
+
group,
|
|
328
|
+
directory,
|
|
329
|
+
path,
|
|
330
|
+
method.upper(),
|
|
331
|
+
tuple(params),
|
|
332
|
+
body,
|
|
333
|
+
types.parse(data["contentSchema"]),
|
|
334
|
+
status,
|
|
335
|
+
tuple(errors),
|
|
336
|
+
tuple(raw.get("security", document.get("security", []))),
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
security: Document = document.get("components", {}).get("securitySchemes", {})
|
|
340
|
+
schemes = tuple(
|
|
341
|
+
SecurityScheme(name, scheme["type"], scheme.get("in"), scheme.get("name"))
|
|
342
|
+
for name, value in sorted(security.items())
|
|
343
|
+
for scheme in [resolve_ref(document, value)]
|
|
344
|
+
)
|
|
345
|
+
from fastapi_streams.codegen.enums import with_enums
|
|
346
|
+
|
|
347
|
+
return with_enums(Contract(tuple(operations), types.declarations(), schemes))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class _Types:
|
|
351
|
+
def __init__(self, document: Document) -> None:
|
|
352
|
+
self.document = document
|
|
353
|
+
self.names: dict[str, str] = {}
|
|
354
|
+
self.pending: dict[str, Document | bool] = {}
|
|
355
|
+
|
|
356
|
+
def parse(self, schema: Any) -> Type:
|
|
357
|
+
if schema is True or schema == {}:
|
|
358
|
+
return Scalar("JsonValue")
|
|
359
|
+
if schema is False:
|
|
360
|
+
return Scalar("never")
|
|
361
|
+
if not isinstance(schema, dict):
|
|
362
|
+
raise ContractError(f"Expected a Schema Object, got {schema!r}")
|
|
363
|
+
node = cast(Document, schema)
|
|
364
|
+
if "$ref" in node:
|
|
365
|
+
ref: str = node["$ref"]
|
|
366
|
+
if not ref.startswith("#/components/schemas/"):
|
|
367
|
+
raise ContractError(
|
|
368
|
+
f"Type references must point to components/schemas: {ref}"
|
|
369
|
+
)
|
|
370
|
+
constraints = sorted(
|
|
371
|
+
key
|
|
372
|
+
for key in node
|
|
373
|
+
if key != "$ref"
|
|
374
|
+
and key not in _REF_METADATA
|
|
375
|
+
and not key.startswith("x-")
|
|
376
|
+
)
|
|
377
|
+
if constraints:
|
|
378
|
+
raise ContractError(
|
|
379
|
+
f"Unsupported constraints alongside $ref {ref}: {constraints}. "
|
|
380
|
+
"Define a standalone schema containing the full restrictions."
|
|
381
|
+
)
|
|
382
|
+
# Keep references in the IR so recursive models stay recursive.
|
|
383
|
+
target = resolve_pointer(self.document, ref)
|
|
384
|
+
if isinstance(target, dict):
|
|
385
|
+
# Validate reference-only cycles without flattening the chain:
|
|
386
|
+
# flattening would hide constraints on intermediate references.
|
|
387
|
+
resolve_ref(self.document, cast(Document, target))
|
|
388
|
+
name = identifier(
|
|
389
|
+
ref.rsplit("/", 1)[-1].replace("~1", "/").replace("~0", "~"), upper=True
|
|
390
|
+
)
|
|
391
|
+
if name == "JsonValue" or (name in self.names and self.names[name] != ref):
|
|
392
|
+
raise ContractError(f"TypeScript type name collision: {name}")
|
|
393
|
+
self.names[name] = ref
|
|
394
|
+
self.pending[name] = target
|
|
395
|
+
return Reference(name)
|
|
396
|
+
if "const" in node:
|
|
397
|
+
return self.literal(node["const"])
|
|
398
|
+
if "enum" in node:
|
|
399
|
+
return Union(tuple(self.literal(value) for value in node["enum"]))
|
|
400
|
+
for keyword in ("oneOf", "anyOf", "allOf"):
|
|
401
|
+
if keyword in node:
|
|
402
|
+
members = tuple(self.parse(part) for part in node[keyword])
|
|
403
|
+
if keyword == "allOf":
|
|
404
|
+
combined: Type = Intersection(members)
|
|
405
|
+
else:
|
|
406
|
+
combined = Union(
|
|
407
|
+
members, node.get("discriminator", {}).get("propertyName")
|
|
408
|
+
)
|
|
409
|
+
siblings = {key: value for key, value in node.items() if key != keyword}
|
|
410
|
+
if any(
|
|
411
|
+
key in siblings
|
|
412
|
+
for key in (
|
|
413
|
+
"type",
|
|
414
|
+
"properties",
|
|
415
|
+
"additionalProperties",
|
|
416
|
+
"oneOf",
|
|
417
|
+
"anyOf",
|
|
418
|
+
"allOf",
|
|
419
|
+
)
|
|
420
|
+
):
|
|
421
|
+
return Intersection((combined, self.parse(siblings)))
|
|
422
|
+
return combined
|
|
423
|
+
kind = node.get("type")
|
|
424
|
+
if isinstance(kind, list):
|
|
425
|
+
return Union(
|
|
426
|
+
tuple(
|
|
427
|
+
self.parse({**node, "type": part}) for part in cast(list[str], kind)
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
if kind in ("string", "boolean", "null"):
|
|
431
|
+
return Scalar(kind)
|
|
432
|
+
if kind in ("number", "integer"):
|
|
433
|
+
return Scalar(kind)
|
|
434
|
+
if kind == "array":
|
|
435
|
+
if "prefixItems" in node:
|
|
436
|
+
raise ContractError("Tuple schemas (prefixItems) are not supported")
|
|
437
|
+
return Array(self.parse(node.get("items", {})))
|
|
438
|
+
if kind == "object" or "properties" in node or "additionalProperties" in node:
|
|
439
|
+
properties: Document = dict(node.get("properties", {}))
|
|
440
|
+
required: list[str] = node.get("required", [])
|
|
441
|
+
extra = node.get("additionalProperties", not properties)
|
|
442
|
+
undeclared = set(required) - properties.keys()
|
|
443
|
+
if undeclared and node.get("additionalProperties") is False:
|
|
444
|
+
raise ContractError(
|
|
445
|
+
f"Required keys {sorted(undeclared)} are forbidden by "
|
|
446
|
+
"additionalProperties: false; declare them in properties."
|
|
447
|
+
)
|
|
448
|
+
# Required keys outside properties are governed by additionalProperties.
|
|
449
|
+
# Omission of that keyword leaves their values unrestricted, even when
|
|
450
|
+
# ordinary declared models retain the generator's default extra policy.
|
|
451
|
+
properties.update(
|
|
452
|
+
(name, node.get("additionalProperties", True))
|
|
453
|
+
for name in sorted(undeclared)
|
|
454
|
+
)
|
|
455
|
+
return Object(
|
|
456
|
+
tuple(
|
|
457
|
+
Field(
|
|
458
|
+
name,
|
|
459
|
+
self.parse(value),
|
|
460
|
+
name in required,
|
|
461
|
+
self.literal(value["default"])
|
|
462
|
+
if isinstance(value, dict)
|
|
463
|
+
and "default" in value
|
|
464
|
+
and isinstance(
|
|
465
|
+
value["default"], str | int | float | bool | None
|
|
466
|
+
)
|
|
467
|
+
else None,
|
|
468
|
+
)
|
|
469
|
+
for name, value in sorted(properties.items())
|
|
470
|
+
),
|
|
471
|
+
self.parse(extra) if extra is not False else None,
|
|
472
|
+
node.get("additionalProperties") is False,
|
|
473
|
+
)
|
|
474
|
+
if set(node) <= {
|
|
475
|
+
"title",
|
|
476
|
+
"description",
|
|
477
|
+
"default",
|
|
478
|
+
"examples",
|
|
479
|
+
"deprecated",
|
|
480
|
+
"readOnly",
|
|
481
|
+
"writeOnly",
|
|
482
|
+
}:
|
|
483
|
+
return Scalar("JsonValue")
|
|
484
|
+
raise ContractError(f"Unsupported or ambiguous JSON Schema: {node!r}")
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def literal(value: Any) -> LiteralType:
|
|
488
|
+
if value is not None and not isinstance(value, str | int | float | bool):
|
|
489
|
+
raise ContractError("Object and array enum/const values are not supported")
|
|
490
|
+
return LiteralType(value)
|
|
491
|
+
|
|
492
|
+
def declarations(self) -> tuple[Declaration, ...]:
|
|
493
|
+
result: dict[str, Declaration] = {}
|
|
494
|
+
while todo := sorted(set(self.pending) - set(result)):
|
|
495
|
+
for name in todo:
|
|
496
|
+
result[name] = Declaration(name, self.parse(self.pending[name]))
|
|
497
|
+
return tuple(result[name] for name in sorted(result))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _parameter_type(
|
|
501
|
+
type_: Type,
|
|
502
|
+
types: _Types,
|
|
503
|
+
seen: frozenset[str] = frozenset(),
|
|
504
|
+
*,
|
|
505
|
+
arrays: bool = True,
|
|
506
|
+
) -> bool:
|
|
507
|
+
match type_:
|
|
508
|
+
case Scalar(name=name):
|
|
509
|
+
return name not in {"JsonValue", "never"}
|
|
510
|
+
case LiteralType():
|
|
511
|
+
return True
|
|
512
|
+
case Array(item=item):
|
|
513
|
+
return arrays and _parameter_type(item, types, seen, arrays=False)
|
|
514
|
+
case Union(members=members):
|
|
515
|
+
return all(
|
|
516
|
+
_parameter_type(member, types, seen, arrays=arrays)
|
|
517
|
+
for member in members
|
|
518
|
+
)
|
|
519
|
+
case Reference(name=name) if name not in seen:
|
|
520
|
+
return _parameter_type(
|
|
521
|
+
types.parse(types.pending[name]), types, seen | {name}, arrays=arrays
|
|
522
|
+
)
|
|
523
|
+
case _:
|
|
524
|
+
return False
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import keyword
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from fastapi_streams.codegen.openapi import ContractError
|
|
5
|
+
|
|
6
|
+
_RESERVED = {
|
|
7
|
+
"break",
|
|
8
|
+
"case",
|
|
9
|
+
"catch",
|
|
10
|
+
"class",
|
|
11
|
+
"const",
|
|
12
|
+
"continue",
|
|
13
|
+
"debugger",
|
|
14
|
+
"default",
|
|
15
|
+
"delete",
|
|
16
|
+
"do",
|
|
17
|
+
"else",
|
|
18
|
+
"enum",
|
|
19
|
+
"export",
|
|
20
|
+
"extends",
|
|
21
|
+
"false",
|
|
22
|
+
"finally",
|
|
23
|
+
"for",
|
|
24
|
+
"function",
|
|
25
|
+
"if",
|
|
26
|
+
"import",
|
|
27
|
+
"in",
|
|
28
|
+
"instanceof",
|
|
29
|
+
"new",
|
|
30
|
+
"null",
|
|
31
|
+
"return",
|
|
32
|
+
"super",
|
|
33
|
+
"switch",
|
|
34
|
+
"this",
|
|
35
|
+
"throw",
|
|
36
|
+
"true",
|
|
37
|
+
"try",
|
|
38
|
+
"typeof",
|
|
39
|
+
"var",
|
|
40
|
+
"void",
|
|
41
|
+
"while",
|
|
42
|
+
"with",
|
|
43
|
+
"yield",
|
|
44
|
+
"let",
|
|
45
|
+
"static",
|
|
46
|
+
"implements",
|
|
47
|
+
"interface",
|
|
48
|
+
"package",
|
|
49
|
+
"private",
|
|
50
|
+
"protected",
|
|
51
|
+
"public",
|
|
52
|
+
"await",
|
|
53
|
+
"constructor",
|
|
54
|
+
"prototype",
|
|
55
|
+
"__proto__",
|
|
56
|
+
"then",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def words(value: str) -> list[str]:
|
|
61
|
+
return re.findall(r"[A-Za-z0-9]+", re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def identifier(value: str, *, upper: bool = False) -> str:
|
|
65
|
+
parts = words(value)
|
|
66
|
+
if not parts:
|
|
67
|
+
raise ContractError(f"Cannot make a TypeScript identifier from {value!r}")
|
|
68
|
+
name = "".join(part[:1].upper() + part[1:] for part in parts)
|
|
69
|
+
if not upper:
|
|
70
|
+
name = name[:1].lower() + name[1:]
|
|
71
|
+
if name[0].isdigit() or name in _RESERVED:
|
|
72
|
+
name = "_" + name
|
|
73
|
+
return name
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def slug(value: str) -> str:
|
|
77
|
+
return "-".join(part.lower() for part in words(value)) or "streams"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def python_identifier(value: str) -> str:
|
|
81
|
+
name = "_".join(part.lower() for part in words(value))
|
|
82
|
+
if not name:
|
|
83
|
+
raise ContractError(f"Cannot make a Python identifier from {value!r}")
|
|
84
|
+
if name[0].isdigit():
|
|
85
|
+
name = "value_" + name
|
|
86
|
+
if keyword.iskeyword(name) or name.startswith("model_"):
|
|
87
|
+
name += "_"
|
|
88
|
+
return name
|