Skip to content

Commit ade256c

Browse files
committed
Fixed issue with parsing repeated query parameters in URL
1 parent d5d2492 commit ade256c

File tree

9 files changed

+113
-34
lines changed

9 files changed

+113
-34
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to FastOpenAPI are documented in this file.
44

55
FastOpenAPI follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format.
66

7+
## [0.7.0] - Unreleased
8+
9+
### Fixed
10+
- Fixed issue with parsing repeated query parameters in URL.
11+
712
## [0.6.0] – 2025‑04‑16
813

914
### Added

fastopenapi/base_router.py

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -436,40 +436,93 @@ def render_redoc_ui(openapi_json_url: str) -> str:
436436
</html>
437437
"""
438438

439+
@staticmethod
440+
def _resolve_pydantic_model(model_class, params, param_name):
441+
"""Resolving parameters for a Pydantic model"""
442+
try:
443+
params_copy = params.copy()
444+
445+
if hasattr(model_class, "model_fields"):
446+
for field_name, field_info in model_class.model_fields.items():
447+
if (
448+
field_name in params_copy
449+
and not isinstance(params_copy[field_name], list)
450+
and hasattr(field_info, "annotation")
451+
and typing.get_origin(field_info.annotation) is list
452+
):
453+
params_copy[field_name] = [params_copy[field_name]]
454+
455+
return model_class(**params_copy)
456+
except Exception as e:
457+
raise ValidationError(
458+
f"Validation error for parameter '{param_name}'", str(e)
459+
)
460+
461+
@staticmethod
462+
def _resolve_list_param(param_name, value, annotation):
463+
"""Resolving a list-type parameter"""
464+
args = typing.get_args(annotation)
465+
try:
466+
if args:
467+
return [args[0](value)]
468+
else:
469+
return [value]
470+
except Exception as e:
471+
type_name = args[0].__name__ if args else "value"
472+
raise BadRequestError(
473+
f"Error parsing parameter '{param_name}' as list item. "
474+
f"Must be a valid {type_name}",
475+
str(e),
476+
)
477+
478+
@staticmethod
479+
def _resolve_scalar_param(param_name, value, annotation):
480+
"""Resolving a scalar parameter"""
481+
try:
482+
return annotation(value)
483+
except Exception as e:
484+
type_name = getattr(annotation, "__name__", str(annotation))
485+
raise BadRequestError(
486+
f"Error parsing parameter '{param_name}'. "
487+
f"Must be a valid {type_name}",
488+
str(e),
489+
)
490+
439491
@staticmethod
440492
def resolve_endpoint_params(
441493
endpoint: Callable, all_params: dict, body: dict
442494
) -> dict:
495+
"""Main method for resolving endpoint parameters"""
443496
sig = inspect.signature(endpoint)
444497
kwargs = {}
498+
445499
for name, param in sig.parameters.items():
446500
annotation = param.annotation
447501
is_required = param.default is inspect.Parameter.empty
502+
448503
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
449-
try:
450-
params = body if body else all_params
451-
kwargs[name] = annotation(**params)
452-
except Exception as e:
453-
# Use 422 for Pydantic model validation errors
454-
raise ValidationError(
455-
f"Validation error for parameter '{name}'", str(e)
456-
)
457-
else:
458-
if name in all_params:
459-
try:
460-
kwargs[name] = annotation(all_params[name])
461-
except Exception as e:
462-
# Use 400 for type conversion errors
463-
raise BadRequestError(
464-
f"Error parsing parameter '{name}'. "
465-
f"Must be a valid {annotation.__name__}",
466-
str(e),
467-
)
468-
elif not is_required:
469-
kwargs[name] = param.default
470-
else:
471-
# Missing a required parameter is 400
504+
kwargs[name] = BaseRouter._resolve_pydantic_model(
505+
annotation, body if body else all_params, name
506+
)
507+
continue
508+
509+
if name not in all_params:
510+
if is_required:
472511
raise BadRequestError(f"Missing required parameter: '{name}'")
512+
kwargs[name] = param.default
513+
continue
514+
515+
origin = typing.get_origin(annotation)
516+
517+
if origin is list and not isinstance(all_params[name], list):
518+
kwargs[name] = BaseRouter._resolve_list_param(
519+
name, all_params[name], annotation
520+
)
521+
else:
522+
kwargs[name] = BaseRouter._resolve_scalar_param(
523+
name, all_params[name], annotation
524+
)
525+
473526
return kwargs
474527

475528
@property

fastopenapi/routers/aiohttp.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ async def _aiohttp_view(cls, request: web.Request, router, endpoint: Callable):
1717
"""
1818
Handle request routing and parameter resolution for AioHttp
1919
"""
20-
query_params = dict(request.query)
20+
query_params = {}
21+
for key in request.query:
22+
values = request.query.getall(key)
23+
query_params[key] = values[0] if len(values) == 1 else values
2124
body = {}
2225
try:
2326
body_bytes = await request.read()

fastopenapi/routers/falcon.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ async def handle(req, resp, **path_params):
7676
async def _handle_request(self, endpoint, req, resp, **path_params):
7777
meta = getattr(endpoint, "__route_meta__", {})
7878
status_code = meta.get("status_code", 200)
79-
all_params = {**dict(req.params), **path_params}
79+
all_params = {**path_params}
80+
for key in req.params.keys():
81+
values = (
82+
req.params.getall(key)
83+
if hasattr(req.params, "getall")
84+
else [req.params.get(key)]
85+
)
86+
all_params[key] = values[0] if len(values) == 1 else values
8087
body = await self._read_body(req)
8188
try:
8289
kwargs = self.resolve_endpoint_params(endpoint, all_params, body)

fastopenapi/routers/flask.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ def add_route(self, path: str, method: str, endpoint: Callable):
1414

1515
def view_func(**path_params):
1616
json_data = request.get_json(silent=True) or {}
17-
query_params = request.args.to_dict()
17+
query_params = {}
18+
for key in request.args:
19+
values = request.args.getlist(key)
20+
query_params[key] = values[0] if len(values) == 1 else values
1821
all_params = {**query_params, **path_params}
1922
body = json_data
2023
try:

fastopenapi/routers/quart.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ def add_route(self, path: str, method: str, endpoint: Callable):
1414

1515
async def view_func(**path_params):
1616
json_data = await request.get_json(silent=True) or {}
17-
query_params = request.args.to_dict()
17+
query_params = {}
18+
for key in request.args:
19+
values = request.args.getlist(key)
20+
query_params[key] = values[0] if len(values) == 1 else values
1821
all_params = {**query_params, **path_params}
1922
body = json_data
2023
try:

fastopenapi/routers/sanic.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@ def add_route(self, path: str, method: str, endpoint: Callable):
1616
sanic_path = re.sub(r"{(\w+)}", r"<\1>", path)
1717

1818
async def view_func(request, **path_params):
19-
query_params = {
20-
k: (v[0] if isinstance(v, list) else v) for k, v in request.args.items()
21-
}
19+
query_params = {}
20+
for k, v in request.args.items():
21+
values = request.args.getall(k)
22+
query_params[k] = values[0] if len(values) == 1 else values
2223
json_body = request.json or {}
2324
all_params = {**query_params, **path_params}
2425
body = json_body

fastopenapi/routers/starlette.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ def __init__(self, app: Starlette = None, **kwargs):
1717

1818
@classmethod
1919
async def _starlette_view(cls, request, router, endpoint):
20-
query_params = dict(request.query_params)
20+
query_params = {}
21+
for key in request.query_params:
22+
values = request.query_params.getlist(key)
23+
query_params[key] = values[0] if len(values) == 1 else values
2124
body = {}
2225
try:
2326
body_bytes = await request.body()

fastopenapi/routers/tornado.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ async def handle_request(self):
4242
self.send_error(405)
4343
return
4444

45-
query_params = {
46-
k: self.get_query_argument(k) for k in self.request.query_arguments
47-
}
45+
query_params = {}
46+
for key in self.request.query_arguments:
47+
values = self.get_query_arguments(key)
48+
query_params[key] = values[0] if len(values) == 1 else values
4849

4950
all_params = {**self.path_kwargs, **query_params}
5051
body = getattr(self, "json_body", {})

0 commit comments

Comments
 (0)