Skip to content

Commit 2ac4e8e

Browse files
author
Raphael Krupinski
committed
✨ Support response envelope.
1 parent 952a506 commit 2ac4e8e

File tree

11 files changed

+213
-59
lines changed

11 files changed

+213
-59
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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
'NamedAuth',
55
'ParamStyle',
66
'RequestBody',
7+
'ResponseEnvelope',
8+
'ResponseHeader',
9+
'ResponseBody',
710
'Responses',
811
'SecurityRequirements',
912
'delete',
@@ -22,8 +25,8 @@
2225
from .client_base import ClientBase
2326
from .model import ModelBase
2427
from .model.encode_param import ParamStyle
25-
from .model.params import RequestBody
26-
from .model.response_map import Responses
28+
from .model.params import Body as RequestBody
29+
from .model.response import Body as ResponseBody, Header as ResponseHeader, ResponseEnvelope, Responses
2730
from .operation import delete, get, head, patch, post, put, trace
2831
from .param import Cookie, Header, Path, Query
2932
from .types_ import NamedAuth, SecurityRequirements

src/lapidary/runtime/model/op.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import dataclasses as dc
22
import inspect
3+
from collections.abc import Sequence
4+
from typing import Union
35

46
import httpx
7+
import pydantic.fields
58
import typing_extensions as typing
69

7-
from ..response import find_type, parse_model
10+
from ..response import find_type
811
from .params import ParameterAnnotation, RequestPartHandler, find_annotations, process_params
9-
from .response_map import ResponseMap, Responses
12+
from .response import DefaultEnvelope, PropertyAnnotation, ResponseEnvelope, ResponseMap, ResponsePartHandler, Responses
1013

1114
if typing.TYPE_CHECKING:
1215
from .request import RequestBuilder
@@ -43,20 +46,43 @@ def handle_response(self, response: httpx.Response) -> typing.Any:
4346
if typ is None:
4447
return None
4548

46-
obj: typing.Any = parse_model(response, typ)
47-
48-
if isinstance(obj, Exception):
49-
raise obj
49+
fields = {}
50+
for field_name, field_info in typ.model_fields.items():
51+
field_info: pydantic.fields.FieldInfo
52+
handlers: Sequence[Union[ResponsePartHandler, type[ResponsePartHandler]]] = [
53+
anno
54+
for anno in field_info.metadata
55+
if isinstance(anno, ResponsePartHandler) or (inspect.isclass(anno) and issubclass(anno, ResponsePartHandler))
56+
]
57+
assert len(handlers) == 1
58+
handler = handlers[0]
59+
if inspect.isclass(handler):
60+
handler = handler()
61+
if isinstance(handler, PropertyAnnotation):
62+
handler.supply_formal(field_name, field_info.annotation)
63+
handler.apply(fields, response)
64+
obj = typ.parse_obj(fields)
65+
# obj: typing.Any = parse_model(response, typ)
66+
67+
if isinstance(obj, DefaultEnvelope):
68+
if isinstance(obj.body, Exception):
69+
raise obj.body
70+
return obj.body
5071
else:
5172
return obj
5273

5374

5475
def get_response_map(return_anno: type) -> ResponseMap:
55-
annos = find_annotations(return_anno, Responses)
76+
annos: typing.Sequence[Responses] = find_annotations(return_anno, Responses)
5677
if len(annos) != 1:
5778
raise TypeError('Operation function must have exactly one Responses annotation')
5879

59-
return annos[0].responses
80+
responses = annos[0].responses
81+
for media_type_map in responses.values():
82+
for media_type, typ in media_type_map.items():
83+
if not issubclass(typ, ResponseEnvelope):
84+
media_type_map[media_type] = DefaultEnvelope[typ]
85+
return responses
6086

6187

6288
def get_operation_model(

src/lapidary/runtime/model/params.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def __eq__(self, other):
7070

7171

7272
@dc.dataclass
73-
class RequestBody(RequestPartHandler, ParameterAnnotation):
73+
class Body(RequestPartHandler, ParameterAnnotation):
7474
name: str = dc.field(init=False)
7575
content: typing.Mapping[str, type]
7676
_serializer: pydantic.TypeAdapter = dc.field(init=False)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import abc
2+
import dataclasses as dc
3+
from collections.abc import Callable
4+
from typing import Any
5+
6+
import httpx
7+
import pydantic
8+
import typing_extensions as typing
9+
10+
T = typing.TypeVar('T')
11+
MimeType = str
12+
ResponseCode = str
13+
14+
MimeMap = typing.MutableMapping[MimeType, type]
15+
ResponseMap = typing.Mapping[ResponseCode, MimeMap]
16+
17+
18+
@dc.dataclass
19+
class Responses:
20+
responses: ResponseMap
21+
22+
23+
class ResponseEnvelopeBuilder:
24+
def __init__(self, typ: type[pydantic.BaseModel]) -> None:
25+
self._type = typ
26+
self.fields = {}
27+
28+
def build(self) -> Any:
29+
return self._type.model_construct(**self.fields)
30+
31+
32+
class ResponsePartHandler(abc.ABC):
33+
@abc.abstractmethod
34+
def apply(self, fields: typing.MutableMapping, response: httpx.Response) -> None:
35+
pass
36+
37+
38+
class PropertyAnnotation(abc.ABC):
39+
_name: str
40+
_type: type
41+
42+
def supply_formal(self, name: str, type_: type) -> None:
43+
self._name = name
44+
self._type = type_
45+
46+
47+
class Body(ResponsePartHandler, PropertyAnnotation):
48+
_parse: Callable[[...], Any]
49+
50+
def supply_formal(self, name: str, type_: type) -> None:
51+
super().supply_formal(name, type_)
52+
self._parse = pydantic.TypeAdapter(type_).validate_json
53+
54+
def apply(self, fields: typing.MutableMapping, response: httpx.Response) -> None:
55+
fields[self._name] = self._parse(response.text)
56+
57+
58+
class Header(ResponsePartHandler, PropertyAnnotation):
59+
def __init__(self, header: str) -> None:
60+
self._header = header
61+
62+
def apply(self, fields: typing.MutableMapping, response: httpx.Response) -> None:
63+
fields[self._name] = response.headers.get(self._header, None)
64+
65+
66+
class ResponseEnvelope(abc.ABC, pydantic.BaseModel):
67+
"""Marker interface for response envelopes."""
68+
69+
70+
BodyT = typing.TypeVar('BodyT')
71+
72+
73+
class DefaultEnvelope(ResponseEnvelope, typing.Generic[BodyT]):
74+
body: typing.Annotated[BodyT, Body]

src/lapidary/runtime/model/response_map.py

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/lapidary/runtime/request.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from .mime import find_mime
66
from .model.op import OperationModel
77
from .model.request import RequestBuilder, RequestFactory
8-
from .model.response_map import ResponseMap
8+
from .model.response import ResponseMap
99

1010

1111
def get_accept_header(response_map: typing.Optional[ResponseMap]) -> typing.Optional[str]:

src/lapidary/runtime/response.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
1-
import inspect
21
import logging
32

43
import httpx
5-
import pydantic
64
import typing_extensions as typing
75

86
from .http_consts import CONTENT_TYPE
97
from .mime import find_mime
10-
from .model.response_map import ResponseMap
8+
from .model.response import ResponseEnvelope, ResponseMap
119

1210
logger = logging.getLogger(__name__)
1311

1412
T = typing.TypeVar('T')
1513
P = typing.TypeVar('P')
1614

1715

18-
def parse_model(response: httpx.Response, typ: type[T]) -> T:
19-
if inspect.isclass(typ):
20-
if issubclass(typ, pydantic.BaseModel):
21-
return typing.cast(T, typ.model_validate_json(response.content))
22-
23-
return pydantic.TypeAdapter(typ).validate_json(response.content)
24-
25-
26-
def find_type(response: httpx.Response, response_map: ResponseMap) -> typing.Optional[type]:
16+
def find_type(response: httpx.Response, response_map: ResponseMap) -> typing.Optional[type[ResponseEnvelope]]:
2717
status_code = str(response.status_code)
2818
if CONTENT_TYPE not in response.headers:
2919
return None

0 commit comments

Comments
 (0)