diff --git a/docs/en/docs/img/tutorial/query-params/image01.png b/docs/en/docs/img/tutorial/query-params/image01.png new file mode 100644 index 000000000..6222a9aef Binary files /dev/null and b/docs/en/docs/img/tutorial/query-params/image01.png differ diff --git a/docs/en/docs/tutorial/query-params.md b/docs/en/docs/tutorial/query-params.md index bc3b11948..508ab8c9a 100644 --- a/docs/en/docs/tutorial/query-params.md +++ b/docs/en/docs/tutorial/query-params.md @@ -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 http://127.0.0.1:8000/docs, you will that OpenAPI supports this format of query parameter: + + + +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`. diff --git a/docs_src/query_params/tutorial007_py310.py b/docs_src/query_params/tutorial007_py310.py new file mode 100644 index 000000000..7d0ffca04 --- /dev/null +++ b/docs_src/query_params/tutorial007_py310.py @@ -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, + } diff --git a/fastapi/_compat.py b/fastapi/_compat.py index 35d4a8723..ab98c3e6d 100644 --- a/fastapi/_compat.py +++ b/fastapi/_compat.py @@ -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 diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index b73473484..b5e1aa8d9 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -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) diff --git a/fastapi/types.py b/fastapi/types.py index 3205654c7..4b680bf3e 100644 --- a/fastapi/types.py +++ b/fastapi/types.py @@ -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]] \ No newline at end of file diff --git a/tests/main.py b/tests/main.py index 15760c039..52b70ed2a 100644 --- a/tests/main.py +++ b/tests/main.py @@ -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" diff --git a/tests/test_application.py b/tests/test_application.py index ea7a80128..741d80182 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -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": { diff --git a/tests/test_invalid_mapping_param.py b/tests/test_invalid_mapping_param.py new file mode 100644 index 000000000..7a24953a9 --- /dev/null +++ b/tests/test_invalid_mapping_param.py @@ -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 diff --git a/tests/test_invalid_sequence_param.py b/tests/test_invalid_sequence_param.py index 475786adb..cb61cfdce 100644 --- a/tests/test_invalid_sequence_param.py +++ b/tests/test_invalid_sequence_param.py @@ -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 diff --git a/tests/test_query.py b/tests/test_query.py index 5bb9995d6..9281cafdb 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -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': []}" diff --git a/tests/test_tutorial/test_query_params/test_tutorial007_py310.py b/tests/test_tutorial/test_query_params/test_tutorial007_py310.py new file mode 100644 index 000000000..a6dfc140f --- /dev/null +++ b/tests/test_tutorial/test_query_params/test_tutorial007_py310.py @@ -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": []}, + }