Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,55 @@ deprecation marker, examples etc.
</tr>
</table>

### 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.

<table>
<tr>
<th>Possible types</th>
<th>Examples</th>
</tr>
<tr>
<td> <code>Mapping[str, Type]</code> </td>
<td>

```python
{"id": UUID}
```

</td>
</tr>
<tr>
<td> <code>Mapping[str, PathParam]</code> </td>
<td>

```python
{
"id": PathParam(
value_type=UUID,
description="ID of the book",
example="eab9d66d-4317-464a-a995-510bd6e2148f",
)
}
```

</td>
</tr>
</table>

#### headers

Dictionary of request headers applicable for the operation, where the key is
Expand Down
3 changes: 2 additions & 1 deletion openapify/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,6 +20,7 @@
"Body",
"Header",
"QueryParam",
"PathParam",
"Example",
"BasePlugin",
]
6 changes: 3 additions & 3 deletions openapify/core/base_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
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


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:
Expand Down Expand Up @@ -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(
Expand Down
45 changes: 41 additions & 4 deletions openapify/core/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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))
Expand All @@ -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={
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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]]:
Expand Down
13 changes: 10 additions & 3 deletions openapify/core/models.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand All @@ -16,14 +15,22 @@
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
method: str
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


Expand Down
11 changes: 11 additions & 0 deletions openapify/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Body,
Cookie,
Header,
PathParam,
QueryParam,
SecurityRequirement,
TypeAnnotation,
Expand All @@ -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]: ...
Expand All @@ -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]: ...
Expand All @@ -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]:
Expand All @@ -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,
},
Expand Down
34 changes: 11 additions & 23 deletions openapify/ext/web/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
Mapping,
Expand All @@ -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

Expand All @@ -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"{([^:{}]+)(?::(.+))?}")
Expand Down Expand Up @@ -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)
Expand All @@ -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


Expand Down
4 changes: 2 additions & 2 deletions openapify/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down