From f823d2df5ae6987f179fd0588a0d42eb87124163 Mon Sep 17 00:00:00 2001 From: nikto_b Date: Tue, 26 Aug 2025 17:33:23 +0300 Subject: [PATCH] Add openapi request basic schema generation, fix SerializedResponse headers, HTTP codes --- src/turbosloth/__main__.py | 50 ++- src/turbosloth/app.py | 197 +++++++++- src/turbosloth/doc/openapi_app.py | 276 +++++++++++++ src/turbosloth/doc/openapi_models.py | 368 ++++++++++++++++++ src/turbosloth/interfaces/serialized/base.py | 7 +- src/turbosloth/interfaces/serialized/html.py | 3 +- src/turbosloth/interfaces/serialized/json.py | 3 +- .../interfaces/serialized/msgpack.py | 3 +- src/turbosloth/interfaces/serialized/text.py | 3 +- src/turbosloth/interfaces/serialized/xml.py | 3 +- 10 files changed, 887 insertions(+), 26 deletions(-) create mode 100644 src/turbosloth/doc/openapi_app.py create mode 100644 src/turbosloth/doc/openapi_models.py diff --git a/src/turbosloth/__main__.py b/src/turbosloth/__main__.py index 32817bb..d02ed2e 100644 --- a/src/turbosloth/__main__.py +++ b/src/turbosloth/__main__.py @@ -1,19 +1,21 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Optional +from typing import Optional import uvicorn from turbosloth import SlothApp +from turbosloth.doc.openapi_app import OpenAPIApp +from turbosloth.doc.openapi_models import Info +from turbosloth.interfaces.base import BasicResponse from turbosloth.interfaces.serialize_selector import SerializeSelector -from turbosloth.interfaces.serialized import SerializedResponse, SerializedRequest -from turbosloth.internal_types import QTYPE, BTYPE, PTYPE, HTYPE -from turbosloth.req_schema import UnwrappedRequest -from turbosloth.schema import RequestBody, HeaderParam, PathParam +from turbosloth.interfaces.serialized import SerializedResponse +from turbosloth.schema import RequestBody, HeaderParam, QueryParam app = SlothApp(di_autodoc_prefix='/didoc', - serialize_selector=SerializeSelector(default_content_type='application/json')) + serialize_selector=SerializeSelector(default_content_type='application/json'), + openapi_app=OpenAPIApp(Info('asdf', '1.0.0'))) # @app.get("/") @@ -117,7 +119,41 @@ async def test_body(r: RequestBody(UserPostSchema), 'user': user.__dict__, 'q2': q2 } - return SerializedResponse(200, {}, resp) + return SerializedResponse(resp) + + +@app.get('/openapi.json') +async def openapi_schema(a: SlothApp, version: QueryParam(Optional[str], 'v') = None) -> SerializedResponse: + dat = a.openapi_app.export_as_dict() + if version is not None: + dat['openapi'] = version + return SerializedResponse(dat) + + +@app.get('/stoplight') +async def stoplight() -> BasicResponse: + ret = ''' + + + + + Elements in HTML + + + + + + + + + + +''' + return BasicResponse(200, {}, ret.encode()) if __name__ == '__main__': diff --git a/src/turbosloth/app.py b/src/turbosloth/app.py index c7ae850..ba37e35 100644 --- a/src/turbosloth/app.py +++ b/src/turbosloth/app.py @@ -2,8 +2,9 @@ from __future__ import annotations import html import typing from dataclasses import dataclass +from types import NoneType from typing import Optional, Callable, Awaitable, Protocol, get_type_hints, get_origin, get_args, Any, Annotated, Tuple, \ - Iterable + Iterable, TypeAliasType, Union import breakshaft.util_mermaid import megasniff.exceptions @@ -11,6 +12,8 @@ from breakshaft.models import ConversionPoint, Callgraph from megasniff import SchemaInflatorGenerator from .didoc import create_di_autodoc_handler +from .doc.openapi_app import OpenAPIApp +from .doc.openapi_models import RequestBody as OpenApiRequestBody, MediaType, Schema, Reference, Parameter, Response from .exceptions import HTTPException from .interfaces.base import BasicRequest, BasicResponse from .interfaces.serialize_selector import SerializeSelector @@ -125,9 +128,9 @@ class HTTPApp(ASGIApp): return except (megasniff.exceptions.FieldValidationException, megasniff.exceptions.MissingFieldException) as e: print(e) - sresp = SerializedResponse(400, {}, 'Schema error') + sresp = SerializedResponse('Schema error', code=400) except HTTPException as e: - sresp = SerializedResponse(e.code, {}, str(e)) + sresp = SerializedResponse(str(e), code=e.code) try: ct = self.extract_content_type(req) @@ -207,13 +210,15 @@ class MethodRoutersApp(ASGIApp): class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): - di_autodoc_prefix: Optional[str] = None + di_autodoc_prefix: Optional[str] + openapi_app: Optional[OpenAPIApp] def __init__(self, on_startup: Optional[Callable[[], Awaitable[None]]] = None, on_shutdown: Optional[Callable[[], Awaitable[None]]] = None, di_autodoc_prefix: Optional[str] = None, serialize_selector: Optional[SerializeSelector] = None, + openapi_app: Optional[OpenAPIApp] = None ): self.router = Router() self._on_startup = on_startup @@ -223,11 +228,12 @@ class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): self.serialize_selector = serialize_selector self.infl_generator = SchemaInflatorGenerator(strict_mode=True, store_sources=True) self.di_autodoc_prefix = di_autodoc_prefix + self.openapi_app = openapi_app if di_autodoc_prefix is not None: self.inj_repo = ConvRepo(store_sources=True, store_callseq=True) else: - self.inj_repo = ConvRepo() + self.inj_repo = ConvRepo(store_callseq=True) @self.inj_repo.mark_injector() def extract_query(req: BasicRequest | SerializedRequest) -> QTYPE: @@ -245,6 +251,10 @@ class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): def extract_body(req: SerializedRequest) -> BTYPE: return req.body + @self.inj_repo.mark_injector() + def provide_sloth_app() -> SlothApp: + return self + self.inj_repo.add_injector(self.extract_content_type) self.inj_repo.add_injector(self.extract_accept_type) self.inj_repo.add_injector(self.serialize_request) @@ -274,6 +284,164 @@ class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): return infl, set(ConversionPoint.from_fn(infl, type_remap=type_remap)) return None, None + @staticmethod + def schema_from_type(tp) -> Schema | Reference: + if isinstance(tp, TypeAliasType): + return SlothApp.schema_from_type(tp.__value__) + origin = get_origin(tp) + args = get_args(tp) + + if origin is Annotated: + return SlothApp.schema_from_type(args[0]) + + if origin is None: + # базовый тип + if tp == str: + return Schema(type='string') + elif tp == int: + return Schema(type='integer') + elif tp == float: + return Schema(type='number', format='float') + elif tp == bool: + return Schema(type='boolean') + elif tp == type(None): + return Schema(type='null') + else: + # кастомный класс → $ref + return Reference(ref=f'#/components/schemas/{tp.__name__}') + + elif origin is list: + item_type = args[0] if args else Any + return Schema(type='array', items=SlothApp.schema_from_type(item_type)) + + elif origin is dict: + key_type, value_type = args if args else (str, Any) + if key_type != str: + raise ValueError('OpenAPI dict keys must be strings') + return Schema( + type="object", + additionalProperties=SlothApp.schema_from_type(value_type) + ) + + elif origin is typing.Union: + schemas = [SlothApp.schema_from_type(a) for a in args] + return Schema(oneOf=schemas) + + else: + raise NotImplementedError(f"Тип {tp} не поддержан") + + def register_endpoint_components(self, handler: Callable): + callseq = getattr(handler, '__breakshaft_callseq__', []) + if len(callseq) == 0: + return [] + + ret = [] + + for call in callseq: + call: ConversionPoint + + config: EndpointConfig | None = getattr(call.fn, '__turbosloth_config__', None) + if config is None: + continue + + for k, v in config.header_schemas.items(): + t = v.replacement_type + s = self.schema_from_type(t) + if isinstance(s, Reference): + self.openapi_app.register_component(t) + + for k, v in config.query_schemas.items(): + t = v.replacement_type + s = self.schema_from_type(t) + if isinstance(s, Reference): + self.openapi_app.register_component(t) + + for k, v in config.path_schemas.items(): + t = v.replacement_type + s = self.schema_from_type(t) + if isinstance(s, Reference): + self.openapi_app.register_component(t) + + if config.body_schema is not None: + t = config.body_schema.replacement_type + s = self.schema_from_type(t) + if isinstance(s, Reference): + self.openapi_app.register_component(t) + + @staticmethod + def construct_req_body(handler: Callable) -> Schema | Reference | None: + callseq = getattr(handler, '__breakshaft_callseq__', []) + if len(callseq) == 0: + return None + + ret = {} + + for call in callseq: + call: ConversionPoint + if BTYPE in call.requires: + return SlothApp.schema_from_type(call.injects) + + return None + + @staticmethod + def construct_req_params(handler: Callable) -> list[Parameter]: + + callseq = getattr(handler, '__breakshaft_callseq__', []) + if len(callseq) == 0: + return [] + + ret = [] + + for call in callseq: + call: ConversionPoint + + config: EndpointConfig | None = getattr(call.fn, '__turbosloth_config__', None) + if config is None: + continue + + for k, v in config.query_schemas.items(): + v: ParamSchema + + allow_empty = v.has_default + t = v.schema + if isinstance(t, TypeAliasType): + t = t.__value__ + origin = get_origin(t) + if origin is Union: + args = get_args(t) + if None in args or NoneType in args: + allow_empty = True + + ret.append(Parameter( + name=v.key_name, + in_='query', + required=not v.has_default, + allowEmptyValue=allow_empty, + schema=SlothApp.schema_from_type(v.schema) + )) + + for k, v in config.path_schemas.items(): + v: ParamSchema + + ret.append(Parameter( + name=v.key_name, + in_='path', + required=not v.has_default, + schema=SlothApp.schema_from_type(v.schema) + )) + + for k, v in config.header_schemas.items(): + v: ParamSchema + + ret.append(Parameter( + name=v.key_name, + in_='header', + required=not v.has_default, + schema=SlothApp.schema_from_type(v.schema) + )) + + return ret + def route(self, method: MethodType, path_pattern: str): def decorator(fn: HandlerType): path_substs = self.router.find_pattern_substs(path_pattern) @@ -306,6 +474,22 @@ class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): self.route('GET', self.di_autodoc_prefix + '/' + method + path_pattern)( create_di_autodoc_handler(method, path_pattern, p, depgraph)) + if self.openapi_app is not None: + self.openapi_app.register_endpoint( + method, + path_pattern, + self.construct_req_params(p), + { + '200': Response('desc', content={'application/json': MediaType(schema=Schema(type='boolean'))}) + }, + request_body=OpenApiRequestBody( + { + 'application/json': MediaType(self.construct_req_body(p)) + } + ) + ) + self.register_endpoint_components(p) + return fn return decorator @@ -379,6 +563,9 @@ class SlothApp(HTTPApp, WSApp, LifespanApp, MethodRoutersApp): setattr(func, '__turbosloth_DI_name__', breakshaft.util.universal_qualname(main_injects)) fork_with |= set(ConversionPoint.from_fn(func, type_remap=fn_type_hints)) + + setattr(func, '__turbosloth_config__', config) + return fork_with, fn_type_hints def add_injector(self, func: Callable): diff --git a/src/turbosloth/doc/openapi_app.py b/src/turbosloth/doc/openapi_app.py new file mode 100644 index 0000000..f72750c --- /dev/null +++ b/src/turbosloth/doc/openapi_app.py @@ -0,0 +1,276 @@ +import re +from dataclasses import is_dataclass, asdict, fields, MISSING +from typing import Sequence, Optional, Any, get_type_hints, get_origin, Annotated, get_args, Union, TypeAliasType + +from mypy.checkpattern import defaultdict + +from turbosloth.doc.openapi_models import Info, Server, PathItem, Components, SecurityRequirement, Tag, \ + ExternalDocumentation, Parameter, RequestBody, Operation, Response, OpenAPI, Schema, Reference, SchemaType +from turbosloth.internal_types import MethodType + + +def resolve_type(tp): + if isinstance(tp, TypeAliasType): + return resolve_type(tp.__value__) + + origin = get_origin(tp) + if origin is Annotated: + base_type, *metadata = get_args(tp) + return resolve_type(base_type) + + return tp + + +def type_to_schema(tp, components: Components, *, + _visited_component_names: Optional[list[str]] = None) -> Schema | Reference: + if _visited_component_names is None: + _visited_component_names = [] + tp = resolve_type(tp) + origin = get_origin(tp) + args = get_args(tp) + + if hasattr(tp, '__name__'): + name = tp.__name__ + else: + name = str(tp) + + if origin is None: + # базовый тип + if tp == str: + return Schema(type='string') + elif tp == int: + return Schema(type='integer') + elif tp == float: + return Schema(type='number', format='float') + elif tp == bool: + return Schema(type='boolean') + elif tp is type(None): + return Schema(type='null') + + # Optional / Union + if origin is Union: + schemas = [] + nullable = False + for a in args: + if a is type(None): + nullable = True + else: + schemas.append(type_to_schema(a, components, _visited_component_names=_visited_component_names)) + if len(schemas) == 1: + schema = schemas[0] + else: + schema = Schema(oneOf=schemas) + if nullable: + schema = Schema(oneOf=[schema, Schema(type='null')]) + return schema + + # Базовые типы + if tp in (str, int, float, bool): + mapping: dict[type, SchemaType] = { + str: 'string', + int: 'integer', + float: 'number', + bool: 'boolean', + None: 'null' + } + schema = Schema(type=mapping[tp]) + if tp is float: + schema.format = 'float' + return schema + + # List + if origin is list: + item_type = args[0] if args else Any + return Schema(type='array', + items=type_to_schema(item_type, components, _visited_component_names=_visited_component_names)) + + # Tuple + if origin is tuple: + if args and args[-1] is Ellipsis: + return Schema(type="array", + items=type_to_schema(args[0], components, _visited_component_names=_visited_component_names)) + else: + return Schema( + type="array", + prefixItems=[type_to_schema(a, components, _visited_component_names=_visited_component_names) for a in + args], + minItems=len(args), + maxItems=len(args) + ) + + # Dict + if origin is dict: + key_type, value_type = args if args else (str, Any) + if key_type is not str: + raise ValueError("OpenAPI dict keys must be strings") + return Schema( + type="object", + additionalProperties=type_to_schema(value_type, components, + _visited_component_names=_visited_component_names) + ) + + if name in _visited_component_names: + return Reference(f'#/components/schemas/{name}') + _visited_component_names.append(name) + + if hasattr(tp, '__init__'): + init_params = get_type_hints(tp.__init__) + props = {} + required = [] + # Проходим по параметрам конструктора + for pname, ptype in init_params.items(): + if pname == "return": + continue + schema = type_to_schema(ptype, components, _visited_component_names=_visited_component_names) + + # Пытаемся получить alias через Annotated / metadata + field_alias = getattr(ptype, "__metadata__", None) + alias = pname + if field_alias: + for meta in field_alias: + if isinstance(meta, dict) and "alias" in meta: + alias = meta["alias"] + + props[alias] = schema + + # Поля без default — required + param_default = getattr(tp, pname, None) + if param_default is None: + required.append(alias) + + comp_schema = Schema(type="object", properties=props) + if required: + comp_schema.required = required + + components.schemas[name] = comp_schema + return comp_schema + + raise NotImplementedError(f"Тип {tp} не поддержан") + + +class OpenAPIApp: + info: Info + servers: list[Server] + paths: dict[str, PathItem] + webhooks: dict[str, PathItem] + components: Components + security: list[SecurityRequirement] + tags: list[Tag] + externalDocs: Optional[ExternalDocumentation] + + def __init__(self, info: Info, + servers: Optional[list[Server]] = None, + externalDocs: Optional[ExternalDocumentation] = None): + if servers is None: + servers = [] + + self.info = info + self.servers = servers + self.paths = defaultdict(lambda: PathItem()) + self.webhooks = {} + self.components = Components() + self.security = [] + self.tags = [] + self.externalDocs = externalDocs + + def register_endpoint(self, + method: MethodType, + path: str, + parameters: list[Parameter], + responses: dict[str, Response], + summary: Optional[str] = None, + request_body: Optional[RequestBody] = None, + desciption: Optional[str] = None, ): + path_params = [] + + substs = set(re.findall(r'\{(.*?)}', path)) + for s in substs: + path_params.append(Parameter( + name=s, + in_='path', + schema=Schema(type='string'), + required=True + )) + + self.paths[path].parameters = path_params + + op = Operation( + responses, + summary, + desciption, + parameters=parameters, + requestBody=request_body + ) + + match method: + case 'GET': + self.paths[path].get = op + case 'POST': + self.paths[path].post = op + case 'PUSH': + return + case 'PUT': + self.paths[path].put = op + case 'PATCH': + self.paths[path].patch = op + case 'DELETE': + self.paths[path].delete = op + case 'HEAD': + self.paths[path].head = op + case 'CONNECT': + return + case 'OPTIONS': + self.paths[path].options = op + case 'TRACE': + self.paths[path].trace = op + + def register_component(self, tp: type): + tp = resolve_type(tp) + schema = type_to_schema(tp, self.components) + self.components.schemas[f'{tp.__name__}'] = schema + + def as_openapi(self) -> OpenAPI: + return OpenAPI( + self.info, + None, + self.servers, + self.paths, + self.webhooks, + self.components, + self.security, + self.tags, + self.externalDocs + ) + + @staticmethod + def export_openapi(obj: Any): + if is_dataclass(obj): + result = {} + hints = get_type_hints(obj.__class__) + for f in fields(obj): + # Проверяем, есть ли alias в metadata + alias = f.metadata.get("alias") if "alias" in f.metadata else None + + # Для Annotated + tp = hints.get(f.name) + if getattr(tp, "__metadata__", None): + for meta in tp.__metadata__: + if isinstance(meta, dict) and "alias" in meta: + alias = meta["alias"] + + key = alias or f.name + value = getattr(obj, f.name) + if value is not None: + result[key] = OpenAPIApp.export_openapi(value) + return result + + elif isinstance(obj, dict): + return {k: OpenAPIApp.export_openapi(v) for k, v in obj.items() if v is not None} + elif isinstance(obj, (list, tuple, set)): + return [OpenAPIApp.export_openapi(v) for v in obj if v is not None] + else: + return obj + + def export_as_dict(self) -> dict: + obj = self.as_openapi() + return self.export_openapi(obj) diff --git a/src/turbosloth/doc/openapi_models.py b/src/turbosloth/doc/openapi_models.py new file mode 100644 index 0000000..1c92b8b --- /dev/null +++ b/src/turbosloth/doc/openapi_models.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, MutableMapping, Optional, Sequence, Tuple, TypeAlias, Union, Literal + +# --- alias metadata key for fields whose JSON key differs from Python attr name --- +ALIAS: Literal["alias"] = "alias" + +# --- Generic helpers --- +JsonValue: TypeAlias = Union[None, bool, int, float, str, list["JsonValue"], dict[str, "JsonValue"]] +ReferenceOr: TypeAlias = Union["Reference", Any] # refined per usage below + + +# --- Spec: https://spec.openapis.org/oas/v3.1.0 --- + + +# Components ------------------------------------------------------------------- +@dataclass +class Components: + schemas: dict[str, Union[Schema, Reference]] = field(default_factory=dict) + responses: dict[str, Union[Response, Reference]] = field(default_factory=dict) + parameters: dict[str, Union[Parameter, Reference]] = field(default_factory=dict) + examples: dict[str, Union[Example, Reference]] = field(default_factory=dict) + requestBodies: dict[str, Union[RequestBody, Reference]] = field(default_factory=dict) + headers: dict[str, Union[Header, Reference]] = field(default_factory=dict) + securitySchemes: dict[str, Union[SecurityScheme, Reference]] = field(default_factory=dict) + links: dict[str, Union[Link, Reference]] = field(default_factory=dict) + callbacks: dict[str, Union[Callback, Reference]] = field(default_factory=dict) + pathItems: dict[str, Union[PathItem, Reference]] = field(default_factory=dict) # OAS 3.1 + + +# Root ------------------------------------------------------------------------- +@dataclass +class OpenAPI: + info: Info + jsonSchemaDialect: Optional[str] = None + servers: list[Server] = field(default_factory=list) + paths: dict[str, PathItem] = field(default_factory=list) + webhooks: dict[str, PathItem] = field(default_factory=list) + components: Components = field(default_factory=Components) + security: list[SecurityRequirement] = field(default_factory=list) + tags: list[Tag] = field(default_factory=list) + externalDocs: Optional[ExternalDocumentation] = None + openapi: str = "3.1.0" + + +# Info ------------------------------------------------------------------------- +@dataclass +class Contact: + name: Optional[str] = None + url: Optional[str] = None + email: Optional[str] = None + + +@dataclass +class License: + name: str = "" + identifier: Optional[str] = None + url: Optional[str] = None + + +@dataclass +class Info: + title: str + version: str + description: Optional[str] = None + termsOfService: Optional[str] = None + contact: Optional[Contact] = None + license: Optional[License] = None + + +# Servers ---------------------------------------------------------------------- +@dataclass +class ServerVariable: + enum: Optional[list[str]] = None + default: str = "" + description: Optional[str] = None + + +@dataclass +class Server: + url: str + description: Optional[str] = None + variables: Optional[dict[str, ServerVariable]] = None + + +# External Docs ---------------------------------------------------------------- +@dataclass +class ExternalDocumentation: + url: str = "" + description: Optional[str] = None + + +# Tags ------------------------------------------------------------------------- +@dataclass +class Tag: + name: str = "" + description: Optional[str] = None + externalDocs: Optional[ExternalDocumentation] = None + + +# Reference -------------------------------------------------------------------- +@dataclass +class Reference: + ref: str = field(metadata={ALIAS: "$ref"}) + summary: Optional[str] = None + description: Optional[str] = None + + +# XML Object (for XML-specific representations) -------------------------------- +@dataclass +class XML: + name: Optional[str] = None + namespace: Optional[str] = None + prefix: Optional[str] = None + attribute: Optional[bool] = None + wrapped: Optional[bool] = None + + +# Schema (JSON Schema vocab with OAS extensions) -------------------------------- +SchemaType: TypeAlias = Literal[ + "null", "boolean", "object", "array", "number", "string", "integer" +] + + +@dataclass +class Discriminator: + propertyName: str = "" + mapping: Optional[dict[str, str]] = None + + +@dataclass +class Schema: + # Core JSON Schema + type: Optional[SchemaType] = None + enum: Optional[list[JsonValue]] = None + const: Optional[JsonValue] = None + multipleOf: Optional[float] = None + maximum: Optional[float] = None + exclusiveMaximum: Optional[float] = None + minimum: Optional[float] = None + exclusiveMinimum: Optional[float] = None + maxLength: Optional[int] = None + minLength: Optional[int] = None + pattern: Optional[str] = None + items: Optional[Union[Schema, list[Schema]]] = None + prefixItems: Optional[list[Schema]] = None + additionalItems: Optional[Union[bool, Schema]] = None + unevaluatedItems: Optional[Union[bool, Schema]] = None + maxItems: Optional[int] = None + minItems: Optional[int] = None + uniqueItems: Optional[bool] = None + contains: Optional[Schema] = None + maxProperties: Optional[int] = None + minProperties: Optional[int] = None + required: Optional[list[str]] = None + properties: Optional[dict[str, Schema]] = None + patternProperties: Optional[dict[str, Schema]] = None + additionalProperties: Optional[Union[bool, Schema]] = None + unevaluatedProperties: Optional[Union[bool, Schema]] = None + dependentRequired: Optional[dict[str, list[str]]] = None + dependentSchemas: Optional[dict[str, Schema]] = None + propertyNames: Optional[Schema] = None + allOf: Optional[list[Schema]] = None + anyOf: Optional[list[Schema]] = None + oneOf: Optional[list[Schema]] = None + not_: Optional[Schema] = field(default=None, metadata={ALIAS: "not"}) + if_: Optional[Schema] = field(default=None, metadata={ALIAS: "if"}) + then: Optional[Schema] = None + else_: Optional[Schema] = field(default=None, metadata={ALIAS: "else"}) + format: Optional[str] = None + contentEncoding: Optional[str] = None + contentMediaType: Optional[str] = None + contentSchema: Optional[Schema] = None + examples: Optional[list[JsonValue]] = None + default: Optional[JsonValue] = None + title: Optional[str] = None + description: Optional[str] = None + deprecated: Optional[bool] = None + readOnly: Optional[bool] = None + writeOnly: Optional[bool] = None + + # OAS schema add-ons + discriminator: Optional[Discriminator] = None + xml: Optional[XML] = None + externalDocs: Optional[ExternalDocumentation] = None + + +# Example ---------------------------------------------------------------------- +@dataclass +class Example: + summary: Optional[str] = None + description: Optional[str] = None + value: Optional[JsonValue] = None + externalValue: Optional[str] = None + + +# Encoding (for multipart/form-data etc.) -------------------------------------- +@dataclass +class Header: + description: Optional[str] = None + required: Optional[bool] = None + deprecated: Optional[bool] = None + allowEmptyValue: Optional[bool] = None + style: Optional[str] = None + explode: Optional[bool] = None + allowReserved: Optional[bool] = None + schema: Optional[Union[Schema, Reference]] = None + example: Optional[JsonValue] = None + examples: Optional[dict[str, Union[Example, Reference]]] = None + content: Optional[dict[str, "MediaType"]] = None + + +@dataclass +class Encoding: + contentType: Optional[str] = None + headers: Optional[dict[str, Union[Header, Reference]]] = None + style: Optional[str] = None + explode: Optional[bool] = None + allowReserved: Optional[bool] = None + + +# Media types / Request & Response bodies -------------------------------------- +@dataclass +class MediaType: + schema: Optional[Union[Schema, Reference]] = None + example: Optional[JsonValue] = None + examples: Optional[dict[str, Union[Example, Reference]]] = None + encoding: Optional[dict[str, Encoding]] = None + + +@dataclass +class RequestBody: + content: dict[str, MediaType] = field(default_factory=dict) + description: Optional[str] = None + required: Optional[bool] = None + + +@dataclass +class Response: + description: str = "" + headers: Optional[dict[str, Union[Header, Reference]]] = None + content: Optional[dict[str, MediaType]] = None + links: Optional[dict[str, Union["Link", Reference]]] = None + + +@dataclass +class Responses: + # keys are HTTP status codes or "default" + _map: dict[str, Union[Response, Reference]] = field(default_factory=dict, metadata={ALIAS: ""}) + + +# Links / Callbacks ------------------------------------------------------------- +@dataclass +class Link: + operationId: Optional[str] = None + operationRef: Optional[str] = None + parameters: Optional[dict[str, JsonValue]] = None + requestBody: Optional[JsonValue] = None + description: Optional[str] = None + server: Optional[Server] = None + + +@dataclass +class Callback: + # expression -> PathItem + _map: dict[str, "PathItem"] = field(default_factory=dict, metadata={ALIAS: ""}) + + +# Parameters ------------------------------------------------------------------- +ParameterLocation: TypeAlias = Literal["query", "header", "path", "cookie"] + + +@dataclass +class Parameter: + name: str = "" + in_: ParameterLocation = field(default="query", metadata={ALIAS: "in"}) + description: Optional[str] = None + required: Optional[bool] = None + deprecated: Optional[bool] = None + allowEmptyValue: Optional[bool] = None + style: Optional[str] = None + explode: Optional[bool] = None + allowReserved: Optional[bool] = None + schema: Optional[Union[Schema, Reference]] = None + example: Optional[JsonValue] = None + examples: Optional[dict[str, Union[Example, Reference]]] = None + content: Optional[dict[str, MediaType]] = None + + +# Operation / PathItem ---------------------------------------------------------- +@dataclass +class Operation: + responses: dict[str, Union[Response, Reference]] = field(default_factory=dict) + summary: Optional[str] = None + description: Optional[str] = None + operationId: Optional[str] = None + parameters: list[Union[Parameter, Reference]] = field(default_factory=list) + requestBody: Optional[Union[RequestBody, Reference]] = None + callbacks: dict[str, Union[Callback, Reference]] = field(default_factory=dict) + deprecated: Optional[bool] = None + security: list["SecurityRequirement"] = field(default_factory=list) + servers: list[Server] = field(default_factory=list) + tags: list[str] = field(default_factory=list) + externalDocs: Optional[ExternalDocumentation] = None + + +@dataclass +class PathItem: + summary: Optional[str] = None + description: Optional[str] = None + get: Optional[Operation] = None + put: Optional[Operation] = None + post: Optional[Operation] = None + delete: Optional[Operation] = None + options: Optional[Operation] = None + head: Optional[Operation] = None + patch: Optional[Operation] = None + trace: Optional[Operation] = None + servers: Optional[list[Server]] = None + parameters: Optional[list[Union[Parameter, Reference]]] = None + + +# Security (incl. OAuth2) ------------------------------------------------------ +SecuritySchemeType: TypeAlias = Literal["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] +ApiKeyLocation: TypeAlias = Literal["query", "header", "cookie"] + + +@dataclass +class OAuthFlow: + authorizationUrl: Optional[str] = None + tokenUrl: Optional[str] = None + refreshUrl: Optional[str] = None + scopes: dict[str, str] = field(default_factory=dict) + + +@dataclass +class OAuthFlows: + implicit: Optional[OAuthFlow] = None + password: Optional[OAuthFlow] = None + clientCredentials: Optional[OAuthFlow] = None + authorizationCode: Optional[OAuthFlow] = None + + +@dataclass +class SecurityScheme: + type: SecuritySchemeType = "http" + description: Optional[str] = None + + # apiKey + name: Optional[str] = None + in_: Optional[ApiKeyLocation] = field(default=None, metadata={ALIAS: "in"}) + + # http + scheme: Optional[str] = None + bearerFormat: Optional[str] = None + + # mutualTLS has no extra fields + + # oauth2 + flows: Optional[OAuthFlows] = None + + # openIdConnect + openIdConnectUrl: Optional[str] = None + + +# A single security requirement (scheme name -> required scopes) +SecurityRequirement: TypeAlias = dict[str, list[str]] diff --git a/src/turbosloth/interfaces/serialized/base.py b/src/turbosloth/interfaces/serialized/base.py index 2e1d130..4ac4cd4 100644 --- a/src/turbosloth/interfaces/serialized/base.py +++ b/src/turbosloth/interfaces/serialized/base.py @@ -1,5 +1,5 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Mapping from case_insensitive_dict import CaseInsensitiveDict @@ -41,10 +41,9 @@ class SerializedRequest: @dataclass class SerializedResponse: - code: int - headers: Mapping[str, str] body: dict[str, Any] | list[Any] | str - content_type: str = '' + code: int = 200 + headers: Mapping[str, str] = field(default_factory=dict) def into_basic(self, charset: str) -> BasicResponse: raise NotImplementedError() diff --git a/src/turbosloth/interfaces/serialized/html.py b/src/turbosloth/interfaces/serialized/html.py index 4e3884a..da77aa2 100644 --- a/src/turbosloth/interfaces/serialized/html.py +++ b/src/turbosloth/interfaces/serialized/html.py @@ -9,9 +9,8 @@ from .base import SerializedResponse @dataclass class HTMLSerializedResponse(SerializedResponse): body: str - content_type = 'text/html' def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = self.content_type + '; charset=' + charset + headers['content-type'] = 'text/html; charset=' + charset return BasicResponse(self.code, headers, str(self.body).encode(charset)) diff --git a/src/turbosloth/interfaces/serialized/json.py b/src/turbosloth/interfaces/serialized/json.py index dba941d..47b080e 100644 --- a/src/turbosloth/interfaces/serialized/json.py +++ b/src/turbosloth/interfaces/serialized/json.py @@ -19,10 +19,9 @@ class JsonSerializedRequest(SerializedRequest): class JsonSerializedResponse(SerializedResponse): - content_type = 'application/json' def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = self.content_type + '; charset=utf-8' + headers['content-type'] = 'application/json; charset=utf-8' b = orjson.dumps(self.body) return BasicResponse(self.code, headers, b) diff --git a/src/turbosloth/interfaces/serialized/msgpack.py b/src/turbosloth/interfaces/serialized/msgpack.py index 216bc73..396657e 100644 --- a/src/turbosloth/interfaces/serialized/msgpack.py +++ b/src/turbosloth/interfaces/serialized/msgpack.py @@ -16,10 +16,9 @@ class MessagePackSerializedRequest(SerializedRequest): class MessagePackSerializedResponse(SerializedResponse): - content_type = 'application/vnd.msgpack' def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = self.content_type + headers['content-type'] = 'application/vnd.msgpack' b = msgpack.packb(self.body) return BasicResponse(self.code, headers, b) diff --git a/src/turbosloth/interfaces/serialized/text.py b/src/turbosloth/interfaces/serialized/text.py index e8acaf1..0d59e3c 100644 --- a/src/turbosloth/interfaces/serialized/text.py +++ b/src/turbosloth/interfaces/serialized/text.py @@ -12,9 +12,8 @@ class TextSerializedRequest(SerializedRequest): class TextSerializedResponse(SerializedResponse): - content_type = 'text/plain' def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = self.content_type + '; charset=' + charset + headers['content-type'] = 'text/plain; charset=' + charset return BasicResponse(self.code, headers, str(self.body).encode(charset)) diff --git a/src/turbosloth/interfaces/serialized/xml.py b/src/turbosloth/interfaces/serialized/xml.py index 24828a9..39dc260 100644 --- a/src/turbosloth/interfaces/serialized/xml.py +++ b/src/turbosloth/interfaces/serialized/xml.py @@ -21,11 +21,10 @@ class XMLSerializedRequest(SerializedRequest): class XMLSerializedResponse(SerializedResponse): - content_type = 'application/xml' def into_basic(self, charset: str) -> BasicResponse: headers = CaseInsensitiveDict(self.headers).copy() - headers['content-type'] = self.content_type + '; charset=' + charset + headers['content-type'] = 'application/xml; charset=' + charset btxt = _into_xml(self.body)