From baa5cd2ca6c67c760387c1bf47ef56037c94343c Mon Sep 17 00:00:00 2001 From: Daniyar Yeralin Date: Wed, 12 Aug 2020 14:37:07 -0400 Subject: [PATCH 01/20] Introduce mapping shapes --- fastapi/dependencies/utils.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 7c9f7e847..d09ccde8f 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -41,7 +41,7 @@ from pydantic.fields import ( SHAPE_TUPLE_ELLIPSIS, FieldInfo, ModelField, - Required, + Required, SHAPE_MAPPING, ) from pydantic.schema import get_annotation_from_field_info from pydantic.typing import ForwardRef, evaluate_forwardref @@ -69,6 +69,13 @@ sequence_shape_to_type = { SHAPE_TUPLE_ELLIPSIS: list, } +mapping_shapes = { + SHAPE_MAPPING +} +mapping_types = (dict) +mapping_shapes_to_type = { + SHAPE_MAPPING: dict +} multipart_not_installed_error = ( 'Form data requires "python-multipart" to be installed. \n' @@ -240,6 +247,20 @@ def is_scalar_sequence_field(field: ModelField) -> bool: return False +def is_scalar_mapping_field(field: ModelField) -> bool: + if (field.shape in mapping_shapes) and not lenient_issubclass( + field.type_, BaseModel + ): + if field.sub_fields is not None: + for sub_field in field.sub_fields: + if not is_scalar_field(sub_field): + return False + return True + if lenient_issubclass(field.type_, mapping_types): + return True + return False + + def get_typed_signature(call: Callable) -> inspect.Signature: signature = inspect.signature(call) globalns = getattr(call, "__globals__", {}) @@ -324,7 +345,8 @@ def get_dependant( add_param_to_fields(field=param_field, dependant=dependant) elif isinstance( param.default, (params.Query, params.Header) - ) and is_scalar_sequence_field(param_field): + ) and (is_scalar_sequence_field(param_field) + or is_scalar_mapping_field(param_field)): add_param_to_fields(field=param_field, dependant=dependant) else: field_info = param_field.field_info @@ -610,6 +632,10 @@ 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 = received_params._dict else: value = received_params.get(field.alias) field_info = field.field_info From e8c83f100e35f012ec3716d9341aee56fb8ddb1b Mon Sep 17 00:00:00 2001 From: Daniyar Yeralin Date: Wed, 12 Aug 2020 14:37:24 -0400 Subject: [PATCH 02/20] Cover mapping shapes with tests --- tests/main.py | 7 ++++++- tests/test_query.py | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/main.py b/tests/main.py index d5603d0e6..fdd16f2d2 100644 --- a/tests/main.py +++ b/tests/main.py @@ -1,5 +1,5 @@ import http -from typing import Optional +from typing import Optional, Dict from fastapi import FastAPI, Path, Query @@ -184,6 +184,11 @@ def get_query_param_required(query=Query(...)): return f"foo bar {query}" +@app.get("/query/params") +def get_query_params(queries: Dict[str, int] = Query({})): + return f"foo bar {queries}" + + @app.get("/query/param-required/int") def get_query_param_required_type(query: int = Query(...)): return f"foo bar {query}" diff --git a/tests/test_query.py b/tests/test_query.py index cdbdd1ccd..6c4f4b0e8 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -53,6 +53,8 @@ response_not_valid_int = { ("/query/param-required/int", 422, response_missing), ("/query/param-required/int?query=50", 200, "foo bar 50"), ("/query/param-required/int?query=foo", 422, response_not_valid_int), + ("/query/params?first-query=1&second-query=2", 200, "foo bar {'first-query': 1, " + "'second-query': 2}") ], ) def test_get_path(path, expected_status, expected_response): From ec624a27be2bc9abc9063263fd7a6173ab47f323 Mon Sep 17 00:00:00 2001 From: Daniyar Yeralin Date: Wed, 12 Aug 2020 14:40:38 -0400 Subject: [PATCH 03/20] Format proposed code --- fastapi/dependencies/utils.py | 21 +++++++++------------ tests/main.py | 2 +- tests/test_query.py | 7 +++++-- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index d09ccde8f..843f59bf6 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -34,6 +34,7 @@ from pydantic.error_wrappers import ErrorWrapper from pydantic.errors import MissingError from pydantic.fields import ( SHAPE_LIST, + SHAPE_MAPPING, SHAPE_SEQUENCE, SHAPE_SET, SHAPE_SINGLETON, @@ -41,7 +42,7 @@ from pydantic.fields import ( SHAPE_TUPLE_ELLIPSIS, FieldInfo, ModelField, - Required, SHAPE_MAPPING, + Required, ) from pydantic.schema import get_annotation_from_field_info from pydantic.typing import ForwardRef, evaluate_forwardref @@ -69,13 +70,9 @@ sequence_shape_to_type = { SHAPE_TUPLE_ELLIPSIS: list, } -mapping_shapes = { - SHAPE_MAPPING -} -mapping_types = (dict) -mapping_shapes_to_type = { - SHAPE_MAPPING: dict -} +mapping_shapes = {SHAPE_MAPPING} +mapping_types = dict +mapping_shapes_to_type = {SHAPE_MAPPING: dict} multipart_not_installed_error = ( 'Form data requires "python-multipart" to be installed. \n' @@ -343,10 +340,10 @@ def get_dependant( add_param_to_fields(field=param_field, dependant=dependant) elif is_scalar_field(field=param_field): add_param_to_fields(field=param_field, dependant=dependant) - elif isinstance( - param.default, (params.Query, params.Header) - ) and (is_scalar_sequence_field(param_field) - or is_scalar_mapping_field(param_field)): + elif isinstance(param.default, (params.Query, params.Header)) and ( + is_scalar_sequence_field(param_field) + or is_scalar_mapping_field(param_field) + ): add_param_to_fields(field=param_field, dependant=dependant) else: field_info = param_field.field_info diff --git a/tests/main.py b/tests/main.py index fdd16f2d2..17b380f6d 100644 --- a/tests/main.py +++ b/tests/main.py @@ -1,5 +1,5 @@ import http -from typing import Optional, Dict +from typing import Dict, Optional from fastapi import FastAPI, Path, Query diff --git a/tests/test_query.py b/tests/test_query.py index 6c4f4b0e8..934d34136 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -53,8 +53,11 @@ response_not_valid_int = { ("/query/param-required/int", 422, response_missing), ("/query/param-required/int?query=50", 200, "foo bar 50"), ("/query/param-required/int?query=foo", 422, response_not_valid_int), - ("/query/params?first-query=1&second-query=2", 200, "foo bar {'first-query': 1, " - "'second-query': 2}") + ( + "/query/params?first-query=1&second-query=2", + 200, + "foo bar {'first-query': 1, " "'second-query': 2}", + ), ], ) def test_get_path(path, expected_status, expected_response): From 23740cf5248a1a2e3ad448ce631915f0da0724a4 Mon Sep 17 00:00:00 2001 From: Daniyar Yeralin Date: Wed, 12 Aug 2020 15:02:49 -0400 Subject: [PATCH 04/20] Adapt tests to a new endpoint Remove unused import Add entry to openapi.json Reformat openapi.json --- tests/main.py | 10 ++++---- tests/test_application.py | 35 ++++++++++++++++++++++++++++ tests/test_invalid_sequence_param.py | 14 +---------- 3 files changed, 41 insertions(+), 18 deletions(-) diff --git a/tests/main.py b/tests/main.py index 17b380f6d..7700b4dbc 100644 --- a/tests/main.py +++ b/tests/main.py @@ -184,16 +184,16 @@ def get_query_param_required(query=Query(...)): return f"foo bar {query}" -@app.get("/query/params") -def get_query_params(queries: Dict[str, int] = Query({})): - return f"foo bar {queries}" - - @app.get("/query/param-required/int") def get_query_param_required_type(query: int = Query(...)): return f"foo bar {query}" +@app.get("/query/params") +def get_query_params(queries: Dict[str, int] = Query({})): + return f"foo bar {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 5ba737307..dea2524a2 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1078,6 +1078,41 @@ openapi_schema = { ], } }, + "/query/params": { + "get": { + "summary": "Get Query Params", + "operationId": "get_query_params_query_params_get", + "parameters": [ + { + "required": False, + "schema": { + "title": "Queries", + "type": "object", + "additionalProperties": {"type": "integer"}, + "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" + } + } + }, + }, + }, + } + }, "/enum-status-code": { "get": { "responses": { diff --git a/tests/test_invalid_sequence_param.py b/tests/test_invalid_sequence_param.py index f00dd7b93..836b5f947 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(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(None)): - pass # pragma: no cover From 279285e4ec5e6ea296f3545bac1d7e4abaa63ca0 Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Mon, 13 Mar 2023 17:09:43 +0000 Subject: [PATCH 05/20] use mapping type --- tests/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/main.py b/tests/main.py index 3bc4150cc..842a0e745 100644 --- a/tests/main.py +++ b/tests/main.py @@ -1,5 +1,5 @@ import http -from typing import FrozenSet, Optional +from typing import FrozenSet, Mapping, Optional from fastapi import FastAPI, Path, Query @@ -190,7 +190,7 @@ def get_query_param_required_type(query: int = Query()): @app.get("/query/params") -def get_query_params(queries: Dict[str, int] = Query({})): +def get_query_params(queries: Mapping[str, int] = Query({})): return f"foo bar {queries}" From 2b0e941acb035ba880a44c8b407a794a3b5d8403 Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Mon, 13 Mar 2023 18:01:40 +0000 Subject: [PATCH 06/20] change parsing of return values to account for multiple copies of same param --- fastapi/dependencies/utils.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index a881312d7..479a740c6 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -18,6 +18,8 @@ from typing import ( cast, ) +from collections import defaultdict + import anyio from fastapi import params from fastapi.concurrency import ( @@ -253,10 +255,11 @@ def is_scalar_mapping_field(field: ModelField) -> bool: if (field.shape in mapping_shapes) and not lenient_issubclass( field.type_, BaseModel ): - if field.sub_fields is not None: - for sub_field in field.sub_fields: - if not is_scalar_field(sub_field): - return False + if field.sub_fields is None: + return True + for sub_field in field.sub_fields: + if (not is_scalar_field(sub_field)) and (not is_scalar_sequence_field(sub_field)): + return False return True if lenient_issubclass(field.type_, mapping_types): return True @@ -625,7 +628,9 @@ def request_params_to_args( elif is_scalar_mapping_field(field) and isinstance( received_params, (QueryParams,) ): - value = received_params._dict + value = defaultdict(list) + for key, field_value in received_params.multi_items(): + value[key].append(field_value) else: value = received_params.get(field.alias) field_info = field.field_info From 668f96029dbfca983b5fd813a994f5f821446eae Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Mon, 13 Mar 2023 18:01:49 +0000 Subject: [PATCH 07/20] linting --- fastapi/dependencies/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 479a740c6..804a2940a 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -17,7 +17,6 @@ from typing import ( Union, cast, ) - from collections import defaultdict import anyio From ee70593999a92d8e8170367870de9d1a86b14d1c Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Mon, 13 Mar 2023 18:14:18 +0000 Subject: [PATCH 08/20] fix test --- tests/test_invalid_sequence_param.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_invalid_sequence_param.py b/tests/test_invalid_sequence_param.py index f03d18fb5..475786adb 100644 --- a/tests/test_invalid_sequence_param.py +++ b/tests/test_invalid_sequence_param.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Tuple +from typing import Dict, List, Optional, Tuple import pytest from fastapi import FastAPI, Query From fb8a2d5fb2e0b083f03eb65142e210bd1db1761b Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Mon, 13 Mar 2023 18:18:06 +0000 Subject: [PATCH 09/20] import order --- fastapi/dependencies/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 804a2940a..01fe85c2b 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1,5 +1,6 @@ import dataclasses import inspect +from collections import defaultdict from contextlib import contextmanager from copy import deepcopy from typing import ( @@ -17,7 +18,6 @@ from typing import ( Union, cast, ) -from collections import defaultdict import anyio from fastapi import params @@ -257,7 +257,9 @@ def is_scalar_mapping_field(field: ModelField) -> bool: if field.sub_fields is None: return True for sub_field in field.sub_fields: - if (not is_scalar_field(sub_field)) and (not is_scalar_sequence_field(sub_field)): + if (not is_scalar_field(sub_field)) and ( + not is_scalar_sequence_field(sub_field) + ): return False return True if lenient_issubclass(field.type_, mapping_types): From 758590f66c5a4810429d869e385f42f0b15ce15f Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 07:12:38 +0000 Subject: [PATCH 10/20] added sequence params to test cases --- tests/main.py | 7 ++++++- tests/test_application.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/tests/main.py b/tests/main.py index 842a0e745..21f0035f5 100644 --- a/tests/main.py +++ b/tests/main.py @@ -1,5 +1,5 @@ import http -from typing import FrozenSet, Mapping, Optional +from typing import FrozenSet, List, Mapping, Optional from fastapi import FastAPI, Path, Query @@ -194,6 +194,11 @@ def get_query_params(queries: Mapping[str, int] = Query({})): return f"foo bar {queries}" +@app.get("/query/sequence-params") +def get_sequence_query_params(queries: Mapping[str, List[int]] = Query({})): + return f"foo bar {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 c412515db..7bd54f0c4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1113,6 +1113,44 @@ openapi_schema = { }, } }, + "/query/sequence-params": { + "get": { + "operationId": "get_sequence_query_params_query_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 Query " "Params", + } + }, "/enum-status-code": { "get": { "responses": { From 378d30d18b36c20ac39b6e920caeb078e538b9c1 Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 07:13:26 +0000 Subject: [PATCH 11/20] handle Map[scalar, List[scalar]] and Map[scalar, scalar] sepaeratly --- fastapi/dependencies/utils.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 01fe85c2b..5002e1a05 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -257,9 +257,22 @@ def is_scalar_mapping_field(field: ModelField) -> bool: if field.sub_fields is None: return True for sub_field in field.sub_fields: - if (not is_scalar_field(sub_field)) and ( - not is_scalar_sequence_field(sub_field) - ): + if not is_scalar_field(sub_field): + return False + return True + if lenient_issubclass(field.type_, mapping_types): + return True + return False + + +def is_scalar_sequence_mapping_field(field: ModelField) -> bool: + if (field.shape in mapping_shapes) and not lenient_issubclass( + field.type_, BaseModel + ): + if field.sub_fields is None: + return True + for sub_field in field.sub_fields: + if not is_scalar_sequence_field(sub_field): return False return True if lenient_issubclass(field.type_, mapping_types): @@ -349,6 +362,7 @@ def get_dependant( elif isinstance(param.default, (params.Query, params.Header)) and ( is_scalar_sequence_field(param_field) or is_scalar_mapping_field(param_field) + or is_scalar_sequence_mapping_field(param_field) ): add_param_to_fields(field=param_field, dependant=dependant) else: @@ -628,10 +642,15 @@ def request_params_to_args( 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()) + elif is_scalar_sequence_mapping_field(field) and isinstance( + received_params, (QueryParams,) ): value = defaultdict(list) for key, field_value in received_params.multi_items(): value[key].append(field_value) + value = dict(value) else: value = received_params.get(field.alias) field_info = field.field_info From cfa97c5884dce8183af95a3d1fdf27255a31ec1a Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 07:13:50 +0000 Subject: [PATCH 12/20] remove check for now-valid sequence param --- tests/test_invalid_sequence_param.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) 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 From 6d411b53cf69b1e47a115d5ed8e66202b3081ef3 Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 07:14:12 +0000 Subject: [PATCH 13/20] add test queries for params and sequence params --- tests/test_query.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_query.py b/tests/test_query.py index b44e188cd..6fbca4d2c 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -57,7 +57,12 @@ response_not_valid_int = { ( "/query/params?first-query=1&second-query=2", 200, - "foo bar {'first-query': 1, " "'second-query': 2}", + "foo bar {'first-query': 1, 'second-query': 2}", + ), + ( + "/query/sequence-params?first-query=1&first-query=2", + 200, + "foo bar {'first-query': [1, 2]}", ), ], ) From 4e15aa1d124a808a422772b9ffa8e20690c8ce98 Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 09:08:45 +0000 Subject: [PATCH 14/20] adding tests and restricting mappings to only Mapping type --- fastapi/dependencies/utils.py | 12 ++---------- tests/main.py | 8 ++++---- tests/test_application.py | 8 ++++---- tests/test_query.py | 2 +- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 5002e1a05..ee8b003cc 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -77,8 +77,8 @@ sequence_shape_to_type = { } mapping_shapes = {SHAPE_MAPPING} -mapping_types = dict -mapping_shapes_to_type = {SHAPE_MAPPING: dict} +mapping_types = (Mapping) +mapping_shapes_to_type = {SHAPE_MAPPING: Mapping} multipart_not_installed_error = ( 'Form data requires "python-multipart" to be installed. \n' @@ -254,14 +254,10 @@ def is_scalar_mapping_field(field: ModelField) -> bool: if (field.shape in mapping_shapes) and not lenient_issubclass( field.type_, BaseModel ): - if field.sub_fields is None: - return True for sub_field in field.sub_fields: if not is_scalar_field(sub_field): return False return True - if lenient_issubclass(field.type_, mapping_types): - return True return False @@ -269,14 +265,10 @@ def is_scalar_sequence_mapping_field(field: ModelField) -> bool: if (field.shape in mapping_shapes) and not lenient_issubclass( field.type_, BaseModel ): - if field.sub_fields is None: - return True for sub_field in field.sub_fields: if not is_scalar_sequence_field(sub_field): return False return True - if lenient_issubclass(field.type_, mapping_types): - return True return False diff --git a/tests/main.py b/tests/main.py index 21f0035f5..6040a53b5 100644 --- a/tests/main.py +++ b/tests/main.py @@ -190,13 +190,13 @@ def get_query_param_required_type(query: int = Query()): @app.get("/query/params") -def get_query_params(queries: Mapping[str, int] = Query({})): - return f"foo bar {queries}" +def get_query_params(query: Mapping[str, int] = Query({})): + return f"foo bar {query}" @app.get("/query/sequence-params") -def get_sequence_query_params(queries: Mapping[str, List[int]] = Query({})): - return f"foo bar {queries}" +def get_sequence_query_params(query: Mapping[str, List[int]] = Query({})): + return f"foo bar {query}" @app.get("/enum-status-code", status_code=http.HTTPStatus.CREATED) diff --git a/tests/test_application.py b/tests/test_application.py index 7bd54f0c4..9363e1280 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1086,12 +1086,12 @@ openapi_schema = { { "required": False, "schema": { - "title": "Queries", + "title": "Query", "type": "object", "additionalProperties": {"type": "integer"}, "default": {}, }, - "name": "queries", + "name": "query", "in": "query", } ], @@ -1119,7 +1119,7 @@ openapi_schema = { "parameters": [ { "in": "query", - "name": "queries", + "name": "query", "required": False, "schema": { "additionalProperties": { @@ -1127,7 +1127,7 @@ openapi_schema = { "type": "array", }, "default": {}, - "title": "Queries", + "title": "Query", "type": "object", }, } diff --git a/tests/test_query.py b/tests/test_query.py index 6fbca4d2c..3e0544bb2 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -63,7 +63,7 @@ response_not_valid_int = { "/query/sequence-params?first-query=1&first-query=2", 200, "foo bar {'first-query': [1, 2]}", - ), + ) ], ) def test_get_path(path, expected_status, expected_response): From 846ae007c8bcaadf5c59cd252dd74af5502fce7e Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 09:10:43 +0000 Subject: [PATCH 15/20] linting --- fastapi/dependencies/utils.py | 2 +- tests/test_query.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index ee8b003cc..dad7e6562 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -77,7 +77,7 @@ sequence_shape_to_type = { } mapping_shapes = {SHAPE_MAPPING} -mapping_types = (Mapping) +mapping_types = Mapping mapping_shapes_to_type = {SHAPE_MAPPING: Mapping} multipart_not_installed_error = ( diff --git a/tests/test_query.py b/tests/test_query.py index 3e0544bb2..6fbca4d2c 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -63,7 +63,7 @@ response_not_valid_int = { "/query/sequence-params?first-query=1&first-query=2", 200, "foo bar {'first-query': [1, 2]}", - ) + ), ], ) def test_get_path(path, expected_status, expected_response): From 2251c78e146752a876340ea9361206d636bb13c2 Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 09:14:49 +0000 Subject: [PATCH 16/20] add none check back in --- fastapi/dependencies/utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index dad7e6562..97c1f7eaf 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -254,6 +254,8 @@ def is_scalar_mapping_field(field: ModelField) -> bool: if (field.shape in mapping_shapes) and not lenient_issubclass( field.type_, BaseModel ): + if field.sub_fields is None: + return False for sub_field in field.sub_fields: if not is_scalar_field(sub_field): return False @@ -265,6 +267,8 @@ def is_scalar_sequence_mapping_field(field: ModelField) -> bool: if (field.shape in mapping_shapes) and not lenient_issubclass( field.type_, BaseModel ): + if field.sub_fields is None: + return False for sub_field in field.sub_fields: if not is_scalar_sequence_field(sub_field): return False From b0109cd3995317816fa767cb8c5b256e90bc720a Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Tue, 14 Mar 2023 11:10:54 +0000 Subject: [PATCH 17/20] add test for invalid mapping --- fastapi/dependencies/utils.py | 16 ++++++++-------- tests/test_invalid_mapping_param.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 tests/test_invalid_mapping_param.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 97c1f7eaf..3d969ed10 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -251,11 +251,11 @@ def is_scalar_sequence_field(field: ModelField) -> bool: def is_scalar_mapping_field(field: ModelField) -> bool: - if (field.shape in mapping_shapes) and not lenient_issubclass( - field.type_, BaseModel + if ( + (field.shape in mapping_shapes) + and not lenient_issubclass(field.type_, BaseModel) + and field.sub_fields is not None ): - if field.sub_fields is None: - return False for sub_field in field.sub_fields: if not is_scalar_field(sub_field): return False @@ -264,11 +264,11 @@ def is_scalar_mapping_field(field: ModelField) -> bool: def is_scalar_sequence_mapping_field(field: ModelField) -> bool: - if (field.shape in mapping_shapes) and not lenient_issubclass( - field.type_, BaseModel + if ( + (field.shape in mapping_shapes) + and not lenient_issubclass(field.type_, BaseModel) + and field.sub_fields is not None ): - if field.sub_fields is None: - return False for sub_field in field.sub_fields: if not is_scalar_sequence_field(sub_field): return False diff --git a/tests/test_invalid_mapping_param.py b/tests/test_invalid_mapping_param.py new file mode 100644 index 000000000..ef49a3218 --- /dev/null +++ b/tests/test_invalid_mapping_param.py @@ -0,0 +1,15 @@ +from typing import Mapping, List + +import pytest +from fastapi import FastAPI, Query +from pydantic import BaseModel + + +def test_invalid_sequence(): + with pytest.raises(AssertionError): + app = FastAPI() + + + @app.get("/items/") + def read_items(q: Mapping[str, List[List[str]]] = Query(default=None)): + pass # pragma: no cover From 3433ddced418e933634fcccb277968dede7cad59 Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Fri, 21 Apr 2023 10:50:44 +0000 Subject: [PATCH 18/20] add mapping shapes --- fastapi/dependencies/utils.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index f131001ce..643e6d81e 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -38,6 +38,7 @@ from pydantic.fields import ( SHAPE_FROZENSET, SHAPE_LIST, SHAPE_SEQUENCE, + SHAPE_MAPPING, SHAPE_SET, SHAPE_SINGLETON, SHAPE_TUPLE, @@ -75,6 +76,9 @@ sequence_shape_to_type = { SHAPE_TUPLE_ELLIPSIS: list, } +mapping_shapes = {SHAPE_MAPPING} +mapping_types = Mapping +mapping_shapes_to_type = {SHAPE_MAPPING: Mapping} multipart_not_installed_error = ( 'Form data requires "python-multipart" to be installed. \n' @@ -245,6 +249,30 @@ def is_scalar_sequence_field(field: ModelField) -> bool: return True return False +def is_scalar_mapping_field(field: ModelField) -> bool: + if (field.shape in mapping_shapes) and not lenient_issubclass( + field.type_, BaseModel + ): + if field.sub_fields is None: + return False + for sub_field in field.sub_fields: + if not is_scalar_field(sub_field): + return False + return True + return False + + +def is_scalar_sequence_mapping_field(field: ModelField) -> bool: + if (field.shape in mapping_shapes) and not lenient_issubclass( + field.type_, BaseModel + ): + if field.sub_fields is None: + return False + for sub_field in field.sub_fields: + if not is_scalar_sequence_field(sub_field): + return False + return True + return False def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: signature = inspect.signature(call) @@ -488,6 +516,10 @@ 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, params.Header) + ) 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 From 6361e9d83270f564ef1c04cb51b291a50935be0b Mon Sep 17 00:00:00 2001 From: JONEMI19 Date: Fri, 21 Apr 2023 11:50:25 +0000 Subject: [PATCH 19/20] working correctly --- fastapi/dependencies/utils.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 643e6d81e..d94a59d4c 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1,5 +1,6 @@ import dataclasses import inspect +from collections import defaultdict from contextlib import contextmanager from copy import copy, deepcopy from typing import ( @@ -714,6 +715,19 @@ def request_params_to_args( received_params, (QueryParams, Headers) ): value = received_params.getlist(field.alias) or field.default + if is_scalar_mapping_field(field) and isinstance( + received_params, (QueryParams, Headers) + ): + value = dict(received_params.multi_items()) or field.default + elif is_scalar_sequence_mapping_field(field) and isinstance( + received_params, (QueryParams, Headers) + ): + if not len(received_params.multi_items()): + value = field.default + else: + 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 From cc9d9d3f13c71b84636b42086dd067896dca966b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 21 Apr 2023 11:53:42 +0000 Subject: [PATCH 20/20] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20f?= =?UTF-8?q?ormat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 12 +++++++----- tests/test_invalid_mapping_param.py | 4 +--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index b3d013241..aa9ed56be 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -40,7 +40,6 @@ from pydantic.fields import ( SHAPE_LIST, SHAPE_MAPPING, SHAPE_SEQUENCE, - SHAPE_MAPPING, SHAPE_SET, SHAPE_SINGLETON, SHAPE_TUPLE, @@ -251,6 +250,7 @@ def is_scalar_sequence_field(field: ModelField) -> bool: return True return False + def is_scalar_mapping_field(field: ModelField) -> bool: if (field.shape in mapping_shapes) and not lenient_issubclass( field.type_, BaseModel @@ -276,6 +276,7 @@ def is_scalar_sequence_mapping_field(field: ModelField) -> bool: return True return False + def is_scalar_mapping_field(field: ModelField) -> bool: if ( (field.shape in mapping_shapes) @@ -544,9 +545,10 @@ 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, params.Header) - ) and (is_scalar_sequence_mapping_field(param_field) or is_scalar_mapping_field(param_field)): + elif isinstance(param_field.field_info, (params.Query, params.Header)) and ( + is_scalar_sequence_mapping_field(param_field) + or is_scalar_mapping_field(param_field) + ): return False else: assert isinstance( @@ -751,7 +753,7 @@ def request_params_to_args( ): if not len(received_params.multi_items()): value = field.default - else: + else: value = defaultdict(list) for k, v in received_params.multi_items(): value[k].append(v) diff --git a/tests/test_invalid_mapping_param.py b/tests/test_invalid_mapping_param.py index ef49a3218..249a759d5 100644 --- a/tests/test_invalid_mapping_param.py +++ b/tests/test_invalid_mapping_param.py @@ -1,15 +1,13 @@ -from typing import Mapping, List +from typing import List, Mapping import pytest from fastapi import FastAPI, Query -from pydantic import BaseModel def test_invalid_sequence(): with pytest.raises(AssertionError): app = FastAPI() - @app.get("/items/") def read_items(q: Mapping[str, List[List[str]]] = Query(default=None)): pass # pragma: no cover