Skip to content

Commit

Permalink
Composition docs and route name (#52)
Browse files Browse the repository at this point in the history
* Composition docs and route name

* Fix lint

* Simplify identity return path

* Mo betta tests

* Fix lint

* Fix Flask

* More docs, tests

* Finish up composition docs

* Cookie docs

* Flesh out svcs examples
  • Loading branch information
Tinche committed Nov 10, 2023
1 parent 861097f commit 3bfa2a3
Show file tree
Hide file tree
Showing 39 changed files with 912 additions and 935 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@
[![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.

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:

Expand Down
2 changes: 1 addition & 1 deletion docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
195 changes: 195 additions & 0 deletions docs/composition.md
Original file line number Diff line number Diff line change
@@ -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 <uapi.types.RouteName>`, which is a string-based NewType.
- The request HTTP method will be provided if a parameter is annotated as {class}`uapi.Method <uapi.types.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 <uapi.base.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).
```
59 changes: 54 additions & 5 deletions docs/handlers.md
Original file line number Diff line number Diff line change
@@ -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() <uapi.base.App.route>`, or helper decorators like {py:meth}`App.get() <uapi.base.App.get>` and {py:meth}`App.post() <uapi.base.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("/")
Expand Down Expand Up @@ -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] <uapi.requests.Header>`.

```{tip}
```{note}
Technically, HTTP requests may contain several headers of the same name.
All underlying frameworks return the *first* value encountered.
```
Expand All @@ -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] <uapi.requests.Header>` 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:

Expand Down Expand Up @@ -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 <uapi.cookies.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.
Expand Down
5 changes: 3 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
self
handlers.md
composition.md
openapi.md
addons.md
changelog.md
Expand All @@ -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**.
Expand Down
3 changes: 3 additions & 0 deletions src/uapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -12,6 +13,8 @@
"ReqBody",
"ReqBytes",
"ResponseException",
"RouteName",
"Method",
]


Expand Down
Loading

0 comments on commit 3bfa2a3

Please sign in to comment.