diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c0033..e16cdd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The **third number** is for emergencies when we need to start branches for older ### Changed - Add the initial header implementation. +- Function composition (dependency injection) is now documented. - Endpoints can be excluded from OpenAPI generation by passing them to `App.make_openapi_spec(exclude=...)` or `App.serve_openapi(exclude=...)`. - Initial implementation of OpenAPI security schemas, supporting the `apikey` type in Redis session backend. - Update the Elements OpenAPI UI to better handle cookies. diff --git a/README.md b/README.md index 6eb6df9..a8dfe4d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![License: Apache2](https://img.shields.io/badge/license-Apache2-C06524)](https://github.com/Tinche/uapi/blob/main/LICENSE) -_uapi_ is an elegant, high-level, extremely fast Python microframework for writing HTTP APIs, either synchronously or asynchronously. +_uapi_ is an elegant, high-level, extremely low-overhead Python microframework for writing HTTP APIs, either synchronously or asynchronously. _uapi_ uses a lower-level HTTP framework to run. Currently supported frameworks are aiohttp, Django, Flask, Quart, and Starlette. An _uapi_ app can be easily integrated into an existing project based on one of these frameworks, and a pure _uapi_ project can be easily switched between them when needed. @@ -14,12 +14,12 @@ An _uapi_ app can be easily integrated into an existing project based on one of Using _uapi_ enables you to: - write **either async or sync** styles of handlers, depending on the underlying framework used. -- use and customize a **depedency injection** system, based on [incant](https://github.com/Tinche/incant/). -- automatically **serialize and deserialize** data through [attrs](https://www.attrs.org/en/stable/) and [cattrs](https://cattrs.readthedocs.io/en/latest/). +- use and customize a **function composition** (dependency injection) system, based on [incant](https://incant.threeofwands.com). +- automatically **serialize and deserialize** data through [attrs](https://www.attrs.org) and [cattrs](https://catt.rs). - generate and use **OpenAPI** descriptions of your endpoints. - optionally **type-check** your handlers with [Mypy](https://mypy.readthedocs.io/en/stable/). - write and use reusable and **powerful middleware**, which integrates with the OpenAPI schema. -- **integrate** with existing apps based on [Django](https://docs.djangoproject.com/en/stable/), [Starlette](https://www.starlette.io/), [Flask](https://flask.palletsprojects.com/en/latest/), [Quart](https://pgjones.gitlab.io/quart/) or [aiohttp](https://docs.aiohttp.org/en/stable/). +- **integrate** with existing apps based on [Django](https://docs.djangoproject.com/en/stable/), [Starlette](https://www.starlette.io/), [Flask](https://flask.palletsprojects.com), [Quart](https://pgjones.gitlab.io/quart/) or [aiohttp](https://docs.aiohttp.org). Here's a simple taste: diff --git a/docs/Makefile b/docs/Makefile index 7533996..eae4d10 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -20,4 +20,4 @@ help: @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) apidoc: - sphinx-apidoc -o . ../src/uapi -f + pdm run sphinx-apidoc -o . ../src/uapi -f diff --git a/docs/composition.md b/docs/composition.md new file mode 100644 index 0000000..2edcb9a --- /dev/null +++ b/docs/composition.md @@ -0,0 +1,195 @@ +# Handler Composition Context + +Handlers and middleware may be composed with the results of other functions (and coroutines, when using an async framework); this is commonly known as dependency injection. +The composition context is a set of rules governing how and when this happens. +_uapi_ uses the [_Incant_](https://incant.threeofwands.com) library for function composition. + +_uapi_ includes a number of composition rules by default, but users and third-party middleware are encouraged to define their own rules. + +## Path and Query Parameters + +Path and query parameters can be provided to handlers and middleware, see [](handlers.md#query-parameters) and [](handlers.md#path-parameters) for details. + +## Headers + +Headers can be provided to handlers and middleware, see [](handlers.md#headers) for details. + +## JSON Payloads as _attrs_ Classes + +JSON payloads, structured into _attrs_ classes by _cattrs_, can by provided to handlers and middleware. See [](handlers.md#attrs-classes) for details. + +## Route Metadata + +```{tip} +_Routes_ are different than _handlers_; a single handler may be registered on multiple routes. +``` + +Route metadata can be provided to handlers and middleware, although it can be more useful to middleware. + +- The route name will be provided if a parameter is annotated as {class}`uapi.RouteName `, which is a string-based NewType. +- The request HTTP method will be provided if a parameter is annotated as {class}`uapi.Method `, which is a string Literal. + +Here's an example using both: + +```python +from uapi import Method, RouteName + +@app.get("/") +def route_name_and_method(route_name: RouteName, method: Method) -> str: + return f"I am route {route_name}, requested with {method}" +``` + +## Customizing the Context + +The composition context can be customized by defining and then using Incant hooks on the {class}`App.incant ` Incanter instance. + +For example, say you'd like to receive a token of some sort via a header, validate it and transform it into a user ID. +The handler should look like this: + +```python +@app.get("/valid-header") +def non_public_handler(user_id: str) -> str: + return "Hello {user_id}!" +``` + +Without any additional configuration, _uapi_ thinks the `user_id` parameter is supposed to be a mandatory [query parameter](handlers.md#query-parameters). +First, we need to create a dependency hook for our use case and register it with the App Incanter. + +```python +from uapi import Header + +@app.incant.register_by_name("user_id") +def validate_token_and_fetch_user(session_token: Header[str]) -> str: + # session token value will be injected from the `session-token` header + + user_id = validate(session_token) # Left as an exercize to the reader + + return user_id +``` + +Now our `non_public_handler` handler will have the validated user ID provided to it. + +```{note} +Since Incant is a true function composition library, the `session-token` dependency will also show up in the generated OpenAPI schema. +This is true of all dependency hooks and middleware. + +The final handler signature available to _uapi_ at time of serving contains all the dependencies as function arguments. +``` + +## Extending the Context + +The composition context can be extended with arbitrary dependencies. + +For example, imagine your application needs to perform HTTP requests. +Ideally, the handlers should use a shared connection pool instance for efficiency. +Here's a complete implementation of a very simple HTTP proxy. +The example can be pasted and ran as-is as long as Starlette and Uvicorn are available. + +```python +from asyncio import run + +from httpx import AsyncClient + +from uapi.starlette import App + +app = App() + +_client = AsyncClient() # We only want one. +app.incant.register_by_type(lambda: _client, type=AsyncClient) + + +@app.get("/proxy") +async def proxy(client: AsyncClient) -> str: + """We just return the payload at www.example.com.""" + return (await client.get("http://example.com")).read().decode() + + +run(app.run()) +``` + +## Integrating the `svcs` Package + +If you'd like to get more serious about application architecture, one of the approaches is to use the [svcs](https://svcs.hynek.me/) library. +Here's a way of integrating it into _uapi_. + +```python +from httpx import AsyncClient +from svcs import Container, Registry +from asyncio import run + +from uapi.starlette import App + +reg = Registry() + +app = App() +app.incant.register_by_type( + lambda: Container(reg), type=Container, is_ctx_manager="async" +) + + +@app.get("/proxy") +async def proxy(container: Container) -> str: + """We just return the payload at www.example.com.""" + client = await container.aget(AsyncClient) + return (await client.get("http://example.com")).read().decode() + +async def main() -> None: + async with AsyncClient() as client: # Clean up connections at the end + reg.register_value(AsyncClient, client, enter=False) + await app.run() + +run(main()) +``` + +We can go even further and instead of providing the `container`, we can provide anything the container contains too. + +```python +from collections.abc import Callable +from inspect import Parameter +from asyncio import run + +from httpx import AsyncClient +from svcs import Container, Registry + +from uapi.starlette import App + +reg = Registry() + + +app = App() +app.incant.register_by_type( + lambda: Container(reg), type=Container, is_ctx_manager="async" +) + + +def svcs_hook_factory(parameter: Parameter) -> Callable: + t = parameter.annotation + + async def from_container(c: Container): + return await c.aget(t) + + return from_container + + +app.incant.register_hook_factory(lambda p: p.annotation in reg, svcs_hook_factory) + + +@app.get("/proxy") +async def proxy(client: AsyncClient) -> str: + """We just return the payload at www.example.com.""" + return (await client.get("http://example.com")).read().decode() + + +async def main() -> None: + async with AsyncClient() as client: + reg.register_value(AsyncClient, client, enter=False) + await app.run() + + +run(main()) +``` + +```{note} +The _svcs_ library includes integrations for several popular web frameworks, and code examples for them. +The examples shown here are independent of the underlying web framework used; they will work on all of them (with a potential sync/async tweak). +``` diff --git a/docs/handlers.md b/docs/handlers.md index 096feff..11ba3c0 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -1,8 +1,12 @@ +```{currentmodule} uapi.base + +``` + # Writing Handlers Handlers are your functions and coroutines that _uapi_ calls to process incoming requests. -Handlers are registered to apps using {py:meth}`App.route() `, or helper decorators like {py:meth}`App.get() ` and {py:meth}`App.post() `. +Handlers are registered to apps using {meth}`App.route`, or helper decorators like {meth}`App.get` and {meth}`App.post`. ```python @app.get("/") @@ -199,9 +203,9 @@ async def create_articles(articles: ReqBody[dict[str, Article]]) -> None: ### Headers -HTTP headers are injected into your handlers when one or more of your handler parameters are annotated using `uapi.Header[T]`. +HTTP headers are provided to your handlers when one or more of your handler parameters are annotated using {class}`uapi.Header[T] `. -```{tip} +```{note} Technically, HTTP requests may contain several headers of the same name. All underlying frameworks return the *first* value encountered. ``` @@ -226,8 +230,8 @@ is left to the underlying framework. The current options are: - Quart: a response with status `400` is returned - All others: a response with status `500` is returned -`uapi.Header[T]` is equivalent to `Annotated[T, uapi.HeaderSpec]`, and header behavior can be customized -by providing your own instance of {py:class}`uapi.requests.HeaderSpec`. +{class}`uapi.Header[T] ` is equivalent to `Annotated[T, uapi.HeaderSpec]`, and header behavior can be customized +by providing your own instance of {class}`uapi.requests.HeaderSpec`. For example, the header name can be customized on a case-by-case basis like this: @@ -258,6 +262,51 @@ Header types may be strings or anything else. Strings are provided directly by the underlying frameworks, any other type is produced by structuring the string value into that type using the App _cattrs_ `Converter`. +### Cookies + +Cookies are provided to your handlers when one or more of your handler parameters are annotated using {class}`uapi.Cookie `, which is a subclass of `str`. +By default, the name of the cookie is the exact name of the handler parameter. + +```python +from uapi import Cookie + + +@app.post("/login") +async def login(session_token: Cookie) -> None: + # `session_token` is a `str` subclass + ... +``` + +The name of the cookie can be customized on an individual basis by using `typing.Annotated`: + +```python +from typing import Annotated +from uapi import Cookie + + +@app.post("/login") +async def login(session_token: Annotated[str, Cookie("session-token")]) -> None: + # `session_token` is a `str` subclass, fetched from the `session-token` cookie + ... +``` + +Cookies may have defaults which will be used if the cookie is not present in the request. +Cookies with defaults will be rendered as `required=False` in the OpenAPI schema. + +Cookies may be set by using {meth}`uapi.cookies.set_cookie`. + +```python +from uapi.status import Ok +from uapi.cookies import set_cookie + +async def sets_cookies() -> Ok[str] + return Ok("response", headers=set_cookie("my_cookie_name", "my_cookie_value")) +``` + +```{tip} +Since {meth}`uapi.cookies.set_cookie` returns a header mapping, multiple cookies can be set by using the `|` operator. +``` + ### Framework-specific Request Objects In case _uapi_ doesn't cover your exact needs, your handler can be given the request object provided by your underlying framework. diff --git a/docs/index.md b/docs/index.md index a549d47..34e06ac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,7 @@ self handlers.md +composition.md openapi.md addons.md changelog.md @@ -19,8 +20,8 @@ _uapi_ is an elegant, fast, and high-level framework for writing network service Using _uapi_ enables you to: - write **either async or sync** styles of handlers, depending on the underlying framework used. -- use and customize a **depedency injection** system, based on [incant](https://github.com/Tinche/incant/). -- automatically **serialize and deserialize** data through [attrs](https://www.attrs.org/en/stable/) and [cattrs](https://cattrs.readthedocs.io/en/latest/). +- use and customize a [**function composition** (dependency injection) system](composition.md), based on [incant](https://incant.threeofwands.com). +- automatically **serialize and deserialize** data through [attrs](https://www.attrs.org/en/stable/) and [cattrs](https://catt.rs). - generate and use **OpenAPI** descriptions of your endpoints. - optionally **type-check** your handlers with [Mypy](https://mypy.readthedocs.io/en/stable/). - write and use **powerful middleware**. diff --git a/src/uapi/__init__.py b/src/uapi/__init__.py index 7a55cc0..517b511 100644 --- a/src/uapi/__init__.py +++ b/src/uapi/__init__.py @@ -2,6 +2,7 @@ from .requests import Header, HeaderSpec, ReqBody, ReqBytes from .responses import ResponseException from .status import Found, Headers, SeeOther +from .types import Method, RouteName __all__ = [ "Cookie", @@ -12,6 +13,8 @@ "ReqBody", "ReqBytes", "ResponseException", + "RouteName", + "Method", ] diff --git a/src/uapi/aiohttp.py b/src/uapi/aiohttp.py index d16ba18..9c3e977 100644 --- a/src/uapi/aiohttp.py +++ b/src/uapi/aiohttp.py @@ -2,7 +2,7 @@ from functools import partial from inspect import Parameter, Signature, signature from logging import Logger -from typing import Any, ClassVar, TypeVar +from typing import Any, ClassVar, TypeAlias, TypeVar from aiohttp.web import Request as FrameworkRequest from aiohttp.web import Response, RouteTableDef @@ -27,13 +27,9 @@ is_header, is_req_body_attrs, ) -from .responses import ( - dict_to_headers, - identity, - make_exception_adapter, - make_return_adapter, -) +from .responses import dict_to_headers, make_exception_adapter, make_return_adapter from .status import BaseResponse, get_status_code +from .types import Method, RouteName __all__ = ["App", "AiohttpApp"] @@ -82,6 +78,12 @@ def read_query(_request: FrameworkRequest): lambda p: get_cookie_name(p.annotation, p.name) is not None, lambda p: make_cookie_dependency(get_cookie_name(p.annotation, p.name), default=p.default), # type: ignore ) + + # RouteNames and methods get an empty hook, so the parameter propagates to the base incanter. + res.hook_factory_registry.insert( + 0, Hook(lambda p: p.annotation in (RouteName, Method), None) + ) + return res @@ -132,22 +134,30 @@ def to_framework_routes(self) -> RouteTableDef: _, loader = get_req_body_attrs(arg) req_ct = loader.content_type + prepared = self.framework_incant.compose(base_handler, hooks, is_async=True) + sig = signature(prepared) + path_types = {p: sig.parameters[p].annotation for p in path_params} + + adapted = self.framework_incant.adapt( + prepared, + lambda p: p.annotation is FrameworkRequest, + lambda p: p.annotation is RouteName, + lambda p: p.annotation is Method, + **{pp: (lambda p, _pp=pp: p.name == _pp) for pp in path_params}, + ) + if ra is None: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=True - ) - sig = signature(prepared) - path_types = {p: sig.parameters[p].annotation for p in path_params} async def adapted( request: FrameworkRequest, - _incant=self.framework_incant.aincant, _fra=_framework_return_adapter, _ea=exc_adapter, - _prepared=prepared, + _prepared=adapted, _path_params=path_params, _path_types=path_types, _req_ct=req_ct, + _rn=name, + _rm=method, ) -> FrameworkResponse: if ( _req_ct is not None @@ -158,103 +168,55 @@ async def adapted( status=415, ) + path_args = { + p: ( + self.converter.structure(request.match_info[p], path_type) + if (path_type := _path_types[p]) + not in (str, Signature.empty) + else request.match_info[p] + ) + for p in _path_params + } try: - path_args = { - p: ( - self.converter.structure( - request.match_info[p], path_type - ) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else request.match_info[p] - ) - for p in _path_params - } - return await _incant(_prepared, request, **path_args) + return await _prepared(request, _rn, _rm, **path_args) except ResponseException as exc: return _fra(_ea(exc)) else: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=True - ) - sig = signature(prepared) - path_types = {p: sig.parameters[p].annotation for p in path_params} - - if ra == identity: - - async def adapted( - request: FrameworkRequest, - _incant=self.framework_incant.aincant, - _fra=_framework_return_adapter, - _ea=exc_adapter, - _prepared=prepared, - _path_params=path_params, - _path_types=path_types, - _req_ct=req_ct, - ) -> FrameworkResponse: - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return Response( - body=f"invalid content type (expected {_req_ct})", - status=415, - ) - path_args = { - p: ( - self.converter.structure( - request.match_info[p], path_type - ) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else request.match_info[p] - ) - for p in _path_params - } - try: - return _fra(await _incant(_prepared, request, **path_args)) - except ResponseException as exc: - return _fra(_ea(exc)) - - else: - - async def adapted( # type: ignore - request: FrameworkRequest, - _incant=self.framework_incant.aincant, - _ra=ra, - _fra=_framework_return_adapter, - _ea=exc_adapter, - _prepared=prepared, - _path_params=path_params, - _path_types=path_types, - _req_ct=req_ct, - ) -> FrameworkResponse: - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return Response( - body=f"invalid content type (expected {_req_ct})", - status=415, - ) - path_args = { - p: ( - self.converter.structure( - request.match_info[p], path_type - ) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else request.match_info[p] - ) - for p in _path_params - } - try: - return _fra( - _ra(await _incant(_prepared, request, **path_args)) - ) - except ResponseException as exc: - return _fra(_ea(exc)) + + async def adapted( + request: FrameworkRequest, + _ra=ra, + _fra=_framework_return_adapter, + _ea=exc_adapter, + _handler=adapted, + _path_params=path_params, + _path_types=path_types, + _req_ct=req_ct, + _rn=name, + _rm=method, + ) -> FrameworkResponse: + if ( + _req_ct is not None + and request.headers.get("content-type") != _req_ct + ): + return Response( + body=f"invalid content type (expected {_req_ct})", + status=415, + ) + path_args = { + p: ( + self.converter.structure(request.match_info[p], path_type) + if (path_type := _path_types[p]) + not in (str, Signature.empty) + else request.match_info[p] + ) + for p in _path_params + } + try: + return _fra(_ra(await _handler(request, _rn, _rm, **path_args))) + except ResponseException as exc: + return _fra(_ea(exc)) r.route(method, path, name=name)(adapted) @@ -283,7 +245,7 @@ async def run( ) -App = AiohttpApp +App: TypeAlias = AiohttpApp def make_header_dependency( diff --git a/src/uapi/base.py b/src/uapi/base.py index 45b82d0..914e81a 100644 --- a/src/uapi/base.py +++ b/src/uapi/base.py @@ -46,7 +46,10 @@ class App: """ converter: Converter = Factory(make_converter) + + #: The incanter used to compose handlers and middleware. incant: Incanter = Factory(make_base_incanter) + _route_map: dict[ tuple[Method, str], tuple[Callable, RouteName, RouteTags] ] = Factory(dict) @@ -74,7 +77,7 @@ def route( if name is None: name = handler.__name__ for method in methods: - self._route_map[(method, path)] = (handler, name, tags) + self._route_map[(method, path)] = (handler, RouteName(name), tags) return handler def get(self, path: str, name: str | None = None, tags: RouteTags = ()): @@ -106,14 +109,14 @@ def route_app( raise Exception("Incompatible apps.") for (method, path), (handler, name, tags) in app._route_map.items(): if name_prefix is not None: - name = f"{name_prefix}.{name}" + name = RouteName(f"{name_prefix}.{name}") self._route_map[(method, (prefix or "") + path)] = (handler, name, tags) def make_openapi_spec( self, title: str = "Server", version: str = "1.0", - exclude: set[RouteName] = set(), + exclude: set[str] = set(), summary_transformer: SummaryTransformer = default_summary_transformer, description_transformer: DescriptionTransformer = default_description_transformer, ) -> OpenAPI: diff --git a/src/uapi/cookies.py b/src/uapi/cookies.py index 7fad2d4..2ebf6fa 100644 --- a/src/uapi/cookies.py +++ b/src/uapi/cookies.py @@ -42,6 +42,7 @@ def _make_delete_cookie_header(name: str) -> dict: return {f"__cookie_{name}": val} +#: A cookie dependency. class Cookie(str): ... @@ -49,6 +50,11 @@ class Cookie(str): def set_cookie( name: str, value: str | None, settings: CookieSettings = CookieSettings() ) -> Headers: + """ + Produce headers that should be returned as part of a response to set the cookie. + + :param value: When `None`, the cookie will be deleted. + """ return ( _make_cookie_header(name, value, settings) if value is not None diff --git a/src/uapi/django.py b/src/uapi/django.py index 6634c1d..b91600a 100644 --- a/src/uapi/django.py +++ b/src/uapi/django.py @@ -1,7 +1,7 @@ from collections.abc import Callable from functools import partial from inspect import Parameter, Signature, signature -from typing import Any, ClassVar, TypeVar +from typing import Any, ClassVar, TypeAlias, TypeVar from attrs import Factory, define from cattrs import Converter @@ -32,15 +32,12 @@ is_header, is_req_body_attrs, ) -from .responses import ( - dict_to_headers, - identity, - make_exception_adapter, - make_return_adapter, -) +from .responses import dict_to_headers, make_exception_adapter, make_return_adapter from .status import BaseResponse, get_status_code from .types import Method, RouteName, RouteTags +__all__ = ["App"] + C = TypeVar("C") @@ -96,6 +93,12 @@ def request_bytes(_request: FrameworkRequest) -> bytes: res.register_hook_factory( is_req_body_attrs, partial(attrs_body_factory, converter=converter) ) + + # RouteNames and methods get an empty hook, so the parameter propagates to the base incanter. + res.hook_factory_registry.insert( + 0, Hook(lambda p: p.annotation in (RouteName, Method), None) + ) + return res @@ -138,11 +141,7 @@ def to_urlpatterns(self) -> list[URLPattern]: # Django does not strip the prefix slash, so we do it for it. path = path.removeprefix("/") per_method_adapted = {} - for method, ( - handler, - name, # noqa: B007 - _, - ) in methods_and_handlers.items(): + for method, (handler, name, _) in methods_and_handlers.items(): ra = make_return_adapter( signature(handler, eval_str=True).return_annotation, FrameworkResponse, @@ -159,23 +158,31 @@ def to_urlpatterns(self) -> list[URLPattern]: if is_req_body_attrs(arg): _, loader = get_req_body_attrs(arg) req_ct = loader.content_type + prepared = self.framework_incant.compose( + base_handler, hooks, is_async=False + ) + sig = signature(prepared) + path_types = {p: sig.parameters[p].annotation for p in path_params} + adapted = self.framework_incant.adapt( + prepared, + lambda p: p.annotation is FrameworkRequest, + lambda p: p.annotation is RouteName, + lambda p: p.annotation is Method, + **{pp: (lambda p, _pp=pp: p.name == _pp) for pp in path_params}, + ) if ra is None: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=False - ) - sig = signature(prepared) - path_types = {p: sig.parameters[p].annotation for p in path_params} def adapted( request: WSGIRequest, - _incant=self.framework_incant.incant, _fra=_framework_return_adapter, _ea=exc_adapter, - _prepared=prepared, + _handler=adapted, _path_params=path_params, _path_types=path_types, _req_ct=req_ct, + _rn=name, + _rm=method, **kwargs: Any, ) -> FrameworkResponse: if ( @@ -185,99 +192,55 @@ def adapted( return FrameworkResponse( f"invalid content type (expected {_req_ct})", status=415 ) + path_args = { + p: ( + self.converter.structure(kwargs[p], path_type) + if (path_type := _path_types[p]) + not in (str, Signature.empty) + else kwargs[p] + ) + for p in _path_params + } try: - path_args = { - p: ( - self.converter.structure(kwargs[p], path_type) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else kwargs[p] - ) - for p in _path_params - } - return _incant(_prepared, request, **path_args) + return _handler(request, _rn, _rm, **path_args) except ResponseException as exc: return _fra(_ea(exc)) else: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=False - ) - sig = signature(prepared) - path_types = {p: sig.parameters[p].annotation for p in path_params} - - if ra == identity: - - def adapted( - request: WSGIRequest, - _incant=self.framework_incant.incant, - _fra=_framework_return_adapter, - _ea=exc_adapter, - _prepared=prepared, - _path_params=path_params, - _path_types=path_types, - _req_ct=req_ct, - **kwargs: Any, - ) -> FrameworkResponse: - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", - status=415, - ) - path_args = { - p: ( - self.converter.structure(kwargs[p], path_type) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else kwargs[p] - ) - for p in _path_params - } - try: - return _fra(_incant(_prepared, request, **path_args)) - except ResponseException as exc: - return _fra(_ea(exc)) - - else: - - def adapted( # type: ignore - request: WSGIRequest, - _incant=self.framework_incant.incant, - _ra=ra, - _fra=_framework_return_adapter, - _ea=exc_adapter, - _prepared=prepared, - _path_params=path_params, - _path_types=path_types, - _req_ct=req_ct, - **kwargs: Any, - ) -> FrameworkResponse: - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", - status=415, - ) - path_args = { - p: ( - self.converter.structure(kwargs[p], path_type) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else kwargs[p] - ) - for p in _path_params - } - try: - return _fra( - _ra(_incant(_prepared, request, **path_args)) - ) - except ResponseException as exc: - return _fra(_ea(exc)) + + def adapted( + request: WSGIRequest, + _ra=ra, + _fra=_framework_return_adapter, + _ea=exc_adapter, + _handler=adapted, + _path_params=path_params, + _path_types=path_types, + _req_ct=req_ct, + _rn=name, + _rm=method, + **kwargs: Any, + ) -> FrameworkResponse: + if ( + _req_ct is not None + and request.headers.get("content-type") != _req_ct + ): + return FrameworkResponse( + f"invalid content type (expected {_req_ct})", status=415 + ) + path_args = { + p: ( + self.converter.structure(kwargs[p], path_type) + if (path_type := _path_types[p]) + not in (str, Signature.empty) + else kwargs[p] + ) + for p in _path_params + } + try: + return _fra(_ra(_handler(request, _rn, _rm, **path_args))) + except ResponseException as exc: + return _fra(_ea(exc)) per_method_adapted[method] = adapted @@ -306,7 +269,7 @@ def adapted( # type: ignore return res -App = DjangoApp +App: TypeAlias = DjangoApp def make_header_dependency( diff --git a/src/uapi/flask.py b/src/uapi/flask.py index 26650c5..39c2ac3 100644 --- a/src/uapi/flask.py +++ b/src/uapi/flask.py @@ -1,6 +1,6 @@ from functools import partial from inspect import Signature, signature -from typing import Any, ClassVar +from typing import Any, ClassVar, TypeAlias from attrs import Factory, define from cattrs import Converter @@ -27,13 +27,11 @@ is_header, is_req_body_attrs, ) -from .responses import ( - dict_to_headers, - identity, - make_exception_adapter, - make_return_adapter, -) +from .responses import dict_to_headers, make_exception_adapter, make_return_adapter from .status import BaseResponse, get_status_code +from .types import Method, RouteName + +__all__ = ["App"] def make_flask_incanter(converter: Converter) -> Incanter: @@ -74,6 +72,12 @@ def request_bytes() -> bytes: res.register_hook_factory( is_req_body_attrs, partial(attrs_body_factory, converter=converter) ) + + # RouteNames and methods get an empty hook, so the parameter propagates to the base incanter. + res.hook_factory_registry.insert( + 0, Hook(lambda p: p.annotation in (RouteName, Method), None) + ) + return res @@ -111,16 +115,24 @@ def to_framework_app(self, import_name: str) -> Flask: _, loader = get_req_body_attrs(arg) req_ct = loader.content_type + prepared = self.framework_incant.compose( + base_handler, hooks, is_async=False + ) + adapted = self.framework_incant.adapt( + prepared, + lambda p: p.annotation is RouteName, + lambda p: p.annotation is Method, + **{pp: (lambda p, _pp=pp: p.name == _pp) for pp in path_params}, + ) if ra is None: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=False - ) def o0( - prepared=prepared, + _handler=adapted, _req_ct=req_ct, _fra=_framework_return_adapter, _ea=exc_adapter, + _rn=name, + _rm=method, ): def adapter(**kwargs): if ( @@ -131,7 +143,7 @@ def adapter(**kwargs): f"invalid content type (expected {_req_ct})", 415 ) try: - return prepared(**kwargs) + return _handler(_rn, _rm, **kwargs) except ResponseException as exc: return _fra(_ea(exc)) @@ -140,48 +152,32 @@ def adapter(**kwargs): adapted = o0() else: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=False - ) - if ra == identity: - - def o1(prepared=prepared, _req_ct=req_ct, _ea=exc_adapter): - def adapter(**kwargs): - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", 415 - ) - try: - return _framework_return_adapter(prepared(**kwargs)) - except ResponseException as exc: - return _framework_return_adapter(_ea(exc)) - - return adapter - - adapted = o1() - - else: - - def o2(prepared=prepared, ra=ra, _req_ct=req_ct, _ea=exc_adapter): - def adapter(**kwargs): - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", 415 - ) - try: - return _framework_return_adapter(ra(prepared(**kwargs))) - except ResponseException as exc: - return _framework_return_adapter(_ea(exc)) - - return adapter - - adapted = o2() + + def o1( + _handler=adapted, + _ra=ra, + _fra=_framework_return_adapter, + _req_ct=req_ct, + _ea=exc_adapter, + _rn=name, + _rm=method, + ): + def adapter(**kwargs): + if ( + _req_ct is not None + and request.headers.get("content-type") != _req_ct + ): + return FrameworkResponse( + f"invalid content type (expected {_req_ct})", 415 + ) + try: + return _fra(_ra(_handler(_rn, _rm, **kwargs))) + except ResponseException as exc: + return _fra(_ea(exc)) + + return adapter + + adapted = o1() f.route( path, @@ -195,7 +191,7 @@ def run(self, import_name: str, port: int = 8000): self.to_framework_app(import_name).run(port=port) -App = FlaskApp +App: TypeAlias = FlaskApp def make_header_dependency( diff --git a/src/uapi/openapi.py b/src/uapi/openapi.py index 8e96ba6..0ee837d 100644 --- a/src/uapi/openapi.py +++ b/src/uapi/openapi.py @@ -218,6 +218,9 @@ def build_operation( if arg in path_params: continue arg_type = arg_param.annotation + if arg_type in (RouteName, Method): + # These are special and fulfilled by uapi itself. + continue if arg_type is not InspectParameter.empty and is_subclass( arg_type, framework_req_cls ): diff --git a/src/uapi/quart.py b/src/uapi/quart.py index a81f340..c76611c 100644 --- a/src/uapi/quart.py +++ b/src/uapi/quart.py @@ -2,7 +2,7 @@ from contextlib import suppress from functools import partial from inspect import Signature, signature -from typing import Any, ClassVar, TypeVar +from typing import Any, ClassVar, TypeAlias, TypeVar from attrs import Factory, define from cattrs import Converter @@ -30,13 +30,11 @@ is_header, is_req_body_attrs, ) -from .responses import ( - dict_to_headers, - identity, - make_exception_adapter, - make_return_adapter, -) +from .responses import dict_to_headers, make_exception_adapter, make_return_adapter from .status import BaseResponse, get_status_code +from .types import Method, RouteName + +__all__ = ["App"] C = TypeVar("C") @@ -79,6 +77,12 @@ async def request_bytes() -> bytes: res.register_hook_factory( is_req_body_attrs, partial(attrs_body_factory, converter=converter) ) + + # RouteNames and methods get an empty hook, so the parameter propagates to the base incanter. + res.hook_factory_registry.insert( + 0, Hook(lambda p: p.annotation in (RouteName, Method), None) + ) + return res @@ -115,17 +119,23 @@ def to_framework_app(self, import_name: str) -> Quart: if is_req_body_attrs(arg): _, loader = get_req_body_attrs(arg) req_ct = loader.content_type + prepared = self.framework_incant.compose(base_handler, hooks, is_async=True) + adapted = self.framework_incant.adapt( + prepared, + lambda p: p.annotation is RouteName, + lambda p: p.annotation is Method, + **{pp: (lambda p, _pp=pp: p.name == _pp) for pp in path_params}, + ) if ra is None: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=True - ) def o0( - prepared=prepared, + handler=adapted, _req_ct=req_ct, _fra=_framework_return_adapter, _ea=exc_adapter, + _rn=name, + _rm=method, ): async def adapter(**kwargs): if ( @@ -136,7 +146,7 @@ async def adapter(**kwargs): f"invalid content type (expected {_req_ct})", 415 ) try: - return await prepared(**kwargs) + return await handler(_rn, _rm, **kwargs) except ResponseException as exc: return _fra(_ea(exc)) @@ -145,61 +155,32 @@ async def adapter(**kwargs): adapted = o0() else: - base_handler = self.incant.compose(handler, is_async=True) - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=True - ) - - if ra == identity: - - def o1( - prepared=prepared, - _fra=_framework_return_adapter, - _req_ct=req_ct, - _ea=exc_adapter, - ): - async def adapter(**kwargs): - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", 415 - ) - try: - return _fra(await prepared(**kwargs)) - except ResponseException as exc: - return _fra(_ea(exc)) - - return adapter - - adapted = o1() - - else: - - def o2( - prepared=prepared, - _fra=_framework_return_adapter, - _ra=ra, - _req_ct=req_ct, - _ea=exc_adapter, - ): - async def adapter(**kwargs): - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", 415 - ) - try: - return _fra(_ra(await prepared(**kwargs))) - except ResponseException as exc: - return _fra(_ea(exc)) - - return adapter - - adapted = o2() + + def o1( + handler=adapted, + _fra=_framework_return_adapter, + _ra=ra, + _req_ct=req_ct, + _ea=exc_adapter, + _rn=name, + _rm=method, + ): + async def adapter(**kwargs): + if ( + _req_ct is not None + and request.headers.get("content-type") != _req_ct + ): + return FrameworkResponse( + f"invalid content type (expected {_req_ct})", 415 + ) + try: + return _fra(_ra(await handler(_rn, _rm, **kwargs))) + except ResponseException as exc: + return _fra(_ea(exc)) + + return adapter + + adapted = o1() q.route( path, @@ -210,7 +191,11 @@ async def adapter(**kwargs): return q async def run( - self, import_name: str, port: int = 8000, handle_signals: bool = True + self, + import_name: str, + port: int = 8000, + handle_signals: bool = True, + log_level: str | int | None = None, ) -> None: """Start serving this app using uvicorn. @@ -218,11 +203,16 @@ async def run( """ from uvicorn import Config, Server - config = Config(self.to_framework_app(import_name), port=port, access_log=False) + config = Config( + self.to_framework_app(import_name), + port=port, + access_log=False, + log_level=log_level, + ) if handle_signals: server = Server(config=config) - + await server.serve() else: class NoSignalsServer(Server): @@ -231,16 +221,16 @@ def install_signal_handlers(self) -> None: server = NoSignalsServer(config=config) - t = create_task(server.serve()) + t = create_task(server.serve()) - with suppress(BaseException): - while True: - await sleep(360) - server.should_exit = True - await t + with suppress(BaseException): + while True: + await sleep(360) + server.should_exit = True + await t -App = QuartApp +App: TypeAlias = QuartApp def make_header_dependency( diff --git a/src/uapi/requests.py b/src/uapi/requests.py index 39d6c53..fe461fd 100644 --- a/src/uapi/requests.py +++ b/src/uapi/requests.py @@ -39,7 +39,9 @@ class HeaderSpec: ReqBody = Annotated[T, JsonBodyLoader()] ReqBytes = NewType("ReqBytes", bytes) -Header = Annotated[T, HeaderSpec()] + +#: A header dependency. +Header: TypeAlias = Annotated[T, HeaderSpec()] def get_cookie_name(t, arg_name: str) -> str | None: diff --git a/src/uapi/responses.py b/src/uapi/responses.py index 3a359bf..6cf6552 100644 --- a/src/uapi/responses.py +++ b/src/uapi/responses.py @@ -1,7 +1,7 @@ from collections.abc import Callable, Mapping from inspect import Signature from types import MappingProxyType, NoneType -from typing import Any, get_args +from typing import Any, TypeVar, get_args from attrs import define, has from cattrs import Converter @@ -16,6 +16,7 @@ from json import dumps # type: ignore __all__ = ["dumps", "return_type_to_statuses", "get_status_code_results"] + empty_dict: Mapping[str, str] = MappingProxyType({}) @@ -121,9 +122,12 @@ def get_status_code_results(t: type) -> list[tuple[int, Any]]: return list(return_type_to_statuses(t).items()) -def identity(*args): +T = TypeVar("T") + + +def identity(x: T) -> T: """The identity function, used and recognized for certain optimizations.""" - return args + return x def dict_to_headers(d: Headers) -> list[tuple[str, str]]: diff --git a/src/uapi/starlette.py b/src/uapi/starlette.py index 03317a8..e7bc7a2 100644 --- a/src/uapi/starlette.py +++ b/src/uapi/starlette.py @@ -3,7 +3,7 @@ from contextlib import suppress from functools import partial from inspect import Parameter, Signature, signature -from typing import Any, ClassVar, TypeVar +from typing import Any, ClassVar, TypeAlias, TypeVar from attrs import Factory, define from cattrs import Converter @@ -25,8 +25,11 @@ is_header, is_req_body_attrs, ) -from .responses import identity, make_exception_adapter, make_return_adapter +from .responses import make_exception_adapter, make_return_adapter from .status import BaseResponse, Headers, get_status_code +from .types import Method, RouteName + +__all__ = ["App"] C = TypeVar("C") @@ -82,6 +85,12 @@ async def request_bytes(_request: FrameworkRequest) -> bytes: res.register_hook_factory( is_req_body_attrs, partial(attrs_body_factory, converter=converter) ) + + # RouteNames and methods get an empty hook, so the parameter propagates to the base incanter. + res.hook_factory_registry.insert( + 0, Hook(lambda p: p.annotation in (RouteName, Method), None) + ) + return res @@ -120,22 +129,30 @@ def to_framework_app(self) -> Starlette: _, loader = get_req_body_attrs(arg) req_ct = loader.content_type + prepared = self.framework_incant.compose(base_handler, hooks, is_async=True) + sig = signature(prepared) + path_types = {p: sig.parameters[p].annotation for p in path_params} + + adapted = self.framework_incant.adapt( + prepared, + lambda p: p.annotation is FrameworkRequest, + lambda p: p.annotation is RouteName, + lambda p: p.annotation is Method, + **{pp: (lambda p, _pp=pp: p.name == _pp) for pp in path_params}, + ) + if ra is None: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=True - ) - sig = signature(prepared) - path_types = {p: sig.parameters[p].annotation for p in path_params} async def adapted( request: FrameworkRequest, - _incant=self.framework_incant.aincant, _fra=_framework_return_adapter, _ea=exc_adapter, - _prepared=prepared, + _handler=adapted, _path_params=path_params, _path_types=path_types, _req_ct=req_ct, + _rn=name, + _rm=method, ) -> FrameworkResponse: if ( _req_ct is not None @@ -156,106 +173,70 @@ async def adapted( ) for p in _path_params } - return await _incant(_prepared, request, **path_args) + return await _handler(request, _rn, _rm, **path_args) except ResponseException as exc: return _fra(_ea(exc)) else: - prepared = self.framework_incant.compose( - base_handler, hooks, is_async=True - ) - sig = signature(prepared) - path_types = {p: sig.parameters[p].annotation for p in path_params} - - if ra == identity: - - async def adapted( - request: FrameworkRequest, - _incant=self.framework_incant.aincant, - _fra=_framework_return_adapter, - _ea=exc_adapter, - _prepared=prepared, - _path_params=path_params, - _path_types=path_types, - _req_ct=req_ct, - ) -> FrameworkResponse: - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", 415 - ) - path_args = { - p: ( - self.converter.structure( - request.path_params[p], path_type - ) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else request.path_params[p] - ) - for p in _path_params - } - try: - return _fra(await _incant(_prepared, request, **path_args)) - except ResponseException as exc: - return _fra(_ea(exc)) - - else: - - async def adapted( # type: ignore - request: FrameworkRequest, - _incant=self.framework_incant.aincant, - _ra=ra, - _fra=_framework_return_adapter, - _ea=exc_adapter, - _prepared=prepared, - _path_params=path_params, - _path_types=path_types, - _req_ct=req_ct, - ) -> FrameworkResponse: - if ( - _req_ct is not None - and request.headers.get("content-type") != _req_ct - ): - return FrameworkResponse( - f"invalid content type (expected {_req_ct})", 415 - ) - path_args = { - p: ( - self.converter.structure( - request.path_params[p], path_type - ) - if (path_type := _path_types[p]) - not in (str, Signature.empty) - else request.path_params[p] - ) - for p in _path_params - } - try: - return _fra( - _ra(await _incant(_prepared, request, **path_args)) - ) - except ResponseException as exc: - return _fra(_ea(exc)) + + async def adapted( + request: FrameworkRequest, + _ra=ra, + _fra=_framework_return_adapter, + _ea=exc_adapter, + _prepared=adapted, + _path_params=path_params, + _path_types=path_types, + _req_ct=req_ct, + _rn=name, + _rm=method, + ) -> FrameworkResponse: + if ( + _req_ct is not None + and request.headers.get("content-type") != _req_ct + ): + return FrameworkResponse( + f"invalid content type (expected {_req_ct})", 415 + ) + path_args = { + p: ( + self.converter.structure(request.path_params[p], path_type) + if (path_type := _path_types[p]) + not in (str, Signature.empty) + else request.path_params[p] + ) + for p in _path_params + } + try: + return _fra( + _ra(await _prepared(request, _rn, _rm, **path_args)) + ) + except ResponseException as exc: + return _fra(_ea(exc)) s.add_route(path, adapted, name=name, methods=[method]) return s - async def run(self, port: int = 8000, handle_signals: bool = True) -> None: + async def run( + self, + port: int = 8000, + handle_signals: bool = True, + log_level: str | int | None = None, + ) -> None: """Start serving this app using uvicorn. Cancel the task running this to shut down uvicorn. """ from uvicorn import Config, Server - config = Config(self.to_framework_app(), port=port, access_log=False) + config = Config( + self.to_framework_app(), port=port, access_log=False, log_level=log_level + ) if handle_signals: server = Server(config=config) - + await server.serve() else: class NoSignalsServer(Server): @@ -264,16 +245,16 @@ def install_signal_handlers(self) -> None: server = NoSignalsServer(config=config) - t = create_task(server.serve()) + t = create_task(server.serve()) - with suppress(BaseException): - while True: - await sleep(360) - server.should_exit = True - await t + with suppress(BaseException): + while True: + await sleep(360) + server.should_exit = True + await t -App = StarletteApp +App: TypeAlias = StarletteApp def make_header_dependency( diff --git a/src/uapi/types.py b/src/uapi/types.py index cb3ae8d..95249c9 100644 --- a/src/uapi/types.py +++ b/src/uapi/types.py @@ -1,12 +1,17 @@ from collections.abc import Callable, Sequence -from typing import Literal, TypeAlias, TypeVar +from typing import Literal, NewType, TypeAlias, TypeVar R = TypeVar("R") CB = Callable[..., R] -RouteName: TypeAlias = str +#: The route name. +RouteName = NewType("RouteName", str) + RouteTags: TypeAlias = Sequence[str] + +#: The HTTP request method. Method: TypeAlias = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + PathParamParser: TypeAlias = Callable[[str], tuple[str, list[str]]] diff --git a/tests/aiohttp.py b/tests/aiohttp.py index 5a7d71a..242d3fe 100644 --- a/tests/aiohttp.py +++ b/tests/aiohttp.py @@ -1,6 +1,6 @@ from aiohttp.web import Request, Response -from uapi import ResponseException +from uapi import Method, ResponseException, RouteName from uapi.aiohttp import App from uapi.status import NoContent @@ -57,6 +57,18 @@ async def post_no_body() -> Response: async def post_path_string(path_id: str) -> str: return str(int(path_id) + 2) + # Route name composition. + @app.get("/comp/route-name-native") + @app.post("/comp/route-name-native", name="route-name-native-post") + def route_name_native(route_name: RouteName) -> Response: + return Response(text=route_name) + + # Request method composition. + @app.get("/comp/req-method-native") + @app.post("/comp/req-method-native", name="request-method-native-post") + def request_method_native(req_method: Method) -> Response: + return Response(text=req_method) + return app diff --git a/tests/apps.py b/tests/apps.py index d840385..fa4178c 100644 --- a/tests/apps.py +++ b/tests/apps.py @@ -1,6 +1,6 @@ from typing import Annotated, TypeAlias, TypeVar -from uapi import Cookie, Header, ReqBody, ResponseException +from uapi import Cookie, Header, Method, ReqBody, ResponseException, RouteName from uapi.base import App from uapi.cookies import CookieSettings, set_cookie from uapi.requests import HeaderSpec, JsonBodyLoader @@ -214,7 +214,7 @@ async def excluded() -> str: """This should be excluded from OpenAPI.""" return "" - # Subapps. + # # Subapps. app.route_app(make_generic_subapp()) app.route_app(make_generic_subapp(), "/subapp", "subapp") @@ -229,6 +229,18 @@ def injected_id(header_for_injection: Header[str]) -> str: async def injection(injected_id: str) -> str: return injected_id + # Route name composition. + @app.get("/comp/route-name") + @app.post("/comp/route-name", name="route-name-post") + async def route_name(route_name: RouteName) -> str: + return route_name + + # Request method composition. + @app.get("/comp/req-method") + @app.post("/comp/req-method", name="request-method-post") + async def request_method(req_method: Method) -> str: + return req_method + def configure_base_sync(app: App) -> None: @app.get("/") @@ -416,3 +428,15 @@ def injected_id(header_for_injection: Header[str]) -> str: @app.get("/injection") def injection(injected_id: str) -> str: return injected_id + + # Route name composition. + @app.get("/comp/route-name") + @app.post("/comp/route-name", name="route-name-post") + def route_name(route_name: RouteName) -> str: + return route_name + + # Request method composition. + @app.get("/comp/req-method") + @app.post("/comp/req-method", name="request-method-post") + def request_method(req_method: Method) -> str: + return req_method diff --git a/tests/conftest.py b/tests/conftest.py index b010658..73b80a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,7 +25,9 @@ def event_loop(): loop.close() -@pytest.fixture(params=["aiohttp", "flask", "quart", "starlette", "django"]) +@pytest.fixture( + params=["aiohttp", "flask", "quart", "starlette", "django"], scope="session" +) async def server(request, unused_tcp_port_factory: Callable[..., int]): unused_tcp_port = unused_tcp_port_factory() if request.param == "aiohttp": @@ -107,5 +109,6 @@ async def server_with_openapi( t = create_task(run_on_django(unused_tcp_port, shutdown_event)) yield unused_tcp_port shutdown_event.set() + await t else: raise Exception("Unknown server framework") diff --git a/tests/django_uapi_app/views.py b/tests/django_uapi_app/views.py index 7268ec1..77fbfbb 100644 --- a/tests/django_uapi_app/views.py +++ b/tests/django_uapi_app/views.py @@ -1,7 +1,7 @@ from django.http import HttpRequest as Request from django.http import HttpResponse as Response -from uapi import ResponseException +from uapi import Method, ResponseException, RouteName from uapi.django import App from uapi.status import NoContent @@ -70,3 +70,17 @@ def post_path_string(path_id: str) -> str: # This is difficult to programatically set, so just always run it. app.serve_openapi() + + +# Route name composition. +@app.get("/comp/route-name-native") +@app.post("/comp/route-name-native", name="route-name-native-post") +def route_name_native(route_name: RouteName) -> Response: + return Response(route_name) + + +# Request method composition. +@app.get("/comp/req-method-native") +@app.post("/comp/req-method-native", name="request-method-native-post") +def request_method_native(req_method: Method) -> Response: + return Response(req_method) diff --git a/tests/flask.py b/tests/flask.py index 7ffafa1..5ed0a72 100644 --- a/tests/flask.py +++ b/tests/flask.py @@ -7,6 +7,7 @@ from uapi import ResponseException from uapi.flask import App from uapi.status import NoContent +from uapi.types import Method, RouteName from .apps import configure_base_sync @@ -57,6 +58,18 @@ def post_no_body() -> Response: def post_path_string(path_id: str) -> str: return str(int(path_id) + 2) + # Route name composition. + @app.get("/comp/route-name-native") + @app.post("/comp/route-name-native", name="route-name-native-post") + def route_name_native(route_name: RouteName) -> Response: + return Response(route_name) + + # Request method composition. + @app.get("/comp/req-method-native") + @app.post("/comp/req-method-native", name="request-method-native-post") + def request_method_native(req_method: Method) -> Response: + return Response(req_method) + return app diff --git a/tests/openapi/__init__.py b/tests/openapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/openapi/conftest.py b/tests/openapi/conftest.py new file mode 100644 index 0000000..5ee1728 --- /dev/null +++ b/tests/openapi/conftest.py @@ -0,0 +1,27 @@ +import pytest + +from uapi.base import App + +from ..aiohttp import make_app as aiohttp_make_app +from ..django_uapi_app.views import app as django_app +from ..flask import make_app as flask_make_app +from ..quart import make_app as quart_make_app +from ..starlette import make_app as starlette_make_app + + +def django_make_app() -> App: + return django_app + + +@pytest.fixture( + params=[ + aiohttp_make_app, + flask_make_app, + quart_make_app, + starlette_make_app, + django_make_app, + ], + ids=["aiohttp", "flask", "quart", "starlette", "django"], +) +def app(request) -> App: + return request.param() diff --git a/tests/test_openapi.py b/tests/openapi/test_openapi.py similarity index 58% rename from tests/test_openapi.py rename to tests/openapi/test_openapi.py index d2eaf2c..f3fd8d4 100644 --- a/tests/test_openapi.py +++ b/tests/openapi/test_openapi.py @@ -1,23 +1,9 @@ """Test the OpenAPI schema generation.""" -from collections.abc import Callable - -import pytest from httpx import AsyncClient from uapi.base import App from uapi.openapi import OpenAPI, Parameter, Response, Schema, converter -from .aiohttp import make_app as aiohttp_make_app -from .django_uapi_app.views import App as DjangoApp -from .django_uapi_app.views import app as django_app -from .flask import make_app as flask_make_app -from .quart import make_app as quart_make_app -from .starlette import make_app as starlette_make_app - - -def django_make_app() -> DjangoApp: - return django_app - async def test_get_index(server_with_openapi: int) -> None: async with AsyncClient() as client: @@ -46,19 +32,7 @@ async def test_get_index(server_with_openapi: int) -> None: assert op.get.description == "To be used as a description." -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_get_path_param(app_factory) -> None: - app = app_factory() +def test_get_path_param(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/path/{path_id}"] @@ -79,19 +53,7 @@ def test_get_path_param(app_factory) -> None: assert op.get.description is None -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_get_query_int(app_factory) -> None: - app = app_factory() +def test_get_query_int(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/query"] @@ -109,19 +71,7 @@ def test_get_query_int(app_factory) -> None: assert op.get.responses["200"] -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_get_query_default(app_factory) -> None: - app = app_factory() +def test_get_query_default(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/query-default"] @@ -139,13 +89,7 @@ def test_get_query_default(app_factory) -> None: assert op.get.responses["200"] -@pytest.mark.parametrize( - "app_factory", - [aiohttp_make_app, flask_make_app, quart_make_app, starlette_make_app], - ids=["aiohttp", "flask", "quart", "starlette"], -) -def test_get_query_unannotated(app_factory) -> None: - app = app_factory() +def test_get_query_unannotated(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/query/unannotated"] @@ -163,19 +107,7 @@ def test_get_query_unannotated(app_factory) -> None: assert op.get.responses["200"] -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_get_query_string(app_factory) -> None: - app = app_factory() +def test_get_query_string(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/query/string"] @@ -193,13 +125,7 @@ def test_get_query_string(app_factory) -> None: assert op.get.responses["200"] -@pytest.mark.parametrize( - "app_factory", - [aiohttp_make_app, flask_make_app, quart_make_app, starlette_make_app], - ids=["aiohttp", "flask", "quart", "starlette"], -) -def test_get_bytes(app_factory) -> None: - app = app_factory() +def test_get_bytes(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/response-bytes"] @@ -213,13 +139,7 @@ def test_get_bytes(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [aiohttp_make_app, flask_make_app, quart_make_app, starlette_make_app], - ids=["aiohttp", "flask", "quart", "starlette"], -) -def test_post_no_body_native_response(app_factory) -> None: - app = app_factory() +def test_post_no_body_native_response(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/post/no-body-native-response"] @@ -231,19 +151,7 @@ def test_post_no_body_native_response(app_factory) -> None: assert op.post.responses["200"] -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_post_no_body_no_response(app_factory) -> None: - app = app_factory() +def test_post_no_body_no_response(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/post/no-body-no-response"] @@ -255,13 +163,7 @@ def test_post_no_body_no_response(app_factory) -> None: assert op.post.responses["204"] -@pytest.mark.parametrize( - "app_factory", - [aiohttp_make_app, flask_make_app, quart_make_app, starlette_make_app], - ids=["aiohttp", "flask", "quart", "starlette"], -) -def test_post_custom_status(app_factory) -> None: - app = app_factory() +def test_post_custom_status(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/post/201"] @@ -273,13 +175,7 @@ def test_post_custom_status(app_factory) -> None: assert op.post.responses["201"] -@pytest.mark.parametrize( - "app_factory", - [aiohttp_make_app, flask_make_app, quart_make_app, starlette_make_app], - ids=["aiohttp", "flask", "quart", "starlette"], -) -def test_post_multiple_statuses(app_factory) -> None: - app = app_factory() +def test_post_multiple_statuses(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/post/multiple"] @@ -296,13 +192,7 @@ def test_post_multiple_statuses(app_factory) -> None: assert not op.post.responses["201"].content -@pytest.mark.parametrize( - "app_factory", - [aiohttp_make_app, flask_make_app, quart_make_app, starlette_make_app], - ids=["aiohttp", "flask", "quart", "starlette"], -) -def test_put_cookie(app_factory) -> None: - app = app_factory() +def test_put_cookie(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/put/cookie"] @@ -324,19 +214,7 @@ def test_put_cookie(app_factory) -> None: assert schema.type == Schema.Type.STRING -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_delete(app_factory) -> None: - app = app_factory() +def test_delete(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/delete/header"] @@ -348,20 +226,8 @@ def test_delete(app_factory) -> None: assert op.delete.responses == {"204": Response("No content", {})} -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_ignore_framework_request(app_factory) -> None: +def test_ignore_framework_request(app: App) -> None: """Framework request params are ignored.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/framework-request"] @@ -375,19 +241,7 @@ def test_ignore_framework_request(app_factory) -> None: assert op.get.parameters == [] -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_get_injection(app_factory) -> None: - app: App = app_factory() +def test_get_injection(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/injection"] @@ -405,38 +259,14 @@ def test_get_injection(app_factory) -> None: assert op.get.responses["200"] -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_excluded(app_factory) -> None: - app: App = app_factory() +def test_excluded(app: App) -> None: spec: OpenAPI = app.make_openapi_spec(exclude={"excluded"}) assert "/excluded" not in spec.paths -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_tags(app_factory: Callable[[], App]) -> None: +def test_tags(app: App) -> None: """Tags are properly generated.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() tagged_routes = [ @@ -456,19 +286,7 @@ def test_tags(app_factory: Callable[[], App]) -> None: assert not getattr(path_item, method).tags -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_user_response_class(app_factory) -> None: - app: App = app_factory() +def test_user_response_class(app: App) -> None: spec: OpenAPI = app.make_openapi_spec(exclude={"excluded"}) pathitem = spec.paths["/throttled"] diff --git a/tests/test_openapi_attrs.py b/tests/openapi/test_openapi_attrs.py similarity index 76% rename from tests/test_openapi_attrs.py rename to tests/openapi/test_openapi_attrs.py index dc37c22..7fa191b 100644 --- a/tests/test_openapi_attrs.py +++ b/tests/openapi/test_openapi_attrs.py @@ -1,8 +1,4 @@ """Test the OpenAPI schema generation for attrs classes.""" -from collections.abc import Callable - -import pytest - from uapi.base import App from uapi.openapi import ( ArraySchema, @@ -15,30 +11,8 @@ Schema, ) -from .aiohttp import make_app as aiohttp_make_app -from .django_uapi_app.views import app as django_app -from .flask import make_app as flask_make_app -from .quart import make_app as quart_make_app -from .starlette import make_app as starlette_make_app - - -def django_make_app() -> App: - return django_app - -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_get_model(app_factory) -> None: - app = app_factory() +def test_get_model(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/get/model"] @@ -72,19 +46,7 @@ def test_get_model(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_get_model_status(app_factory) -> None: - app = app_factory() +def test_get_model_status(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/get/model-status"] @@ -119,19 +81,7 @@ def test_get_model_status(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_post_model(app_factory) -> None: - app = app_factory() +def test_post_model(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/post/model"] @@ -167,20 +117,8 @@ def test_post_model(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_patch_union(app_factory) -> None: +def test_patch_union(app: App) -> None: """Unions of attrs classes.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/patch/attrs"] @@ -217,20 +155,8 @@ def test_patch_union(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_custom_loader(app_factory: Callable[[], App]) -> None: +def test_custom_loader(app: App) -> None: """Custom loaders advertise proper content types.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/custom-loader"] @@ -270,19 +196,7 @@ def test_custom_loader(app_factory: Callable[[], App]) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_models_same_name(app_factory) -> None: - app: App = app_factory() +def test_models_same_name(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() assert spec.components.schemas["SimpleModel"] == Schema( @@ -300,20 +214,8 @@ def test_models_same_name(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_response_models(app_factory) -> None: +def test_response_models(app: App) -> None: """Response models should be properly added to the spec.""" - app: App = app_factory() spec: OpenAPI = app.make_openapi_spec() assert spec.components.schemas["ResponseModel"] == Schema( @@ -326,20 +228,8 @@ def test_response_models(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_response_union_none(app_factory) -> None: +def test_response_union_none(app: App) -> None: """Response models of unions containing an inner None should be properly added to the spec.""" - app: App = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/response-union-none"] @@ -365,20 +255,8 @@ def test_response_union_none(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_model_with_literal(app_factory) -> None: +def test_model_with_literal(app: App) -> None: """Models with Literal types are properly added to the spec.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/literal-model"] @@ -405,20 +283,8 @@ def test_model_with_literal(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_generic_model(app_factory) -> None: +def test_generic_model(app: App) -> None: """Models with Literal types are properly added to the spec.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/generic-model"] @@ -469,20 +335,8 @@ def test_generic_model(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_generic_response_model(app_factory) -> None: +def test_generic_response_model(app: App) -> None: """Models from responses are collected properly.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/response-generic-model"] @@ -524,20 +378,8 @@ def test_generic_response_model(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_sum_types_model(app_factory) -> None: +def test_sum_types_model(app: App) -> None: """Sum types are handled properly.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/sum-types-model"] @@ -600,20 +442,8 @@ def test_sum_types_model(app_factory) -> None: ) -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_dictionary_models(app_factory) -> None: +def test_dictionary_models(app: App) -> None: """Dictionary models are handled properly.""" - app = app_factory() spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/dictionary-models"] diff --git a/tests/openapi/test_openapi_composition.py b/tests/openapi/test_openapi_composition.py new file mode 100644 index 0000000..a934af3 --- /dev/null +++ b/tests/openapi/test_openapi_composition.py @@ -0,0 +1,48 @@ +"""Test OpenAPI while composing.""" +from uapi.base import App +from uapi.openapi import MediaType, OpenAPI, Response, Schema + + +def test_route_name_and_methods(app: App): + """Route names and methods should be filtered out of OpenAPI schemas.""" + spec = app.make_openapi_spec() + + op = spec.paths["/comp/route-name"].get + assert op is not None + assert op == OpenAPI.PathItem.Operation( + {"200": Response("OK", {"text/plain": MediaType(Schema(Schema.Type.STRING))})}, + summary="Route Name", + operationId="route_name", + ) + + op = spec.paths["/comp/route-name"].post + assert op is not None + assert op == OpenAPI.PathItem.Operation( + {"200": Response("OK", {"text/plain": MediaType(Schema(Schema.Type.STRING))})}, + summary="Route-Name-Post", + operationId="route-name-post", + ) + + +def test_native_route_name_and_methods(app: App): + """ + Route names and methods in native handlers should be filtered out of OpenAPI + schemas. + """ + spec = app.make_openapi_spec() + + op = spec.paths["/comp/route-name-native"].get + assert op is not None + assert op == OpenAPI.PathItem.Operation( + {"200": Response("OK")}, + summary="Route Name Native", + operationId="route_name_native", + ) + + op = spec.paths["/comp/route-name-native"].post + assert op is not None + assert op == OpenAPI.PathItem.Operation( + {"200": Response("OK")}, + summary="Route-Name-Native-Post", + operationId="route-name-native-post", + ) diff --git a/tests/test_openapi_headers.py b/tests/openapi/test_openapi_headers.py similarity index 54% rename from tests/test_openapi_headers.py rename to tests/openapi/test_openapi_headers.py index 7e2b341..5a64c47 100644 --- a/tests/test_openapi_headers.py +++ b/tests/openapi/test_openapi_headers.py @@ -1,35 +1,10 @@ """Test headers.""" -from collections.abc import Callable - -import pytest - from uapi.openapi import OpenAPI, Parameter, Schema -from .aiohttp import make_app as aiohttp_make_app -from .django_uapi_app.views import App -from .django_uapi_app.views import app as django_app -from .flask import make_app as flask_make_app -from .quart import make_app as quart_make_app -from .starlette import make_app as starlette_make_app - - -def django_make_app() -> App: - return django_app +from ..django_uapi_app.views import App -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_header(app_factory: Callable[[], App]) -> None: - app = app_factory() +def test_header(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/header"] @@ -53,19 +28,7 @@ def test_header(app_factory: Callable[[], App]) -> None: assert schema.type == Schema.Type.STRING -@pytest.mark.parametrize( - "app_factory", - [ - aiohttp_make_app, - flask_make_app, - quart_make_app, - starlette_make_app, - django_make_app, - ], - ids=["aiohttp", "flask", "quart", "starlette", "django"], -) -def test_default_header(app_factory: Callable[[], App]) -> None: - app = app_factory() +def test_default_header(app: App) -> None: spec: OpenAPI = app.make_openapi_spec() op = spec.paths["/header-default"] diff --git a/tests/test_openapi_metadata.py b/tests/openapi/test_openapi_metadata.py similarity index 100% rename from tests/test_openapi_metadata.py rename to tests/openapi/test_openapi_metadata.py diff --git a/tests/test_openapi_uis.py b/tests/openapi/test_openapi_uis.py similarity index 96% rename from tests/test_openapi_uis.py rename to tests/openapi/test_openapi_uis.py index 29e1b7c..905400a 100644 --- a/tests/test_openapi_uis.py +++ b/tests/openapi/test_openapi_uis.py @@ -6,7 +6,7 @@ from uapi.flask import App -from .flask import run_on_flask +from ..flask import run_on_flask @pytest.fixture(scope="session") diff --git a/tests/quart.py b/tests/quart.py index 261aeda..a1e2ff8 100644 --- a/tests/quart.py +++ b/tests/quart.py @@ -1,6 +1,6 @@ from quart import Response, request -from uapi import ResponseException +from uapi import Method, ResponseException, RouteName from uapi.quart import App from uapi.status import NoContent @@ -53,8 +53,20 @@ async def query_default(page: int = 0) -> Response: async def post_no_body() -> Response: return Response("post", status=201) + # Route name composition. + @app.get("/comp/route-name-native") + @app.post("/comp/route-name-native", name="route-name-native-post") + def route_name_native(route_name: RouteName) -> Response: + return Response(route_name) + + # Request method composition. + @app.get("/comp/req-method-native") + @app.post("/comp/req-method-native", name="request-method-native-post") + def request_method_native(req_method: Method) -> Response: + return Response(req_method) + return app async def run_on_quart(app: App, port: int) -> None: - await app.run(__name__, port, handle_signals=False) + await app.run(__name__, port, handle_signals=False, log_level="critical") diff --git a/tests/starlette.py b/tests/starlette.py index 39eb723..cdf6e45 100644 --- a/tests/starlette.py +++ b/tests/starlette.py @@ -1,7 +1,7 @@ from starlette.requests import Request from starlette.responses import PlainTextResponse, Response -from uapi import ResponseException +from uapi import Method, ResponseException, RouteName from uapi.starlette import App from uapi.status import NoContent @@ -53,6 +53,18 @@ async def query_default(page: int = 0) -> Response: async def post_path_string(path_id: str) -> str: return str(int(path_id) + 2) + # Route name composition. + @app.get("/comp/route-name-native") + @app.post("/comp/route-name-native", name="route-name-native-post") + def route_name_native(route_name: RouteName) -> Response: + return Response(route_name) + + # Request method composition. + @app.get("/comp/req-method-native") + @app.post("/comp/req-method-native", name="request-method-native-post") + def request_method_native(req_method: Method) -> Response: + return Response(req_method) + return app diff --git a/tests/test_composition.py b/tests/test_composition.py new file mode 100644 index 0000000..24d2644 --- /dev/null +++ b/tests/test_composition.py @@ -0,0 +1,32 @@ +"""Test the composition context.""" +from httpx import AsyncClient + + +async def test_route_name(server: int): + async with AsyncClient() as client: + resp = await client.get(f"http://localhost:{server}/comp/route-name") + assert (await resp.aread()) == b"route_name" + + resp = await client.get(f"http://localhost:{server}/comp/route-name-native") + assert (await resp.aread()) == b"route_name_native" + + resp = await client.post(f"http://localhost:{server}/comp/route-name") + assert (await resp.aread()) == b"route-name-post" + + resp = await client.post(f"http://localhost:{server}/comp/route-name-native") + assert (await resp.aread()) == b"route-name-native-post" + + +async def test_request_method(server: int): + async with AsyncClient() as client: + resp = await client.get(f"http://localhost:{server}/comp/req-method") + assert (await resp.aread()) == b"GET" + + resp = await client.post(f"http://localhost:{server}/comp/req-method") + assert (await resp.aread()) == b"POST" + + resp = await client.get(f"http://localhost:{server}/comp/req-method-native") + assert (await resp.aread()) == b"GET" + + resp = await client.post(f"http://localhost:{server}/comp/req-method-native") + assert (await resp.aread()) == b"POST" diff --git a/tests/test_cookies.py b/tests/test_cookies.py new file mode 100644 index 0000000..28828e1 --- /dev/null +++ b/tests/test_cookies.py @@ -0,0 +1,25 @@ +from httpx import AsyncClient + + +async def test_put_cookie(server: int): + """Cookies work (and on a PUT request).""" + async with AsyncClient() as client: + resp = await client.put( + f"http://localhost:{server}/put/cookie", cookies={"a_cookie": "test"} + ) + assert resp.status_code == 200 + assert resp.text == "test" + + +async def test_put_cookie_optional(server): + """Optional cookies work.""" + async with AsyncClient() as client: + resp = await client.put(f"http://localhost:{server}/put/cookie-optional") + assert resp.status_code == 200 + assert resp.text == "missing" + resp = await client.put( + f"http://localhost:{server}/put/cookie-optional", + cookies={"A-COOKIE": "cookie"}, + ) + assert resp.status_code == 200 + assert resp.text == "cookie" diff --git a/tests/test_get.py b/tests/test_get.py index 2d366ff..78a9700 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -9,14 +9,6 @@ async def test_index(server): assert resp.headers["content-type"] == "text/plain" -async def test_path_parameter(server): - """Test path parameter handling.""" - async with AsyncClient() as client: - resp = await client.get(f"http://localhost:{server}/path/15") - assert resp.status_code == 200 - assert resp.text == "16" - - async def test_query_parameter_unannotated(server): """Test query parameter handling for unannotated parameters.""" async with AsyncClient() as client: diff --git a/tests/test_path.py b/tests/test_path.py new file mode 100644 index 0000000..362f609 --- /dev/null +++ b/tests/test_path.py @@ -0,0 +1,18 @@ +"""Tests for path parameters.""" +from httpx import AsyncClient + + +async def test_path_parameter(server): + """Test path parameter handling.""" + async with AsyncClient() as client: + resp = await client.get(f"http://localhost:{server}/path/15") + assert resp.status_code == 200 + assert resp.text == "16" + + +async def test_path_string(server): + """Posting to a path URL which returns a string.""" + async with AsyncClient() as client: + resp = await client.post(f"http://localhost:{server}/path1/20") + assert resp.status_code == 200 + assert resp.text == "22" diff --git a/tests/test_post.py b/tests/test_post.py index c5f2de9..5eb0631 100644 --- a/tests/test_post.py +++ b/tests/test_post.py @@ -27,11 +27,3 @@ async def test_multiple(server): resp = await client.post(f"http://localhost:{server}/post/multiple") assert resp.status_code == 201 assert resp.read() == b"" - - -async def test_path_string(server): - """Posting to a path URL which returns a string.""" - async with AsyncClient() as client: - resp = await client.post(f"http://localhost:{server}/path1/20") - assert resp.status_code == 200 - assert resp.text == "22" diff --git a/tests/test_put.py b/tests/test_put.py index d56b44d..f8464bb 100644 --- a/tests/test_put.py +++ b/tests/test_put.py @@ -1,28 +1,6 @@ from httpx import AsyncClient -async def test_put_cookie(server): - async with AsyncClient() as client: - resp = await client.put( - f"http://localhost:{server}/put/cookie", cookies={"a_cookie": "test"} - ) - assert resp.status_code == 200 - assert resp.text == "test" - - -async def test_put_cookie_optional(server): - async with AsyncClient() as client: - resp = await client.put(f"http://localhost:{server}/put/cookie-optional") - assert resp.status_code == 200 - assert resp.text == "missing" - resp = await client.put( - f"http://localhost:{server}/put/cookie-optional", - cookies={"A-COOKIE": "cookie"}, - ) - assert resp.status_code == 200 - assert resp.text == "cookie" - - async def test_put_custom_loader(server: int) -> None: async with AsyncClient() as client: resp = await client.put(