From 254d6c9621de3dc3762efabfbf9841a523509ff3 Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Mon, 8 Sep 2025 14:33:39 +0300 Subject: [PATCH 1/4] Add support for path parameters configuration --- openapify/__init__.py | 3 ++- openapify/core/base_plugins.py | 6 ++--- openapify/core/builder.py | 45 +++++++++++++++++++++++++++++++--- openapify/core/models.py | 12 +++++++-- openapify/decorators.py | 11 +++++++++ openapify/ext/web/aiohttp.py | 26 ++++++++------------ openapify/plugin.py | 4 +-- 7 files changed, 79 insertions(+), 28 deletions(-) diff --git a/openapify/__init__.py b/openapify/__init__.py index d6c183b..a8347e2 100644 --- a/openapify/__init__.py +++ b/openapify/__init__.py @@ -1,6 +1,6 @@ from .core.builder import build_spec from .core.document import OpenAPIDocument -from .core.models import Body, Header, QueryParam +from .core.models import Body, Header, PathParam, QueryParam from .core.openapi.models import Example from .decorators import ( operation_docs, @@ -20,6 +20,7 @@ "Body", "Header", "QueryParam", + "PathParam", "Example", "BasePlugin", ] diff --git a/openapify/core/base_plugins.py b/openapify/core/base_plugins.py index a91d4b2..9df7643 100644 --- a/openapify/core/base_plugins.py +++ b/openapify/core/base_plugins.py @@ -4,7 +4,7 @@ from mashumaro.jsonschema import OPEN_API_3_1, JSONSchemaBuilder from mashumaro.jsonschema.plugins import BasePlugin as BaseJSONSchemaPlugin -from openapify.core.models import Body, Cookie, Header, QueryParam +from openapify.core.models import Body, Cookie, Header, PathParam, QueryParam from openapify.core.utils import get_value_type from openapify.plugin import BasePlugin @@ -12,7 +12,7 @@ class BodyBinaryPlugin(BasePlugin): def schema_helper( self, - obj: Union[Body, Cookie, Header, QueryParam], + obj: Union[Body, Cookie, Header, QueryParam, PathParam], name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: try: @@ -44,7 +44,7 @@ def __init__(self, plugins: Sequence[BaseJSONSchemaPlugin] = ()): def schema_helper( self, - obj: Union[Body, Cookie, Header, QueryParam], + obj: Union[Body, Cookie, Header, QueryParam, PathParam], name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: builder = JSONSchemaBuilder( diff --git a/openapify/core/builder.py b/openapify/core/builder.py index 9e40c52..ff5b1a6 100644 --- a/openapify/core/builder.py +++ b/openapify/core/builder.py @@ -30,12 +30,14 @@ Body, Cookie, Header, + PathParam, QueryParam, RouteDef, SecurityRequirement, TypeAnnotation, ) from openapify.core.openapi import models as openapi +from openapify.core.openapi.models import ParameterLocation from openapify.plugin import BasePlugin BASE_PLUGINS = (BodyBinaryPlugin(), GuessMediaTypePlugin(), BaseSchemaPlugin()) @@ -120,7 +122,11 @@ def _process_route(self, route: RouteDef) -> None: operation_id = None external_docs = None security = None - parameters = route.parameters.copy() if route.parameters else [] + parameters = [] + route_path_params = { + param.name: param + for param in self._build_path_params(route.path_params or {}) + } request_body: Optional[openapi.RequestBody] = None for args_type, args in meta: if args_type == "request": @@ -152,6 +158,11 @@ def _process_route(self, route: RouteDef) -> None: query_params = args.get("query_params") if query_params: parameters.extend(self._build_query_params(query_params)) + path_params = args.get("path_params") + if path_params: + for new_param in self._build_path_params(path_params): + route_path_params.pop(new_param.name, None) + parameters.append(new_param) headers = args.get("headers") if headers: parameters.extend(self._build_request_headers(headers)) @@ -176,6 +187,7 @@ def _process_route(self, route: RouteDef) -> None: security = self._build_security_requirements( args.get("requirements") ) + parameters.extend(route_path_params.values()) self.spec.path( route.path, operations={ @@ -227,6 +239,31 @@ def _build_query_params( ) return result + def _build_path_params( + self, path_params: Dict[str, Union[Type, PathParam]] + ) -> Sequence[openapi.Parameter]: + result = [] + for name, param in path_params.items(): + if not isinstance(param, PathParam): + param = PathParam(param) + parameter_schema = self.__build_object_schema_with_plugins( + param, name + ) + if parameter_schema is None: + parameter_schema = {} + result.append( + openapi.Parameter( + name=name, + location=openapi.ParameterLocation.PATH, + description=param.description, + required=True, + schema=parameter_schema, + example=param.example, + examples=self._build_examples(param.examples), + ) + ) + return result + def _build_request_headers( self, headers: Dict[str, Union[str, Header]] ) -> Sequence[openapi.Parameter]: @@ -404,7 +441,7 @@ def _update_responses( @staticmethod def _build_external_docs( - data: Union[str, Tuple[str, str]] + data: Union[str, Tuple[str, str]], ) -> Optional[openapi.ExternalDocumentation]: if not data: return None @@ -450,7 +487,7 @@ def _build_examples( def __build_object_schema_with_plugins( self, - obj: Union[Body, Cookie, Header, QueryParam], + obj: Union[Body, Cookie, Header, QueryParam, PathParam], name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: return build_object_schema_with_plugins(obj, self.plugins, name) @@ -469,7 +506,7 @@ def _determine_body_media_type( def build_object_schema_with_plugins( - obj: Union[Body, Cookie, Header, QueryParam], + obj: Union[Body, Cookie, Header, QueryParam, PathParam], plugins: Sequence[BasePlugin], name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: diff --git a/openapify/core/models.py b/openapify/core/models.py index c5165b8..ce1c3e8 100644 --- a/openapify/core/models.py +++ b/openapify/core/models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, List, Mapping, Optional, Type, Union +from typing import Any, Dict, List, Mapping, Optional, Type, Union from typing_extensions import TypeAlias @@ -16,6 +16,14 @@ TypeAnnotation: TypeAlias = Any +@dataclass +class PathParam: + value_type: TypeAnnotation = str + description: Optional[str] = None + example: Optional[Any] = None + examples: Optional[Mapping[str, Union[Example, Any]]] = None + + @dataclass class RouteDef: path: str @@ -23,7 +31,7 @@ class RouteDef: handler: Any summary: Optional[str] = None description: Optional[str] = None - parameters: Optional[List[Parameter]] = None + path_params: Optional[Dict[str, PathParam]] = None tags: Optional[List[str]] = None diff --git a/openapify/decorators.py b/openapify/decorators.py index aa5fb28..ee37a3f 100644 --- a/openapify/decorators.py +++ b/openapify/decorators.py @@ -15,6 +15,7 @@ Body, Cookie, Header, + PathParam, QueryParam, SecurityRequirement, TypeAnnotation, @@ -34,6 +35,9 @@ def request_schema( query_params: Optional[ Mapping[str, Union[TypeAnnotation, QueryParam]] ] = None, + path_params: Optional[ + Mapping[str, Union[TypeAnnotation, PathParam]] + ] = None, headers: Optional[Mapping[str, Union[str, Header]]] = None, cookies: Optional[Mapping[str, Union[str, Cookie]]] = None, ) -> Callable[[Handler], Handler]: ... @@ -51,6 +55,9 @@ def request_schema( query_params: Optional[ Mapping[str, Union[TypeAnnotation, QueryParam]] ] = None, + path_params: Optional[ + Mapping[str, Union[TypeAnnotation, PathParam]] + ] = None, headers: Optional[Mapping[str, Union[str, Header]]] = None, cookies: Optional[Mapping[str, Union[str, Cookie]]] = None, ) -> Callable[[Handler], Handler]: ... @@ -67,6 +74,9 @@ def request_schema( # type: ignore[misc] query_params: Optional[ Mapping[str, Union[TypeAnnotation, QueryParam]] ] = None, + path_params: Optional[ + Mapping[str, Union[TypeAnnotation, PathParam]] + ] = None, headers: Optional[Mapping[str, Union[str, Header]]] = None, cookies: Optional[Mapping[str, Union[str, Cookie]]] = None, ) -> Callable[[Handler], Handler]: @@ -85,6 +95,7 @@ def decorator(handler: Handler) -> Handler: "body_example": body_example, "body_examples": body_examples, "query_params": query_params, + "path_params": path_params, "headers": headers, "cookies": cookies, }, diff --git a/openapify/ext/web/aiohttp.py b/openapify/ext/web/aiohttp.py index 2e4e4cd..6f1294e 100644 --- a/openapify/ext/web/aiohttp.py +++ b/openapify/ext/web/aiohttp.py @@ -2,6 +2,7 @@ from typing import ( Any, Callable, + Dict, Iterable, List, Mapping, @@ -29,7 +30,7 @@ DEFAULT_SPEC_TITLE, DEFAULT_SPEC_VERSION, ) -from openapify.core.models import RouteDef +from openapify.core.models import PathParam, RouteDef from openapify.core.openapi.models import ( Parameter, ParameterLocation, @@ -69,8 +70,10 @@ def _aiohttp_route_defs_to_route_defs( yield RouteDef(route.path, route.method, route.handler) -def _pull_out_path_parameters(path: str) -> Tuple[str, List[Parameter]]: - parameters = [] +def _pull_out_path_parameters( + route: RouteDef, +) -> Tuple[str, Dict[str, PathParam]]: + parameters = {} def _sub(match: re.Match) -> str: name = match.group(1) @@ -79,26 +82,17 @@ def _sub(match: re.Match) -> str: instance_type = Annotated[str, Pattern(regex)] else: instance_type = str # type: ignore[misc] - parameters.append( - Parameter( - name=name, - location=ParameterLocation.PATH, - required=True, - schema=build_json_schema( - instance_type, dialect=OPEN_API_3_1 - ).to_dict(), - ) - ) + parameters[name] = PathParam(value_type=instance_type) return f"{{{name}}}" - return re.sub(PARAMETER_TEMPLATE, _sub, path), parameters + return re.sub(PARAMETER_TEMPLATE, _sub, route.path), parameters def _complete_routes(routes: Iterable[RouteDef]) -> Iterable[RouteDef]: for route in routes: - route.path, parameters = _pull_out_path_parameters(route.path) + route.path, parameters = _pull_out_path_parameters(route) if parameters: - route.parameters = parameters + route.path_params = parameters yield route diff --git a/openapify/plugin.py b/openapify/plugin.py index ee47f15..56462e0 100644 --- a/openapify/plugin.py +++ b/openapify/plugin.py @@ -2,7 +2,7 @@ from apispec import APISpec -from openapify.core.models import Body, Cookie, Header, QueryParam +from openapify.core.models import Body, Cookie, Header, PathParam, QueryParam class BasePlugin: @@ -13,7 +13,7 @@ def init_spec(self, spec: APISpec) -> None: def schema_helper( self, - obj: Union[Body, Cookie, Header, QueryParam], + obj: Union[Body, Cookie, Header, QueryParam, PathParam], name: Optional[str] = None, ) -> Optional[Dict[str, Any]]: raise NotImplementedError From fa158f4ecc4b7217dbcaa5ff3dbedf4904281f34 Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Mon, 8 Sep 2025 14:40:10 +0300 Subject: [PATCH 2/4] Update README --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index 85c2717..43fecb5 100644 --- a/README.md +++ b/README.md @@ -673,6 +673,55 @@ deprecation marker, examples etc. +### path_params + +Dictionary of path parameters applicable for the operation, where the key is +the parameter name and the value can be either a Python data type or +a `PathParam` object. + +In the first case it is the Python data type for the path parameter for which +the JSON Schema will be built. This affects the value of +the [`parameters`](https://spec.openapis.org/oas/v3.1.0#operation-object) +field of the Operation object, or more precisely, +the [`schema`](https://spec.openapis.org/oas/v3.1.0#parameter-object) field of +Parameter object. + +In the second case it is `openapify.core.models.PathParam` object that can +have extended information about the parameter, such as a description, examples. + + + + + + + + + + + + + + +
Possible typesExamples
Mapping[str, Type] + +```python +{"id": UUID} +``` + +
Mapping[str, PathParam] + +```python +{ + "id": PathParam( + value_type=UUID, + description="ID of the book", + example="eab9d66d-4317-464a-a995-510bd6e2148f", + ) +} +``` + +
+ #### headers Dictionary of request headers applicable for the operation, where the key is From 07cc56ea848350a5515ad104f71267442dc06ea6 Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Mon, 8 Sep 2025 14:44:46 +0300 Subject: [PATCH 3/4] Remove unused imports --- openapify/core/builder.py | 1 - openapify/core/models.py | 1 - openapify/ext/web/aiohttp.py | 8 +------- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/openapify/core/builder.py b/openapify/core/builder.py index ff5b1a6..97d5bc5 100644 --- a/openapify/core/builder.py +++ b/openapify/core/builder.py @@ -37,7 +37,6 @@ TypeAnnotation, ) from openapify.core.openapi import models as openapi -from openapify.core.openapi.models import ParameterLocation from openapify.plugin import BasePlugin BASE_PLUGINS = (BodyBinaryPlugin(), GuessMediaTypePlugin(), BaseSchemaPlugin()) diff --git a/openapify/core/models.py b/openapify/core/models.py index ce1c3e8..795f473 100644 --- a/openapify/core/models.py +++ b/openapify/core/models.py @@ -5,7 +5,6 @@ from openapify.core.openapi.models import ( Example, - Parameter, ParameterStyle, SecurityScheme, ) diff --git a/openapify/ext/web/aiohttp.py b/openapify/ext/web/aiohttp.py index 6f1294e..8b85f39 100644 --- a/openapify/ext/web/aiohttp.py +++ b/openapify/ext/web/aiohttp.py @@ -20,7 +20,6 @@ from aiohttp.typedefs import Handler from aiohttp.web_app import Application from apispec import APISpec -from mashumaro.jsonschema import OPEN_API_3_1, build_json_schema from mashumaro.jsonschema.annotations import Pattern from typing_extensions import Annotated @@ -31,12 +30,7 @@ DEFAULT_SPEC_VERSION, ) from openapify.core.models import PathParam, RouteDef -from openapify.core.openapi.models import ( - Parameter, - ParameterLocation, - SecurityScheme, - Server, -) +from openapify.core.openapi.models import SecurityScheme, Server from openapify.plugin import BasePlugin PARAMETER_TEMPLATE = re.compile(r"{([^:{}]+)(?::(.+))?}") From 0e855b0d8237d00c8ec110e208bb95b4d35add0d Mon Sep 17 00:00:00 2001 From: Alexander Tikhonov Date: Mon, 8 Sep 2025 14:48:52 +0300 Subject: [PATCH 4/4] Fix mypy errors --- openapify/core/builder.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openapify/core/builder.py b/openapify/core/builder.py index 97d5bc5..f68e404 100644 --- a/openapify/core/builder.py +++ b/openapify/core/builder.py @@ -37,6 +37,7 @@ TypeAnnotation, ) from openapify.core.openapi import models as openapi +from openapify.core.openapi.models import Parameter from openapify.plugin import BasePlugin BASE_PLUGINS = (BodyBinaryPlugin(), GuessMediaTypePlugin(), BaseSchemaPlugin()) @@ -121,7 +122,7 @@ def _process_route(self, route: RouteDef) -> None: operation_id = None external_docs = None security = None - parameters = [] + parameters: list[Parameter] = [] route_path_params = { param.name: param for param in self._build_path_params(route.path_params or {}) @@ -239,7 +240,7 @@ def _build_query_params( return result def _build_path_params( - self, path_params: Dict[str, Union[Type, PathParam]] + self, path_params: Mapping[str, Union[Type, PathParam]] ) -> Sequence[openapi.Parameter]: result = [] for name, param in path_params.items():