Merge branch 'master-mj' into task/add-freeform-queries-110

This commit is contained in:
JONEMI19 2024-02-29 08:07:10 +00:00
commit 04ba0b302b
12 changed files with 489 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

View File

@ -225,3 +225,45 @@ In this case, there are 3 query parameters:
!!! tip
You could also use `Enum`s the same way as with [Path Parameters](path-params.md#predefined-values){.internal-link target=_blank}.
## Free Form Query Parameters
Sometimes you want to receive some query parameters, but you don't know in advance what they are called. **FastAPI** provides support for this use case.
=== "Python 3.10+"
```Python hl_lines="8"
{!> ../../../docs_src/query_params/tutorial007_py310.py!}
```
And when you open your browser at <a href="http://127.0.0.1:8000/docs" class="external-link" target="_blank">http://127.0.0.1:8000/docs</a>, you will that OpenAPI supports this format of query parameter:
<img src="/img/tutorial/path-params/image01.png">
However, since the query parameters are declared in the request as
```
http://127.0.0.1:8000/query/mixed-type-params?query=1&foo=bar&foo=baz
```
**FastAPI** greedily adds all the query parameters to every `Query` argument for which it is valid. The above request will be parsed as:
```Python
{
"query": 1,
"string_mapping": {
"query": "1",
"foo": "baz"
},
"mapping_query_int": {
"query": 1
},
"sequence_mapping_queries": {
"query": [
"1"
],
"foo": []
}
}
```
As you can see the `query` parameter is added to every `Query` argument for which it is valid. This is because **FastAPI** does not know which `Query` argument you want to add the `query` parameter to, and `1` validates as both an `int` and a `str`. `List[str]`. `foo` is only added to the `string_mapping` and `sequence_mapping_queries` arguments because it is not a valid `int`.

View File

@ -0,0 +1,20 @@
from typing import List, Mapping
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/query/mixed-type-params")
def get_mixed_mapping_mixed_type_query_params(
query: int = Query(),
mapping_query_str: Mapping[str, str] = Query({}),
mapping_query_int: Mapping[str, int] = Query({}),
sequence_mapping_queries: Mapping[str, List[int]] = Query({}),
):
return {
"query": query,
"string_mapping": mapping_query_str,
"mapping_query_int": mapping_query_int,
"sequence_mapping_queries": sequence_mapping_queries,
}

View File

@ -18,7 +18,7 @@ from typing import (
)
from fastapi.exceptions import RequestErrorModel
from fastapi.types import IncEx, ModelNameMap, UnionType
from fastapi.types import IncEx, ModelNameMap, UnionType, FFQuery
from pydantic import BaseModel, create_model
from pydantic.version import VERSION as PYDANTIC_VERSION
from starlette.datastructures import UploadFile
@ -43,6 +43,13 @@ sequence_annotation_to_type = {
sequence_types = tuple(sequence_annotation_to_type.keys())
mapping_annotation_to_type = {
FFQuery: list,
}
mapping_types = tuple(mapping_annotation_to_type.keys())
if PYDANTIC_V2:
from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError
from pydantic import TypeAdapter
@ -242,6 +249,12 @@ if PYDANTIC_V2:
def is_scalar_sequence_field(field: ModelField) -> bool:
return field_annotation_is_scalar_sequence(field.field_info.annotation)
def is_scalar_sequence_mapping_field(field: ModelField) -> bool:
return field_annotation_is_scalar_sequence_mapping(field.field_info.annotation)
def is_scalar_mapping_field(field: ModelField) -> bool:
return field_annotation_is_scalar_mapping(field.field_info.annotation)
def is_bytes_field(field: ModelField) -> bool:
return is_bytes_or_nonable_bytes_annotation(field.type_)
@ -294,6 +307,7 @@ else:
from pydantic.fields import ( # type: ignore[attr-defined]
SHAPE_FROZENSET,
SHAPE_LIST,
SHAPE_MAPPING,
SHAPE_SEQUENCE,
SHAPE_SET,
SHAPE_SINGLETON,
@ -341,6 +355,7 @@ else:
SHAPE_SEQUENCE,
SHAPE_TUPLE_ELLIPSIS,
}
sequence_shape_to_type = {
SHAPE_LIST: list,
SHAPE_SET: set,
@ -349,6 +364,11 @@ else:
SHAPE_TUPLE_ELLIPSIS: list,
}
mapping_shapes = {
SHAPE_MAPPING,
}
mapping_shapes_to_type = {SHAPE_MAPPING: FFQuery}
@dataclass
class GenerateJsonSchema: # type: ignore[no-redef]
ref_template: str
@ -416,6 +436,28 @@ else:
return True
return False
def is_pv1_scalar_mapping_field(field: ModelField) -> bool:
if (field.shape in mapping_shapes) and not lenient_issubclass( # type: ignore[attr-defined]
field.type_, BaseModel
):
if field.sub_fields is not None: # type: ignore[attr-defined]
for sub_field in field.sub_fields: # type: ignore[attr-defined]
if not is_scalar_field(sub_field):
return False
return True
return False
def is_pv1_scalar_sequence_mapping_field(field: ModelField) -> bool:
if (field.shape in mapping_shapes) and not lenient_issubclass( # type: ignore[attr-defined]
field.type_, BaseModel
):
if field.sub_fields is not None: # type: ignore[attr-defined]
for sub_field in field.sub_fields: # type: ignore[attr-defined]
if not is_scalar_sequence_field(sub_field):
return False
return True
return False
def _normalize_errors(errors: Sequence[Any]) -> List[Dict[str, Any]]:
use_errors: List[Any] = []
for error in errors:
@ -486,6 +528,12 @@ else:
def is_scalar_sequence_field(field: ModelField) -> bool:
return is_pv1_scalar_sequence_field(field)
def is_scalar_sequence_mapping_field(field: ModelField) -> bool:
return is_pv1_scalar_sequence_mapping_field(field)
def is_scalar_mapping_field(field: ModelField) -> bool:
return is_pv1_scalar_mapping_field(field)
def is_bytes_field(field: ModelField) -> bool:
return lenient_issubclass(field.type_, bytes)
@ -535,14 +583,27 @@ def field_annotation_is_sequence(annotation: Union[Type[Any], None]) -> bool:
)
def _annotation_is_mapping(annotation: Union[Type[Any], None]) -> bool:
if lenient_issubclass(annotation, (str, bytes)):
return False
return lenient_issubclass(annotation, mapping_types)
def field_annotation_is_mapping(annotation: Union[Type[Any], None]) -> bool:
return _annotation_is_mapping(annotation) or _annotation_is_mapping(
get_origin(annotation)
)
def value_is_sequence(value: Any) -> bool:
return isinstance(value, sequence_types) and not isinstance(value, (str, bytes)) # type: ignore[arg-type]
def _annotation_is_complex(annotation: Union[Type[Any], None]) -> bool:
return (
lenient_issubclass(annotation, (BaseModel, Mapping, UploadFile))
lenient_issubclass(annotation, (BaseModel, UploadFile))
or _annotation_is_sequence(annotation)
or _annotation_is_mapping(annotation)
or is_dataclass(annotation)
)
@ -573,8 +634,6 @@ def field_annotation_is_scalar_sequence(annotation: Union[Type[Any], None]) -> b
if field_annotation_is_scalar_sequence(arg):
at_least_one_scalar_sequence = True
continue
elif not field_annotation_is_scalar(arg):
return False
return at_least_one_scalar_sequence
return field_annotation_is_sequence(annotation) and all(
field_annotation_is_scalar(sub_annotation)
@ -582,6 +641,22 @@ def field_annotation_is_scalar_sequence(annotation: Union[Type[Any], None]) -> b
)
def field_annotation_is_scalar_mapping(annotation: Union[Type[Any], None]) -> bool:
return field_annotation_is_mapping(annotation) and all(
field_annotation_is_scalar(sub_annotation)
for sub_annotation in get_args(annotation)
)
def field_annotation_is_scalar_sequence_mapping(
annotation: Union[Type[Any], None]
) -> bool:
return field_annotation_is_mapping(annotation) and all(
field_annotation_is_scalar_sequence(sub_annotation)
for sub_annotation in get_args(annotation)[1:]
)
def is_bytes_or_nonable_bytes_annotation(annotation: Any) -> bool:
if lenient_issubclass(annotation, bytes):
return True

View File

@ -1,4 +1,5 @@
import inspect
from collections import defaultdict
from contextlib import AsyncExitStack, contextmanager
from copy import deepcopy
from typing import (
@ -35,7 +36,9 @@ from fastapi._compat import (
is_bytes_field,
is_bytes_sequence_field,
is_scalar_field,
is_scalar_mapping_field,
is_scalar_sequence_field,
is_scalar_sequence_mapping_field,
is_sequence_field,
is_uploadfile_or_nonable_uploadfile_annotation,
is_uploadfile_sequence_annotation,
@ -463,6 +466,11 @@ def is_body_param(*, param_field: ModelField, is_path_param: bool) -> bool:
param_field.field_info, (params.Query, params.Header)
) and is_scalar_sequence_field(param_field):
return False
elif isinstance(param_field.field_info, params.Query) and (
is_scalar_sequence_mapping_field(param_field)
or is_scalar_mapping_field(param_field)
):
return False
else:
assert isinstance(
param_field.field_info, params.Body
@ -647,6 +655,10 @@ async def solve_dependencies(
return values, errors, background_tasks, response, dependency_cache
class Marker:
pass
def request_params_to_args(
required_params: Sequence[ModelField],
received_params: Union[Mapping[str, Any], QueryParams, Headers],
@ -658,6 +670,16 @@ def request_params_to_args(
received_params, (QueryParams, Headers)
):
value = received_params.getlist(field.alias) or field.default
elif is_scalar_mapping_field(field) and isinstance(
received_params, QueryParams
):
value = dict(received_params.multi_items()) or field.default
elif is_scalar_sequence_mapping_field(field) and isinstance(
received_params, QueryParams
):
value = defaultdict(list)
for k, v in received_params.multi_items():
value[k].append(v)
else:
value = received_params.get(field.alias)
field_info = field.field_info
@ -674,6 +696,31 @@ def request_params_to_args(
v_, errors_ = field.validate(value, values, loc=loc)
if isinstance(errors_, ErrorWrapper):
errors.append(errors_)
elif (
isinstance(errors_, list)
and is_scalar_sequence_mapping_field(field)
and isinstance(received_params, QueryParams)
):
new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
# remove all invalid parameters
marker = Marker()
for _, _, key, index in [error["loc"] for error in new_errors]:
value[key][index] = marker
for key in value:
value[key] = [x for x in value[key] if x != marker]
v_, _ = field.validate(value, values, loc=loc)
values[field.name] = v_
elif (
isinstance(errors_, list)
and is_scalar_mapping_field(field)
and isinstance(received_params, QueryParams)
):
new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
# remove all invalid parameters
for _, _, key in [error["loc"] for error in new_errors]:
value.pop(key)
v_, _ = field.validate(value, values, loc=loc)
values[field.name] = v_
elif isinstance(errors_, list):
new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
errors.extend(new_errors)

View File

@ -8,3 +8,4 @@ DecoratedCallable = TypeVar("DecoratedCallable", bound=Callable[..., Any])
UnionType = getattr(types, "UnionType", Union)
ModelNameMap = Dict[Union[Type[BaseModel], Type[Enum]], str]
IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]]
FFQuery = Dict[str, Union[str, IncEx]]

View File

@ -1,7 +1,8 @@
import http
from typing import FrozenSet, Optional
from typing import FrozenSet, List, Mapping, Optional, Union
from fastapi import FastAPI, Path, Query
from fastapi.types import FFQuery
app = FastAPI()
@ -184,6 +185,38 @@ def get_query_param_required_type(query: int = Query()):
return f"foo bar {query}"
@app.get("/query/mapping-params")
def get_mapping_query_params(queries: FFQuery[str, str] = Query({})):
return f"foo bar {queries['foo']} {queries['bar']}"
@app.get("/query/mapping-sequence-params")
def get_sequence_mapping_query_params(queries: FFQuery[str, List[int]] = Query({})):
return f"foo bar {dict(queries)}"
@app.get("/query/mixed-params")
def get_mixed_mapping_query_params(
sequence_mapping_queries: FFQuery[str, List[Union[str, int]]] = Query({}),
mapping_query: FFQuery[str, str] = Query(),
query: str = Query(),
):
return (
f"foo bar {sequence_mapping_queries['foo'][0]} {sequence_mapping_queries['foo'][1]} "
f"{mapping_query['foo']} {mapping_query['bar']} {query}"
)
@app.get("/query/mixed-type-params")
def get_mixed_mapping_mixed_type_query_params(
sequence_mapping_queries: FFQuery[str, List[int]] = Query({}),
mapping_query_str: FFQuery[str, str] = Query({}),
mapping_query_int: FFQuery[str, int] = Query({}),
query: int = Query(),
):
return f"foo bar {query} {mapping_query_str} {mapping_query_int} {dict(sequence_mapping_queries)}"
@app.get("/enum-status-code", status_code=http.HTTPStatus.CREATED)
def get_enum_status_code():
return "foo bar"

View File

@ -1163,6 +1163,204 @@ def test_openapi_schema():
},
}
},
"/query/mapping-params": {
"get": {
"summary": "Get Mapping Query Params",
"operationId": "get_mapping_query_params_query_mapping_params_get",
"parameters": [
{
"required": False,
"schema": {
"additionalProperties": {"type": "string"},
"type": "object",
"title": "Queries",
"default": {},
},
"name": "queries",
"in": "query",
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/query/mapping-sequence-params": {
"get": {
"summary": "Get Sequence Mapping Query Params",
"operationId": "get_sequence_mapping_query_params_query_mapping_sequence_params_get",
"parameters": [
{
"required": False,
"schema": {
"additionalProperties": {
"items": {"type": "integer"},
"type": "array",
},
"type": "object",
"title": "Queries",
"default": {},
},
"name": "queries",
"in": "query",
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/query/mixed-params": {
"get": {
"summary": "Get Mixed Mapping Query Params",
"operationId": "get_mixed_mapping_query_params_query_mixed_params_get",
"parameters": [
{
"required": False,
"schema": {
"additionalProperties": {
"items": {
"anyOf": [
{"type": "string"},
{"type": "integer"},
]
},
"type": "array",
},
"type": "object",
"title": "Sequence Mapping Queries",
"default": {},
},
"name": "sequence_mapping_queries",
"in": "query",
},
{
"required": True,
"schema": {
"additionalProperties": {"type": "string"},
"type": "object",
"title": "Mapping Query",
},
"name": "mapping_query",
"in": "query",
},
{
"required": True,
"schema": {"type": "string", "title": "Query"},
"name": "query",
"in": "query",
},
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
"/query/mixed-type-params": {
"get": {
"summary": "Get Mixed Mapping Mixed Type Query Params",
"operationId": "get_mixed_mapping_mixed_type_query_params_query_mixed_type_params_get",
"parameters": [
{
"required": False,
"schema": {
"additionalProperties": {
"items": {"type": "integer"},
"type": "array",
},
"type": "object",
"title": "Sequence Mapping Queries",
"default": {},
},
"name": "sequence_mapping_queries",
"in": "query",
},
{
"required": False,
"schema": {
"additionalProperties": {"type": "string"},
"type": "object",
"title": "Mapping Query Str",
"default": {},
},
"name": "mapping_query_str",
"in": "query",
},
{
"required": False,
"schema": {
"additionalProperties": {"type": "integer"},
"type": "object",
"title": "Mapping Query Int",
"default": {},
},
"name": "mapping_query_int",
"in": "query",
},
{
"required": True,
"schema": {"type": "integer", "title": "Query"},
"name": "query",
"in": "query",
},
],
"responses": {
"200": {
"description": "Successful Response",
"content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
},
},
}
},
},
"components": {
"schemas": {

View File

@ -0,0 +1,13 @@
from typing import List, Mapping
import pytest
from fastapi import FastAPI, Query
from fastapi.types import FFQuery
def test_invalid_sequence():
with pytest.raises(AssertionError):
app = FastAPI()
@app.get("/items/")
def read_items(q: FFQuery[str, List[List[str]]] = Query(default=None)):
pass # pragma: no cover

View File

@ -1,4 +1,4 @@
from typing import Dict, List, Optional, Tuple
from typing import Dict, List, Tuple
import pytest
from fastapi import FastAPI, Query
@ -39,15 +39,3 @@ def test_invalid_dict():
@app.get("/items/")
def read_items(q: Dict[str, Item] = Query(default=None)):
pass # pragma: no cover
def test_invalid_simple_dict():
with pytest.raises(AssertionError):
app = FastAPI()
class Item(BaseModel):
title: str
@app.get("/items/")
def read_items(q: Optional[dict] = Query(default=None)):
pass # pragma: no cover

View File

@ -408,3 +408,36 @@ def test_query_frozenset_query_1_query_1_query_2():
response = client.get("/query/frozenset/?query=1&query=1&query=2")
assert response.status_code == 200
assert response.json() == "1,2"
def test_mapping_query():
response = client.get("/query/mapping-params/?foo=fuzz&bar=buzz")
assert response.status_code == 200
assert response.json() == "foo bar fuzz buzz"
def test_mapping_with_non_mapping_query():
response = client.get("/query/mixed-params/?foo=fuzz&foo=baz&bar=buzz&query=fizz")
assert response.status_code == 200
assert response.json() == "foo bar fuzz baz baz buzz fizz"
def test_mapping_with_non_mapping_query_mixed_types():
response = client.get("/query/mixed-type-params/?foo=fuzz&foo=baz&bar=buzz&query=1")
assert response.status_code == 200
assert (
response.json()
== "foo bar 1 {'foo': 'baz', 'bar': 'buzz', 'query': '1'} {'query': 1} {'foo': [], 'bar': [], 'query': [1]}"
)
def test_sequence_mapping_query():
response = client.get("/query/mapping-sequence-params/?foo=1&foo=2")
assert response.status_code == 200
assert response.json() == "foo bar {'foo': [1, 2]}"
def test_sequence_mapping_query_drops_invalid():
response = client.get("/query/mapping-sequence-params/?foo=fuzz&foo=buzz")
assert response.status_code == 200
assert response.json() == "foo bar {'foo': []}"

View File

@ -0,0 +1,21 @@
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(name="client")
def get_client():
from docs_src.query_params.tutorial007_py310 import app
c = TestClient(app)
return c
def test_foo_needy_very(client: TestClient):
response = client.get("/query/mixed-type-params?query=1&query=2&foo=bar&foo=baz")
assert response.status_code == 200
assert response.json() == {
"query": 2,
"string_mapping": {"query": "2", "foo": "baz"},
"mapping_query_int": {"query": 2},
"sequence_mapping_queries": {"query": [1, 2], "foo": []},
}