qstd-openapi 0.1.0__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,656 @@
1
+ """Declarative API: decorators, ``describe`` and ``attach``.
2
+
3
+ Every decorator here is ``describe`` with a single option, so all of them
4
+ follow the same rules. Decorators return the decorated object unchanged;
5
+ metadata is stored on the object itself (see :mod:`qstd_openapi.meta.storage`)
6
+ and is only interpreted when a document is built.
7
+
8
+ Usage::
9
+
10
+ from qstd_openapi import openapi
11
+
12
+ @openapi.tag('Users')
13
+ @openapi.errors(UserAlreadyExistsError)
14
+ @openapi.response(UserDTO, status=201)
15
+ async def register_user(request, body): ...
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Hashable, Iterable, Mapping, Sequence
21
+ from enum import Enum
22
+ from typing import Any, Callable, Optional, TypeVar, Union, cast
23
+
24
+ from typing_extensions import TypeAlias, TypedDict, Unpack
25
+
26
+ from qstd_openapi.markers import File, FileList, FormFields
27
+ from qstd_openapi.meta.model import (
28
+ BodyPart,
29
+ ErrorRef,
30
+ Example,
31
+ Examples,
32
+ OperationPatch,
33
+ Parameter,
34
+ ParameterLocation,
35
+ ParameterModel,
36
+ ResponseHeader,
37
+ ResponsePart,
38
+ Security,
39
+ StatusCode,
40
+ Webhook,
41
+ )
42
+ from qstd_openapi.meta.storage import attach_patch
43
+
44
+ __all__ = (
45
+ 'DescribeOptions',
46
+ 'DocumentationOptions',
47
+ 'ErrorLike',
48
+ 'Example',
49
+ 'ExampleValue',
50
+ 'OperationOptions',
51
+ 'Parameter',
52
+ 'ParameterModel',
53
+ 'ResponseHeader',
54
+ 'ResponsePart',
55
+ 'SchemaLike',
56
+ 'Security',
57
+ 'SecurityValue',
58
+ 'Webhook',
59
+ 'attach',
60
+ 'body',
61
+ 'body_binary',
62
+ 'body_form_data_file',
63
+ 'body_form_data_files',
64
+ 'body_one_of',
65
+ 'cookie',
66
+ 'cookies',
67
+ 'deprecated',
68
+ 'describe',
69
+ 'description',
70
+ 'errors',
71
+ 'exclude',
72
+ 'extra',
73
+ 'header',
74
+ 'headers',
75
+ 'no_content',
76
+ 'operation_id',
77
+ 'params',
78
+ 'path',
79
+ 'query',
80
+ 'response',
81
+ 'response_file',
82
+ 'response_header',
83
+ 'responses',
84
+ 'scope',
85
+ 'security',
86
+ 'summary',
87
+ 'tag',
88
+ 'webhook',
89
+ )
90
+
91
+ F = TypeVar('F', bound=Callable[..., Any])
92
+
93
+ JSON = 'application/json'
94
+
95
+ SecurityValue: TypeAlias = Union[str, Mapping[str, Sequence[str]], Security]
96
+ """A scheme name, ``{scheme: [scopes]}`` (all required together) or ``Security``."""
97
+
98
+ SchemaLike: TypeAlias = object
99
+ """Anything a schema provider understands: a Pydantic model, a dataclass, a
100
+ type such as ``list[UserDTO]`` or ``Optional[int]``, a ``TypeAdapter``, a raw
101
+ JSON Schema dict (with ``markers.Ref`` inside) or a marker (``File``...)."""
102
+
103
+ ErrorLike: TypeAlias = type
104
+ """An error class (usually an exception) for the document's error providers.
105
+
106
+ ``ErrorProvider.supports`` takes any object at runtime; the annotation is a
107
+ class so that a string or an exception instance is a type error."""
108
+
109
+ ExampleValue: TypeAlias = object
110
+ """An example value, or :class:`Example` with a summary and a description."""
111
+
112
+ ExamplesArg: TypeAlias = Optional[Mapping[str, ExampleValue]]
113
+
114
+ ResponseValue: TypeAlias = Union[SchemaLike, Sequence[SchemaLike], ResponsePart, None]
115
+ """``responses={status: ...}`` value: a schema, alternatives (``oneOf``), ``None``
116
+ (no body) or a ``ResponsePart``."""
117
+
118
+
119
+ class OperationOptions(TypedDict, total=False):
120
+ """Operation fields that framework route decorators may accept themselves."""
121
+
122
+ summary: str
123
+ description: str
124
+ operation_id: str
125
+ deprecated: bool
126
+ tags: Union[str, Iterable[str]]
127
+
128
+
129
+ class DocumentationOptions(TypedDict, total=False):
130
+ """Options only the library understands (never passed to a framework)."""
131
+
132
+ exclude: bool
133
+ webhook: Union[str, Webhook]
134
+ scope: Union[Hashable, Iterable[Hashable]]
135
+ security: Union[SecurityValue, Iterable[SecurityValue]]
136
+ body: SchemaLike
137
+ body_media_type: str
138
+ body_examples: Mapping[str, ExampleValue]
139
+ """Named examples of the request body: ``{'name': value | Example}``."""
140
+ query: SchemaLike
141
+ """A model whose fields become query parameters."""
142
+ path: SchemaLike
143
+ headers: SchemaLike
144
+ cookies: SchemaLike
145
+ parameters: Iterable[Union[Parameter, ParameterModel]]
146
+ response: SchemaLike
147
+ """Schema of the ``200`` response."""
148
+ response_media_type: str
149
+ response_examples: Mapping[StatusCode, Mapping[str, ExampleValue]]
150
+ """Named examples per response status: ``{201: {'name': value | Example}}``."""
151
+ responses: Mapping[StatusCode, ResponseValue]
152
+ """``{status: schema | [schema, ...] | None | ResponsePart}``."""
153
+ errors: Iterable[ErrorLike]
154
+ errors_media_type: str
155
+ extra: Mapping[str, Any]
156
+ """Raw OpenAPI operation fields, written for the dialect being built."""
157
+
158
+
159
+ class DescribeOptions(OperationOptions, DocumentationOptions, total=False):
160
+ """Options shared by :func:`describe`, :func:`attach` and router wrappers."""
161
+
162
+
163
+ def attach(target: F, /, **options: Unpack[DescribeOptions]) -> F:
164
+ """Attach metadata to ``target`` and return it unchanged.
165
+
166
+ Meant for project decorators, middlewares and validators that document
167
+ what they add, e.g. ``openapi.attach(wrapper, errors=[...])``.
168
+ """
169
+ attach_patch(target, _build_patch(options), 'attach')
170
+ return target
171
+
172
+
173
+ def describe(**options: Unpack[DescribeOptions]) -> Callable[[F], F]:
174
+ """Attach several documentation options with one decorator."""
175
+ return _decorator(_build_patch(options), 'describe')
176
+
177
+
178
+ # --- single-purpose decorators ---------------------------------------------
179
+
180
+
181
+ def tag(*names: str) -> Callable[[F], F]:
182
+ """Add one or more tags to an operation."""
183
+ return _decorator(OperationPatch(tags=names), 'tag')
184
+
185
+
186
+ def summary(text: str) -> Callable[[F], F]:
187
+ """Set the operation summary."""
188
+ return _decorator(OperationPatch(summary=text), 'summary')
189
+
190
+
191
+ def description(text: str) -> Callable[[F], F]:
192
+ """Set the operation description."""
193
+ return _decorator(OperationPatch(description=text), 'description')
194
+
195
+
196
+ def operation_id(value: str) -> Callable[[F], F]:
197
+ """Set an explicit OpenAPI ``operationId``."""
198
+ return _decorator(OperationPatch(operation_id=value), 'operation_id')
199
+
200
+
201
+ def deprecated(value: bool = True) -> Callable[[F], F]:
202
+ """Mark an operation as deprecated (or clear the flag with ``False``)."""
203
+ return _decorator(OperationPatch(deprecated=value), 'deprecated')
204
+
205
+
206
+ def exclude(value: bool = True) -> Callable[[F], F]:
207
+ """Exclude an operation from documents (or restore it with ``False``)."""
208
+ return _decorator(OperationPatch(exclude=value), 'exclude')
209
+
210
+
211
+ def scope(*scopes: Hashable) -> Callable[[F], F]:
212
+ """Label an operation for selection by :class:`ScopeFilter`."""
213
+ return _decorator(OperationPatch(scopes=scopes), 'scope')
214
+
215
+
216
+ def security(
217
+ scheme: SecurityValue,
218
+ scopes: Optional[Sequence[str]] = None,
219
+ ) -> Callable[[F], F]:
220
+ """Add one security requirement (several decorators mean alternatives)."""
221
+ return _decorator(
222
+ OperationPatch(security=(_security(scheme, scopes),)),
223
+ 'security',
224
+ )
225
+
226
+
227
+ def webhook(
228
+ name: str,
229
+ method: str = 'post',
230
+ *,
231
+ scope: Union[Hashable, Iterable[Hashable], None] = None,
232
+ ) -> Callable[[F], F]:
233
+ """Mark a function that *sends* a webhook; it is documented, not routed."""
234
+ return _decorator(
235
+ OperationPatch(webhook=Webhook(name, method.lower()), scopes=_as_tuple(scope)),
236
+ 'webhook',
237
+ )
238
+
239
+
240
+ def body(
241
+ schema: SchemaLike,
242
+ media_type: str = JSON,
243
+ *,
244
+ examples: ExamplesArg = None,
245
+ ) -> Callable[[F], F]:
246
+ """Describe one request body schema and optional named examples."""
247
+ part = BodyPart(media_type, schema, _examples(examples))
248
+ return _decorator(OperationPatch(body=(part,)), 'body')
249
+
250
+
251
+ def body_one_of(
252
+ *schemas: SchemaLike,
253
+ media_type: str = JSON,
254
+ examples: ExamplesArg = None,
255
+ ) -> Callable[[F], F]:
256
+ """Describe alternative request body schemas combined with ``oneOf``."""
257
+ parts = [BodyPart(media_type, s) for s in schemas]
258
+ if examples:
259
+ parts.append(BodyPart(media_type, None, _examples(examples)))
260
+ return _decorator(OperationPatch(body=tuple(parts)), 'body_one_of')
261
+
262
+
263
+ def body_binary(media_type: str = '*/*') -> Callable[[F], F]:
264
+ """Describe an arbitrary binary request body."""
265
+ return _decorator(
266
+ OperationPatch(body=(BodyPart(media_type, File()),)),
267
+ 'body_binary',
268
+ )
269
+
270
+
271
+ def body_form_data_file(
272
+ name: str = 'file',
273
+ *,
274
+ description: Optional[str] = None,
275
+ required: bool = True,
276
+ media_type: str = 'multipart/form-data',
277
+ ) -> Callable[[F], F]:
278
+ """Describe one file field in a multipart form body."""
279
+ schema = FormFields.of({name: File()}, (name,) if required else (), description)
280
+ return _decorator(
281
+ OperationPatch(body=(BodyPart(media_type, schema),)),
282
+ 'body_form_data_file',
283
+ )
284
+
285
+
286
+ def body_form_data_files(
287
+ name: str = 'files',
288
+ *,
289
+ max_items: Optional[int] = None,
290
+ description: Optional[str] = None,
291
+ required: bool = True,
292
+ media_type: str = 'multipart/form-data',
293
+ ) -> Callable[[F], F]:
294
+ """Describe a list of files in a multipart form body."""
295
+ schema = FormFields.of(
296
+ {name: FileList(max_items)},
297
+ (name,) if required else (),
298
+ description,
299
+ )
300
+ return _decorator(
301
+ OperationPatch(body=(BodyPart(media_type, schema),)),
302
+ 'body_form_data_files',
303
+ )
304
+
305
+
306
+ def query(
307
+ model_or_name: Union[str, SchemaLike],
308
+ schema: SchemaLike = str,
309
+ *,
310
+ required: Optional[bool] = None,
311
+ description: Optional[str] = None,
312
+ deprecated: bool = False,
313
+ examples: ExamplesArg = None,
314
+ ) -> Callable[[F], F]:
315
+ """``query(Model)`` expands the model's fields; ``query('page', int)`` adds one."""
316
+ return _parameter_decorator(
317
+ 'query',
318
+ 'query',
319
+ model_or_name,
320
+ schema,
321
+ required,
322
+ description,
323
+ deprecated,
324
+ examples,
325
+ )
326
+
327
+
328
+ def path(
329
+ model_or_name: Union[str, SchemaLike],
330
+ schema: SchemaLike = str,
331
+ *,
332
+ description: Optional[str] = None,
333
+ deprecated: bool = False,
334
+ examples: ExamplesArg = None,
335
+ ) -> Callable[[F], F]:
336
+ """Add a required path parameter or expand the fields of a model."""
337
+ return _parameter_decorator(
338
+ 'path',
339
+ 'path',
340
+ model_or_name,
341
+ schema,
342
+ True,
343
+ description,
344
+ deprecated,
345
+ examples,
346
+ )
347
+
348
+
349
+ params = path
350
+
351
+
352
+ def header(
353
+ name: str,
354
+ schema: SchemaLike = str,
355
+ *,
356
+ required: Optional[bool] = None,
357
+ description: Optional[str] = None,
358
+ deprecated: bool = False,
359
+ examples: ExamplesArg = None,
360
+ ) -> Callable[[F], F]:
361
+ """Add one header parameter."""
362
+ return _parameter_decorator(
363
+ 'header',
364
+ 'header',
365
+ name,
366
+ schema,
367
+ required,
368
+ description,
369
+ deprecated,
370
+ examples,
371
+ )
372
+
373
+
374
+ def headers(model: SchemaLike) -> Callable[[F], F]:
375
+ """Expand a model's fields into header parameters."""
376
+ return _decorator(
377
+ OperationPatch(parameters=(ParameterModel('header', model),)),
378
+ 'headers',
379
+ )
380
+
381
+
382
+ def cookie(
383
+ name: str,
384
+ schema: SchemaLike = str,
385
+ *,
386
+ required: Optional[bool] = None,
387
+ description: Optional[str] = None,
388
+ deprecated: bool = False,
389
+ examples: ExamplesArg = None,
390
+ ) -> Callable[[F], F]:
391
+ """Add one cookie parameter."""
392
+ return _parameter_decorator(
393
+ 'cookie',
394
+ 'cookie',
395
+ name,
396
+ schema,
397
+ required,
398
+ description,
399
+ deprecated,
400
+ examples,
401
+ )
402
+
403
+
404
+ def cookies(model: SchemaLike) -> Callable[[F], F]:
405
+ """Expand a model's fields into cookie parameters."""
406
+ return _decorator(
407
+ OperationPatch(parameters=(ParameterModel('cookie', model),)),
408
+ 'cookies',
409
+ )
410
+
411
+
412
+ def response(
413
+ schema: SchemaLike,
414
+ status: StatusCode = 200,
415
+ media_type: str = JSON,
416
+ description: Optional[str] = None,
417
+ *,
418
+ examples: ExamplesArg = None,
419
+ ) -> Callable[[F], F]:
420
+ """Describe one response schema and optional named examples."""
421
+ part = ResponsePart(
422
+ status,
423
+ media_type,
424
+ schema,
425
+ description,
426
+ examples=_examples(examples),
427
+ )
428
+ return _decorator(OperationPatch(responses=(part,)), 'response')
429
+
430
+
431
+ def responses(
432
+ *schemas: SchemaLike,
433
+ status: StatusCode = 200,
434
+ media_type: str = JSON,
435
+ description: Optional[str] = None,
436
+ examples: ExamplesArg = None,
437
+ ) -> Callable[[F], F]:
438
+ """Several alternative bodies (``oneOf``) for one status."""
439
+ parts = [ResponsePart(status, media_type, s, description) for s in schemas]
440
+ if examples:
441
+ parts.append(ResponsePart(status, media_type, examples=_examples(examples)))
442
+ return _decorator(OperationPatch(responses=tuple(parts)), 'responses')
443
+
444
+
445
+ def response_file(
446
+ media_type: str = '*/*',
447
+ status: StatusCode = 200,
448
+ description: Optional[str] = None,
449
+ ) -> Callable[[F], F]:
450
+ """Describe a binary response."""
451
+ return _decorator(
452
+ OperationPatch(
453
+ responses=(ResponsePart(status, media_type, File(), description),),
454
+ ),
455
+ 'response_file',
456
+ )
457
+
458
+
459
+ def response_header(
460
+ name: str,
461
+ schema: SchemaLike = str,
462
+ *,
463
+ status: StatusCode = 200,
464
+ description: Optional[str] = None,
465
+ required: bool = False,
466
+ ) -> Callable[[F], F]:
467
+ """Add a header to the response for one status."""
468
+ part = ResponsePart(
469
+ status,
470
+ headers=(ResponseHeader(name, schema, description, required),),
471
+ )
472
+ return _decorator(OperationPatch(responses=(part,)), 'response_header')
473
+
474
+
475
+ def no_content(
476
+ status: StatusCode = 204,
477
+ description: Optional[str] = None,
478
+ ) -> Callable[[F], F]:
479
+ """Describe a response status without a body."""
480
+ return _decorator(
481
+ OperationPatch(responses=(ResponsePart(status, description=description),)),
482
+ 'no_content',
483
+ )
484
+
485
+
486
+ def errors(*errors: ErrorLike, media_type: str = JSON) -> Callable[[F], F]:
487
+ """Add error classes handled by the document's error providers."""
488
+ return _decorator(
489
+ OperationPatch(errors=tuple(ErrorRef(e, media_type) for e in errors)),
490
+ 'errors',
491
+ )
492
+
493
+
494
+ def extra(fields: Mapping[str, Any]) -> Callable[[F], F]:
495
+ """Raw OpenAPI operation fields, written for the dialect being built."""
496
+ return _decorator(OperationPatch(extra=(fields,)), 'extra')
497
+
498
+
499
+ # --- helpers ----------------------------------------------------------------
500
+
501
+
502
+ def _decorator(patch: OperationPatch, api: str) -> Callable[[F], F]:
503
+ def decorate(target: F) -> F:
504
+ attach_patch(target, patch, api)
505
+ return target
506
+
507
+ return decorate
508
+
509
+
510
+ def _parameter_decorator(
511
+ api: str,
512
+ location: ParameterLocation,
513
+ model_or_name: Union[str, SchemaLike],
514
+ schema: SchemaLike,
515
+ required: Optional[bool],
516
+ description: Optional[str],
517
+ deprecated: bool,
518
+ examples: ExamplesArg = None,
519
+ ) -> Callable[[F], F]:
520
+ parameter: Union[Parameter, ParameterModel]
521
+ if isinstance(model_or_name, str):
522
+ parameter = Parameter(
523
+ location,
524
+ model_or_name,
525
+ schema,
526
+ required,
527
+ description,
528
+ deprecated,
529
+ _examples(examples),
530
+ )
531
+ elif examples:
532
+ raise TypeError(
533
+ f'{api}(Model, examples=...): examples belong to one parameter; '
534
+ f'use {api}(name, schema, examples=...) or examples in the model',
535
+ )
536
+ else:
537
+ parameter = ParameterModel(location, model_or_name)
538
+ return _decorator(OperationPatch(parameters=(parameter,)), api)
539
+
540
+
541
+ def _examples(examples: ExamplesArg) -> Examples:
542
+ if not examples:
543
+ return ()
544
+ return tuple(
545
+ (name, value if isinstance(value, Example) else Example(value))
546
+ for name, value in examples.items()
547
+ )
548
+
549
+
550
+ def _as_tuple(value: Any) -> tuple[Any, ...]:
551
+ """One value or an iterable of values; strings and enum members are single."""
552
+ if value is None:
553
+ return ()
554
+ if isinstance(value, (str, bytes, Enum)) or not isinstance(value, Iterable):
555
+ return (value,)
556
+ return tuple(cast('Iterable[Any]', value))
557
+
558
+
559
+ def _security(value: SecurityValue, scopes: Optional[Sequence[str]] = None) -> Security:
560
+ if isinstance(value, Security):
561
+ if scopes is not None:
562
+ raise TypeError('scopes cannot be combined with a Security object')
563
+ return value
564
+ if isinstance(value, str):
565
+ return Security(((value, tuple(scopes or ())),))
566
+ if scopes is not None:
567
+ raise TypeError('scopes can only be given together with a single scheme name')
568
+ return Security(tuple((name, tuple(values)) for name, values in value.items()))
569
+
570
+
571
+ def _security_list(value: Any) -> tuple[Security, ...]:
572
+ if isinstance(value, (str, Security, Mapping)):
573
+ return (_security(cast('SecurityValue', value)),)
574
+ return tuple(_security(item) for item in cast('Iterable[SecurityValue]', value))
575
+
576
+
577
+ def _responses(
578
+ statuses: Mapping[StatusCode, ResponseValue],
579
+ media_type: str,
580
+ ) -> list[ResponsePart]:
581
+ parts: list[ResponsePart] = []
582
+ for status, value in statuses.items():
583
+ if value is None:
584
+ parts.append(ResponsePart(status))
585
+ elif isinstance(value, ResponsePart):
586
+ parts.append(value)
587
+ elif isinstance(value, (list, tuple)):
588
+ schemas = cast('Sequence[Any]', value)
589
+ parts.extend(ResponsePart(status, media_type, s) for s in schemas)
590
+ else:
591
+ parts.append(ResponsePart(status, media_type, value))
592
+ return parts
593
+
594
+
595
+ def _build_patch(options: DescribeOptions) -> OperationPatch:
596
+ unknown = set(options) - set(DescribeOptions.__annotations__)
597
+ if unknown:
598
+ raise TypeError(f'Unknown describe options: {", ".join(sorted(unknown))}')
599
+
600
+ parameters: list[Union[Parameter, ParameterModel]] = list(
601
+ options.get('parameters', ()),
602
+ )
603
+ locations: tuple[tuple[str, ParameterLocation], ...] = (
604
+ ('query', 'query'),
605
+ ('path', 'path'),
606
+ ('headers', 'header'),
607
+ ('cookies', 'cookie'),
608
+ )
609
+ for key, location in locations:
610
+ model = options.get(key)
611
+ if model is not None:
612
+ parameters.append(ParameterModel(location, model))
613
+
614
+ body_parts: tuple[BodyPart, ...] = ()
615
+ body_examples = _examples(options.get('body_examples'))
616
+ if options.get('body') is not None or body_examples:
617
+ body_parts = (
618
+ BodyPart(
619
+ options.get('body_media_type', JSON),
620
+ options.get('body'),
621
+ body_examples,
622
+ ),
623
+ )
624
+
625
+ response_media = options.get('response_media_type', JSON)
626
+ response_parts: list[ResponsePart] = []
627
+ if options.get('response') is not None:
628
+ response_parts.append(
629
+ ResponsePart(200, response_media, options.get('response')),
630
+ )
631
+ response_parts.extend(_responses(options.get('responses', {}), response_media))
632
+ for status, examples in options.get('response_examples', {}).items():
633
+ response_parts.append(
634
+ ResponsePart(status, response_media, examples=_examples(examples)),
635
+ )
636
+
637
+ hook = options.get('webhook')
638
+ errors_media = options.get('errors_media_type', JSON)
639
+ extra_fields = options.get('extra')
640
+
641
+ return OperationPatch(
642
+ summary=options.get('summary'),
643
+ description=options.get('description'),
644
+ operation_id=options.get('operation_id'),
645
+ deprecated=options.get('deprecated'),
646
+ exclude=options.get('exclude'),
647
+ webhook=Webhook(hook) if isinstance(hook, str) else hook,
648
+ tags=_as_tuple(options.get('tags')),
649
+ scopes=_as_tuple(options.get('scope')),
650
+ security=_security_list(options['security']) if 'security' in options else (),
651
+ parameters=tuple(parameters),
652
+ body=body_parts,
653
+ responses=tuple(response_parts),
654
+ errors=tuple(ErrorRef(e, errors_media) for e in options.get('errors', ())),
655
+ extra=(extra_fields,) if extra_fields is not None else (),
656
+ )
qstd_openapi/py.typed ADDED
File without changes