Skip to content

Commit f08cf64

Browse files
committed
Reworked _build_parameters_and_body in BaseRouter, fixed list for scalar args. Added tests for BaseRouter and AioHttpRouter
1 parent ade256c commit f08cf64

File tree

4 files changed

+295
-62
lines changed

4 files changed

+295
-62
lines changed

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ FastOpenAPI follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
99
### Fixed
1010
- Fixed issue with parsing repeated query parameters in URL.
1111

12+
### Removed
13+
- Removed the `use_aliases` from `BaseRouter` and reverted changes from 0.6.0.
14+
1215
## [0.6.0] – 2025‑04‑16
1316

1417
### Added
15-
- The `use_aliases` parameter was added to the `BaseRouter` constructor. Default is `True`. To preserve the previous behavior (without using aliases from Pydantic), set `use_aliases=False`.
18+
- The `use_aliases` parameter was added to the `BaseRouter` constructor. Default is `True`. To preserve the previous behavior (without using aliases from Pydantic), set `use_aliases=False`.
19+
- support for Pydantic models with `alias=` by [PR #8](https://github.com/mr-fatalyst/fastopenapi/pull/8)
1620

1721
### Changed
1822
- The `_serialize_response method` is now an instance method (was a `@staticmethod`) — to support `use_aliases`.

fastopenapi/base_router.py

Lines changed: 98 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
1+
# ==============================================================================
2+
# WARNING: This module currently violates the Single Responsibility Principle.
3+
# It takes on multiple concerns that should ideally be decoupled:
4+
#
5+
# Responsibilities overloaded:
6+
# - Route registration
7+
# - OpenAPI schema generation
8+
# - Parameter resolution and validation
9+
# - Response serialization
10+
# - Error handling
11+
# - API documentation UI rendering
12+
#
13+
# Structural issues:
14+
# - Methods operate at inconsistent levels of abstraction
15+
# - Some functions are overly long and do too much
16+
# - Inter-method cohesion is weak or unclear
17+
#
18+
# Risks and pitfalls:
19+
# - Missing or weak type annotations in places
20+
# - Logic and presentation are mixed in some implementations
21+
#
22+
# I'm actively collecting feedback and tracking bugs.
23+
# A full refactor is planned once this phase is complete.
24+
# ==============================================================================
25+
126
import inspect
227
import re
328
import typing
4-
import warnings
529
from collections.abc import Callable
630
from http import HTTPStatus
731
from typing import Any, ClassVar
@@ -44,7 +68,6 @@ class BaseRouter:
4468
- title: Title of the API documentation (defaults to "My App").
4569
- version: Version of the API (defaults to "0.1.0").
4670
- description: Description of the API
47-
- use_aliases: Temporary argument to maintain backward compatibility
4871
(included in OpenAPI info, default "API documentation").
4972
5073
The BaseRouter allows defining routes using decorator methods (get, post, etc.).
@@ -65,7 +88,6 @@ def __init__(
6588
title: str = "My App",
6689
version: str = "0.1.0",
6790
description: str = "API documentation",
68-
use_aliases: bool = True,
6991
):
7092
self.app = app
7193
self.docs_url = docs_url
@@ -77,15 +99,6 @@ def __init__(
7799
self.description = description
78100
self._routes: list[tuple[str, str, Callable]] = []
79101
self._openapi_schema = None
80-
self.use_aliases = use_aliases
81-
# TODO Remove use_aliases in 0.7.0
82-
if not use_aliases:
83-
warnings.warn(
84-
"Setting use_aliases=False is deprecated. "
85-
"It will be removed in version 0.7.0",
86-
FutureWarning,
87-
stacklevel=2,
88-
)
89102
if self.app is not None:
90103
if self.docs_url and self.redoc_url and self.openapi_url:
91104
self._register_docs_endpoints()
@@ -226,53 +239,85 @@ def _build_operation(
226239
def _build_parameters_and_body(
227240
self, endpoint, definitions: dict, route_path: str, http_method: str
228241
):
242+
"""Build OpenAPI parameters and request body from endpoint signature."""
229243
sig = inspect.signature(endpoint)
230244
parameters = []
231245
request_body = None
232-
233246
path_params = {match.group(1) for match in re.finditer(r"{(\w+)}", route_path)}
234247

235248
for param_name, param in sig.parameters.items():
236-
if isinstance(param.annotation, type) and issubclass(
237-
param.annotation, BaseModel
238-
):
249+
if self._is_pydantic_model(param.annotation):
239250
if http_method.upper() == "GET":
240-
# TODO Remove use_aliases in 0.7.0
241-
model_schema = param.annotation.model_json_schema(
242-
mode="serialization" if self.use_aliases else "validation"
243-
)
244-
required_fields = model_schema.get("required", [])
245-
properties = model_schema.get("properties", {})
246-
for prop_name, prop_schema in properties.items():
247-
parameters.append(
248-
{
249-
"name": prop_name,
250-
"in": "query",
251-
"required": prop_name in required_fields,
252-
"schema": prop_schema,
253-
}
254-
)
251+
query_params = self._build_query_params_from_model(param.annotation)
252+
parameters.extend(query_params)
255253
else:
256254
model_schema = self._get_model_schema(param.annotation, definitions)
257255
request_body = {
258256
"content": {"application/json": {"schema": model_schema}},
259257
"required": param.default is inspect.Parameter.empty,
260258
}
261259
else:
262-
location = "path" if param_name in path_params else "query"
263-
openapi_type = PYTHON_TYPE_MAPPING.get(param.annotation, "string")
264-
parameters.append(
265-
{
266-
"name": param_name,
267-
"in": location,
268-
"required": (param.default is inspect.Parameter.empty)
269-
or (location == "path"),
270-
"schema": {"type": openapi_type},
271-
}
272-
)
260+
param_info = self._build_parameter_info(param_name, param, path_params)
261+
parameters.append(param_info)
273262

274263
return parameters, request_body
275264

265+
@staticmethod
266+
def _is_pydantic_model(annotation) -> bool:
267+
return isinstance(annotation, type) and issubclass(annotation, BaseModel)
268+
269+
@staticmethod
270+
def _build_query_params_from_model(model_class) -> list:
271+
"""Convert Pydantic model fields to query parameters."""
272+
parameters = []
273+
model_schema = model_class.model_json_schema(mode="serialization")
274+
required_fields = model_schema.get("required", [])
275+
properties = model_schema.get("properties", {})
276+
277+
for prop_name, prop_schema in properties.items():
278+
parameters.append(
279+
{
280+
"name": prop_name,
281+
"in": "query",
282+
"required": prop_name in required_fields,
283+
"schema": prop_schema,
284+
}
285+
)
286+
287+
return parameters
288+
289+
def _build_parameter_info(self, param_name, param, path_params) -> dict:
290+
"""Build parameter info for path or query parameters."""
291+
location = "path" if param_name in path_params else "query"
292+
schema = self._build_parameter_schema(param.annotation)
293+
is_required = param.default is inspect.Parameter.empty or location == "path"
294+
295+
return {
296+
"name": param_name,
297+
"in": location,
298+
"required": is_required,
299+
"schema": schema,
300+
}
301+
302+
def _build_parameter_schema(self, annotation) -> dict:
303+
"""Build OpenAPI schema for a parameter based on its type annotation."""
304+
origin = typing.get_origin(annotation)
305+
if origin is list:
306+
return self._build_array_schema(annotation)
307+
308+
return {"type": PYTHON_TYPE_MAPPING.get(annotation, "string")}
309+
310+
def _build_array_schema(self, list_annotation) -> dict:
311+
"""Build OpenAPI schema for an array type."""
312+
args = typing.get_args(list_annotation)
313+
item_type = "string" # default
314+
315+
# Get the inner type if available
316+
if args and args[0] in PYTHON_TYPE_MAPPING:
317+
item_type = PYTHON_TYPE_MAPPING[args[0]]
318+
319+
return {"type": "array", "items": {"type": item_type}}
320+
276321
def _build_responses(self, meta: dict, definitions: dict, status_code: str) -> dict:
277322
responses = {status_code: {"description": HTTPStatus(int(status_code)).phrase}}
278323
response_model = meta.get("response_model")
@@ -347,31 +392,29 @@ def _register_docs_endpoints(self):
347392
"""
348393
raise NotImplementedError
349394

350-
def _serialize_response(self, result: Any) -> Any:
351-
from pydantic import BaseModel
352-
395+
@classmethod
396+
def _serialize_response(cls, result: Any) -> Any:
353397
if isinstance(result, BaseModel):
354-
# TODO Remove use_aliases in 0.7.0
355-
return result.model_dump(by_alias=self.use_aliases)
398+
return result.model_dump(by_alias=True)
356399
if isinstance(result, list):
357-
return [self._serialize_response(item) for item in result]
400+
return [cls._serialize_response(item) for item in result]
358401
if isinstance(result, dict):
359-
return {k: self._serialize_response(v) for k, v in result.items()}
402+
return {k: cls._serialize_response(v) for k, v in result.items()}
360403
return result
361404

362-
def _get_model_schema(self, model: type[BaseModel], definitions: dict) -> dict:
405+
@classmethod
406+
def _get_model_schema(cls, model: type[BaseModel], definitions: dict) -> dict:
363407
"""
364408
Get the OpenAPI schema for a Pydantic model, with caching for better performance
365409
"""
366410
model_name = model.__name__
367411
cache_key = f"{model.__module__}.{model_name}"
368412

369413
# Check if the schema is already in the class-level cache
370-
if cache_key not in self._model_schema_cache:
414+
if cache_key not in cls._model_schema_cache:
371415
# Generate the schema if it's not in the cache
372-
# TODO Remove use_aliases in 0.7.0
373416
model_schema = model.model_json_schema(
374-
mode="serialization" if self.use_aliases else "validation",
417+
mode="serialization",
375418
ref_template="#/components/schemas/{model}",
376419
)
377420

@@ -382,11 +425,11 @@ def _get_model_schema(self, model: type[BaseModel], definitions: dict) -> dict:
382425
del model_schema[key]
383426

384427
# Add schema to the cache
385-
self._model_schema_cache[cache_key] = model_schema
428+
cls._model_schema_cache[cache_key] = model_schema
386429

387430
# Make sure the schema is in the definitions dictionary
388431
if model_name not in definitions:
389-
definitions[model_name] = self._model_schema_cache[cache_key]
432+
definitions[model_name] = cls._model_schema_cache[cache_key]
390433

391434
return {"$ref": f"#/components/schemas/{model_name}"}
392435

tests/aiohttp/test_aiohttp_router.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import functools
2+
import json
3+
from unittest.mock import AsyncMock, MagicMock
24

5+
import pytest
36
from aiohttp import web
47
from pydantic import BaseModel
58

@@ -126,3 +129,53 @@ async def get_test(id: int):
126129
assert "get" in schema["paths"]["/test/{id}"]
127130
assert schema["paths"]["/test/{id}"]["get"]["summary"] == "Test endpoint"
128131
assert "TestModel" in schema["components"]["schemas"]
132+
133+
@pytest.mark.asyncio
134+
async def test_aiohttp_view_query_params_processing(self):
135+
"""
136+
Tests query parameter handling in the _aiohttp_view method. Verifies the logic
137+
for processing both single and multiple query parameter values.
138+
"""
139+
# Create the router
140+
app = web.Application()
141+
router = AioHttpRouter(app=app)
142+
143+
# Define an endpoint that echoes back the received parameters
144+
async def echo_params(q1: str, q2: list[str] = None):
145+
return {"q1": q1, "q2": q2}
146+
147+
# Create a mock request object with query parameters
148+
mock_request = MagicMock()
149+
150+
# Simulate aiohttp's MultiDict
151+
class MockMultiDict:
152+
def __init__(self, data):
153+
self.data = data
154+
155+
def __iter__(self):
156+
return iter(self.data.keys())
157+
158+
def getall(self, key):
159+
return self.data[key]
160+
161+
# Test 1: A parameter with a single value
162+
mock_request.query = MockMultiDict({"q1": ["value1"]})
163+
mock_request.match_info = {}
164+
mock_request.read = AsyncMock(return_value=b"")
165+
166+
response = await AioHttpRouter._aiohttp_view(mock_request, router, echo_params)
167+
assert response.status == 200
168+
response_data = json.loads(response.body)
169+
assert response_data["q1"] == "value1"
170+
assert response_data["q2"] is None
171+
172+
# Test 2: A parameter with multiple values
173+
mock_request.query = MockMultiDict(
174+
{"q1": ["value1"], "q2": ["value2", "value3"]}
175+
)
176+
177+
response = await AioHttpRouter._aiohttp_view(mock_request, router, echo_params)
178+
assert response.status == 200
179+
response_data = json.loads(response.body)
180+
assert response_data["q1"] == "value1"
181+
assert response_data["q2"] == ["value2", "value3"]

0 commit comments

Comments
 (0)