Skip to content

Commit fafb825

Browse files
authored
Merge pull request #9 from Fatal1ty/path-params
Path params
2 parents 3420fda + 0e855b0 commit fafb825

File tree

8 files changed

+129
-36
lines changed

8 files changed

+129
-36
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,55 @@ deprecation marker, examples etc.
673673
</tr>
674674
</table>
675675

676+
### path_params
677+
678+
Dictionary of path parameters applicable for the operation, where the key is
679+
the parameter name and the value can be either a Python data type or
680+
a `PathParam` object.
681+
682+
In the first case it is the Python data type for the path parameter for which
683+
the JSON Schema will be built. This affects the value of
684+
the [`parameters`](https://spec.openapis.org/oas/v3.1.0#operation-object)
685+
field of the Operation object, or more precisely,
686+
the [`schema`](https://spec.openapis.org/oas/v3.1.0#parameter-object) field of
687+
Parameter object.
688+
689+
In the second case it is `openapify.core.models.PathParam` object that can
690+
have extended information about the parameter, such as a description, examples.
691+
692+
<table>
693+
<tr>
694+
<th>Possible types</th>
695+
<th>Examples</th>
696+
</tr>
697+
<tr>
698+
<td> <code>Mapping[str, Type]</code> </td>
699+
<td>
700+
701+
```python
702+
{"id": UUID}
703+
```
704+
705+
</td>
706+
</tr>
707+
<tr>
708+
<td> <code>Mapping[str, PathParam]</code> </td>
709+
<td>
710+
711+
```python
712+
{
713+
"id": PathParam(
714+
value_type=UUID,
715+
description="ID of the book",
716+
example="eab9d66d-4317-464a-a995-510bd6e2148f",
717+
)
718+
}
719+
```
720+
721+
</td>
722+
</tr>
723+
</table>
724+
676725
#### headers
677726

678727
Dictionary of request headers applicable for the operation, where the key is

openapify/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from .core.builder import build_spec
22
from .core.document import OpenAPIDocument
3-
from .core.models import Body, Header, QueryParam
3+
from .core.models import Body, Header, PathParam, QueryParam
44
from .core.openapi.models import Example
55
from .decorators import (
66
operation_docs,
@@ -20,6 +20,7 @@
2020
"Body",
2121
"Header",
2222
"QueryParam",
23+
"PathParam",
2324
"Example",
2425
"BasePlugin",
2526
]

openapify/core/base_plugins.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44
from mashumaro.jsonschema import OPEN_API_3_1, JSONSchemaBuilder
55
from mashumaro.jsonschema.plugins import BasePlugin as BaseJSONSchemaPlugin
66

7-
from openapify.core.models import Body, Cookie, Header, QueryParam
7+
from openapify.core.models import Body, Cookie, Header, PathParam, QueryParam
88
from openapify.core.utils import get_value_type
99
from openapify.plugin import BasePlugin
1010

1111

1212
class BodyBinaryPlugin(BasePlugin):
1313
def schema_helper(
1414
self,
15-
obj: Union[Body, Cookie, Header, QueryParam],
15+
obj: Union[Body, Cookie, Header, QueryParam, PathParam],
1616
name: Optional[str] = None,
1717
) -> Optional[Dict[str, Any]]:
1818
try:
@@ -44,7 +44,7 @@ def __init__(self, plugins: Sequence[BaseJSONSchemaPlugin] = ()):
4444

4545
def schema_helper(
4646
self,
47-
obj: Union[Body, Cookie, Header, QueryParam],
47+
obj: Union[Body, Cookie, Header, QueryParam, PathParam],
4848
name: Optional[str] = None,
4949
) -> Optional[Dict[str, Any]]:
5050
builder = JSONSchemaBuilder(

openapify/core/builder.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@
3030
Body,
3131
Cookie,
3232
Header,
33+
PathParam,
3334
QueryParam,
3435
RouteDef,
3536
SecurityRequirement,
3637
TypeAnnotation,
3738
)
3839
from openapify.core.openapi import models as openapi
40+
from openapify.core.openapi.models import Parameter
3941
from openapify.plugin import BasePlugin
4042

4143
BASE_PLUGINS = (BodyBinaryPlugin(), GuessMediaTypePlugin(), BaseSchemaPlugin())
@@ -120,7 +122,11 @@ def _process_route(self, route: RouteDef) -> None:
120122
operation_id = None
121123
external_docs = None
122124
security = None
123-
parameters = route.parameters.copy() if route.parameters else []
125+
parameters: list[Parameter] = []
126+
route_path_params = {
127+
param.name: param
128+
for param in self._build_path_params(route.path_params or {})
129+
}
124130
request_body: Optional[openapi.RequestBody] = None
125131
for args_type, args in meta:
126132
if args_type == "request":
@@ -152,6 +158,11 @@ def _process_route(self, route: RouteDef) -> None:
152158
query_params = args.get("query_params")
153159
if query_params:
154160
parameters.extend(self._build_query_params(query_params))
161+
path_params = args.get("path_params")
162+
if path_params:
163+
for new_param in self._build_path_params(path_params):
164+
route_path_params.pop(new_param.name, None)
165+
parameters.append(new_param)
155166
headers = args.get("headers")
156167
if headers:
157168
parameters.extend(self._build_request_headers(headers))
@@ -176,6 +187,7 @@ def _process_route(self, route: RouteDef) -> None:
176187
security = self._build_security_requirements(
177188
args.get("requirements")
178189
)
190+
parameters.extend(route_path_params.values())
179191
self.spec.path(
180192
route.path,
181193
operations={
@@ -227,6 +239,31 @@ def _build_query_params(
227239
)
228240
return result
229241

242+
def _build_path_params(
243+
self, path_params: Mapping[str, Union[Type, PathParam]]
244+
) -> Sequence[openapi.Parameter]:
245+
result = []
246+
for name, param in path_params.items():
247+
if not isinstance(param, PathParam):
248+
param = PathParam(param)
249+
parameter_schema = self.__build_object_schema_with_plugins(
250+
param, name
251+
)
252+
if parameter_schema is None:
253+
parameter_schema = {}
254+
result.append(
255+
openapi.Parameter(
256+
name=name,
257+
location=openapi.ParameterLocation.PATH,
258+
description=param.description,
259+
required=True,
260+
schema=parameter_schema,
261+
example=param.example,
262+
examples=self._build_examples(param.examples),
263+
)
264+
)
265+
return result
266+
230267
def _build_request_headers(
231268
self, headers: Dict[str, Union[str, Header]]
232269
) -> Sequence[openapi.Parameter]:
@@ -404,7 +441,7 @@ def _update_responses(
404441

405442
@staticmethod
406443
def _build_external_docs(
407-
data: Union[str, Tuple[str, str]]
444+
data: Union[str, Tuple[str, str]],
408445
) -> Optional[openapi.ExternalDocumentation]:
409446
if not data:
410447
return None
@@ -450,7 +487,7 @@ def _build_examples(
450487

451488
def __build_object_schema_with_plugins(
452489
self,
453-
obj: Union[Body, Cookie, Header, QueryParam],
490+
obj: Union[Body, Cookie, Header, QueryParam, PathParam],
454491
name: Optional[str] = None,
455492
) -> Optional[Dict[str, Any]]:
456493
return build_object_schema_with_plugins(obj, self.plugins, name)
@@ -469,7 +506,7 @@ def _determine_body_media_type(
469506

470507

471508
def build_object_schema_with_plugins(
472-
obj: Union[Body, Cookie, Header, QueryParam],
509+
obj: Union[Body, Cookie, Header, QueryParam, PathParam],
473510
plugins: Sequence[BasePlugin],
474511
name: Optional[str] = None,
475512
) -> Optional[Dict[str, Any]]:

openapify/core/models.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from dataclasses import dataclass
2-
from typing import Any, List, Mapping, Optional, Type, Union
2+
from typing import Any, Dict, List, Mapping, Optional, Type, Union
33

44
from typing_extensions import TypeAlias
55

66
from openapify.core.openapi.models import (
77
Example,
8-
Parameter,
98
ParameterStyle,
109
SecurityScheme,
1110
)
@@ -16,14 +15,22 @@
1615
TypeAnnotation: TypeAlias = Any
1716

1817

18+
@dataclass
19+
class PathParam:
20+
value_type: TypeAnnotation = str
21+
description: Optional[str] = None
22+
example: Optional[Any] = None
23+
examples: Optional[Mapping[str, Union[Example, Any]]] = None
24+
25+
1926
@dataclass
2027
class RouteDef:
2128
path: str
2229
method: str
2330
handler: Any
2431
summary: Optional[str] = None
2532
description: Optional[str] = None
26-
parameters: Optional[List[Parameter]] = None
33+
path_params: Optional[Dict[str, PathParam]] = None
2734
tags: Optional[List[str]] = None
2835

2936

openapify/decorators.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
Body,
1616
Cookie,
1717
Header,
18+
PathParam,
1819
QueryParam,
1920
SecurityRequirement,
2021
TypeAnnotation,
@@ -34,6 +35,9 @@ def request_schema(
3435
query_params: Optional[
3536
Mapping[str, Union[TypeAnnotation, QueryParam]]
3637
] = None,
38+
path_params: Optional[
39+
Mapping[str, Union[TypeAnnotation, PathParam]]
40+
] = None,
3741
headers: Optional[Mapping[str, Union[str, Header]]] = None,
3842
cookies: Optional[Mapping[str, Union[str, Cookie]]] = None,
3943
) -> Callable[[Handler], Handler]: ...
@@ -51,6 +55,9 @@ def request_schema(
5155
query_params: Optional[
5256
Mapping[str, Union[TypeAnnotation, QueryParam]]
5357
] = None,
58+
path_params: Optional[
59+
Mapping[str, Union[TypeAnnotation, PathParam]]
60+
] = None,
5461
headers: Optional[Mapping[str, Union[str, Header]]] = None,
5562
cookies: Optional[Mapping[str, Union[str, Cookie]]] = None,
5663
) -> Callable[[Handler], Handler]: ...
@@ -67,6 +74,9 @@ def request_schema( # type: ignore[misc]
6774
query_params: Optional[
6875
Mapping[str, Union[TypeAnnotation, QueryParam]]
6976
] = None,
77+
path_params: Optional[
78+
Mapping[str, Union[TypeAnnotation, PathParam]]
79+
] = None,
7080
headers: Optional[Mapping[str, Union[str, Header]]] = None,
7181
cookies: Optional[Mapping[str, Union[str, Cookie]]] = None,
7282
) -> Callable[[Handler], Handler]:
@@ -85,6 +95,7 @@ def decorator(handler: Handler) -> Handler:
8595
"body_example": body_example,
8696
"body_examples": body_examples,
8797
"query_params": query_params,
98+
"path_params": path_params,
8899
"headers": headers,
89100
"cookies": cookies,
90101
},

openapify/ext/web/aiohttp.py

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import (
33
Any,
44
Callable,
5+
Dict,
56
Iterable,
67
List,
78
Mapping,
@@ -19,7 +20,6 @@
1920
from aiohttp.typedefs import Handler
2021
from aiohttp.web_app import Application
2122
from apispec import APISpec
22-
from mashumaro.jsonschema import OPEN_API_3_1, build_json_schema
2323
from mashumaro.jsonschema.annotations import Pattern
2424
from typing_extensions import Annotated
2525

@@ -29,13 +29,8 @@
2929
DEFAULT_SPEC_TITLE,
3030
DEFAULT_SPEC_VERSION,
3131
)
32-
from openapify.core.models import RouteDef
33-
from openapify.core.openapi.models import (
34-
Parameter,
35-
ParameterLocation,
36-
SecurityScheme,
37-
Server,
38-
)
32+
from openapify.core.models import PathParam, RouteDef
33+
from openapify.core.openapi.models import SecurityScheme, Server
3934
from openapify.plugin import BasePlugin
4035

4136
PARAMETER_TEMPLATE = re.compile(r"{([^:{}]+)(?::(.+))?}")
@@ -69,8 +64,10 @@ def _aiohttp_route_defs_to_route_defs(
6964
yield RouteDef(route.path, route.method, route.handler)
7065

7166

72-
def _pull_out_path_parameters(path: str) -> Tuple[str, List[Parameter]]:
73-
parameters = []
67+
def _pull_out_path_parameters(
68+
route: RouteDef,
69+
) -> Tuple[str, Dict[str, PathParam]]:
70+
parameters = {}
7471

7572
def _sub(match: re.Match) -> str:
7673
name = match.group(1)
@@ -79,26 +76,17 @@ def _sub(match: re.Match) -> str:
7976
instance_type = Annotated[str, Pattern(regex)]
8077
else:
8178
instance_type = str # type: ignore[misc]
82-
parameters.append(
83-
Parameter(
84-
name=name,
85-
location=ParameterLocation.PATH,
86-
required=True,
87-
schema=build_json_schema(
88-
instance_type, dialect=OPEN_API_3_1
89-
).to_dict(),
90-
)
91-
)
79+
parameters[name] = PathParam(value_type=instance_type)
9280
return f"{{{name}}}"
9381

94-
return re.sub(PARAMETER_TEMPLATE, _sub, path), parameters
82+
return re.sub(PARAMETER_TEMPLATE, _sub, route.path), parameters
9583

9684

9785
def _complete_routes(routes: Iterable[RouteDef]) -> Iterable[RouteDef]:
9886
for route in routes:
99-
route.path, parameters = _pull_out_path_parameters(route.path)
87+
route.path, parameters = _pull_out_path_parameters(route)
10088
if parameters:
101-
route.parameters = parameters
89+
route.path_params = parameters
10290
yield route
10391

10492

openapify/plugin.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from apispec import APISpec
44

5-
from openapify.core.models import Body, Cookie, Header, QueryParam
5+
from openapify.core.models import Body, Cookie, Header, PathParam, QueryParam
66

77

88
class BasePlugin:
@@ -13,7 +13,7 @@ def init_spec(self, spec: APISpec) -> None:
1313

1414
def schema_helper(
1515
self,
16-
obj: Union[Body, Cookie, Header, QueryParam],
16+
obj: Union[Body, Cookie, Header, QueryParam, PathParam],
1717
name: Optional[str] = None,
1818
) -> Optional[Dict[str, Any]]:
1919
raise NotImplementedError

0 commit comments

Comments
 (0)