Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
runs-on: "ubuntu-latest"

strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
redis-version: [6]
fail-fast: false

steps:
- uses: "actions/checkout@v4"
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The **third number** is for emergencies when we need to start branches for older
([#58](https://github.com/Tinche/uapi/pull/58))
- Dictionaries are now supported in the OpenAPI schema, rendering to object schemas with `additionalProperties`.
([#58](https://github.com/Tinche/uapi/pull/58))
- Multiple query parameters can now be received by annotating a parameter with `list` or `Sequence`.
([#68](https://github.com/Tinche/uapi/pull/68))
- {meth}`uapi.flask.FlaskApp.run`, {meth}`uapi.quart.QuartApp.run` and {meth}`uapi.starlette.StarletteApp.run` now expose `host` parameters.
([#59](https://github.com/Tinche/uapi/pull/59))
- _uapi_ is now tested against Python 3.13 and 3.14.
Expand Down
31 changes: 31 additions & 0 deletions docs/handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ To receive query parameters, annotate a handler parameter with any type that has
The {class}`App <uapi.base.App>`'s dependency injection system is configured to fulfill handler parameters from query parameters by default; directly when annotated as strings or Any or through the App's converter if any other type.
Query parameters may have default values.

```{note}
Technically, HTTP requests may contain multiple query parameters with the same name.
Unless the parameter is annotated as a list or sequence, all underlying frameworks return the *first* value encountered, except Django; it returns the last.
```

Query params will be present in the [OpenAPI schema](openapi.md); parameters with defaults will be rendered as `required=False`.

```python
Expand All @@ -58,6 +63,32 @@ async def query_handler(string_query: str, int_query: int = 0) -> None:
return
```

When a required query parameter is not provided, the result depends on the underlying framework used:

* Starlette, aiohttp and Django return a `500 Internal Server Error`.
* Quart and Flask return a `400 Bad Request` error.

#### Multiple Query Parameters

To receive multiple query parameters, annotate a handler parameter with `list[T]` or [`Sequence[T]`](https://docs.python.org/3/library/collections.abc.html#collections.abc.Sequence).
When `list[str]` is used, the underlying framework's result will be directly returned;
otherwise the result will be structured into the parameter type by the App converter.
Because the underlying frameworks generally support only basic parsing of query parameters, this is usually only useful with simple types, like `list[int]` or `Sequence[int]`.

```python
@app.get("/query_handler")
async def query_handler(string_query: list[str]) -> None:
# `string_query` can be provided multiple times.
return
```

```{note}
A multiple query parameter without a default value will be marked as `required` in the OpenAPI schema
even though technically it is not.
This is done mostly for consistency.
Assign a default value to make it non-required.
```

### Path Parameters

One of the simplest ways of getting data into a handler is by using _path parameters_.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"cattrs >= 23.2.2",
"cattrs>=25.3.0",
"incant >= 23.2.0",
"itsdangerous",
"attrs >= 23.1.0",
Expand Down
5 changes: 4 additions & 1 deletion src/uapi/_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,17 @@ def build_operation(
builder.get_schema_for_type(form_type)
)
else:
# Query params
if is_union_type(arg_type):
refs: list[Reference | Schema | IntegerSchema] = []
for union_member in arg_type.__args__:
if union_member is NoneType:
refs.append(Schema(Schema.Type.NULL))
elif union_member in builder.PYTHON_PRIMITIVES_TO_OPENAPI:
refs.append(builder.PYTHON_PRIMITIVES_TO_OPENAPI[union_member])
param_schema: OneOfSchema | Schema | IntegerSchema = OneOfSchema(refs)
param_schema: AnySchema | Reference = OneOfSchema(refs)
elif getattr(arg_type, "__origin__", None) in (list, Sequence):
param_schema = builder.get_schema_for_type(arg_type)
else:
param_schema = builder.PYTHON_PRIMITIVES_TO_OPENAPI.get(
arg_param.annotation, builder.PYTHON_PRIMITIVES_TO_OPENAPI[str]
Expand Down
44 changes: 43 additions & 1 deletion src/uapi/aiohttp.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from asyncio import sleep
from collections.abc import Callable, Coroutine
from collections.abc import Callable, Coroutine, Sequence
from functools import partial
from inspect import Parameter, Signature, signature
from logging import Logger
Expand Down Expand Up @@ -248,6 +248,48 @@ def read_query(_request: FrameworkRequest):
res.register_hook_factory(
lambda p: p.annotation in (Signature.empty, str), string_query_factory
)

def nonstring_list_query_factory(
p: Parameter,
) -> Callable[[FrameworkRequest], list]:
def read_query(_request: FrameworkRequest):
return (
converter.structure(_request.query.getall(p.name), p.annotation)
if p.default is Signature.empty
else (
converter.structure(_request.query.getall(p.name), p.annotation)
if p.name in _request.query
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: getattr(p.annotation, "__origin__", None) in (list, Sequence),
nonstring_list_query_factory,
)

def string_list_query_factory(
p: Parameter,
) -> Callable[[FrameworkRequest], list[str]]:
def read_query(_request: FrameworkRequest):
return (
_request.query.getall(p.name)
if p.default is Signature.empty
else (
_request.query.getall(p.name)
if p.name in _request.query
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: p.annotation == list[str], string_list_query_factory
)

res.register_hook_factory(
is_header,
lambda p: _make_header_dependency(
Expand Down
39 changes: 38 additions & 1 deletion src/uapi/django.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from collections.abc import Callable
from collections.abc import Callable, Sequence
from functools import partial
from inspect import Parameter, Signature, signature
from typing import Any, ClassVar, Generic, TypeAlias, TypeVar
Expand Down Expand Up @@ -255,6 +255,43 @@ def read_query(_request: FrameworkRequest) -> str:
lambda p: p.annotation in (Signature.empty, str), string_query_factory
)

def nonstring_list_query_factory(
p: Parameter,
) -> Callable[[FrameworkRequest], Sequence]:
def read_query(_request: FrameworkRequest):
return (
converter.structure(_request.GET.getlist(p.name), p.annotation)
if p.default is Signature.empty
else (
converter.structure(_request.GET.getlist(p.name), p.annotation)
if p.name in _request.GET
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: getattr(p.annotation, "__origin__", None) in (list, Sequence),
nonstring_list_query_factory,
)

def string_list_query_factory(
p: Parameter,
) -> Callable[[FrameworkRequest], list[str]]:
def read_query(_request: FrameworkRequest) -> list[str]:
return (
_request.GET.getlist(p.name)
if p.default is Signature.empty
else _request.GET.getlist(p.name, p.default)
)

return read_query

res.register_hook_factory(
lambda p: p.annotation == list[str], string_list_query_factory
)

res.register_hook_factory(
is_header,
lambda p: _make_header_dependency(
Expand Down
42 changes: 40 additions & 2 deletions src/uapi/flask.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from collections.abc import Callable
from collections.abc import Callable, Sequence
from functools import partial
from inspect import Signature, signature
from inspect import Parameter, Signature, signature
from typing import Any, ClassVar, Generic, TypeAlias, TypeVar

from attrs import Factory, define
Expand Down Expand Up @@ -178,6 +178,44 @@ def _make_flask_incanter(converter: Converter) -> Incanter:
else request.args.get(p.name, p.default)
),
)

def nonstring_list_query_factory(p: Parameter) -> Callable[[], Sequence]:
def read_query():
return (
converter.structure(request.args.getlist(p.name), p.annotation)
if p.default is Signature.empty
else (
converter.structure(request.args.getlist(p.name), p.annotation)
if p.name in request.args
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: getattr(p.annotation, "__origin__", None) in (list, Sequence),
nonstring_list_query_factory,
)

def string_list_query_factory(p: Parameter) -> Callable[[], list[str]]:
def read_query() -> list[str]:
return (
request.args.getlist(p.name)
if p.default is Signature.empty
else (
request.args.getlist(p.name)
if p.name in request.args
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: p.annotation == list[str], string_list_query_factory
)

res.register_hook_factory(
is_header,
lambda p: _make_header_dependency(
Expand Down
6 changes: 1 addition & 5 deletions src/uapi/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from contextlib import suppress
from datetime import date, datetime
from enum import Enum, unique
from typing import Any, ClassVar, Literal, TypeAlias
Expand Down Expand Up @@ -218,10 +217,7 @@ def get_schema_for_type(
return ArraySchema(inner)
raise Exception("Nested arrays are unsupported")

mapping = False
# TODO: remove this when cattrs 24.1 releases
with suppress(TypeError):
mapping = is_mapping(type)
mapping = is_mapping(type)
if mapping:
# Dicts also get created inline.
args = get_args(type)
Expand Down
42 changes: 40 additions & 2 deletions src/uapi/quart.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from asyncio import create_task, sleep
from collections.abc import Callable, Coroutine, Generator
from collections.abc import Callable, Coroutine, Generator, Sequence
from contextlib import contextmanager, suppress
from functools import partial
from inspect import Signature, signature
from inspect import Parameter, Signature, signature
from typing import Any, ClassVar, Generic, TypeAlias, TypeVar

from attrs import Factory, define
Expand Down Expand Up @@ -237,6 +237,44 @@ def _make_quart_incanter(converter: Converter) -> Incanter:
else request.args.get(p.name, p.default)
),
)

def nonstring_list_query_factory(p: Parameter) -> Callable[[], Sequence]:
def read_query():
return (
converter.structure(request.args.getlist(p.name), p.annotation)
if p.default is Signature.empty
else (
converter.structure(request.args.getlist(p.name), p.annotation)
if p.name in request.args
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: getattr(p.annotation, "__origin__", None) in (list, Sequence),
nonstring_list_query_factory,
)

def string_list_query_factory(p: Parameter) -> Callable[[], list[str]]:
def read_query() -> list[str]:
return (
request.args.getlist(p.name)
if p.default is Signature.empty
else (
request.args.getlist(p.name)
if p.name in request.args
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: p.annotation == list[str], string_list_query_factory
)

res.register_hook_factory(
is_header,
lambda p: _make_header_dependency(
Expand Down
46 changes: 45 additions & 1 deletion src/uapi/starlette.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from asyncio import create_task, sleep
from collections.abc import Callable, Coroutine, Generator
from collections.abc import Callable, Coroutine, Generator, Sequence
from contextlib import contextmanager, suppress
from functools import partial
from inspect import Parameter, Signature, signature
Expand Down Expand Up @@ -261,6 +261,50 @@ def read_query(_request: FrameworkRequest) -> Any:
res.register_hook_factory(
lambda p: p.annotation in (Signature.empty, str), string_query_factory
)

def nonstring_list_query_factory(
p: Parameter,
) -> Callable[[FrameworkRequest], list]:
def read_query(_request: FrameworkRequest):
return (
converter.structure(_request.query_params.getlist(p.name), p.annotation)
if p.default is Signature.empty
else (
converter.structure(
_request.query_params.getlist(p.name), p.annotation
)
if p.name in _request.query_params
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: getattr(p.annotation, "__origin__", None) in (list, Sequence),
nonstring_list_query_factory,
)

def string_list_query_factory(
p: Parameter,
) -> Callable[[FrameworkRequest], list[str]]:
def read_query(_request: FrameworkRequest):
return (
_request.query_params.getlist(p.name)
if p.default is Signature.empty
else (
_request.query_params.getlist(p.name)
if p.name in _request.query_params
else p.default
)
)

return read_query

res.register_hook_factory(
lambda p: p.annotation == list[str], string_list_query_factory
)

res.register_hook_factory(
is_header,
lambda p: _make_header_dependency(
Expand Down
Loading