Skip to content

Commit fed7e51

Browse files
author
Raphael Krupinski
committed
Merge branch 'refs/heads/feature/metadata-model' into develop
2 parents c38b606 + 6782595 commit fed7e51

File tree

16 files changed

+419
-282
lines changed

16 files changed

+419
-282
lines changed

ChangeLog.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,17 @@ All notable changes to this project will be documented in this file.
55
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
66
and the format of this file is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
77

8+
9+
## [NEXT]
10+
### Added
11+
- Support response envelope objects to allow returning headers together with the body model.
12+
13+
814
## [0.9.1](https://github.com/python-lapidary/lapidary/releases/tag/v0.9.0) - 2024-05-25
915
### Fixed
1016
- Moved pytest-asyncio dependency to dev group.
1117

18+
1219
## [0.9.0](https://github.com/python-lapidary/lapidary/releases/tag/v0.9.0) - 2024-05-16
1320
### Added
1421
- Added a default user-agent header.
@@ -23,6 +30,7 @@ and the format of this file is based on [Keep a Changelog](https://keepachangelo
2330
- Removed support for invalid HTTP status codes patterns, like 20X ([OAS3.1 4.8.16.2](https://spec.openapis.org/oas/v3.1.0#patterned-fields-0)).
2431
- Removed Absent class #50.
2532

33+
2634
## [0.8.0](https://github.com/python-lapidary/lapidary/releases/tag/v0.8.0) - 2023-01-02
2735
### Added
2836
- Support for arbitrary specification extensions (x- properties).
@@ -32,19 +40,23 @@ and the format of this file is based on [Keep a Changelog](https://keepachangelo
3240
- Property cross-validation (e.g. only one property of example and examples is allowed).
3341
- Bearer security scheme.
3442

43+
3544
## [0.7.3](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.3) - 2022-12-15
3645
### Fixed
3746
- None error on missing x-lapidary-responses-global
3847
- Enum params are rendered as their string representation instead of value.
3948

49+
4050
## [0.7.2](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.2) - 2022-12-15
4151
### Fixed
4252
- platformdirs dependency missing from pyproject.
4353

54+
4455
## [0.7.1](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.1) - 2022-12-15
4556
### Fixed
4657
- Error while constructing a type.
4758

59+
4860
## [0.7.0](https://github.com/python-lapidary/lapidary/releases/tag/v0.7.0) - 2022-12-15
4961
### Added
5062
- Support for api responses.

docs/usage/operation.md

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,15 @@ to JSON using Pydantic's BaseModel.model_dump_json() and included in the body of
171171
## Return type
172172

173173
The Responses annotation plays a crucial role in mapping HTTP status codes and Content-Type headers to specific return
174-
types. This mechanism allows developers to define how responses are parsed and returned with high precision.
174+
types. This mechanism allows developers to define how responses are parsed and returned.
175175

176-
The return type is specified in two essential places:
176+
The return type is specified in two places:
177177

178178
1. At the method signature level - The declared return type here should reflect the expected successful response
179179
structure. It can be a single type or a Union of types, accommodating various potential non-error response bodies.
180180

181-
2. Within the Responses annotation - This details the specific type or types to be used for parsing the response body,
182-
contingent upon the response's HTTP status code and content type matching those specified.
181+
2. Within the `Responses` annotation - This details the specific type or types to be used for parsing the response body,
182+
depending on the response's HTTP status code and content type matching those specified.
183183

184184
!!! Note
185185

@@ -189,12 +189,7 @@ Example:
189189

190190
```python
191191
@get('/cat')
192-
async def list_cats(
193-
self: Self,
194-
cat: Annotated[Cat, RequestBody({
195-
'application/json': Cat,
196-
})],
197-
) -> Annotated[
192+
async def list_cats(self: Self) -> Annotated[
198193
List[Cat],
199194
Responses({
200195
'2XX': {
@@ -210,6 +205,38 @@ application/json, the response body will be parsed as a list of Cat objects. Thi
210205
method's return type is tightly coupled with the anticipated successful response structure, providing clarity and type
211206
safety for API interactions.
212207

208+
209+
### Mapping headers
210+
211+
Lapidary supports an additional response type that envelops the response body and allows declaring response headers.
212+
213+
Example:
214+
215+
```python
216+
217+
class CatsListResponse(ResponseEnvelope):
218+
body: Annotated[List[Cat], ResponseBody()]
219+
total_count: Annotated[int, ResponseHeader('X-Total-Count')]
220+
221+
class CatClient(ClientBase):
222+
@get('/cat')
223+
async def list_cats(self: Self) -> Annotated[
224+
CatsListResponse,
225+
Responses({
226+
'2XX': {
227+
'application/json': CatsListResponse,
228+
},
229+
})
230+
]:
231+
pass
232+
233+
client = CatClient()
234+
cats_response = await client.list_cats()
235+
assert cats_response.body == [Cat(...)]
236+
assert cats_response.count == 1
237+
```
238+
239+
213240
### Handling error responses
214241

215242
Lapidary allows you to map specific responses to exceptions. This feature is commonly used for error responses, like

src/lapidary/runtime/__init__.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
'NamedAuth',
55
'ParamStyle',
66
'RequestBody',
7+
'ResponseEnvelope',
8+
'ResponseBody',
79
'Responses',
810
'SecurityRequirements',
911
'delete',
@@ -21,9 +23,16 @@
2123

2224
from .client_base import ClientBase
2325
from .model import ModelBase
26+
from .model.annotations import (
27+
Cookie,
28+
Header,
29+
Path,
30+
Query,
31+
RequestBody,
32+
ResponseBody,
33+
Responses,
34+
)
2435
from .model.encode_param import ParamStyle
25-
from .model.params import RequestBody
26-
from .model.response_map import Responses
36+
from .model.response import ResponseEnvelope
2737
from .operation import delete, get, head, patch, post, put, trace
28-
from .param import Cookie, Header, Path, Query
2938
from .types_ import NamedAuth, SecurityRequirements

src/lapidary/runtime/client_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
from typing_extensions import Self
99

1010
from ._httpx import AuthType
11-
from .model.op import OperationModel, get_operation_model
11+
from .model.op import OperationModel
1212
from .model.request import RequestFactory
13+
from .operation import get_operation_model
1314
from .request import build_request
1415
from .types_ import MultiAuth, NamedAuth, SecurityRequirements
1516

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import abc
2+
import dataclasses as dc
3+
import inspect
4+
from typing import TYPE_CHECKING, Callable, Optional
5+
6+
import httpx
7+
import pydantic
8+
import typing_extensions as typing
9+
10+
from ..http_consts import CONTENT_TYPE, MIME_JSON
11+
from .encode_param import ParamStyle, get_encode_fn
12+
from .request import RequestHandler
13+
from .response import ResponseHandler
14+
15+
if TYPE_CHECKING:
16+
from .._httpx import ParamValue
17+
from .encode_param import Encoder
18+
from .request import RequestBuilder
19+
20+
21+
class NameTypeAwareAnnotation(abc.ABC):
22+
_name: str
23+
_type: type
24+
25+
def supply_formal(self, name: str, typ: type) -> None:
26+
self._name = name
27+
self._type = typ
28+
29+
30+
@dc.dataclass
31+
class RequestBody(NameTypeAwareAnnotation, RequestHandler):
32+
content: typing.Mapping[str, type]
33+
_serializer: Callable[[typing.Any], bytes] = dc.field(init=False)
34+
35+
def supply_formal(self, name: str, typ: type) -> None:
36+
super().supply_formal(name, typ)
37+
self._serializer = lambda content: pydantic.TypeAdapter(typ).dump_json(
38+
content,
39+
by_alias=True,
40+
exclude_unset=True,
41+
)
42+
43+
def apply_request(self, builder: 'RequestBuilder', value: typing.Any) -> None:
44+
content_type = self.find_content_type(value)
45+
builder.headers[CONTENT_TYPE] = content_type
46+
47+
plain_content_type = content_type[: content_type.index(';')] if ';' in content_type else content_type
48+
if plain_content_type == MIME_JSON:
49+
builder.content = self._serializer(value)
50+
else:
51+
raise NotImplementedError(content_type)
52+
53+
def find_content_type(self, value: typing.Any) -> str:
54+
for content_type, typ in self.content.items():
55+
origin_type = typing.get_origin(typ) or typ
56+
if isinstance(value, origin_type):
57+
return content_type
58+
59+
raise ValueError('Could not determine content type')
60+
61+
62+
class ResponseBody(NameTypeAwareAnnotation, ResponseHandler):
63+
"""Annotate response body within an Envelope type."""
64+
65+
_parse: Callable[[bytes], typing.Any]
66+
67+
def supply_formal(self, name: str, typ: type) -> None:
68+
super().supply_formal(name, typ)
69+
self._parse = pydantic.TypeAdapter(typ).validate_json
70+
71+
def apply_response(self, response: httpx.Response, fields: typing.MutableMapping) -> None:
72+
fields[self._name] = self._parse(response.content)
73+
74+
75+
class Param(RequestHandler, NameTypeAwareAnnotation, abc.ABC):
76+
style: ParamStyle
77+
alias: typing.Optional[str]
78+
explode: typing.Optional[bool]
79+
_encoder: 'typing.Optional[Encoder]'
80+
81+
def __init__(self, alias: Optional[str], /, *, style: ParamStyle, explode: Optional[bool]) -> None:
82+
self.alias = alias
83+
self.style = style
84+
self.explode = explode
85+
self._encoder = None
86+
87+
def _get_explode(self) -> bool:
88+
return self.explode or self.style == ParamStyle.form
89+
90+
@abc.abstractmethod
91+
def _apply_request(self, builder: 'RequestBuilder', value: 'ParamValue'):
92+
pass
93+
94+
def apply_request(self, builder: 'RequestBuilder', value: typing.Any) -> None:
95+
if not value:
96+
return
97+
98+
if not self._encoder:
99+
self._encoder = get_encode_fn(self._type, self.style, self._get_explode())
100+
assert self._encoder
101+
value = self._encoder(self._name, value)
102+
self._apply_request(builder, value)
103+
104+
@property
105+
def http_name(self) -> str:
106+
return self.alias or self._name
107+
108+
def __eq__(self, other):
109+
if not isinstance(other, type(self)):
110+
raise NotImplementedError
111+
return self.__dict__ == other.__dict__
112+
113+
114+
T = typing.TypeVar('T')
115+
116+
117+
def find_annotations(
118+
user_type: type,
119+
annotation_type: type[T],
120+
) -> typing.Sequence[T]:
121+
if user_type is inspect.Signature.empty or '__metadata__' not in dir(user_type):
122+
return ()
123+
return [anno for anno in user_type.__metadata__ if isinstance(anno, annotation_type)] # type: ignore[attr-defined]
124+
125+
126+
MimeType: typing.TypeAlias = str
127+
ResponseCode: typing.TypeAlias = str
128+
MimeMap: typing.TypeAlias = typing.MutableMapping[MimeType, type]
129+
ResponseMap: typing.TypeAlias = typing.Mapping[ResponseCode, MimeMap]
130+
131+
132+
@dc.dataclass
133+
class Responses:
134+
responses: ResponseMap
135+
136+
137+
class Header(Param, ResponseHandler):
138+
def __init__(
139+
self,
140+
alias: Optional[str] = None,
141+
/,
142+
*,
143+
style: ParamStyle = ParamStyle.simple,
144+
explode: Optional[bool] = None,
145+
) -> None:
146+
super().__init__(alias, style=style, explode=explode)
147+
148+
def _apply_request(self, builder: 'RequestBuilder', value: 'ParamValue') -> None:
149+
builder.headers[self.http_name] = str(value)
150+
151+
def apply_response(self, response: 'httpx.Response', fields: typing.MutableMapping[str, typing.Any]) -> None:
152+
# TODO decode
153+
if self.http_name in response.headers:
154+
fields[self._name] = response.headers[self.http_name]
155+
156+
157+
class Cookie(Param, ResponseHandler):
158+
def __init__(
159+
self,
160+
alias: Optional[str] = None,
161+
/,
162+
*,
163+
style: ParamStyle = ParamStyle.form,
164+
explode: Optional[bool] = None,
165+
) -> None:
166+
super().__init__(alias, style=style, explode=explode)
167+
168+
def _apply_request(self, builder: 'RequestBuilder', value: typing.Any) -> None:
169+
builder.cookies[self.http_name] = value
170+
171+
def apply_response(self, response: 'httpx.Response', fields: typing.MutableMapping[str, typing.Any]) -> None:
172+
# TODO handle decoding
173+
if self.http_name in response.headers:
174+
fields[self._name] = response.cookies[self.http_name]
175+
176+
177+
class Path(Param):
178+
def __init__(
179+
self,
180+
alias: Optional[str] = None,
181+
/,
182+
*,
183+
style: ParamStyle = ParamStyle.simple,
184+
explode: Optional[bool] = None,
185+
) -> None:
186+
super().__init__(alias, style=style, explode=explode)
187+
188+
def _apply_request(self, builder: 'RequestBuilder', value: typing.Any) -> None:
189+
builder.path_params[self.http_name] = value
190+
191+
192+
class Query(Param):
193+
def __init__(
194+
self,
195+
alias: Optional[str] = None,
196+
/,
197+
*,
198+
style: ParamStyle = ParamStyle.form,
199+
explode: Optional[bool] = None,
200+
) -> None:
201+
super().__init__(alias, style=style, explode=explode)
202+
203+
def _apply_request(self, builder: 'RequestBuilder', value: typing.Any) -> None:
204+
builder.query_params.append((self.http_name, value))
205+
206+
207+
class Link(NameTypeAwareAnnotation, ResponseHandler):
208+
def __init__(self, name: str) -> None:
209+
self._link_name = name
210+
211+
def apply_response(self, response: httpx.Response, fields: typing.MutableMapping[str, typing.Any]) -> None:
212+
if 'Link' in response.headers:
213+
links = response.links
214+
if self._link_name in links:
215+
fields[self._name] = links[self._link_name]

0 commit comments

Comments
 (0)