diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5360b97..b2a627ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -54,7 +54,11 @@ repos: hooks: - id: yamlfmt args: [--mapping, '2', --sequence, '4', --offset, '2', --preserve-quotes, --implicit_start, --width, '1500'] - exclude: tests/fixtures/schema-enum.yaml + exclude: | + (?x)( + ^tests/fixtures/schema-enum.yaml| + ^tests/fixtures/paths-parameter-querystring.yaml + ) - repo: https://github.com/astral-sh/uv-pre-commit # uv version. diff --git a/docs/source/advanced.rst b/docs/source/advanced.rst index cf68cc5c..a0032419 100644 --- a/docs/source/advanced.rst +++ b/docs/source/advanced.rst @@ -320,6 +320,50 @@ The main difference in the async use of the streaming is await & async for. session.close() +Sequential Media Types +^^^^^^^^^^^^^^^^^^^^^^ +`Sequential Media Types `_ as defined in OpenAPI 3.2 allow +streaming encoded objects using itemSchema. + + +:meth:`~aiopenapi3.request.RequestBase.sequence` is a Generator/contextmanager returning an headers and the Iterator +streaming the sequence. + + * :class:`aiopenapi3.request.AsyncRequestBase.sequence` + * :class:`aiopenapi3.request.AsyncRequestBase.EventIterator` + +.. rubric:: asyncio + +.. code:: python + + req = api.createRequest("json_seq") + async with req.sequence() as (headers, sequence): + async for event in sequence: + print(event) + + + * :class:`aiopenapi3.request.RequestBase.sequence` + * :class:`aiopenapi3.request.RequestBase.EventIterator` + + +.. rubric:: sync + +.. code:: python + + req = api.createRequest("json_seq") + with req.sequence() as (headers, sequence): + for event in sequence: + print(event) + + +aiopenapi3 supports sequential media for the following content types: + + * application/json-seq e.g. '\x1E{…}\n via jsonseq + * application/jsonl e.g. '{…}\n' http.Response.iter_lines http.Response.aiter_lines + * application/x-ndjson e.g. '{…}\n' http.Response.iter_lines http.Response.aiter_lines + * text/event-stream e.g. '[{…},' via ijson + + Non-JSON Content ^^^^^^^^^^^^^^^^ In case the content is not a model (application/octet-stream), the data can be read iteratively and written/processed. diff --git a/docs/source/index.rst b/docs/source/index.rst index d4a55a2c..07fadc9a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -31,6 +31,7 @@ While aiopenapi3 supports some of the more exotic features of the Swagger/OpenAP * Swagger 2.0 * OpenAPI 3.0 * OpenAPI 3.1 + * OpenAPI 3.2 * multi file description documents * recursive schemas @@ -42,6 +43,7 @@ While aiopenapi3 supports some of the more exotic features of the Swagger/OpenAP * :ref:`advanced:Forms` * :ref:`advanced:mutualTLS` authentication * :ref:`Request ` and :ref:`Response ` streaming to reduce memory usage +* :ref:`Sequential Media Types ` * Culling :ref:`extra:Large Description Documents` some aspects of the specifications are implemented loose diff --git a/docs/source/use.rst b/docs/source/use.rst index 3f00ed30..47d30ce8 100644 --- a/docs/source/use.rst +++ b/docs/source/use.rst @@ -136,6 +136,15 @@ scoping the access to the methods. n.id == user.id # True +In OpenAPI 3.2, Tags can form hierarchies via the parent attribute. +In this case, resolving the hierarchy is required. + +.. code:: python + + n = t._.external.partner.x() + + + Operation Parameters -------------------- diff --git a/pyproject.toml b/pyproject.toml index 45ffd7fb..150176fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "aiopenapi3" -description = "client and validator for OpenAPI3 3.0, OpenAPI 3.1, Swagger 2.0" +description = "client and validator for OpenAPI3 3.0, OpenAPI 3.1, OpenAPI 3.2, Swagger 2.0" authors = [ {name = "Markus Kötter", email = "commonism@users.noreply.github.com"}, ] @@ -11,8 +11,10 @@ dependencies = [ "yarl", "httpx", "more-itertools", - 'typing_extensions; python_version<"3.10"', + 'typing_extensions; python_version<"3.12"', "jmespath", + "ijson", + "jsonseq", ] requires-python = ">=3.10" readme = "README.md" @@ -104,8 +106,8 @@ filterwarnings = [ "ignore:'flask.Markup' is deprecated and will be removed in Flask 2.4. Import 'markupsafe.Markup' instead.:DeprecationWarning", "ignore:unclosed resource "RootType": return v30.Root.model_validate(document) elif v[1] == 1: return v31.Root.model_validate(document) + elif v[1] == 2: + return v32.Root.model_validate(document) else: raise ValueError(f"openapi version 3.{v[1]} not supported") else: @@ -307,8 +310,12 @@ def __init__( Document Plugins get called via OpenAPI.load… - this is processed already """ self._root = self._parse_obj(document) + if isinstance(self._root, v32.Root) and self._root.self_: + docref = yarl.URL(str(self._root.self_)) + else: + docref = self._base_url - self._documents[self._base_url] = self._root + self._documents[docref] = self._root self._init_session_factory(session_factory) self._init_references() @@ -328,7 +335,7 @@ def _init_session_factory(self, session_factory): ): if isinstance(self._root, v20.Root): self._createRequest = v20.Request - elif isinstance(self._root, (v30.Root, v31.Root)): + elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)): self._createRequest = v30.Request else: raise ValueError(self._root) @@ -337,7 +344,7 @@ def _init_session_factory(self, session_factory): ) or (type(session_factory) is type and issubclass(session_factory, httpx.AsyncClient)): if isinstance(self._root, v20.Root): self._createRequest = v20.AsyncRequest - elif isinstance(self._root, (v30.Root, v31.Root)): + elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)): self._createRequest = v30.AsyncRequest else: raise ValueError(self._root) @@ -392,7 +399,7 @@ def _init_operationindex(self, use_operation_tags: bool) -> bool: if isinstance(response.schema_, (v20.Schema,)): response.schema_._get_identity("OP", f"{path}.{m}.{r}") - elif isinstance(self._root, (v30.Root, v31.Root)): + elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)): allschemas = [ x.components.schemas for x in filter(has_components, self._documents.values()) @@ -428,6 +435,8 @@ def _init_operationindex(self, use_operation_tags: bool) -> bool: self._root.paths = v30.Paths(paths={}, extensions={}) elif isinstance(self._root, v31.Root): self._root.paths = v31.Paths(paths={}, extensions={}) + elif isinstance(self._root, v32.Root): + self._root.paths = v32.Paths(paths={}, extensions={}) else: raise ValueError(self._root) else: @@ -675,7 +684,7 @@ def url(self) -> yarl.URL: r = yarl.URL.build(scheme=scheme, host=host, port=port, path=path) return r - elif isinstance(self._root, (v30.Root, v31.Root)): + elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)): assert self._root.servers server: "ServerType" = self._server_select(self._root.servers) return self._base_url.join(yarl.URL(server.createUrl(self._server_variables))) @@ -761,11 +770,14 @@ def createRequest(self, operationId: str | tuple[str, "HTTPMethodType"]) -> "Req if pathitem.ref: pathitem = pathitem.ref._target - operation = getattr(pathitem, method) + if method in HTTP_METHODS: + operation = getattr(pathitem, method) + else: # v32 + operation = pathitem.additionalOperations.get(method, None) assert operation is not None if isinstance(self._root, v20.Root): servers = None - elif isinstance(self._root, (v30.Root, v31.Root)): + elif isinstance(self._root, (v30.Root, v31.Root, v32.Root)): servers = operation.servers or pathitem.servers or self.servers else: raise TypeError(self._root) diff --git a/src/aiopenapi3/request.py b/src/aiopenapi3/request.py index a49ff284..5e5395b3 100644 --- a/src/aiopenapi3/request.py +++ b/src/aiopenapi3/request.py @@ -1,8 +1,12 @@ import abc import collections +import contextlib import typing +import json +import logging from contextlib import closing from typing import Any, NamedTuple, Optional, Union, cast +from collections.abc import AsyncIterator, AsyncGenerator, Generator from collections.abc import Iterator from contextlib import aclosing @@ -35,9 +39,12 @@ ResponseDataType, ResponseHeadersType, HTTPMethodType, + TagType, ) from aiopenapi3 import OpenAPI +log = logging.getLogger("aiopenapi3.request") + class RequestParameter: def __init__(self, url: yarl.URL | str): @@ -60,6 +67,22 @@ class StreamResponse(NamedTuple): session: httpx.Client result: httpx.Response + class Sequencer: + def __init__(self, headers: "ResponseHeadersType", stream: Iterator["JSON"], model: pydantic.BaseModel) -> None: + self.headers: ResponseHeadersType = headers + self.stream: Iterator["JSON"] = stream + self.model = model + + def __iter__(self) -> Iterator: + return self + + def __next__(self) -> pydantic.BaseModel: + data: JSON + for data in self.stream: + obj = self.model.model_validate(data) + return obj + raise StopIteration + class Response(NamedTuple): headers: "ResponseHeadersType" data: Any @@ -183,6 +206,14 @@ def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType """ ... + @abc.abstractmethod + def _process_sequence(self, result: httpx.Response) -> tuple["ResponseHeadersType", "ResponseDataType", Any]: + """ + process response headers + lookup Model + """ + ... + @abc.abstractmethod def _prepare(self, data: Optional["RequestData"], parameters: Optional["RequestParameters"]) -> None: ... @@ -271,6 +302,79 @@ def stream( headers, schema_ = self._process_stream(result) return RequestBase.StreamResponse(headers, schema_, session, result) + @contextlib.contextmanager + def sequence( # type: ignore[override] + self, + data: Optional["RequestData"] = None, + parameters: Optional["RequestParameters"] = None, + context: Any = None, + ) -> Generator["RequestBase.Sequencer", None, None]: + self.vars = RequestBase.Vars(parameters, data, context) + self._prepare(data, parameters) + session: httpx.Client = self.api._session_factory(**self._session_factory_default_args) + result = self._send(session, data, parameters) + headers, schema_, content_type = self._process_sequence(result) + + if content_type in ["application/jsonl", "application/x-ndjson"]: + """ + https://jsonlines.org/ + https://github.com/ndjson/ndjson-spec + """ + + def iter_json(response: httpx.Response) -> Iterator["JSON"]: + for i in response.iter_lines(): + yield json.loads(i) + + elif content_type == "application/json-seq": + """ + JSON Text Sequence + https://datatracker.ietf.org/doc/html/rfc7464 + """ + + import jsonseq.decode + + def iter_json(response: httpx.Response) -> Iterator["JSON"]: + decoder = jsonseq.decode.JSONSeqDecoder() + for text in response.iter_text(): + yield from decoder.decode(text) + + elif content_type == "text/event-stream": + """ + Server-Sent Events (SSE) + https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events + """ + + import ijson + + class ReadEventStream: + """ + Using a AsyncIterator input to feed a coroutine + """ + + def __init__(self, response: httpx.Response) -> None: + self._iter_bytes = response.iter_bytes() + + def read(self, num_bytes: int) -> bytes: + if num_bytes == 0: + return b"" + + return next(self._iter_bytes) + + def iter_json(response: httpx.Response) -> Iterator["JSON"]: + reader = ReadEventStream(response) + yield from ijson.items(reader, "item") + else: + raise NotImplementedError(content_type) + + try: + """__enter__""" + stream = iter_json(result) + yield RequestBase.Sequencer(headers, stream, schema_.get_type()) + finally: + """__exit__""" + if not result.is_closed: + result.close() + @property @abc.abstractmethod def data(self) -> Optional["SchemaType"]: @@ -295,6 +399,24 @@ class StreamResponse(NamedTuple): session: httpx.AsyncClient result: httpx.Response + class Sequencer: + def __init__( + self, headers: "ResponseHeadersType", stream: AsyncIterator["JSON"], model: pydantic.BaseModel + ) -> None: + self.headers: "ResponseHeadersType" = headers + self.stream: AsyncIterator["JSON"] = stream + self.model = model + + def __aiter__(self) -> AsyncIterator: + return self + + async def __anext__(self) -> pydantic.BaseModel: + data: JSON + async for data in self.stream: + obj = self.model.model_validate(data) + return obj + raise StopAsyncIteration + async def __call__( # type: ignore[override] self, *args, return_headers: bool = False, context: Any = None, **kwargs ) -> Union["JSON", tuple[dict[str, str], "JSON"]]: @@ -347,6 +469,103 @@ async def stream( # type: ignore[override] headers, schema_ = self._process_stream(result) return AsyncRequestBase.StreamResponse(headers, schema_, session, result) + @contextlib.asynccontextmanager + async def sequence( # type: ignore[override] + self, + data: Optional["RequestData"] = None, + parameters: Optional["RequestParameters"] = None, + context: Any = None, + ) -> AsyncGenerator["AsyncRequestBase.Sequencer", None]: + self.vars = RequestBase.Vars(parameters, data, context) + self._prepare(data, parameters) + session = self.api._session_factory(**self._session_factory_default_args) + result = await self._send(session, data, parameters) + headers, schema_, content_type = self._process_sequence(result) + + if content_type in ["application/jsonl", "application/x-ndjson"]: + """ + https://jsonlines.org/ + https://github.com/ndjson/ndjson-spec + """ + + async def aiter_json(response: httpx.Response) -> AsyncIterator["JSON"]: + async for i in response.aiter_lines(): + yield json.loads(i) + + elif content_type == "application/json-seq": + """ + JSON Text Sequence + https://datatracker.ietf.org/doc/html/rfc7464 + """ + + import jsonseq.decode + + async def aiter_json(response: httpx.Response) -> AsyncIterator["JSON"]: + decoder = jsonseq.decode.JSONSeqDecoder() + async for text in response.aiter_text(): + for obj in decoder.decode(text): + yield obj + + elif content_type == "text/event-stream": + """ + Server-Sent Events (SSE) + https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events + https://html.spec.whatwg.org/multipage/server-sent-events.html#parsing-an-event-stream + https://github.com/mpetazzoni/sseclient/blob/main/sseclient/__init__.py#L36 + """ + + async def aiter_json(response: httpx.Response) -> AsyncIterator["JSON"]: + + async for chunk in response.aiter_text(): + data_ = "" + for line in chunk.splitlines(keepends=True): + data_ += line + if not data_.endswith(("\r\r", "\n\n", "\r\n\r\n")): + continue + + v = dict() + for l in data_.splitlines(keepends=False): + if l == "": + continue + cmd, _, value = l.partition(":") + if cmd not in ("event", "data", "id", "retry", ""): + # ignore + continue + v[cmd or "comment"] = value.lstrip() + data_ = "" + yield v + elif False: + import ijson + + class ReadEventStream: + """ + Using a AsyncIterator input to feed a coroutine + """ + + def __init__(self, response: httpx.Response) -> None: + self._aiter_bytes = response.aiter_bytes() + + async def read(self, num_bytes: int) -> bytes: + if num_bytes == 0: + return b"" + + return await anext(self._aiter_bytes) + + async def aiter_json(response: httpx.Response) -> AsyncIterator["JSON"]: + reader = ReadEventStream(response) + async for item in ijson.items(reader, "item"): + yield item + else: + raise NotImplementedError(content_type) + + try: + """__aenter__""" + stream = aiter_json(result) + yield AsyncRequestBase.Sequencer(headers, stream, schema_.get_type()) + finally: + """__aexit__""" + await session.aclose() + class OperationIndex: class OperationTag: @@ -355,17 +574,21 @@ def __init__(self, oi: "OperationIndex") -> None: self._operations: dict[str, tuple["HTTPMethodType", str, "OperationType", list["ServerType"] | None]] = ( dict() ) + self._tags: dict[str, "OperationIndex.OperationTag"] = dict() def __getattr__(self, item) -> RequestBase: - (method, path, op, servers) = self._operations[item] - return self._oi._api._createRequest(self._oi._api, method, path, op, servers) + if item in self._operations: + (method, path, op, servers) = self._operations[item] + return self._oi._api._createRequest(self._oi._api, method, path, op, servers) + else: + return self._tags[item] class Iter: - def __init__(self, spec: "OpenAPI", use_operation_tags: bool): + def __init__(self, api: "OpenAPI", use_operation_tags: bool): self.operations = [] self.r: Iterator[int] pi: "PathItemType" - for path, pi in spec.paths.items(): + for path, pi in api.paths.items(): op: "OperationType" if pi.ref: # pi = pi.ref._target @@ -377,9 +600,30 @@ def __init__(self, spec: "OpenAPI", use_operation_tags: bool): continue if use_operation_tags and op.tags: for tag in op.tags: - self.operations.append(f"{tag}.{op.operationId}") + tags = list() + while tag: + tags.append(tag) + tag = api._operationindex.tag(tag) + tag = getattr(tag, "parent", None) + self.operations.append(f"{'.'.join(tags[::-1])}.{op.operationId}") else: self.operations.append(op.operationId) + + if hasattr(pi, "additionalOperations"): # v32 + if pi.additionalOperations: + for method, op in pi.additionalOperations.items(): + if use_operation_tags and op.tags: + for tag in op.tags: + tags = list() + while tag: + tags.append(tag) + tag = api._operationindex.tag(tag) + tag = getattr(tag, "parent", None) + + self.operations.append(f"{'.'.join(tags[::-1])}.{op.operationId}") + else: + self.operations.append(op.operationId) + self.r = iter(range(len(self.operations))) def __iter__(self): @@ -413,15 +657,53 @@ def __init__(self, api: "OpenAPI", use_operation_tags: bool): else: servers = None item = (method, path, op, servers) + if use_operation_tags and op.tags: for tag in op.tags: - if (other := self._tags[tag]._operations.get(operationId, None)) is not None: + tree: list[str] = list() + t: str | None = tag + v: TagType | None + while t: + tree.append(t) + if (v := self.tag(t)) is None: + break + t = getattr(v, "parent", None) + if t in tree: + break + + x = self + for t in tree[::-1]: + if t not in x._tags: + x._tags[t] = OperationIndex.OperationTag(self) + x = x._tags[t] + + if (other := x._operations.get(operationId, None)) is not None: raise OperationIdDuplicationError(operationId, [item, other]) - self._tags[tag]._operations[operationId] = item + x._operations[operationId] = item + else: if (other := self._operations.get(operationId, None)) is not None: raise OperationIdDuplicationError(operationId, [item, other]) self._operations[operationId] = item + + if hasattr(pi, "additionalOperations"): # v32 + if pi.additionalOperations: + for method, op in pi.additionalOperations.items(): + if op.operationId is None: + continue + operationId = op.operationId.replace(" ", "_") + servers = op.servers or pi.servers or None + item = (method, path, op, servers) + if use_operation_tags and op.tags: + for tag in op.tags: + if (other := self._tags[tag]._operations.get(operationId, None)) is not None: + raise OperationIdDuplicationError(operationId, [item, other]) + self._tags[tag]._operations[operationId] = item + else: + if (other := self._operations.get(operationId, None)) is not None: + raise OperationIdDuplicationError(operationId, [item, other]) + self._operations[operationId] = item + # convert to dict as pickle does not like local functions self._tags = dict(self._tags) self._use_operation_tags = use_operation_tags @@ -452,10 +734,18 @@ def __getitem__(self, item: str | tuple[str, "HTTPMethodType"]) -> "RequestType" return getattr(self, item) if isinstance(item, str) else self._api.createRequest(item) def __iter__(self) -> Iter: - return self.Iter(self._root, self._use_operation_tags) + return self.Iter(self._api, self._use_operation_tags) def __getstate__(self): return self.__dict__ def __setstate__(self, values): self.__dict__.update(values) + + def tag(self, name: str): + for tag in self._api._root.tags: + if tag.name == name: + break + else: + return None + return tag diff --git a/src/aiopenapi3/v30/glue.py b/src/aiopenapi3/v30/glue.py index a368ac5c..776532ae 100644 --- a/src/aiopenapi3/v30/glue.py +++ b/src/aiopenapi3/v30/glue.py @@ -274,9 +274,9 @@ def _prepare_parameters(self, provided: Optional["RequestParameters"]) -> dict[s provided = provided or dict() possible = {_.name: _ for _ in self.operation.parameters + self.root.paths[self.path].parameters} - from .. import v30, v31 + from .. import v30, v31, v32 - assert isinstance(self.operation, (v30.Operation, v31.Operation)) + assert isinstance(self.operation, (v30.Operation, v31.Operation, v32.Operation)) if self.operation.requestBody: rbq: dict[str, str] = dict() # requestBody Parameters @@ -329,7 +329,7 @@ def _prepare_parameters(self, provided: Optional["RequestParameters"]) -> dict[s # as such we need to collect all the path parameters before # applying them to the format string. path_parameters.update(values) - elif spec.in_ == "query": + elif spec.in_ in ["query", "querystring"]: self.req.params.update(values) elif spec.in_ == "cookie": self.req.cookies.update(values) @@ -338,9 +338,9 @@ def _prepare_parameters(self, provided: Optional["RequestParameters"]) -> dict[s return mph def _prepare_body(self, data_: Optional["RequestData"], mph: dict[str, str]) -> None: - from .. import v30, v31 + from .. import v30, v31, v32 - assert isinstance(self.operation, (v30.Operation, v31.Operation)) + assert isinstance(self.operation, (v30.Operation, v31.Operation, v32.Operation)) if not self.operation.requestBody: ctx = self.api.plugins.message.sending( @@ -482,7 +482,6 @@ def _prepare_body(self, data_: Optional["RequestData"], mph: dict[str, str]) -> self.req.content = ctx.sending self.req.headers = ctx.headers self.req.cookies = ctx.cookies - else: raise NotImplementedError(self.operation.requestBody.content) @@ -572,6 +571,17 @@ def _process_stream(self, result: httpx.Response) -> tuple["ResponseHeadersType" return headers, expected_media.schema_ + def _process_sequence(self, result: httpx.Response) -> tuple["ResponseHeadersType", Optional["SchemaType"], str]: + status_code = str(result.status_code) + content_type = result.headers.get("Content-Type", None) + + expected_response = self._process__status_code(result, status_code) + content_type, expected_media = self._process__content_type(result, expected_response, content_type) + + headers = self._process__headers(result, result.headers, expected_response) + + return headers, expected_media.itemSchema, content_type + def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType", "ResponseDataType"]: rheaders = dict() # spec enforces these are strings @@ -600,7 +610,7 @@ def _process_request(self, result: httpx.Response) -> tuple["ResponseHeadersType content_type, expected_media = self._process__content_type(result, expected_response, content_type) - if content_type.lower() == "application/json": + if (ct := content_type.lower()) == "application/json": data = ctx.received expected_type = getattr(expected_media.schema_, "_target", expected_media.schema_) diff --git a/src/aiopenapi3/v30/parameter.py b/src/aiopenapi3/v30/parameter.py index 8c8cf591..9456af09 100644 --- a/src/aiopenapi3/v30/parameter.py +++ b/src/aiopenapi3/v30/parameter.py @@ -41,6 +41,10 @@ def _codec(self): style = self.style or "form" assert style in frozenset(["form"]) explode = self.explode if self.explode is not None else (False if style != "form" else True) + elif self.in_ == "querystring": + style = "querystring" + explode = None + return next(iter(self.content.values())).schema_, style, explode else: raise ParameterFormatError(self) @@ -260,6 +264,24 @@ def flatten_dict(d: MutableMapping, key: str = ""): values = {k: v for k, v in flatten_dict(values, name).items()} return values + def _encode__querystring(self, name: str, type_: str, value, schema: "v3xSchemaType", explode: bool): + print(name, type_, value, schema, explode) + values = dict() + ct = next(iter(self.content.keys())) + media = self.content[ct] + if ct == "application/x-www-form-urlencoded": + from .formdata import parameters_from_urlencoded + + values = parameters_from_urlencoded(value, media) + else: # ct == "application/json": + if isinstance(value, BaseModel): + values[value.model_dump_json()] = None + elif isinstance(value, str): + values[value] = "" + else: + values[str(value)] = "" + return values + def _decode(self, value): schema, style, explode = self._codec() if style == "simple": diff --git a/src/aiopenapi3/v32/__init__.py b/src/aiopenapi3/v32/__init__.py new file mode 100644 index 00000000..8a8a4a4a --- /dev/null +++ b/src/aiopenapi3/v32/__init__.py @@ -0,0 +1,56 @@ +from .components import Components +from .example import Example +from .general import ExternalDocumentation, Reference +from .info import Contact, License, Info +from .media import Encoding, MediaType +from .parameter import Parameter, Header +from .paths import RequestBody, Link, Response, Operation, PathItem, Paths, Callback, RuntimeExpression +from .root import Root +from .schemas import Discriminator, Schema +from .security import OAuthFlow, OAuthFlows, SecurityScheme, SecurityRequirement +from .servers import ServerVariable, Server +from .tag import Tag +from .xml import XML + + +def __init(): + r = dict() + CLASSES = [ + Components, + Example, + ExternalDocumentation, + Reference, + Contact, + License, + Info, + Encoding, + MediaType, + Parameter, + Header, + RequestBody, + Link, + Response, + Operation, + PathItem, + Paths, + Callback, + RuntimeExpression, + Discriminator, + Schema, + OAuthFlow, + OAuthFlows, + SecurityScheme, + SecurityRequirement, + ServerVariable, + Server, + Tag, + XML, + Root, + ] + for i in CLASSES: + r[i.__name__] = i + for i in CLASSES: + i.model_rebuild(_types_namespace=r) + + +__init() diff --git a/src/aiopenapi3/v32/components.py b/src/aiopenapi3/v32/components.py new file mode 100644 index 00000000..cceedc38 --- /dev/null +++ b/src/aiopenapi3/v32/components.py @@ -0,0 +1,35 @@ +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .paths import RequestBody, Link, Response, Callback, PathItem +from .general import Reference +from .parameter import Header, Parameter +from .schemas import Schema +from .security import SecurityScheme +from .media import MediaType + + +class Components(ObjectExtended): + """ + 4.7 Components Object + Holds a set of reusable objects for different aspects of the OAS. + All objects defined within the Components Object will have no effect on the API unless they are explicitly + referenced from outside the Components Object. + + As described `here`_ + .. _Components Object: https://spec.openapis.org/oas/v3.2.0.html#components-object + """ + + schemas: dict[str, Schema] = Field(default_factory=dict) + responses: dict[str, Response | Reference] = Field(default_factory=dict) + parameters: dict[str, Parameter | Reference] = Field(default_factory=dict) + examples: dict[str, Example | Reference] = Field(default_factory=dict) + requestBodies: dict[str, RequestBody | Reference] = Field(default_factory=dict) + headers: dict[str, Header | Reference] = Field(default_factory=dict) + securitySchemes: dict[str, SecurityScheme | Reference] = Field(default_factory=dict) + links: dict[str, Link | Reference] = Field(default_factory=dict) + callbacks: dict[str, Callback | Reference] = Field(default_factory=dict) + pathItems: dict[str, PathItem | Reference] = Field(default_factory=dict) + mediaTypes: dict[str, MediaType | Reference] = Field(default_factory=dict) diff --git a/src/aiopenapi3/v32/example.py b/src/aiopenapi3/v32/example.py new file mode 100644 index 00000000..b58f048e --- /dev/null +++ b/src/aiopenapi3/v32/example.py @@ -0,0 +1,24 @@ +from typing import Any + +from pydantic import Field + +from ..base import ObjectExtended + + +class Example(ObjectExtended): + """ + 4.19 Example Object + + An object grouping an internal or external example value with basic summary and description metadata. + The examples can show either data suitable for schema validation, or serialized data as required by the + containing Media Type Object, Parameter Object, or Header Object. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#example-object + """ + + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + dataValue: Any | None = Field(default=None) + serializedValue: str | None = Field(default=None) + externalValue: str | None = Field(default=None) + value: Any | None = Field(default=None) diff --git a/src/aiopenapi3/v32/general.py b/src/aiopenapi3/v32/general.py new file mode 100644 index 00000000..9db600a7 --- /dev/null +++ b/src/aiopenapi3/v32/general.py @@ -0,0 +1,56 @@ +import typing +from typing import Union, Any + +from pydantic import Field, AnyUrl, PrivateAttr, ConfigDict + + +from ..base import ObjectExtended, ObjectBase, ReferenceBase + +if typing.TYPE_CHECKING: + from .schemas import Schema + from .paths import Parameter, PathItem + + +class ExternalDocumentation(ObjectExtended): + """ + 4.11 External Documentation Object + Allows referencing an external resource for extended documentation. + + As described `here`_ + .. _here: https://spec.openapis.org/oas/v3.2.0.html#external-documentation-object + """ + + url: AnyUrl = Field(...) + description: str | None = Field(default=None) + + +class Reference(ObjectBase, ReferenceBase): + """ + 4.23 Reference Object + A simple object to allow referencing other components in the OpenAPI Description, internally and externally. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#reference-object + """ + + ref: str = Field(alias="$ref") + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + + _target: Union["Schema", "Parameter", "Reference", "PathItem"] = PrivateAttr(default=None) + + model_config = ConfigDict( + # """This object cannot be extended with additional properties and any properties added SHALL be ignored.""" + extra="ignore" + ) + + def __getattr__(self, item: str) -> Any: + if item != "_target" and not item.startswith("__pydantic_private__"): + return getattr(self._target, item) + else: + return super().__getattr__(item) + + def __setattr__(self, item, value): + if item != "_target" and not item.startswith("__pydantic_private__"): + setattr(self._target, item, value) + else: + super().__setattr__(item, value) diff --git a/src/aiopenapi3/v32/info.py b/src/aiopenapi3/v32/info.py new file mode 100644 index 00000000..33dd3090 --- /dev/null +++ b/src/aiopenapi3/v32/info.py @@ -0,0 +1,63 @@ +from pydantic import Field, EmailStr, model_validator + +from aiopenapi3.base import ObjectExtended + + +class Contact(ObjectExtended): + """ + 4.3 Contact Object + + Contact information for the exposed API. + + As described `here`_ + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#contact-object + """ + + email: EmailStr = Field(default=None) + name: str = Field(default=None) + url: str = Field(default=None) + + +class License(ObjectExtended): + """ + 4.4 License Object + + License information for the exposed API. + + As described `here`_ + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#license-object + """ + + name: str = Field(...) + identifier: str | None = Field(default=None) + url: str | None = Field(default=None) + + @model_validator(mode="after") + def validate_License(self): + """ + A URL to the license used for the API. This MUST be in the form of a URL. The url field is mutually exclusive of the identifier field. + """ + assert not all([getattr(self, i, None) is not None for i in ["identifier", "url"]]) + return self + + +class Info(ObjectExtended): + """ + 4.2 Info Object + + The object provides metadata about the API. The metadata MAY be used by the clients if needed, + and MAY be presented in editing or documentation generation tools for convenience. + + As described `here`_ + .. _here: https://spec.openapis.org/oas/v3.2.0.html#info-object + """ + + title: str = Field(...) + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + termsOfService: str | None = Field(default=None) + contact: Contact | None = Field(default=None) + license: License | None = Field(default=None) + version: str = Field(...) diff --git a/src/aiopenapi3/v32/media.py b/src/aiopenapi3/v32/media.py new file mode 100644 index 00000000..825a9f1e --- /dev/null +++ b/src/aiopenapi3/v32/media.py @@ -0,0 +1,57 @@ +import sys + +if sys.version_info < (3, 12): + from typing import Any + from typing_extensions import Self +else: + from typing import Any, Self + +from pydantic import Field + +from ..base import ObjectExtended + +from .example import Example +from .general import Reference +from .schemas import Schema +from .parameter import Header + + +class Encoding(ObjectExtended): + """ + 4.15 Encoding Object + + A single encoding definition applied to a single value, with the mapping of Encoding Objects to values determined by + the Media Type Object as described under Encoding Usage and Restrictions. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#encoding-object + """ + + contentType: str | None = Field(default=None) + headers: dict[str, Header | Reference] = Field(default_factory=dict) + + encoding: dict[str, Self] = Field(default_factory=dict) + prefixEncoding: list[Self] = Field(default_factory=list) + itemEncoding: Self | None = Field(default=None) + + # 4.15.1.2 Fixed Fields for RFC6570-style Serialization + style: str | None = Field(default=None) + explode: bool | None = Field(default=None) + allowReserved: bool | None = Field(default=None) + + +class MediaType(ObjectExtended): + """ + 4.14 Media Type Object + Each Media Type Object describes content structured in accordance with the media type identified by its key. + Multiple Media Type Objects can be used to describe content that can appear in any of several different media types. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#media-type-object + """ + + schema_: Schema | None = Field(default=None, alias="schema") + itemSchema: Schema | None = Field(default=None) + example: Any | None = Field(default=None) # 'any' type + examples: dict[str, Example | Reference] = Field(default_factory=dict) + encoding: dict[str, Encoding] = Field(default_factory=dict) + prefixEncoding: list[Encoding] = Field(default_factory=list) + itemEncoding: Encoding | None = Field(default=None) diff --git a/src/aiopenapi3/v32/parameter.py b/src/aiopenapi3/v32/parameter.py new file mode 100644 index 00000000..3072ff0d --- /dev/null +++ b/src/aiopenapi3/v32/parameter.py @@ -0,0 +1,75 @@ +import enum +import typing +from typing import Union, Any + +from pydantic import Field + +from ..base import ObjectExtended, ParameterBase as _ParameterBase + +from .example import Example +from .general import Reference +from .schemas import Schema + +from ..v30.parameter import _ParameterCodec + +if typing.TYPE_CHECKING: + from .paths import MediaType + + +class ParameterBase(ObjectExtended, _ParameterBase): + """ + 4.12 Parameter Object + + Describes a single operation parameter. + + A `Parameter Object`_ defines a single operation parameter. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#parameter-object + """ + + description: str | None = Field(default=None) + required: bool | None = Field(default=None) + deprecated: bool | None = Field(default=None) + allowEmptyValue: bool | None = Field(default=None) + example: Any | None = Field(default=None) + examples: dict[str, Union["Example", Reference]] = Field(default_factory=dict) + + # 4.12.2.2 Fixed Fields for use with schema + style: str | None = Field(default=None) # FIXME 4.12.3 Style Values + explode: bool | None = Field(default=None) + allowReserved: bool | None = Field(default=None) + schema_: Schema | None = Field(default=None, alias="schema") + + # 4.12.2.3 Fixed Fields for use with content + content: dict[str, "MediaType"] | None = None + + +class _In(str, enum.Enum): + query = "query" + querystring = "querystring" + header = "header" + path = "path" + cookie = "cookie" + + +class Parameter(ParameterBase, _ParameterCodec): + name: str = Field() + in_: _In = Field(alias="in") + + +class Header(ParameterBase, _ParameterCodec): + """ + 4.21 Header Object + Describes a single header for HTTP responses and for individual parts in multipart representations; + see the relevant Response Object and Encoding Object documentation for restrictions on which headers + can be described. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#header-object + """ + + allowEmptyValue: None + allowReserved: None + + def _codec(self): + schema = self.schema_ or self.content.get("application/json").schema_ + return schema, "simple", False diff --git a/src/aiopenapi3/v32/paths.py b/src/aiopenapi3/v32/paths.py new file mode 100644 index 00000000..578f2ea4 --- /dev/null +++ b/src/aiopenapi3/v32/paths.py @@ -0,0 +1,177 @@ +from typing import Union, Any + +from pydantic import Field, model_validator, RootModel + +from ..base import ObjectExtended, PathsBase, OperationBase, PathItemBase +from .general import ExternalDocumentation +from .general import Reference +from .media import MediaType +from .parameter import Header, Parameter +from .servers import Server +from .security import SecurityRequirement + + +class RequestBody(ObjectExtended): + """ + 4.13 Request Body Object + Describes a single request body. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#request-body-object + """ + + description: str | None = Field(default=None) + content: dict[str, MediaType] = Field(...) + required: bool | None = Field(default=False) + + +class Link(ObjectExtended): + """ + 4.20 Link Object + The Link Object represents a possible design-time link for a response. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#link-object + """ + + operationRef: str | None = Field(default=None) + operationId: str | None = Field(default=None) + parameters: dict[str, Union[str, Any, "RuntimeExpression"]] | None = Field(default=None) + requestBody: Union[Any, "RuntimeExpression"] | None = Field(default=None) + description: str | None = Field(default=None) + server: Server | None = Field(default=None) + + @model_validator(mode="after") + def validate_Link_operation(self): # type: ignore[name-defined] + assert not (self.operationId is not None and self.operationRef is not None), ( + "operationId and operationRef are mutually exclusive, only one of them is allowed" + ) + assert not (self.operationId == self.operationRef is None), ( + "operationId and operationRef are mutually exclusive, one of them must be specified" + ) + return self + + +class Response(ObjectExtended): + """ + 4.17 Response Object + Describes a single response from an API operation, including design-time, static links to operations + based on the response. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#response-object + """ + + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + headers: dict[str, Header | Reference] = Field(default_factory=dict) + content: dict[str, MediaType] = Field(default_factory=dict) + links: dict[str, Link | Reference] = Field(default_factory=dict) + + +class Operation(ObjectExtended, OperationBase): + """ + 4.10 Operation Object + Describes a single API operation on a path. + + As described `here`_ + .. _here: https://spec.openapis.org/oas/v3.2.0.html#operation-object + """ + + tags: list[str] | None = Field(default=None) + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + externalDocs: ExternalDocumentation | None = Field(default=None) + operationId: str | None = Field(default=None) + parameters: list[Parameter | Reference] = Field(default_factory=list) + requestBody: RequestBody | Reference | None = Field(default=None) + responses: dict[str, Response | Reference] = Field(default_factory=dict) + callbacks: dict[str, Union["Callback", Reference]] = Field(default_factory=dict) + deprecated: bool | None = Field(default=None) + security: list[SecurityRequirement] | None = Field(default=None) + servers: list[Server] | None = Field(default=None) + + +class PathItem(ObjectExtended, PathItemBase): + """ + 4.9 Path Item Object + Describes the operations available on a single path. A Path Item MAY be empty, due to ACL constraints. + The path itself is still exposed to the documentation viewer but they will not know which operations and + parameters are available. + + As described `here`_ + .. _here: https://spec.openapis.org/oas/v3.2.0.html#path-item-object + """ + + ref: str | None = Field(default=None, alias="$ref") + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + get: Operation | None = Field(default=None) + put: Operation | None = Field(default=None) + post: Operation | None = Field(default=None) + delete: Operation | None = Field(default=None) + options: Operation | None = Field(default=None) + head: Operation | None = Field(default=None) + patch: Operation | None = Field(default=None) + trace: Operation | None = Field(default=None) + query: Operation | None = Field(default=None) + additionalOperations: dict[str, Operation] | None = Field(default_factory=dict) + servers: list[Server] | None = Field(default=None) + parameters: list[Parameter | Reference] = Field(default_factory=list) + + +class Paths(PathsBase): + """ + 4.8 Paths Object + Holds the relative paths to the individual endpoints and their operations. The path is appended to the URL from the + Server Object in order to construct the full URL. + The Paths Object MAY be empty, due to Access Control List (ACL) constraints. + + As described `here`_ + .. _here: https://spec.openapis.org/oas/v3.2.0.html#paths-object + """ + + paths: dict[str, PathItem] + + @model_validator(mode="before") + def validate_Paths(cls, values): + assert values is not None + p = {} + e = {} + for k, v in values.items(): + if k[:2] == "x-": + e[k[2:]] = v + else: + p[k] = v + return {"paths": p, "extensions": e} + + +class Callback(RootModel): + """ + 4.18 Callback Object + + A map of possible out-of band callbacks related to the parent operation. + Each value in the map is a Path Item Object that describes a set of requests that may be initiated by + the API provider and the expected responses. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#callback-object + + This object MAY be extended with Specification Extensions. + """ + + """ + 4.18.2 Key Expression + The key that identifies the Path Item Object is a runtime expression that can be evaluated in the context of a + runtime HTTP request/response to identify the URL to be used for the callback request. + """ + root: dict["RuntimeExpression", PathItem] + + +class RuntimeExpression(RootModel): + """ + 4.20.3 Runtime Expressions + Runtime expressions allow defining values based on information that will only be available within the HTTP message + in an actual API call. This mechanism is used by Link Objects and Callback Objects. + + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#runtime-expressions + """ + + root: str diff --git a/src/aiopenapi3/v32/root.py b/src/aiopenapi3/v32/root.py new file mode 100644 index 00000000..4cf58d83 --- /dev/null +++ b/src/aiopenapi3/v32/root.py @@ -0,0 +1,46 @@ +from typing import Any + +import pydantic +from pydantic import Field, model_validator + +from ..base import ObjectExtended, RootBase + +from .info import Info +from .paths import Paths, PathItem +from .security import SecurityRequirement +from .servers import Server + +from .components import Components +from .general import Reference +from .tag import Tag + + +class Root(ObjectExtended, RootBase): + """ + 4.1 OpenAPI Object + + This is the root object of the `OpenAPI Description`_ + + .. _OpenAPI Description: https://spec.openapis.org/oas/v3.2.0.html#openapi-object + """ + + openapi: str = Field(...) + self_: pydantic.AnyHttpUrl | None = Field(default=None, alias="$self") + info: Info | None = Field(default_factory=Info) + info: Info = Field(...) + jsonSchemaDialect: pydantic.AnyHttpUrl | None = Field(default=None) + servers: list[Server] | None = Field(default_factory=list) + paths: Paths = Field(default_factory=dict) + webhooks: dict[str, PathItem | Reference] = Field(default_factory=dict) + components: Components | None = Field(default_factory=Components) + security: list[SecurityRequirement] | None = Field(default_factory=list) + tags: list[Tag] = Field(default_factory=list) + externalDocs: dict[Any, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_Root(self) -> "Self": # noqa: F821 + assert self.paths or self.components or self.webhooks + return self + + def _resolve_references(self, api): + RootBase.resolve(api, self, self, PathItem, Reference) diff --git a/src/aiopenapi3/v32/schemas.py b/src/aiopenapi3/v32/schemas.py new file mode 100644 index 00000000..6a7b6384 --- /dev/null +++ b/src/aiopenapi3/v32/schemas.py @@ -0,0 +1,191 @@ +import typing +from typing import Union, Any, Optional + +from pydantic import Field, model_validator, ConfigDict + +from ..base import ObjectExtended, SchemaBase, DiscriminatorBase +from .xml import XML + +if typing.TYPE_CHECKING: + from .general import Reference + + +class Discriminator(ObjectExtended, DiscriminatorBase): + """ + 4.25 Discriminator Object + When request bodies or response payloads may be one of a number of different schemas, these should use the + JSON Schema anyOf or oneOf keywords to describe the possible schemas (see Composition and Inheritance). + + .. here: https://spec.openapis.org/oas/v3.2.0.html#discriminator-object + """ + + propertyName: str = Field(...) + mapping: dict[str, str] = Field(default_factory=dict) + defaultMapping: str | None = Field(default=None) + + +class Schema(ObjectExtended, SchemaBase): + """ + 8. The JSON Schema Core Vocabulary + + .. _here: https://www.ietf.org/archive/id/draft-bhutton-json-schema-01.html#section-8 + """ + + model_config = ConfigDict(extra="allow") + + """ + JSON Schema: A Media Type for Describing JSON Documents + https://www.ietf.org/archive/id/draft-bhutton-json-schema-01.html#section-8 + """ + + """ + 8. The JSON Schema Core Vocabulary + """ + schema_: str | None = Field(default=None, alias="$schema") + vocabulary: dict[str, bool] | None = Field(default=None, alias="$vocabulary") + id: str | None = Field(default=None, alias="$id") + anchor: str | None = Field(default=None, alias="$anchor") + dynamicAnchor: bool | None = Field(default=None, alias="$dynamicAnchor") + ref: str | None = Field(default=None, alias="$ref") + dynamicRef: str | None = Field(default=None, alias="$dynamicRef") + defs: dict[str, Any] | None = Field(default=None, alias="$defs") + comment: str | None = Field(default=None, alias="$comment") + + """ + 10. A Vocabulary for Applying Subschemas + """ + + """ + 10.2.1. Keywords for Applying Subschemas With Logic + """ + allOf: list["Schema"] = Field(default_factory=list) + anyOf: list["Schema"] = Field(default_factory=list) + oneOf: list["Schema"] = Field(default_factory=list) + not_: Optional["Schema"] = Field(default=None, alias="not") + + """ + 10.2.2. Keywords for Applying Subschemas Conditionally + """ + if_: Optional["Schema"] = Field(default=None, alias="if") + then_: Optional["Schema"] = Field(default=None, alias="then") + else_: Optional["Schema"] = Field(default=None, alias="else") + dependentSchemas: dict[str, "Schema"] = Field(default_factory=dict) + + """ + 10.3.1. Keywords for Applying Subschemas to Arrays + """ + prefixItems: list["Schema"] | None = Field(default=None) + items: Union["Schema", list["Schema"]] | None = Field(default=None) + contains: Optional["Schema"] = Field(default=None) + + """ + 10.3.2. Keywords for Applying Subschemas to Objects + """ + properties: dict[str, "Schema"] = Field(default_factory=dict) + patternProperties: dict[str, "Schema"] = Field(default_factory=dict) + additionalProperties: Optional["Schema"] = Field(default=None) + propertyNames: Optional["Schema"] = Field(default=None) + + """ + 11. A Vocabulary for Unevaluated Locations + """ + unevaluatedItems: Optional["Schema"] = Field(default=None) + unevaluatedProperties: Optional["Schema"] = Field(default=None) + + """ + JSON Schema Validation: A Vocabulary for Structural Validation of JSON + https://www.ietf.org/archive/id/draft-bhutton-json-schema-validation-01.html#section-6.1.1 + """ + + """ + 6.1. Validation Keywords for Any Instance Type + """ + + type: str | list[str] | None = Field(default=None) + enum: list[Any] | None = Field(default=None) + const: str | None = Field(default=None) + + """ + 6.2. Validation Keywords for Numeric Instances (number and integer) + """ + multipleOf: int | None = Field(default=None) + maximum: float | None = Field(default=None) # FIXME Field(discriminator='type') would be better + exclusiveMaximum: int | None = Field(default=None) + minimum: float | None = Field(default=None) + exclusiveMinimum: int | None = Field(default=None) + + """ + 6.3. Validation Keywords for Strings + """ + maxLength: int | None = Field(default=None) + minLength: int | None = Field(default=None) + pattern: str | None = Field(default=None) + + """ + 6.4. Validation Keywords for Arrays + """ + maxItems: int | None = Field(default=None) + minItems: int | None = Field(default=None) + uniqueItems: bool | None = Field(default=None) + maxContains: int | None = Field(default=None) + minContains: int | None = Field(default=None) + + """ + 6.5. Validation Keywords for Objects + """ + maxProperties: int | None = Field(default=None) + minProperties: int | None = Field(default=None) + required: list[str] = Field(default_factory=list) + dependentRequired: dict[str, str] = Field(default_factory=dict) # FIXME + + """ + 7. A Vocabulary for Semantic Content With "format" + """ + format: str | None = Field(default=None) + + """ + 8. A Vocabulary for the Contents of String-Encoded Data + """ + contentEncoding: str | None = Field(default=None) + contentMediaType: str | None = Field(default=None) + contentSchema: Union["Schema", "Reference"] = Field(default=None) + + """ + 9. A Vocabulary for Basic Meta-Data Annotations + """ + title: str | None = Field(default=None) + description: str | None = Field(default=None) + default: Any | None = Field(default=None) + deprecated: bool | None = Field(default=None) + readOnly: bool | None = Field(default=None) + writeOnly: bool | None = Field(default=None) + examples: Any | None = Field(default=None) + + """ + The OpenAPI Specification's base vocabulary is comprised of the following keywords: + """ + discriminator: Discriminator | None = Field(default=None) # 'Discriminator' + xml: XML | None = Field(default=None) # 'XML' + externalDocs: dict | None = Field(default=None) # 'ExternalDocs' + example: Any | None = Field(default=None) + + @model_validator(mode="before") + @classmethod + def is_boolean_schema(cls, data: Any) -> Any: + if not isinstance(data, bool): + return data + if data: + return {} + else: + return {"not": {}} + + @model_validator(mode="after") + def validate_Schema_number_type(self): + if self.type == "integer": + for i in ["minimum", "maximum"]: + if (v := getattr(self, i, None)) is not None and not isinstance(v, int): + setattr(self, i, int(v)) + return self + + def __getstate__(self): + return SchemaBase.__getstate__(self) diff --git a/src/aiopenapi3/v32/security.py b/src/aiopenapi3/v32/security.py new file mode 100644 index 00000000..bb5c9cd4 --- /dev/null +++ b/src/aiopenapi3/v32/security.py @@ -0,0 +1,112 @@ +from pathlib import Path + +from typing import Union, Annotated, Literal +from pydantic import Field, RootModel, constr + +from ..base import ObjectExtended + + +class OAuthFlow(ObjectExtended): + """ + Configuration details for a supported OAuth Flow + + .. here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#oauth-flow-object + """ + + authorizationUrl: str | None = Field(default=None) + deviceAuthorizationUrl: str | None = Field(default=None) + tokenUrl: str | None = Field(default=None) + refreshUrl: str | None = Field(default=None) + scopes: dict[str, str] = Field(default_factory=dict) + + +class OAuthFlows(ObjectExtended): + """ + 4.28 OAuth Flows Object + Allows configuration of the supported OAuth Flows. + + .. here: https://spec.openapis.org/oas/v3.2.0.html#oauth-flows-object + """ + + implicit: OAuthFlow | None = Field(default=None) + password: OAuthFlow | None = Field(default=None) + clientCredentials: OAuthFlow | None = Field(default=None) + authorizationCode: OAuthFlow | None = Field(default=None) + deviceAuthorization: OAuthFlow | None = Field(default=None) + + +class _SecuritySchemes: + class _SecurityScheme(ObjectExtended): + type: Literal["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] + description: str | None = Field(default=None) + deprecated: bool | None = Field(default=None) + + def validate_authentication_value(self, value): + pass + + class apiKey(_SecurityScheme): + type: Literal["apiKey"] + in_: str = Field(alias="in") + name: str + + class http(_SecurityScheme): + type: Literal["http"] + scheme_: constr(to_lower=True) = Field(default=None, alias="scheme") # type: ignore[valid-type] + bearerFormat: str | None = Field(default=None) + + class mutualTLS(_SecurityScheme): + type: Literal["mutualTLS"] + + def validate_authentication_value(self, value) -> None: + if not isinstance(value, (list, tuple)): + raise TypeError(type(value)) + if len(value) != 2: + raise ValueError(f"Invalid number of tuple parameters {len(value)} - 2 required") + files: tuple[Path, Path] = (Path(value[0]), Path(value[1])) + if missing := sorted(filter(lambda x: not (x.exists() and x.is_file()), files)): + raise FileNotFoundError(missing) + + class oauth2(_SecurityScheme): + type: Literal["oauth2"] + flows: OAuthFlows + oauth2MetadataUrl: str | None = Field(default=None) + + class openIdConnect(_SecurityScheme): + type: Literal["openIdConnect"] + openIdConnectUrl: str + + +class SecurityScheme( + RootModel[ + Annotated[ + Union[ + _SecuritySchemes.apiKey, + _SecuritySchemes.http, + _SecuritySchemes.mutualTLS, + _SecuritySchemes.oauth2, + _SecuritySchemes.openIdConnect, + ], + Field(discriminator="type"), + ] + ] +): + """ + 4.27 Security Scheme Object + Defines a security scheme that can be used by the operations. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#security-scheme-object + """ + + pass + + +class SecurityRequirement(RootModel[dict[str, list[str]]]): + """ + 4.30 Security Requirement Object + + Lists the required security schemes to execute this operation. + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#security-requirement-object + """ + + pass diff --git a/src/aiopenapi3/v32/servers.py b/src/aiopenapi3/v32/servers.py new file mode 100644 index 00000000..3737b53f --- /dev/null +++ b/src/aiopenapi3/v32/servers.py @@ -0,0 +1,60 @@ +import re + +from pydantic import Field, model_validator + +from ..base import ObjectExtended + + +class ServerVariable(ObjectExtended): + """ + 4.6 Server Variable Object + An object representing a Server Variable for server URL template substitution. + + As described `here`_ + .. _here: https://spec.openapis.org/oas/v3.2.0.html#server-variable-object + """ + + enum: list[str] | None = Field(default=None) + default: str + description: str | None = Field(default=None) + + @model_validator(mode="after") + def validate_ServerVariable(self): + assert isinstance(self.enum, (list, None.__class__)) + # default value must be in enum + assert self.default is None or self.default in (self.enum or [self.default]) + return self + + +class Server(ObjectExtended): + """ + 4.5 Server Object + An object representing a Server. + + As described `here`_ + .. _here: https://spec.openapis.org/oas/v3.2.0.html#server-object + """ + + url: str = Field(...) + description: str | None = Field(default=None) + name: str | None = Field(default=None) + variables: dict[str, ServerVariable] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_server_url_parameters(self) -> "Server": + if (p := frozenset(re.findall(r"\{([^\}]+)\}", self.url))) != (r := frozenset(self.variables.keys())): + raise ValueError(f"Missing Server Variables {sorted(p - r)} in {self.url}") + return self + + def validate_parameter_enum(self, parameters: dict[str, str]): + for name, value in parameters.items(): + if v := self.variables.get(name): + if v.enum and value not in v.enum: + raise ValueError(f"Server Variable {name} value {value} not allowed ({v.enum})") + + def createUrl(self, variables: dict[str, str]) -> str: + self.validate_parameter_enum(variables) + vars: dict[str, str | None] = dict(map(lambda x: (x[0], x[1].default), self.variables.items())) + vars.update(variables) + url: str = self.url.format(**vars) + return url diff --git a/src/aiopenapi3/v32/tag.py b/src/aiopenapi3/v32/tag.py new file mode 100644 index 00000000..1001bd45 --- /dev/null +++ b/src/aiopenapi3/v32/tag.py @@ -0,0 +1,21 @@ +from pydantic import Field + +from ..base import ObjectExtended +from .general import ExternalDocumentation + + +class Tag(ObjectExtended): + """ + 4.22 Tag Object + Adds metadata to a single tag that is used by the Operation Object. + It is not mandatory to have a Tag Object per tag defined in the Operation Object instances. + + .. _here: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#tag-object + """ + + name: str = Field(...) + summary: str | None = Field(default=None) + description: str | None = Field(default=None) + externalDocs: ExternalDocumentation | None = Field(default=None) + parent: str | None = Field(default=None) + kind: str | None = Field(default=None) diff --git a/src/aiopenapi3/v32/xml.py b/src/aiopenapi3/v32/xml.py new file mode 100644 index 00000000..e8795984 --- /dev/null +++ b/src/aiopenapi3/v32/xml.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from .general import ObjectExtended + + +class XML(ObjectExtended): + """ + + .. _here: https://spec.openapis.org/oas/v3.2.0.html#xml-object + """ + + nodeType: str | None = Field(default=None) + name: str | None = Field(default=None) + namespace: str | None = Field(default=None) + prefix: str | None = Field(default=None) + attribute: bool = Field(default=False, deprecated=True) + wrapped: bool = Field(default=False, deprecated=True) diff --git a/tests/apiv1_test.py b/tests/apiv1_test.py index 047d2523..9a8f3dbc 100644 --- a/tests/apiv1_test.py +++ b/tests/apiv1_test.py @@ -4,7 +4,6 @@ import pytest import pytest_asyncio -import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config @@ -34,11 +33,6 @@ async def server(config): await task -@pytest.fixture(scope="session") -def event_loop_policy(): - return uvloop.EventLoopPolicy() - - @pytest_asyncio.fixture(loop_scope="session") async def client(server): api = await asyncio.to_thread(aiopenapi3.OpenAPI.load_sync, f"http://{server.bind[0]}/v1/openapi.json") diff --git a/tests/apiv2_test.py b/tests/apiv2_test.py index 8ab66f24..2d0419fd 100644 --- a/tests/apiv2_test.py +++ b/tests/apiv2_test.py @@ -11,7 +11,6 @@ import pytest import pytest_asyncio -import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config @@ -39,11 +38,6 @@ def config(unused_tcp_port_factory): return c -@pytest.fixture(scope="session") -def event_loop_policy(): - return uvloop.EventLoopPolicy() - - @pytest_asyncio.fixture(loop_scope="session") async def server(config): event_loop = asyncio.get_running_loop() diff --git a/tests/conftest.py b/tests/conftest.py index 932503bb..3e572219 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -570,3 +570,33 @@ def with_schema_title_name_collision(): @pytest.fixture def with_schema_discriminated_union_extends(): yield _get_parsed_yaml("schema-discriminated-union-extends.yaml") + + +@pytest.fixture +def with_schema_v32(): + yield _get_parsed_yaml("schema-v32.yaml") + + +@pytest.fixture +def with_path_query(): + yield _get_parsed_yaml("paths-query.yaml") + + +@pytest.fixture +def with_path_additionalOperations(): + yield _get_parsed_yaml("paths-additionalOperations.yaml") + + +@pytest.fixture +def with_schema_itemSchema(): + yield _get_parsed_yaml("schema-itemSchema.yaml") + + +@pytest.fixture +def with_paths_parameter_querystring(): + yield _get_parsed_yaml("paths-parameter-querystring.yaml") + + +@pytest.fixture +def with_schema_tags_v32(): + yield _get_parsed_yaml("schema-tags-v32.yaml") diff --git a/tests/content_length_test.py b/tests/content_length_test.py index 1b5d0f16..4e4e5d35 100644 --- a/tests/content_length_test.py +++ b/tests/content_length_test.py @@ -1,7 +1,6 @@ import asyncio import random -import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config from fastapi import FastAPI, Request, Response, Query @@ -36,11 +35,6 @@ async def server(config): await task -@pytest.fixture(scope="session") -def event_loop_policy(): - return uvloop.EventLoopPolicy() - - @pytest_asyncio.fixture(loop_scope="session") async def client(server): api = await aiopenapi3.OpenAPI.load_async(f"http://{server.bind[0]}/openapi.json") diff --git a/tests/fixtures/paths-additionalOperations.yaml b/tests/fixtures/paths-additionalOperations.yaml new file mode 100644 index 00000000..742ebb41 --- /dev/null +++ b/tests/fixtures/paths-additionalOperations.yaml @@ -0,0 +1,20 @@ +openapi: 3.2.0 +info: + title: query Example + version: 1.0.0 + +servers: + - url: / + +paths: + /api/data: + additionalOperations: + test: + operationId: test + responses: + "200": + content: + application/json: + schema: + type: string + const: ok diff --git a/tests/fixtures/paths-parameter-querystring.yaml b/tests/fixtures/paths-parameter-querystring.yaml new file mode 100644 index 00000000..0398e3ce --- /dev/null +++ b/tests/fixtures/paths-parameter-querystring.yaml @@ -0,0 +1,108 @@ +openapi: 3.2.0 +info: + title: '' + version: 0.0.0 +servers: + - url: http://127.0.0.1/api + +security: + - {} + +paths: + /qs0: + parameters: + - $ref: "#/components/parameters/qs0" + get: + operationId: qs0 + responses: + '204': {} + /json: + parameters: + - $ref: "#/components/parameters/json" + get: + operationId: json + responses: + '204': {} + + /selector: + parameters: + - $ref: "#/components/parameters/selector" + get: + operationId: selector + responses: + '204': {} + + +components: + parameters: + qs0: + name: qs0 + in: querystring + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + foo: + type: string + bar: + type: boolean + examples: + spacesAndPluses: + description: Note handling of spaces and "+" per media type. + dataValue: + foo: a + b + bar: true + serializedValue: foo=a+%2B+b&bar=true + examples: + spacesAndPluses: + description: | + Note that no additional percent encoding is done, as this + media type is URI query string-ready by definition. + dataValue: + foo: a + b + bar: true + serializedValue: foo=a+%2B+b&bar=true + + json: + in: querystring + name: json + content: + application/json: + schema: + type: object + properties: + numbers: + type: array + items: + type: integer + flag: + type: [boolean, "null"] + examples: + TwoNoFlag: + description: Serialize with minimized whitespace + dataValue: + numbers: + - 1 + - 2 + flag: null + serializedValue: '{"numbers":[1,2],"flag":null}' + examples: + TwoNoFlag: + dataValue: + numbers: + - 1 + - 2 + flag: null + serializedValue: "%7B%22numbers%22%3A%5B1%2C2%5D%2C%22flag%22%3Anull%7D" + selector: + in: querystring + name: selector + content: + application/jsonpath: + schema: + type: string + example: $.a.b[1:1] + examples: + Selector: + serializedValue: "%24.a.b%5B1%3A1%5D" diff --git a/tests/fixtures/paths-query.yaml b/tests/fixtures/paths-query.yaml new file mode 100644 index 00000000..56d2bd3c --- /dev/null +++ b/tests/fixtures/paths-query.yaml @@ -0,0 +1,13 @@ +openapi: 3.2.0 +info: + title: query Example + version: 1.0.0 +paths: + /query: + query: + responses: + '200': + content: + application/json: + schema: + type: string diff --git a/tests/fixtures/schema-itemSchema.yaml b/tests/fixtures/schema-itemSchema.yaml new file mode 100644 index 00000000..fa718395 --- /dev/null +++ b/tests/fixtures/schema-itemSchema.yaml @@ -0,0 +1,146 @@ +openapi: 3.2.0 +info: + version: 1.0.0 + title: itemSchema + +servers: + - url: ./ + name: prod + description: The production API on this device + +components: + schemas: + ServerSentEvent: + type: object + properties: + data: + type: string + event: + type: string + id: + type: string + retry: + minimum: 0 + type: integer + LogEntry: + type: object + properties: + timestamp: + type: string + format: date-time + level: + type: integer + minimum: 0 + message: + type: string + Log: + type: array + items: + $ref: "#/components/schemas/LogEntry" + maxItems: 100 + examples: + LogJSONSeq: + summary: Log entries in application/json-seq + # JSON Text Sequences require an unprintable character + # that cannot be escaped in a YAML string, and therefore + # must be placed in an external document shown below + externalValue: examples/log.json-seq + LogJSONPerLine: + summary: Log entries in application/jsonl or application/x-ndjson + description: JSONL and NDJSON are identical for this example + # Note that the value must be written as a string with newlines, + # as JSONL and NDJSON are not valid YAML + value: | + {"timestamp": "1985-04-12T23:20:50.52Z", "level": 1, "message": "Hi!"} + {"timestamp": "1985-04-12T23:20:51.37Z", "level": 1, "message": "Bye!"} + responses: + LogStream: + description: | + A stream of JSON-format log messages that can be read + for as long as the application is running, and is available + in any of the sequential JSON media types. + content: + application/json-seq: + itemSchema: + $ref: "#/components/schemas/LogEntry" + examples: + JSON-SEQ: + $ref: "#/components/examples/LogJSONSeq" + application/jsonl: + itemSchema: + $ref: "#/components/schemas/LogEntry" + examples: + JSONL: + $ref: "#/components/examples/LogJSONPerLine" + application/x-ndjson: + itemSchema: + $ref: "#/components/schemas/LogEntry" + examples: + NDJSON: + $ref: "#/components/examples/LogJSONPerLine" + LogExcerpt: + description: | + A response consisting of no more than 100 log records, + generally as a result of a query of the historical log, + available in any of the sequential JSON media types. + content: + application/json-seq: + schema: + $ref: "#/components/schemas/Log" + examples: + JSON-SEQ: + $ref: "#/components/examples/LogJSONSeq" + application/jsonl: + schema: + $ref: "#/components/schemas/Log" + examples: + JSONL: + $ref: "#/components/examples/LogJSONPerLine" + application/x-ndjson: + schema: + $ref: "#/components/schemas/Log" + examples: + NDJSON: + $ref: "#/components/examples/LogJSONPerLine" + + +paths: + /json_seq: + get: + operationId: json_seq + responses: + "200": + content: + application/json-seq: + itemSchema: + $ref: "#/components/schemas/LogEntry" + + /jsonl: + get: + operationId: jsonl + responses: + "200": + content: + application/jsonl: + itemSchema: + $ref: "#/components/responses/LogEntry" + + /ndjson: + get: + operationId: ndjson + responses: + "200": + content: + application/x-ndjson: + itemSchema: + $ref: "#/components/schemas/LogEntry" + + /text_events: + get: + operationId: text_events + responses: + "200": + content: + text/event-stream: + itemSchema: + $ref: "#/components/schemas/ServerSentEvent" diff --git a/tests/fixtures/schema-tags-v32.yaml b/tests/fixtures/schema-tags-v32.yaml new file mode 100644 index 00000000..2408db38 --- /dev/null +++ b/tests/fixtures/schema-tags-v32.yaml @@ -0,0 +1,35 @@ +openapi: 3.2.0 +info: + version: 1.0.0 + title: 4.22.2 Tag Object Example + +servers: + - url: v32 + name: prod + description: The production API on this device + + +tags: + - name: account-updates + summary: Account Updates + description: Account update operations + kind: nav + + - name: partner + summary: Partner + description: Operations available to the partners network + parent: external + kind: audience + + - name: external + summary: External + description: Operations available to external consumers + kind: audience + +paths: + /x: + get: + operationId: x + tags: [partner] + responses: + '204': {} diff --git a/tests/fixtures/schema-v32.yaml b/tests/fixtures/schema-v32.yaml new file mode 100644 index 00000000..b4e22228 --- /dev/null +++ b/tests/fixtures/schema-v32.yaml @@ -0,0 +1,52 @@ +openapi: 3.2.0 +info: + version: 1.0.0 + title: serializedValue https://quobix.com/articles/openapi-3.2/ + +$self: http://example.org/ + +servers: + - url: v32 + name: prod + description: The production API on this device + + +components: + schemas: + String: + properties: + string: + type: string + +paths: + /filter: + get: + operationId: filter + parameters: + - name: X-Filter-Options + in: header + style: simple + explode: false + schema: + type: object + properties: + include: + type: array + items: + type: string + exclude: + type: array + items: + type: string + examples: + filter_header: + summary: Complex filter in header + description: Shows how objects are serialized in headers + dataValue: + include: + - posts + - comments + exclude: + - draft + - deleted + serializedValue: "include,posts,comments,exclude,draft,deleted" diff --git a/tests/path_test.py b/tests/path_test.py index 91ae6c44..9a9ec8f9 100644 --- a/tests/path_test.py +++ b/tests/path_test.py @@ -633,3 +633,42 @@ def test_paths_server_variables_missing(with_paths_server_variables): dd["servers"][0]["url"] = "https://{missing}/test" with pytest.raises(ValueError, match=r"Missing Server Variables \[\'missing\'\]"): OpenAPI("http://example/openapi.yaml", dd, session_factory=httpx.Client) + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_paths_parameter_querystring(httpx_mock, with_paths_parameter_querystring): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, status_code=204) + api = OpenAPI("/", with_paths_parameter_querystring, session_factory=httpx.Client) + + def querymatch(a, b): + assert dict(yarl.URL(f"http://example.org/?{a}").query) == dict(yarl.URL(f"http://example.org/?{b}").query) + return True + + # qs0 + param = api._.qs0.parameters[0]._target + t = param.content["application/x-www-form-urlencoded"].schema_.get_type() + ex = param.examples["spacesAndPluses"] + obj = t.model_validate(ex.dataValue) + api._.qs0(parameters={"qs0": obj}) + req = httpx_mock.get_requests()[-1] + assert querymatch(req.url.query.decode(), ex.serializedValue) + + # json + param = api._.json.parameters[0]._target + t = param.content["application/json"].schema_.get_type() + ex = param.examples["TwoNoFlag"] + obj = t.model_validate(ex.dataValue) + api._.json(parameters={"json": obj}) + req = httpx_mock.get_requests()[-1] + assert querymatch(req.url.query.decode(), ex.serializedValue) + assert req.url.query.decode() == ex.serializedValue + "=" + + # selector + param = api._.selector.parameters[0]._target + t = (c := param.content["application/jsonpath"]).schema_.get_type() + ex = param.examples["Selector"] + obj = t.model_validate_strings(c.example) + api._.selector(parameters={"selector": obj}) + req = httpx_mock.get_requests()[-1] + assert querymatch(req.url.query.decode(), ex.serializedValue) + assert req.url.query.decode() == ex.serializedValue + "=" diff --git a/tests/sequential_test.py b/tests/sequential_test.py new file mode 100644 index 00000000..9bdc50d3 --- /dev/null +++ b/tests/sequential_test.py @@ -0,0 +1,112 @@ +import asyncio + +from collections.abc import AsyncIterable + + +from hypercorn.asyncio import serve +from hypercorn.config import Config +import pydantic +from fastapi import FastAPI +from fastapi.sse import EventSourceResponse, ServerSentEvent + +import pytest +import pytest_asyncio + + +import aiopenapi3 + +app = FastAPI( + version="1.0.0", + title="Sequential Streaming tests", + servers=[{"url": "/", "description": "Default, relative server"}], +) + + +def openapi32(): + """ + https://github.com/fastapi/fastapi/discussions/15328#discussioncomment-16543627 + """ + if app.openapi_schema: + return app.openapi_schema + from fastapi.openapi.utils import get_openapi + + openapi_schema = get_openapi( + openapi_version="3.2.0", title=app.title, version=app.version, routes=app.routes, servers=app.servers + ) + app.openapi_schema = openapi_schema + return app.openapi_schema + + +app.openapi = openapi32 + + +@pytest.fixture(scope="session") +def config(unused_tcp_port_factory): + c = Config() + c.bind = [f"localhost:{unused_tcp_port_factory()}"] + return c + + +@pytest_asyncio.fixture(loop_scope="session") +async def server(config): + event_loop = asyncio.get_event_loop() + try: + sd = asyncio.Event() + task = event_loop.create_task(serve(app, config, shutdown_trigger=sd.wait)) + yield config + finally: + sd.set() + await task + + +@pytest_asyncio.fixture(loop_scope="session") +async def client(server): + api = await aiopenapi3.OpenAPI.load_async(f"http://{server.bind[0]}/openapi.json") + return api + + +class Item(pydantic.BaseModel): + name: str + description: str | None + + +items = [ + Item(name="Plumbus", description="A multi-purpose household device."), + Item(name="Portal Gun", description="A portal opening device."), + Item(name="Meeseeks Box", description="A box that summons a Meeseeks."), +] + + +@app.get("/jsonl", operation_id="jsonl") +async def jsonl() -> AsyncIterable[Item]: + """ + https://fastapi.tiangolo.com/tutorial/stream-json-lines/#use-cases + """ + for item in items: + yield item + + +@app.get("/sse", operation_id="sse", response_class=EventSourceResponse) +async def sse() -> AsyncIterable[ServerSentEvent]: + for idx, item in enumerate(items): + yield ServerSentEvent(comment=str(idx), data=item) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_jsonl(server, client): + req = client.createRequest("jsonl") + async with req.sequence() as sequence: + async for obj in sequence: + print(obj) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_sse(server, client): + from aiopenapi3.request import AsyncRequestBase + + req: AsyncRequestBase + req = client.createRequest("sse") + async with req.sequence() as sequence: + async for obj in sequence: + print(obj) + await asyncio.sleep(1.1) diff --git a/tests/stream_test.py b/tests/stream_test.py index 56e7c810..bc0283dd 100644 --- a/tests/stream_test.py +++ b/tests/stream_test.py @@ -6,7 +6,6 @@ from pathlib import Path -import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config import pydantic @@ -42,11 +41,6 @@ async def server(config): await task -@pytest.fixture(scope="session") -def event_loop_policy(): - return uvloop.EventLoopPolicy() - - @pytest_asyncio.fixture(loop_scope="session") async def client(server): api = await aiopenapi3.OpenAPI.load_async(f"http://{server.bind[0]}/openapi.json") @@ -66,7 +60,7 @@ def file(request: Request, response: Response, content_length: int = Query()): @app.get("/files", operation_id="files", response_model=list[File]) def files(request: Request, response: Response, number: int = Query(), size: int = Query()): with Path("/dev/urandom").open("rb") as f: - return [File(data=f.read(size)) for _ in range(number)] + return [File.model_construct(data=f.read(size)) for _ in range(number)] @app.post("/request-streaming", operation_id="request_streaming", response_model=int) diff --git a/tests/tls_test.py b/tests/tls_test.py index ce3da442..927dfbb5 100644 --- a/tests/tls_test.py +++ b/tests/tls_test.py @@ -7,7 +7,6 @@ import pytest import pytest_asyncio -import uvloop from hypercorn.asyncio import serve from hypercorn.config import Config @@ -104,11 +103,6 @@ async def wait_for_server(server): return server -@pytest.fixture(scope="session") -def event_loop_policy(): - return uvloop.EventLoopPolicy() - - class MutualTLSSecurity(Document): """ patch FastAPI description document to authenticate using mutualTLS diff --git a/tests/v32_test.py b/tests/v32_test.py new file mode 100644 index 00000000..6e283db0 --- /dev/null +++ b/tests/v32_test.py @@ -0,0 +1,167 @@ +import httpx + +import pytest +from pytest_httpx import IteratorStream + +from aiopenapi3 import OpenAPI +from aiopenapi3 import v32 + + +def test_Components(): + # mediaTypes + pass + + +def test_Example(): + # dataValue + # serializedValue + pass + + +def test_Encoding(): + # encoding + # prefixEncoding + # itemEncoding + pass + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +@pytest.mark.asyncio(loop_scope="session") +async def test_MediaType(httpx_mock, with_schema_itemSchema): + # itemSchema + import pydantic + + api = OpenAPI("https://example.org/api/", with_schema_itemSchema, session_factory=httpx.AsyncClient) + ServerSentEvent: pydantic.BaseModel = api.components.schemas["ServerSentEvent"].get_type() + + records = with_schema_itemSchema["components"]["examples"]["LogJSONPerLine"]["value"].strip("\n").split("\n") + t = pydantic.TypeAdapter(list[ServerSentEvent]) + ct = "\n\n".join(f"data: {i}" for i in records * 16) + + httpx_mock.add_response( + url="https://example.org/api/json_seq", + headers={"Content-Type": "application/json-seq"}, + stream=IteratorStream([b"\x1e" + i.encode() + b"\n" for i in (records * 16)]), + ) + httpx_mock.add_response( + url="https://example.org/api/jsonl", + headers={"Content-Type": "application/jsonl"}, + stream=IteratorStream([i.encode() + b"\n" for i in (records * 16)]), + ) + httpx_mock.add_response( + url="https://example.org/api/ndjson", + headers={"Content-Type": "application/x-ndjson"}, + stream=IteratorStream([i.encode() + b"\n" for i in (records * 16)]), + ) + httpx_mock.add_response( + url="https://example.org/api/text_events", headers={"Content-Type": "text/event-stream"}, content=ct + ) + + import aiopenapi3.v30 + + req: aiopenapi3.v30.glue.AsyncRequest + req = api.createRequest("json_seq") + async with req.sequence() as sequence: + async for obj in sequence: + print(obj) + + req = api.createRequest("jsonl") + async with req.sequence() as sequence: + async for obj in sequence: + print(obj) + + req = api.createRequest("ndjson") + async with req.sequence() as sequence: + async for obj in sequence: + print(obj) + + req = api.createRequest("text_events") + async with req.sequence() as sequence: + async for obj in sequence: + print(obj) + + # prefixEncoding + # itemEncoding + pass + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_MediaType_itemSchema_sync(httpx_mock, with_schema_itemSchema): + # itemSchema + import pydantic + + api = OpenAPI("https://example.org/api/", with_schema_itemSchema, session_factory=httpx.Client) + LogEntry: pydantic.BaseModel = api.components.schemas["LogEntry"].get_type() + + records = with_schema_itemSchema["components"]["examples"]["LogJSONPerLine"]["value"].strip("\n").split("\n") + + httpx_mock.add_response( + url="https://example.org/api/json_seq", + headers={"Content-Type": "application/json-seq"}, + stream=IteratorStream([b"\x1e" + i.encode() + b"\n" for i in (records * 16)]), + ) + + req = api.createRequest("json_seq") + with req.sequence() as sequence: + print(sequence.headers) + for obj in sequence: + print(obj) + + +def test_Response(): + # description + # summary + pass + + +def test_PathItem_query(with_path_query): + api = OpenAPI("https://example.org/api/", with_path_query) + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_PathItem_additionalOperations(httpx_mock, with_path_additionalOperations): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, json="ok") + + api = OpenAPI("https://example.org/api/", with_path_additionalOperations, session_factory=httpx.Client) + assert api._.test() == "ok" + + request: httpx.Request = httpx_mock.get_requests()[-1] + assert request.method == "TEST" and request.url.path == "/api/data" + + assert api._[("/api/data", "test")]() == "ok" + request = httpx_mock.get_requests()[-1] + assert request.method == "TEST" and request.url.path == "/api/data" + + +def test_Callback_RuntimeExpression(): + pass + + +def test_Root(with_schema_v32): + api = OpenAPI("https://example.org/api/", with_schema_v32) + # $self + + v = api.resolve_jr(api._root, None, v32.Reference(**{"$ref": "http://example.org#/components/schemas/String"})) + assert v is not None + + # For API URLs the $self field, which identifies the OpenAPI document, is ignored and the retrieval URI is used instead. + assert str(api.url) == "https://example.org/api/v32" + + # https://spec.openapis.org/oas/v3.2.0.html#base-uri-within-content + + +def test_Discriminator_defaultMapping(): + # c.f. https://github.com/pydantic/pydantic/issues/11188 + pass + + +def test_Server_name(): + pass + + +def test_Tag(httpx_mock, with_schema_tags_v32): + httpx_mock.add_response(headers={"Content-Type": "application/json"}, status_code=204) + api = OpenAPI("https://example.org/api/", with_schema_tags_v32, session_factory=httpx.Client) + api._.external.partner.x() + + assert sorted(filter(lambda x: x.partition(".")[0] == "external", api._.Iter(api, True))) == ["external.partner.x"] diff --git a/uv.lock b/uv.lock index 72093c48..a3a8838b 100644 --- a/uv.lock +++ b/uv.lock @@ -8,10 +8,13 @@ source = { editable = "." } dependencies = [ { name = "email-validator" }, { name = "httpx" }, + { name = "ijson" }, { name = "jmespath" }, + { name = "jsonseq" }, { name = "more-itertools" }, { name = "pydantic" }, { name = "pyyaml" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, { name = "yarl" }, ] @@ -50,12 +53,14 @@ requires-dist = [ { name = "email-validator" }, { name = "httpx" }, { name = "httpx-auth", marker = "extra == 'auth'", specifier = ">=0.21.0" }, + { name = "ijson" }, { name = "jmespath" }, + { name = "jsonseq" }, { name = "more-itertools" }, { name = "pydantic", specifier = ">=2.13.0b2" }, { name = "pydantic-extra-types", marker = "extra == 'types'", specifier = ">=2.10.1" }, { name = "pyyaml" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, { name = "yarl" }, ] provides-extras = ["auth", "types"] @@ -760,6 +765,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "jsonseq" +version = "1.0.0" +source = { registry = "https://pypi.org/simple/" } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/faca1f522bc03f92ac75da1eb29fe045bf89246a8e8ed04ccbd563540520/jsonseq-1.0.0.tar.gz", hash = "sha256:238f51aa741132d2a41d1fb89e58eb8d43c6da9d34845c9499dd882a4cd0253a", size = 3341, upload-time = "2019-07-31T19:47:34.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/f5/367876253306f752190203917a51670682780179665b38fc713629e0be71/jsonseq-1.0.0-py3-none-any.whl", hash = "sha256:d4add916420fc02796a503e59ce4d8008152830fd1625cc70692b1f980a32231", size = 4567, upload-time = "2019-07-31T19:47:33.486Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3"