Skip to content

Commit 6782595

Browse files
author
Raphael Krupinski
committed
🚧♻️✨ Consolidate request and response header handling. Support response cookies and add basic support for links.
1 parent 12f33dc commit 6782595

File tree

13 files changed

+305
-314
lines changed

13 files changed

+305
-314
lines changed

‎src/lapidary/runtime/__init__.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
'ParamStyle',
66
'RequestBody',
77
'ResponseEnvelope',
8-
'ResponseHeader',
98
'ResponseBody',
109
'Responses',
1110
'SecurityRequirements',
@@ -24,9 +23,16 @@
2423

2524
from .client_base import ClientBase
2625
from .model import ModelBase
26+
from .model.annotations import (
27+
Cookie,
28+
Header,
29+
Path,
30+
Query,
31+
RequestBody,
32+
ResponseBody,
33+
Responses,
34+
)
2735
from .model.encode_param import ParamStyle
28-
from .model.params import Body as RequestBody
29-
from .model.response import Body as ResponseBody, Header as ResponseHeader, ResponseEnvelope, Responses
36+
from .model.response import ResponseEnvelope
3037
from .operation import delete, get, head, patch, post, put, trace
31-
from .param import Cookie, Header, Path, Query
3238
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]

‎src/lapidary/runtime/model/op.py

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import dataclasses as dc
2-
import inspect
32
from collections.abc import Sequence
43

54
import httpx
65
import typing_extensions as typing
76

87
from ..response import find_type
9-
from .params import ParameterAnnotation, RequestPartHandler, find_annotations, process_params
10-
from .response import DefaultEnvelope, PropertyAnnotation, ResponseEnvelope, ResponseMap, ResponsePartHandler, Responses
8+
from .annotations import NameTypeAwareAnnotation, ResponseBody, ResponseMap
9+
from .request import RequestHandler
10+
from .response import ResponseEnvelope, ResponseHandler
1111

1212
if typing.TYPE_CHECKING:
1313
from .request import RequestBuilder
@@ -17,7 +17,7 @@
1717
class OperationModel:
1818
method: str
1919
path: str
20-
params: typing.Mapping[str, ParameterAnnotation]
20+
params: typing.Mapping[str, NameTypeAwareAnnotation]
2121
response_map: ResponseMap
2222

2323
def process_params(
@@ -27,8 +27,8 @@ def process_params(
2727
) -> None:
2828
for param_name, value in actual_params.items():
2929
param_handler = self.params[param_name]
30-
if isinstance(param_handler, RequestPartHandler):
31-
param_handler.apply(request, actual_params[param_name])
30+
if isinstance(param_handler, RequestHandler):
31+
param_handler.apply_request(request, actual_params[param_name])
3232
else:
3333
raise TypeError(param_name, type(value))
3434

@@ -46,14 +46,14 @@ def handle_response(self, response: httpx.Response) -> typing.Any:
4646

4747
fields: typing.MutableMapping[str, typing.Any] = {}
4848
for field_name, field_info in typ.model_fields.items():
49-
handlers: Sequence[ResponsePartHandler] = [anno for anno in field_info.metadata if isinstance(anno, ResponsePartHandler)]
49+
handlers: Sequence[ResponseHandler] = [anno for anno in field_info.metadata if isinstance(anno, ResponseHandler)]
5050
assert len(handlers) == 1
5151
handler = handlers[0]
52-
if isinstance(handler, PropertyAnnotation):
52+
if isinstance(handler, NameTypeAwareAnnotation):
5353
field_type = field_info.annotation
5454
assert field_type
5555
handler.supply_formal(field_name, field_type)
56-
handler.apply(fields, response)
56+
handler.apply_response(response, fields)
5757
obj = typ.parse_obj(fields)
5858
# obj: typing.Any = parse_model(response, typ)
5959

@@ -65,28 +65,8 @@ def handle_response(self, response: httpx.Response) -> typing.Any:
6565
return obj
6666

6767

68-
def get_response_map(return_anno: type) -> ResponseMap:
69-
annos: typing.Sequence[Responses] = find_annotations(return_anno, Responses)
70-
if len(annos) != 1:
71-
raise TypeError('Operation function must have exactly one Responses annotation')
68+
BodyT = typing.TypeVar('BodyT')
7269

73-
responses = annos[0].responses
74-
for media_type_map in responses.values():
75-
for media_type, typ in media_type_map.items():
76-
if not issubclass(typ, ResponseEnvelope):
77-
media_type_map[media_type] = DefaultEnvelope[typ] # type: ignore[valid-type]
78-
return responses
7970

80-
81-
def get_operation_model(
82-
method: str,
83-
path: str,
84-
fn: typing.Callable,
85-
) -> OperationModel:
86-
sig = inspect.signature(fn)
87-
return OperationModel(
88-
method=method,
89-
path=path,
90-
params=process_params(sig),
91-
response_map=get_response_map(sig.return_annotation),
92-
)
71+
class DefaultEnvelope(ResponseEnvelope, typing.Generic[BodyT]):
72+
body: typing.Annotated[BodyT, ResponseBody()]

0 commit comments

Comments
 (0)