mirror of https://github.com/tiangolo/fastapi.git
Merge 53f2a62071 into 5ca11c59e3
This commit is contained in:
commit
a2081e61d3
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
|
|
@ -186,3 +186,17 @@ In this case, there are 3 query parameters:
|
|||
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 { #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 as well.
|
||||
|
||||
=== "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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/query/mixed-type-params")
|
||||
def get_mixed_mapping_mixed_type_query_params(
|
||||
query: Annotated[int, Query()] = None,
|
||||
mapping_query_str: Annotated[dict[str, str], Query()] = None,
|
||||
mapping_query_int: Annotated[dict[str, int], Query()] = None,
|
||||
sequence_mapping_int: Annotated[dict[str, list[int]], Query()] = None,
|
||||
):
|
||||
return {
|
||||
"query": query,
|
||||
"mapping_query_str": mapping_query_str,
|
||||
"mapping_query_int": mapping_query_int,
|
||||
"sequence_mapping_int": sequence_mapping_int,
|
||||
}
|
||||
|
|
@ -2,6 +2,12 @@ from .shared import PYDANTIC_V2 as PYDANTIC_V2
|
|||
from .shared import PYDANTIC_VERSION_MINOR_TUPLE as PYDANTIC_VERSION_MINOR_TUPLE
|
||||
from .shared import annotation_is_pydantic_v1 as annotation_is_pydantic_v1
|
||||
from .shared import field_annotation_is_scalar as field_annotation_is_scalar
|
||||
from .shared import (
|
||||
field_annotation_is_scalar_mapping as field_annotation_is_scalar_mapping,
|
||||
)
|
||||
from .shared import (
|
||||
field_annotation_is_scalar_sequence_mapping as field_annotation_is_scalar_sequence_mapping,
|
||||
)
|
||||
from .shared import is_pydantic_v1_model_class as is_pydantic_v1_model_class
|
||||
from .shared import is_pydantic_v1_model_instance as is_pydantic_v1_model_instance
|
||||
from .shared import (
|
||||
|
|
@ -33,8 +39,11 @@ from .v2 import get_schema_from_model_field as get_schema_from_model_field
|
|||
from .v2 import is_bytes_field as is_bytes_field
|
||||
from .v2 import is_bytes_sequence_field as is_bytes_sequence_field
|
||||
from .v2 import is_scalar_field as is_scalar_field
|
||||
from .v2 import is_scalar_mapping_field as is_scalar_mapping_field
|
||||
from .v2 import is_scalar_sequence_field as is_scalar_sequence_field
|
||||
from .v2 import is_scalar_sequence_mapping_field as is_scalar_sequence_mapping_field
|
||||
from .v2 import is_sequence_field as is_sequence_field
|
||||
from .v2 import omit_by_default as omit_by_default
|
||||
from .v2 import serialize_sequence_value as serialize_sequence_value
|
||||
from .v2 import (
|
||||
with_info_plain_validator_function as with_info_plain_validator_function,
|
||||
|
|
|
|||
|
|
@ -125,6 +125,52 @@ def field_annotation_is_scalar_sequence(annotation: Union[type[Any], None]) -> b
|
|||
)
|
||||
|
||||
|
||||
def field_annotation_is_scalar_mapping(
|
||||
annotation: Union[type[Any], None],
|
||||
) -> bool:
|
||||
origin = get_origin(annotation)
|
||||
if origin is Annotated:
|
||||
return field_annotation_is_scalar_mapping(get_args(annotation)[0])
|
||||
if origin is Union or origin is UnionType:
|
||||
at_least_one_scalar_mapping = False
|
||||
for arg in get_args(annotation):
|
||||
if field_annotation_is_scalar_mapping(arg):
|
||||
at_least_one_scalar_mapping = True
|
||||
continue
|
||||
elif not field_annotation_is_scalar(arg):
|
||||
return False
|
||||
return at_least_one_scalar_mapping
|
||||
return lenient_issubclass(origin, Mapping) 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:
|
||||
origin = get_origin(annotation)
|
||||
if origin is Annotated:
|
||||
return field_annotation_is_scalar_sequence_mapping(get_args(annotation)[0])
|
||||
if origin is Union or origin is UnionType:
|
||||
at_least_one_scalar_mapping = False
|
||||
for arg in get_args(annotation):
|
||||
if field_annotation_is_scalar_sequence_mapping(arg):
|
||||
at_least_one_scalar_mapping = True
|
||||
continue
|
||||
elif not (
|
||||
field_annotation_is_scalar_sequence_mapping(arg)
|
||||
or field_annotation_is_scalar_mapping(arg)
|
||||
):
|
||||
return False
|
||||
return at_least_one_scalar_mapping
|
||||
return lenient_issubclass(origin, Mapping) and all(
|
||||
field_annotation_is_scalar_sequence(sub_annotation)
|
||||
or field_annotation_is_scalar(sub_annotation)
|
||||
for sub_annotation in get_args(annotation)
|
||||
)
|
||||
|
||||
|
||||
def is_bytes_or_nonable_bytes_annotation(annotation: Any) -> bool:
|
||||
if lenient_issubclass(annotation, bytes):
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -5,17 +5,20 @@ from copy import copy, deepcopy
|
|||
from dataclasses import dataclass, is_dataclass
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from typing import Annotated, Any, Callable, Union, cast
|
||||
|
||||
from fastapi._compat import shared
|
||||
from fastapi.openapi.constants import REF_TEMPLATE
|
||||
from fastapi.types import IncEx, ModelNameMap, UnionType
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
OnErrorOmit,
|
||||
TypeAdapter,
|
||||
WrapValidator,
|
||||
create_model,
|
||||
)
|
||||
from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError
|
||||
from pydantic import PydanticUndefinedAnnotation as PydanticUndefinedAnnotation
|
||||
from pydantic import ValidationError as ValidationError
|
||||
|
|
@ -411,6 +414,16 @@ def is_scalar_sequence_field(field: ModelField) -> bool:
|
|||
return shared.field_annotation_is_scalar_sequence(field.field_info.annotation)
|
||||
|
||||
|
||||
def is_scalar_mapping_field(field: ModelField) -> bool:
|
||||
return shared.field_annotation_is_scalar_mapping(field.field_info.annotation)
|
||||
|
||||
|
||||
def is_scalar_sequence_mapping_field(field: ModelField) -> bool:
|
||||
return shared.field_annotation_is_scalar_sequence_mapping(
|
||||
field.field_info.annotation
|
||||
)
|
||||
|
||||
|
||||
def is_bytes_field(field: ModelField) -> bool:
|
||||
return shared.is_bytes_or_nonable_bytes_annotation(field.type_)
|
||||
|
||||
|
|
@ -566,3 +579,83 @@ def _regenerate_error_with_loc(
|
|||
]
|
||||
|
||||
return updated_loc_errors
|
||||
|
||||
|
||||
if shared.PYDANTIC_VERSION_MINOR_TUPLE >= (2, 6):
|
||||
# Omit by default for scalar mapping and scalar sequence mapping annotations
|
||||
# added in Pydantic v2.6 https://github.com/pydantic/pydantic/releases/tag/v2.6.0
|
||||
def _omit_by_default(annotation: Any, depth: int = 0) -> Any:
|
||||
origin = get_origin(annotation)
|
||||
args = get_args(annotation)
|
||||
|
||||
if (origin is Union or origin is UnionType) and depth == 0:
|
||||
# making the depth check since the values of dicts being Union types
|
||||
# is not working as expected as of Pydantic v2.12.3 so we just omit at
|
||||
# the top level Union here for now
|
||||
# https://github.com/pydantic/pydantic-core/issues/1900
|
||||
# https://github.com/pydantic/pydantic/issues/12750
|
||||
return Union[tuple(_omit_by_default(arg) for arg in args)]
|
||||
elif origin is list:
|
||||
return list[_omit_by_default(args[0], depth=depth + 1)] # type: ignore[misc]
|
||||
elif origin is dict:
|
||||
return dict[args[0], _omit_by_default(args[1], depth=depth + 1)] # type: ignore[misc,valid-type]
|
||||
else:
|
||||
return OnErrorOmit[annotation] # type: ignore[misc]
|
||||
|
||||
def omit_by_default(
|
||||
field_info: FieldInfo,
|
||||
) -> tuple[FieldInfo, dict[str, Callable[..., Any]]]:
|
||||
new_annotation = _omit_by_default(field_info.annotation)
|
||||
new_field_info = copy_field_info(
|
||||
field_info=field_info, annotation=new_annotation
|
||||
)
|
||||
return new_field_info, {}
|
||||
|
||||
else: # pragma: no cover
|
||||
|
||||
def ignore_invalid(v: Any, handler: Callable[[Any], Any]) -> Any:
|
||||
try:
|
||||
return handler(v)
|
||||
except ValidationError as exc:
|
||||
# pop the keys or elements that caused the validation errors and revalidate
|
||||
for error in exc.errors():
|
||||
loc = error["loc"]
|
||||
if len(loc) == 0:
|
||||
continue
|
||||
if isinstance(loc[0], int) and isinstance(v, list):
|
||||
index = loc[0]
|
||||
if 0 <= index < len(v):
|
||||
v[index] = None
|
||||
|
||||
# Handle nested list validation errors (e.g., dict[str, list[str]])
|
||||
elif isinstance(loc[0], str) and isinstance(v, dict):
|
||||
key = loc[0]
|
||||
if (
|
||||
len(loc) > 1
|
||||
and isinstance(loc[1], int)
|
||||
and key in v
|
||||
and isinstance(v[key], list)
|
||||
):
|
||||
list_index = loc[1]
|
||||
v[key][list_index] = None
|
||||
elif key in v:
|
||||
v.pop(key)
|
||||
|
||||
if isinstance(v, list):
|
||||
v = [el for el in v if el is not None]
|
||||
|
||||
if isinstance(v, dict):
|
||||
for key in v.keys():
|
||||
if isinstance(v[key], list):
|
||||
v[key] = [el for el in v[key] if el is not None]
|
||||
|
||||
return handler(v)
|
||||
|
||||
def omit_by_default(
|
||||
field_info: FieldInfo,
|
||||
) -> tuple[FieldInfo, dict[str, Callable[..., Any]]]:
|
||||
"""add a wrap validator to omit invalid values by default."""
|
||||
field_info.metadata = field_info.metadata or [] + [
|
||||
WrapValidator(ignore_invalid)
|
||||
]
|
||||
return field_info, {}
|
||||
|
|
|
|||
|
|
@ -26,16 +26,21 @@ from fastapi._compat import (
|
|||
create_body_model,
|
||||
evaluate_forwardref,
|
||||
field_annotation_is_scalar,
|
||||
field_annotation_is_scalar_mapping,
|
||||
field_annotation_is_scalar_sequence_mapping,
|
||||
get_cached_model_fields,
|
||||
get_missing_field_error,
|
||||
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,
|
||||
lenient_issubclass,
|
||||
omit_by_default,
|
||||
sequence_types,
|
||||
serialize_sequence_value,
|
||||
value_is_sequence,
|
||||
|
|
@ -502,7 +507,17 @@ def analyze_param(
|
|||
alias = param_name.replace("_", "-")
|
||||
else:
|
||||
alias = field_info.alias or param_name
|
||||
|
||||
field_info.alias = alias
|
||||
|
||||
# Omit by default for scalar mapping and scalar sequence mapping query fields
|
||||
class_validators: dict[str, Callable[..., Any]] = {}
|
||||
if isinstance(field_info, params.Query) and (
|
||||
field_annotation_is_scalar_sequence_mapping(use_annotation_from_field_info)
|
||||
or field_annotation_is_scalar_mapping(use_annotation_from_field_info)
|
||||
):
|
||||
field_info, class_validators = omit_by_default(field_info)
|
||||
|
||||
field = create_model_field(
|
||||
name=param_name,
|
||||
type_=use_annotation_from_field_info,
|
||||
|
|
@ -510,6 +525,7 @@ def analyze_param(
|
|||
alias=alias,
|
||||
required=field_info.default in (RequiredParam, Undefined),
|
||||
field_info=field_info,
|
||||
class_validators=class_validators,
|
||||
)
|
||||
if is_path_param:
|
||||
assert is_scalar_field(field=field), (
|
||||
|
|
@ -519,6 +535,8 @@ def analyze_param(
|
|||
assert (
|
||||
is_scalar_field(field)
|
||||
or is_scalar_sequence_field(field)
|
||||
or is_scalar_mapping_field(field)
|
||||
or is_scalar_sequence_mapping_field(field)
|
||||
or (
|
||||
lenient_issubclass(field.type_, BaseModel)
|
||||
# For Pydantic v1
|
||||
|
|
@ -714,6 +732,7 @@ def _validate_value_with_model_field(
|
|||
else:
|
||||
return deepcopy(field.default), []
|
||||
v_, errors_ = field.validate(value, values, loc=loc)
|
||||
|
||||
if isinstance(errors_, list):
|
||||
new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
|
||||
return None, new_errors
|
||||
|
|
@ -725,10 +744,19 @@ def _get_multidict_value(
|
|||
field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None
|
||||
) -> Any:
|
||||
alias = alias or get_validation_alias(field)
|
||||
value: Any = None
|
||||
if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
|
||||
value = values.getlist(alias)
|
||||
else:
|
||||
value = values.get(alias, None)
|
||||
elif alias in values:
|
||||
value = values[alias]
|
||||
elif values and is_scalar_mapping_field(field) and isinstance(values, QueryParams):
|
||||
value = dict(values)
|
||||
elif (
|
||||
values
|
||||
and is_scalar_sequence_mapping_field(field)
|
||||
and isinstance(values, QueryParams)
|
||||
):
|
||||
value = {key: values.getlist(key) for key in values.keys()}
|
||||
if (
|
||||
value is None
|
||||
or (
|
||||
|
|
@ -825,6 +853,14 @@ def request_params_to_args(
|
|||
errors.extend(errors_)
|
||||
else:
|
||||
values[field.name] = v_
|
||||
# remove keys which were captured by a mapping query field but were
|
||||
# specified as individual fields
|
||||
for field in fields:
|
||||
if isinstance(values.get(field.name), dict) and (
|
||||
is_scalar_mapping_field(field) or is_scalar_sequence_mapping_field(field)
|
||||
):
|
||||
for f_ in fields:
|
||||
values[field.name].pop(f_.alias, None)
|
||||
return values, errors
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -189,6 +189,48 @@ 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: dict[str, str] = Query({})):
|
||||
return {"queries": queries}
|
||||
|
||||
|
||||
@app.get("/query/mixed-params")
|
||||
def get_mixed_mapping_query_params(
|
||||
sequence_mapping_queries: dict[str, list[int]] = Query({}),
|
||||
mapping_query: dict[str, str] = Query(),
|
||||
query: str = Query(),
|
||||
):
|
||||
return {
|
||||
"queries": {
|
||||
"query": query,
|
||||
"mapping_query": mapping_query,
|
||||
"sequence_mapping_queries": sequence_mapping_queries,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/query/mapping-sequence-params")
|
||||
def get_sequence_mapping_query_params(queries: dict[str, list[int]] = Query({})):
|
||||
return {"queries": queries}
|
||||
|
||||
|
||||
@app.get("/query/mixed-type-params")
|
||||
def get_mixed_mapping_mixed_type_query_params(
|
||||
sequence_mapping_queries: dict[str, list[int]] = Query({}),
|
||||
mapping_query_str: dict[str, str] = Query({}),
|
||||
mapping_query_int: dict[str, int] = Query({}),
|
||||
query: int = Query(),
|
||||
):
|
||||
return {
|
||||
"queries": {
|
||||
"query": query,
|
||||
"mapping_query_str": mapping_query_str,
|
||||
"mapping_query_int": mapping_query_int,
|
||||
"sequence_mapping_queries": sequence_mapping_queries,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@app.get("/enum-status-code", status_code=http.HTTPStatus.CREATED)
|
||||
def get_enum_status_code():
|
||||
return "foo bar"
|
||||
|
|
|
|||
|
|
@ -1111,6 +1111,235 @@ def test_openapi_schema():
|
|||
],
|
||||
}
|
||||
},
|
||||
"/query/mapping-params": {
|
||||
"get": {
|
||||
"operationId": "get_mapping_query_params_query_mapping_params_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "queries",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
},
|
||||
"default": {},
|
||||
"title": "Queries",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {},
|
||||
},
|
||||
},
|
||||
"description": "Successful Response",
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Validation Error",
|
||||
},
|
||||
},
|
||||
"summary": "Get Mapping Query Params",
|
||||
},
|
||||
},
|
||||
"/query/mapping-sequence-params": {
|
||||
"get": {
|
||||
"operationId": "get_sequence_mapping_query_params_query_mapping_sequence_params_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "queries",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "integer",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"default": {},
|
||||
"title": "Queries",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {},
|
||||
},
|
||||
},
|
||||
"description": "Successful Response",
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Validation Error",
|
||||
},
|
||||
},
|
||||
"summary": "Get Sequence Mapping Query Params",
|
||||
},
|
||||
},
|
||||
"/query/mixed-params": {
|
||||
"get": {
|
||||
"operationId": "get_mixed_mapping_query_params_query_mixed_params_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "sequence_mapping_queries",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "integer",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"default": {},
|
||||
"title": "Sequence Mapping Queries",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mapping_query",
|
||||
"required": True,
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
},
|
||||
"title": "Mapping Query",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "query",
|
||||
"required": True,
|
||||
"schema": {
|
||||
"title": "Query",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {},
|
||||
},
|
||||
},
|
||||
"description": "Successful Response",
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Validation Error",
|
||||
},
|
||||
},
|
||||
"summary": "Get Mixed Mapping Query Params",
|
||||
},
|
||||
},
|
||||
"/query/mixed-type-params": {
|
||||
"get": {
|
||||
"operationId": "get_mixed_mapping_mixed_type_query_params_query_mixed_type_params_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "sequence_mapping_queries",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"type": "integer",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"default": {},
|
||||
"title": "Sequence Mapping Queries",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mapping_query_str",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
},
|
||||
"default": {},
|
||||
"title": "Mapping Query Str",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mapping_query_int",
|
||||
"required": False,
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"type": "integer",
|
||||
},
|
||||
"default": {},
|
||||
"title": "Mapping Query Int",
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "query",
|
||||
"required": True,
|
||||
"schema": {
|
||||
"title": "Query",
|
||||
"type": "integer",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {},
|
||||
},
|
||||
},
|
||||
"description": "Successful Response",
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError",
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Validation Error",
|
||||
},
|
||||
},
|
||||
"summary": "Get Mixed Mapping Mixed Type Query Params",
|
||||
},
|
||||
},
|
||||
"/enum-status-code": {
|
||||
"get": {
|
||||
"responses": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import pytest
|
||||
from fastapi import FastAPI, Query
|
||||
|
||||
|
||||
def test_invalid_sequence():
|
||||
with pytest.raises(AssertionError):
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/items/")
|
||||
def read_items(q: dict[str, list[list[str]]] = Query(default=None)):
|
||||
pass # pragma: no cover
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Query
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -48,18 +46,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,
|
||||
match="Query parameter 'q' must be one of the supported types",
|
||||
):
|
||||
app = FastAPI()
|
||||
|
||||
class Item(BaseModel):
|
||||
title: str
|
||||
|
||||
@app.get("/items/")
|
||||
def read_items(q: Optional[dict] = Query(default=None)):
|
||||
pass # pragma: no cover
|
||||
|
|
|
|||
|
|
@ -248,12 +248,6 @@ def test_query_param_required_int_query_foo():
|
|||
}
|
||||
|
||||
|
||||
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_query_list():
|
||||
response = client.get("/query/list/?device_ids=1&device_ids=2")
|
||||
assert response.status_code == 200
|
||||
|
|
@ -275,3 +269,49 @@ def test_query_list_default_empty():
|
|||
response = client.get("/query/list-default/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
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() == {"queries": {"bar": "buzz", "foo": "fuzz"}}
|
||||
|
||||
|
||||
def test_sequence_mapping_query():
|
||||
response = client.get("/query/mapping-sequence-params/?foo=1&foo=2")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"queries": {"foo": [1, 2]}}
|
||||
|
||||
|
||||
def test_mixed_sequence_mapping_query():
|
||||
response = client.get("/query/mixed-type-params?query=2&foo=1&bar=3&foo=2&foo=baz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"queries": {
|
||||
"mapping_query_int": {"bar": 3},
|
||||
"mapping_query_str": {"bar": "3", "foo": "baz"},
|
||||
"query": 2,
|
||||
"sequence_mapping_queries": {"bar": [3], "foo": [1, 2]},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_mapping_with_non_mapping_query():
|
||||
response = client.get("/query/mixed-params/?foo=1&foo=2&bar=3&query=fizz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"queries": {
|
||||
"query": "fizz",
|
||||
"mapping_query": {"foo": "2", "bar": "3"},
|
||||
"sequence_mapping_queries": {
|
||||
"foo": [1, 2],
|
||||
"bar": [3],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,298 @@
|
|||
from typing import Annotated, Union
|
||||
|
||||
import pytest
|
||||
from dirty_equals import IsOneOf
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# =====================================================================================
|
||||
# Without aliases which exercise the "Wildcard" capture behavior
|
||||
|
||||
|
||||
@app.get("/required-dict-str")
|
||||
async def read_required_dict_str(p: Annotated[dict[str, str], Query()]):
|
||||
return {"p": p}
|
||||
|
||||
|
||||
class QueryModelRequiredDictStr(BaseModel):
|
||||
p: dict[str, str]
|
||||
|
||||
|
||||
@app.get("/model-required-dict-str")
|
||||
def read_model_required_dict_str(p: Annotated[QueryModelRequiredDictStr, Query()]):
|
||||
return {"p": p.p}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-str", "/model-required-dict-str"],
|
||||
)
|
||||
def test_required_dict_str_schema(path: str):
|
||||
assert app.openapi()["paths"][path]["get"]["parameters"] == [
|
||||
{
|
||||
"required": True,
|
||||
"schema": {
|
||||
"title": "P",
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
"name": "p",
|
||||
"in": "query",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-str", "/model-required-dict-str"],
|
||||
)
|
||||
def test_required_dict_str_missing(path: str):
|
||||
client = TestClient(app)
|
||||
response = client.get(path)
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"detail": [
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ["query", "p"],
|
||||
"msg": "Field required",
|
||||
"input": IsOneOf(None, {}),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-str", "/model-required-dict-str"],
|
||||
)
|
||||
def test_required_dict_str(path: str):
|
||||
client = TestClient(app)
|
||||
response = client.get(f"{path}?foo=bar&baz=qux")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"p": {"foo": "bar", "baz": "qux"}}
|
||||
|
||||
|
||||
# =====================================================================================
|
||||
# With union types
|
||||
|
||||
|
||||
@app.get("/required-dict-union")
|
||||
async def read_required_dict_union(
|
||||
p: Annotated[Union[dict[str, str], dict[str, int]], Query()],
|
||||
):
|
||||
return {"p": p}
|
||||
|
||||
|
||||
class QueryModelRequiredDictUnion(BaseModel):
|
||||
p: Union[dict[str, str], dict[str, int]]
|
||||
|
||||
|
||||
@app.get("/model-required-dict-union")
|
||||
def read_model_required_dict_union(p: Annotated[QueryModelRequiredDictUnion, Query()]):
|
||||
return {"p": p.p}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-union", "/model-required-dict-union"],
|
||||
)
|
||||
def test_required_dict_union_schema(path: str):
|
||||
assert app.openapi()["paths"][path]["get"]["parameters"] == [
|
||||
{
|
||||
"required": True,
|
||||
"schema": {
|
||||
"title": "P",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "integer"},
|
||||
},
|
||||
],
|
||||
},
|
||||
"name": "p",
|
||||
"in": "query",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-union", "/model-required-dict-union"],
|
||||
)
|
||||
def test_required_dict_union_missing(path: str):
|
||||
client = TestClient(app)
|
||||
response = client.get(path)
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"detail": [
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ["query", "p"],
|
||||
"msg": "Field required",
|
||||
"input": IsOneOf(None, {}),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-union", "/model-required-dict-union"],
|
||||
)
|
||||
def test_required_dict_union(path: str):
|
||||
client = TestClient(app)
|
||||
response = client.get(f"{path}?foo=bar&baz=42")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"p": {"foo": "bar", "baz": "42"}}
|
||||
|
||||
|
||||
@app.get("/required-dict-of-union")
|
||||
async def read_required_dict_of_union(
|
||||
p: Annotated[dict[str, Union[int, bool]], Query()],
|
||||
):
|
||||
return {"p": p}
|
||||
|
||||
|
||||
class QueryModelRequiredDictOfUnion(BaseModel):
|
||||
p: dict[str, Union[int, bool]]
|
||||
|
||||
|
||||
@app.get("/model-required-dict-of-union")
|
||||
def read_model_required_dict_of_union(
|
||||
p: Annotated[QueryModelRequiredDictOfUnion, Query()],
|
||||
):
|
||||
return {"p": p.p}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-of-union", "/model-required-dict-of-union"],
|
||||
)
|
||||
def test_required_dict_of_union_schema(path: str):
|
||||
assert app.openapi()["paths"][path]["get"]["parameters"] == [
|
||||
{
|
||||
"required": True,
|
||||
"schema": {
|
||||
"title": "P",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{"type": "integer"},
|
||||
{"type": "boolean"},
|
||||
]
|
||||
},
|
||||
},
|
||||
"name": "p",
|
||||
"in": "query",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-of-union", "/model-required-dict-of-union"],
|
||||
)
|
||||
def test_required_dict_of_union_missing(path: str):
|
||||
client = TestClient(app)
|
||||
response = client.get(path)
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"detail": [
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ["query", "p"],
|
||||
"msg": "Field required",
|
||||
"input": IsOneOf(None, {}),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-of-union", "/model-required-dict-of-union"],
|
||||
)
|
||||
def test_required_dict_of_union(path: str):
|
||||
client = TestClient(app)
|
||||
# Testing the "Wildcard" capture behavior for dicts
|
||||
response = client.get(f"{path}?foo=True&baz=42")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"p": {"foo": True, "baz": 42}}
|
||||
|
||||
|
||||
@app.get("/required-dict-of-list")
|
||||
async def read_required_dict_of_list(p: Annotated[dict[str, list[int]], Query()]):
|
||||
return {"p": p}
|
||||
|
||||
|
||||
class QueryModelRequiredDictOfList(BaseModel):
|
||||
p: dict[str, list[int]]
|
||||
|
||||
|
||||
@app.get("/model-required-dict-of-list")
|
||||
def read_model_required_dict_of_list(
|
||||
p: Annotated[QueryModelRequiredDictOfList, Query()],
|
||||
):
|
||||
return {"p": p.p}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-of-list", "/model-required-dict-of-list"],
|
||||
)
|
||||
def test_required_dict_of_list_schema(path: str):
|
||||
assert app.openapi()["paths"][path]["get"]["parameters"] == [
|
||||
{
|
||||
"required": True,
|
||||
"schema": {
|
||||
"title": "P",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"name": "p",
|
||||
"in": "query",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-of-list", "/model-required-dict-of-list"],
|
||||
)
|
||||
def test_required_dict_of_list_missing(path: str):
|
||||
client = TestClient(app)
|
||||
response = client.get(path)
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"detail": [
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ["query", "p"],
|
||||
"msg": "Field required",
|
||||
"input": IsOneOf(None, {}),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/required-dict-of-list", "/model-required-dict-of-list"],
|
||||
)
|
||||
def test_required_dict_of_list(path: str):
|
||||
client = TestClient(app)
|
||||
# Testing the "Wildcard" capture behavior for dicts with list values
|
||||
response = client.get(f"{path}?foo=1&foo=2&baz=3")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"p": {"foo": [1, 2], "baz": [3]}}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.utils import needs_py310
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def get_client():
|
||||
from docs_src.query_params.tutorial007_py310 import app
|
||||
|
||||
c = TestClient(app)
|
||||
return c
|
||||
|
||||
|
||||
@needs_py310
|
||||
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,
|
||||
"mapping_query_str": {"foo": "baz"},
|
||||
"mapping_query_int": {},
|
||||
"sequence_mapping_int": {"foo": []},
|
||||
}
|
||||
|
||||
|
||||
@needs_py310
|
||||
def test_just_string_not_scalar_mapping(client: TestClient):
|
||||
response = client.get("/query/mixed-type-params?query=2&foo=1&bar=3&foo=2&foo=baz")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"query": 2,
|
||||
"mapping_query_str": {"bar": "3", "foo": "baz"},
|
||||
"mapping_query_int": {"bar": 3},
|
||||
"sequence_mapping_int": {"bar": [3], "foo": [1, 2]},
|
||||
}
|
||||
Loading…
Reference in New Issue