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 types |
+Examples |
+
+
+ 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