Add openapi request basic schema generation, fix SerializedResponse headers, HTTP codes

This commit is contained in:
2025-08-26 17:33:23 +03:00
parent c40bdca9e4
commit f823d2df5a
10 changed files with 887 additions and 26 deletions

View File

@@ -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 = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Elements in HTML</title>
<!-- Embed elements Elements via Web Component -->
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
<style>.sl-elements {height: 100vh}</style>
</head>
<body>
<elements-api
apiDescriptionUrl="/openapi.json"
router="hash"
layout="sidebar"
/>
</body>
</html>'''
return BasicResponse(200, {}, ret.encode())
if __name__ == '__main__':

View File

@@ -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):

View File

@@ -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)

View File

@@ -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]]

View File

@@ -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()

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)