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 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..f68e404 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 Parameter 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: list[Parameter] = [] + 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: Mapping[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..795f473 100644 --- a/openapify/core/models.py +++ b/openapify/core/models.py @@ -1,11 +1,10 @@ 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 from openapify.core.openapi.models import ( Example, - Parameter, ParameterStyle, SecurityScheme, ) @@ -16,6 +15,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 +30,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..8b85f39 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, @@ -19,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 @@ -29,13 +29,8 @@ DEFAULT_SPEC_TITLE, DEFAULT_SPEC_VERSION, ) -from openapify.core.models import RouteDef -from openapify.core.openapi.models import ( - Parameter, - ParameterLocation, - SecurityScheme, - Server, -) +from openapify.core.models import PathParam, RouteDef +from openapify.core.openapi.models import SecurityScheme, Server from openapify.plugin import BasePlugin PARAMETER_TEMPLATE = re.compile(r"{([^:{}]+)(?::(.+))?}") @@ -69,8 +64,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 +76,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