patera-apiinterface 0.9.0__tar.gz → 0.10.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {patera_apiinterface-0.9.0 → patera_apiinterface-0.10.0}/PKG-INFO +1 -1
- {patera_apiinterface-0.9.0 → patera_apiinterface-0.10.0}/pyproject.toml +1 -1
- patera_apiinterface-0.10.0/src/patera/apiinterface/__init__.py +33 -0
- patera_apiinterface-0.10.0/src/patera/apiinterface/api_interface.py +1008 -0
- patera_apiinterface-0.9.0/src/patera/apiinterface/__init__.py +0 -25
- patera_apiinterface-0.9.0/src/patera/apiinterface/api_interface.py +0 -304
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Declarative interfaces for external HTTP APIs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .api_interface import (
|
|
6
|
+
AuthType,
|
|
7
|
+
service,
|
|
8
|
+
method,
|
|
9
|
+
resource,
|
|
10
|
+
consumes,
|
|
11
|
+
produces,
|
|
12
|
+
headers,
|
|
13
|
+
timeout,
|
|
14
|
+
auth,
|
|
15
|
+
no_auth,
|
|
16
|
+
MissingApiInterfaceConfigurations,
|
|
17
|
+
ApiInterface,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AuthType",
|
|
22
|
+
"service",
|
|
23
|
+
"method",
|
|
24
|
+
"resource",
|
|
25
|
+
"consumes",
|
|
26
|
+
"produces",
|
|
27
|
+
"headers",
|
|
28
|
+
"timeout",
|
|
29
|
+
"auth",
|
|
30
|
+
"no_auth",
|
|
31
|
+
"MissingApiInterfaceConfigurations",
|
|
32
|
+
"ApiInterface",
|
|
33
|
+
]
|
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interface for declarative external API integrations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import inspect
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from typing import (
|
|
14
|
+
Any,
|
|
15
|
+
Callable,
|
|
16
|
+
Generic,
|
|
17
|
+
Optional,
|
|
18
|
+
TypeVar,
|
|
19
|
+
cast,
|
|
20
|
+
get_type_hints,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
from httpx import Response
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from patera import (
|
|
28
|
+
BaseExtension,
|
|
29
|
+
HttpMethod,
|
|
30
|
+
MediaType,
|
|
31
|
+
Patera,
|
|
32
|
+
Request,
|
|
33
|
+
UploadedFile,
|
|
34
|
+
)
|
|
35
|
+
from patera.ctx import current_request, has_request_context
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AuthType(StrEnum):
|
|
39
|
+
BASIC_AUTH = "basic_auth"
|
|
40
|
+
BEARER = "bearer_token"
|
|
41
|
+
API_KEY = "api_key"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
F = TypeVar(
|
|
45
|
+
"F",
|
|
46
|
+
bound=Callable[..., Any],
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
DecoratorTargetT = TypeVar(
|
|
50
|
+
"DecoratorTargetT",
|
|
51
|
+
bound=Callable[..., Any] | type[Any],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
ClassT = TypeVar(
|
|
55
|
+
"ClassT",
|
|
56
|
+
bound=type[Any],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
AppT = TypeVar(
|
|
60
|
+
"AppT",
|
|
61
|
+
bound="Patera[Any]",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_NO_AUTH = object()
|
|
66
|
+
_MISSING_BODY = object()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _set_api_interface_config(
|
|
70
|
+
target: DecoratorTargetT,
|
|
71
|
+
key: str,
|
|
72
|
+
value: Any,
|
|
73
|
+
*,
|
|
74
|
+
allow_class: bool,
|
|
75
|
+
) -> DecoratorTargetT:
|
|
76
|
+
"""
|
|
77
|
+
Add configuration metadata to an API interface class or method.
|
|
78
|
+
|
|
79
|
+
Class-level defaults are stored in __api_interface_defaults__.
|
|
80
|
+
Method-level configuration is stored in __api_interface__.
|
|
81
|
+
"""
|
|
82
|
+
if inspect.isclass(target):
|
|
83
|
+
if not allow_class:
|
|
84
|
+
raise TypeError(f"@{key} cannot be applied to a class.")
|
|
85
|
+
|
|
86
|
+
configs: dict[str, Any] = dict(
|
|
87
|
+
getattr(
|
|
88
|
+
target,
|
|
89
|
+
"__api_interface_defaults__",
|
|
90
|
+
{},
|
|
91
|
+
)
|
|
92
|
+
or {}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
attribute_name = "__api_interface_defaults__"
|
|
96
|
+
|
|
97
|
+
else:
|
|
98
|
+
configs = dict(
|
|
99
|
+
getattr(
|
|
100
|
+
target,
|
|
101
|
+
"__api_interface__",
|
|
102
|
+
{},
|
|
103
|
+
)
|
|
104
|
+
or {}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
attribute_name = "__api_interface__"
|
|
108
|
+
|
|
109
|
+
if key == "custom_headers":
|
|
110
|
+
existing_headers = dict(
|
|
111
|
+
configs.get(
|
|
112
|
+
"custom_headers",
|
|
113
|
+
{},
|
|
114
|
+
)
|
|
115
|
+
or {}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
configs[key] = {
|
|
119
|
+
**existing_headers,
|
|
120
|
+
**cast(
|
|
121
|
+
dict[str, Any],
|
|
122
|
+
value,
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
else:
|
|
127
|
+
configs[key] = value
|
|
128
|
+
|
|
129
|
+
setattr(
|
|
130
|
+
target,
|
|
131
|
+
attribute_name,
|
|
132
|
+
configs,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return target
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def service(
|
|
139
|
+
service_url: str,
|
|
140
|
+
) -> Callable[[ClassT], ClassT]:
|
|
141
|
+
"""
|
|
142
|
+
Configure the base URL of an API interface.
|
|
143
|
+
|
|
144
|
+
The supplied value can be an application configuration key or a literal
|
|
145
|
+
URL. Application configuration is checked first.
|
|
146
|
+
|
|
147
|
+
The exact decorated class type is preserved for static type checking.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def decorator(
|
|
151
|
+
cls: ClassT,
|
|
152
|
+
) -> ClassT:
|
|
153
|
+
setattr(
|
|
154
|
+
cls,
|
|
155
|
+
"__service_url__",
|
|
156
|
+
service_url,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return cls
|
|
160
|
+
|
|
161
|
+
return decorator
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def method(
|
|
165
|
+
http_method: HttpMethod,
|
|
166
|
+
) -> Callable[[F], F]:
|
|
167
|
+
"""
|
|
168
|
+
Configure the HTTP method of an API interface method.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
def decorator(
|
|
172
|
+
func: F,
|
|
173
|
+
) -> F:
|
|
174
|
+
return _set_api_interface_config(
|
|
175
|
+
func,
|
|
176
|
+
"method",
|
|
177
|
+
http_method,
|
|
178
|
+
allow_class=False,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return decorator
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def resource(
|
|
185
|
+
url: str,
|
|
186
|
+
follow_redirects: bool = True,
|
|
187
|
+
) -> Callable[[F], F]:
|
|
188
|
+
"""
|
|
189
|
+
Configure the remote resource URL of an API interface method.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def decorator(
|
|
193
|
+
func: F,
|
|
194
|
+
) -> F:
|
|
195
|
+
configured_func = _set_api_interface_config(
|
|
196
|
+
func,
|
|
197
|
+
"target",
|
|
198
|
+
url,
|
|
199
|
+
allow_class=False,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return _set_api_interface_config(
|
|
203
|
+
configured_func,
|
|
204
|
+
"follow_redirects",
|
|
205
|
+
follow_redirects,
|
|
206
|
+
allow_class=False,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return decorator
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def consumes(
|
|
213
|
+
media: MediaType,
|
|
214
|
+
) -> Callable[[DecoratorTargetT], DecoratorTargetT]:
|
|
215
|
+
"""
|
|
216
|
+
Configure the outgoing request media type.
|
|
217
|
+
|
|
218
|
+
Can be applied to an API interface class as a default or to an individual
|
|
219
|
+
method as an override.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def decorator(
|
|
223
|
+
target: DecoratorTargetT,
|
|
224
|
+
) -> DecoratorTargetT:
|
|
225
|
+
return _set_api_interface_config(
|
|
226
|
+
target,
|
|
227
|
+
"consumes",
|
|
228
|
+
media,
|
|
229
|
+
allow_class=True,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return decorator
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def produces(
|
|
236
|
+
media: MediaType,
|
|
237
|
+
) -> Callable[[DecoratorTargetT], DecoratorTargetT]:
|
|
238
|
+
"""
|
|
239
|
+
Configure the expected response media type.
|
|
240
|
+
|
|
241
|
+
Can be applied to an API interface class as a default or to an individual
|
|
242
|
+
method as an override.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
def decorator(
|
|
246
|
+
target: DecoratorTargetT,
|
|
247
|
+
) -> DecoratorTargetT:
|
|
248
|
+
return _set_api_interface_config(
|
|
249
|
+
target,
|
|
250
|
+
"produces",
|
|
251
|
+
media,
|
|
252
|
+
allow_class=True,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return decorator
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def headers(
|
|
259
|
+
custom_headers: dict[str, Any],
|
|
260
|
+
) -> Callable[[DecoratorTargetT], DecoratorTargetT]:
|
|
261
|
+
"""
|
|
262
|
+
Configure custom request headers.
|
|
263
|
+
|
|
264
|
+
Class-level and method-level headers are merged. A method-level header
|
|
265
|
+
overrides a class-level header with the same name.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def decorator(
|
|
269
|
+
target: DecoratorTargetT,
|
|
270
|
+
) -> DecoratorTargetT:
|
|
271
|
+
return _set_api_interface_config(
|
|
272
|
+
target,
|
|
273
|
+
"custom_headers",
|
|
274
|
+
dict(custom_headers),
|
|
275
|
+
allow_class=True,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return decorator
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def timeout(
|
|
282
|
+
timeout_seconds: int | float,
|
|
283
|
+
) -> Callable[[DecoratorTargetT], DecoratorTargetT]:
|
|
284
|
+
"""
|
|
285
|
+
Configure the outgoing request timeout.
|
|
286
|
+
|
|
287
|
+
Can be applied to an API interface class as a default or to an individual
|
|
288
|
+
method as an override.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def decorator(
|
|
292
|
+
target: DecoratorTargetT,
|
|
293
|
+
) -> DecoratorTargetT:
|
|
294
|
+
return _set_api_interface_config(
|
|
295
|
+
target,
|
|
296
|
+
"timeout",
|
|
297
|
+
timeout_seconds,
|
|
298
|
+
allow_class=True,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
return decorator
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def auth(
|
|
305
|
+
auth_type: AuthType,
|
|
306
|
+
*,
|
|
307
|
+
username: str | None = None,
|
|
308
|
+
password: str | None = None,
|
|
309
|
+
token: str | None = None,
|
|
310
|
+
api_key: str | None = None,
|
|
311
|
+
header_name: str = "Authorization",
|
|
312
|
+
) -> Callable[[DecoratorTargetT], DecoratorTargetT]:
|
|
313
|
+
"""
|
|
314
|
+
Configure authentication on an API interface class or method.
|
|
315
|
+
|
|
316
|
+
BASIC_AUTH requires username and password.
|
|
317
|
+
BEARER requires token.
|
|
318
|
+
API_KEY requires api_key.
|
|
319
|
+
|
|
320
|
+
Credential values can be application configuration keys or literal values.
|
|
321
|
+
Application configuration is checked first.
|
|
322
|
+
"""
|
|
323
|
+
if auth_type == AuthType.BASIC_AUTH:
|
|
324
|
+
if username is None or password is None:
|
|
325
|
+
raise ValueError(
|
|
326
|
+
"Basic authentication requires both username and password."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
elif auth_type == AuthType.BEARER:
|
|
330
|
+
if token is None:
|
|
331
|
+
raise ValueError("Bearer authentication requires token.")
|
|
332
|
+
|
|
333
|
+
elif auth_type == AuthType.API_KEY:
|
|
334
|
+
if api_key is None:
|
|
335
|
+
raise ValueError("API-key authentication requires api_key.")
|
|
336
|
+
|
|
337
|
+
else:
|
|
338
|
+
raise ValueError(f"Unsupported API authentication type: {auth_type!r}")
|
|
339
|
+
|
|
340
|
+
auth_config: dict[str, Any] = {
|
|
341
|
+
"type": auth_type,
|
|
342
|
+
"username": username,
|
|
343
|
+
"password": password,
|
|
344
|
+
"token": token,
|
|
345
|
+
"api_key": api_key,
|
|
346
|
+
"header_name": header_name,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
def decorator(
|
|
350
|
+
target: DecoratorTargetT,
|
|
351
|
+
) -> DecoratorTargetT:
|
|
352
|
+
return _set_api_interface_config(
|
|
353
|
+
target,
|
|
354
|
+
"auth",
|
|
355
|
+
auth_config,
|
|
356
|
+
allow_class=True,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
return decorator
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def no_auth(
|
|
363
|
+
func: F,
|
|
364
|
+
) -> F:
|
|
365
|
+
"""
|
|
366
|
+
Disable inherited class-level authentication for one method.
|
|
367
|
+
"""
|
|
368
|
+
return _set_api_interface_config(
|
|
369
|
+
func,
|
|
370
|
+
"auth",
|
|
371
|
+
_NO_AUTH,
|
|
372
|
+
allow_class=False,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class MissingApiInterfaceConfigurations(Exception):
|
|
377
|
+
"""
|
|
378
|
+
Raised when required API interface configuration is missing.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class ApiInterface(
|
|
383
|
+
BaseExtension[AppT],
|
|
384
|
+
Generic[AppT],
|
|
385
|
+
):
|
|
386
|
+
"""
|
|
387
|
+
Base extension for declarative external API integrations.
|
|
388
|
+
|
|
389
|
+
API interface methods obtain the active Patera Request through
|
|
390
|
+
patera.ctx.current_request.
|
|
391
|
+
"""
|
|
392
|
+
|
|
393
|
+
def init(self) -> None:
|
|
394
|
+
service_url = getattr(
|
|
395
|
+
self,
|
|
396
|
+
"__service_url__",
|
|
397
|
+
None,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if service_url is None:
|
|
401
|
+
raise MissingApiInterfaceConfigurations(
|
|
402
|
+
"Missing service URL for API interface. "
|
|
403
|
+
"Use @service on the interface class."
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
self._service_url = self._app.get_conf(
|
|
407
|
+
service_url,
|
|
408
|
+
service_url,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def _wrap_method(
|
|
413
|
+
cls,
|
|
414
|
+
func: F,
|
|
415
|
+
) -> F:
|
|
416
|
+
@wraps(func)
|
|
417
|
+
async def inner(
|
|
418
|
+
self: ApiInterface[Any],
|
|
419
|
+
*args: Any,
|
|
420
|
+
**kwargs: Any,
|
|
421
|
+
) -> Any:
|
|
422
|
+
return await self._wrapper(
|
|
423
|
+
func,
|
|
424
|
+
*args,
|
|
425
|
+
**kwargs,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
return cast(
|
|
429
|
+
F,
|
|
430
|
+
inner,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def _get_method_configs(
|
|
434
|
+
self,
|
|
435
|
+
func: Callable[..., Any],
|
|
436
|
+
) -> dict[str, Any]:
|
|
437
|
+
raw_method_configs: Optional[dict[str, Any]] = getattr(
|
|
438
|
+
func,
|
|
439
|
+
"__api_interface__",
|
|
440
|
+
None,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if raw_method_configs is None:
|
|
444
|
+
raise MissingApiInterfaceConfigurations(
|
|
445
|
+
f"API interface method {func.__name__} does not have any "
|
|
446
|
+
"configuration. Add at least @resource to the method."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
class_configs: dict[str, Any] = dict(
|
|
450
|
+
getattr(
|
|
451
|
+
type(self),
|
|
452
|
+
"__api_interface_defaults__",
|
|
453
|
+
{},
|
|
454
|
+
)
|
|
455
|
+
or {}
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
method_configs = dict(raw_method_configs)
|
|
459
|
+
|
|
460
|
+
configs = {
|
|
461
|
+
**class_configs,
|
|
462
|
+
**method_configs,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
class_headers = dict(
|
|
466
|
+
class_configs.get(
|
|
467
|
+
"custom_headers",
|
|
468
|
+
{},
|
|
469
|
+
)
|
|
470
|
+
or {}
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
method_headers = dict(
|
|
474
|
+
method_configs.get(
|
|
475
|
+
"custom_headers",
|
|
476
|
+
{},
|
|
477
|
+
)
|
|
478
|
+
or {}
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
if class_headers or method_headers:
|
|
482
|
+
configs["custom_headers"] = {
|
|
483
|
+
**class_headers,
|
|
484
|
+
**method_headers,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if method_configs.get("auth") is _NO_AUTH:
|
|
488
|
+
configs.pop(
|
|
489
|
+
"auth",
|
|
490
|
+
None,
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
return configs
|
|
494
|
+
|
|
495
|
+
async def _wrapper(
|
|
496
|
+
self,
|
|
497
|
+
func: Callable[..., Any],
|
|
498
|
+
*args: Any,
|
|
499
|
+
**kwargs: Any,
|
|
500
|
+
) -> Any:
|
|
501
|
+
configs = self._get_method_configs(func)
|
|
502
|
+
|
|
503
|
+
target = configs.get("target")
|
|
504
|
+
|
|
505
|
+
if target is None:
|
|
506
|
+
raise MissingApiInterfaceConfigurations(
|
|
507
|
+
f"API interface method {func.__name__} is missing @resource."
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# The active request (if any) supplies route parameters for <name>
|
|
511
|
+
# placeholders in the resource URL and the forwarded query string.
|
|
512
|
+
# Without a request context (background tasks, CLI) neither is used.
|
|
513
|
+
req: Optional[Request] = (
|
|
514
|
+
cast(Request, current_request.req) if has_request_context() else None
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
return_type = get_type_hints(func).get("return")
|
|
519
|
+
except Exception:
|
|
520
|
+
return_type = None
|
|
521
|
+
|
|
522
|
+
url = cast(
|
|
523
|
+
str,
|
|
524
|
+
self._service_url,
|
|
525
|
+
) + cast(
|
|
526
|
+
str,
|
|
527
|
+
target,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
url = self._format_url(
|
|
531
|
+
req,
|
|
532
|
+
url,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
follow_redirects = cast(
|
|
536
|
+
bool,
|
|
537
|
+
configs.get(
|
|
538
|
+
"follow_redirects",
|
|
539
|
+
True,
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
http_method = cast(
|
|
544
|
+
HttpMethod,
|
|
545
|
+
configs.get(
|
|
546
|
+
"method",
|
|
547
|
+
HttpMethod.GET,
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
request_timeout = configs.get(
|
|
552
|
+
"timeout",
|
|
553
|
+
10,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
request_headers = self._construct_headers(configs)
|
|
557
|
+
|
|
558
|
+
request_body = self._extract_request_body(
|
|
559
|
+
func,
|
|
560
|
+
args,
|
|
561
|
+
kwargs,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
request_options: dict[str, Any] = {
|
|
565
|
+
"headers": request_headers,
|
|
566
|
+
"timeout": request_timeout,
|
|
567
|
+
"follow_redirects": follow_redirects,
|
|
568
|
+
}
|
|
569
|
+
if req is not None:
|
|
570
|
+
request_options["params"] = req.query_parameters
|
|
571
|
+
|
|
572
|
+
if http_method != HttpMethod.GET and request_body is not _MISSING_BODY:
|
|
573
|
+
consumes_media = configs.get(
|
|
574
|
+
"consumes",
|
|
575
|
+
MediaType.APPLICATION_JSON,
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
if consumes_media in {
|
|
579
|
+
MediaType.APPLICATION_JSON,
|
|
580
|
+
MediaType.APPLICATION_PROBLEM_JSON,
|
|
581
|
+
MediaType.APPLICATION_X_NDJSON,
|
|
582
|
+
}:
|
|
583
|
+
request_options["json"] = self._prepare_json_body(request_body)
|
|
584
|
+
|
|
585
|
+
elif consumes_media == MediaType.MULTIPART_FORM_DATA:
|
|
586
|
+
request_options["files"] = self._get_multipart_parts(request_body)
|
|
587
|
+
|
|
588
|
+
else:
|
|
589
|
+
form_data, files = self._get_form_and_files(request_body)
|
|
590
|
+
|
|
591
|
+
if form_data is not None:
|
|
592
|
+
request_options["data"] = form_data
|
|
593
|
+
|
|
594
|
+
if files is not None:
|
|
595
|
+
request_options["files"] = files
|
|
596
|
+
|
|
597
|
+
async with httpx.AsyncClient() as client:
|
|
598
|
+
response = await client.request(
|
|
599
|
+
http_method,
|
|
600
|
+
url,
|
|
601
|
+
**request_options,
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
response_body = self._get_response_body(
|
|
605
|
+
response,
|
|
606
|
+
configs.get(
|
|
607
|
+
"produces",
|
|
608
|
+
MediaType.APPLICATION_JSON,
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
if (
|
|
613
|
+
return_type
|
|
614
|
+
and inspect.isclass(return_type)
|
|
615
|
+
and issubclass(
|
|
616
|
+
return_type,
|
|
617
|
+
BaseModel,
|
|
618
|
+
)
|
|
619
|
+
):
|
|
620
|
+
return return_type.model_validate(response_body)
|
|
621
|
+
|
|
622
|
+
return response_body
|
|
623
|
+
|
|
624
|
+
def _extract_request_body(
|
|
625
|
+
self,
|
|
626
|
+
func: Callable[..., Any],
|
|
627
|
+
args: tuple[Any, ...],
|
|
628
|
+
kwargs: dict[str, Any],
|
|
629
|
+
) -> Any:
|
|
630
|
+
"""
|
|
631
|
+
Build the outgoing body from interface method arguments.
|
|
632
|
+
|
|
633
|
+
No arguments:
|
|
634
|
+
No outgoing body.
|
|
635
|
+
|
|
636
|
+
One argument:
|
|
637
|
+
The argument value is used directly as the body.
|
|
638
|
+
|
|
639
|
+
Multiple arguments:
|
|
640
|
+
A dictionary is created using the method parameter names.
|
|
641
|
+
|
|
642
|
+
Positional and keyword arguments are both supported.
|
|
643
|
+
"""
|
|
644
|
+
signature = inspect.signature(func)
|
|
645
|
+
|
|
646
|
+
bound = signature.bind_partial(
|
|
647
|
+
self,
|
|
648
|
+
*args,
|
|
649
|
+
**kwargs,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
arguments = dict(bound.arguments)
|
|
653
|
+
|
|
654
|
+
arguments.pop(
|
|
655
|
+
"self",
|
|
656
|
+
None,
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
if not arguments:
|
|
660
|
+
return _MISSING_BODY
|
|
661
|
+
|
|
662
|
+
if len(arguments) == 1:
|
|
663
|
+
return next(iter(arguments.values()))
|
|
664
|
+
|
|
665
|
+
return arguments
|
|
666
|
+
|
|
667
|
+
def _prepare_json_body(
|
|
668
|
+
self,
|
|
669
|
+
value: Any,
|
|
670
|
+
) -> Any:
|
|
671
|
+
"""
|
|
672
|
+
Normalize Pydantic models and verify JSON serializability.
|
|
673
|
+
"""
|
|
674
|
+
normalized = self._normalize_json_value(value)
|
|
675
|
+
|
|
676
|
+
try:
|
|
677
|
+
json.dumps(normalized)
|
|
678
|
+
except (TypeError, ValueError) as exc:
|
|
679
|
+
raise TypeError(
|
|
680
|
+
"API interface request body is not JSON serializable."
|
|
681
|
+
) from exc
|
|
682
|
+
|
|
683
|
+
return normalized
|
|
684
|
+
|
|
685
|
+
def _normalize_json_value(
|
|
686
|
+
self,
|
|
687
|
+
value: Any,
|
|
688
|
+
) -> Any:
|
|
689
|
+
"""
|
|
690
|
+
Recursively convert supported values to JSON-compatible structures.
|
|
691
|
+
"""
|
|
692
|
+
if isinstance(
|
|
693
|
+
value,
|
|
694
|
+
BaseModel,
|
|
695
|
+
):
|
|
696
|
+
return value.model_dump(mode="json")
|
|
697
|
+
|
|
698
|
+
if isinstance(
|
|
699
|
+
value,
|
|
700
|
+
Mapping,
|
|
701
|
+
):
|
|
702
|
+
return {
|
|
703
|
+
str(key): self._normalize_json_value(item)
|
|
704
|
+
for key, item in value.items()
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if isinstance(
|
|
708
|
+
value,
|
|
709
|
+
(list, tuple),
|
|
710
|
+
):
|
|
711
|
+
return [self._normalize_json_value(item) for item in value]
|
|
712
|
+
|
|
713
|
+
return value
|
|
714
|
+
|
|
715
|
+
def _construct_headers(
|
|
716
|
+
self,
|
|
717
|
+
configs: Optional[dict[str, Any]],
|
|
718
|
+
) -> dict[str, str]:
|
|
719
|
+
configs = configs or {}
|
|
720
|
+
|
|
721
|
+
consumes_media = cast(
|
|
722
|
+
MediaType,
|
|
723
|
+
configs.get(
|
|
724
|
+
"consumes",
|
|
725
|
+
MediaType.APPLICATION_JSON,
|
|
726
|
+
),
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
request_headers: dict[str, str] = {
|
|
730
|
+
"Accept": str(
|
|
731
|
+
configs.get(
|
|
732
|
+
"produces",
|
|
733
|
+
MediaType.APPLICATION_JSON,
|
|
734
|
+
)
|
|
735
|
+
),
|
|
736
|
+
**{
|
|
737
|
+
str(key): str(value)
|
|
738
|
+
for key, value in configs.get(
|
|
739
|
+
"custom_headers",
|
|
740
|
+
{},
|
|
741
|
+
).items()
|
|
742
|
+
},
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if consumes_media == MediaType.MULTIPART_FORM_DATA:
|
|
746
|
+
for header_name in list(request_headers):
|
|
747
|
+
if header_name.lower() == "content-type":
|
|
748
|
+
request_headers.pop(header_name)
|
|
749
|
+
else:
|
|
750
|
+
request_headers.setdefault(
|
|
751
|
+
"Content-Type",
|
|
752
|
+
str(consumes_media),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
auth_config: Optional[dict[str, Any]] = configs.get("auth")
|
|
756
|
+
|
|
757
|
+
if auth_config is not None:
|
|
758
|
+
header_name = cast(
|
|
759
|
+
str,
|
|
760
|
+
auth_config.get(
|
|
761
|
+
"header_name",
|
|
762
|
+
"Authorization",
|
|
763
|
+
),
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
request_headers[header_name] = self._create_auth_header(auth_config)
|
|
767
|
+
|
|
768
|
+
return request_headers
|
|
769
|
+
|
|
770
|
+
def _format_url(
|
|
771
|
+
self,
|
|
772
|
+
req: Optional[Request],
|
|
773
|
+
url: str,
|
|
774
|
+
) -> str:
|
|
775
|
+
if req is None:
|
|
776
|
+
return url
|
|
777
|
+
for key, value in req.route_parameters.items():
|
|
778
|
+
url = url.replace(
|
|
779
|
+
f"<{key}>",
|
|
780
|
+
str(value),
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
return url
|
|
784
|
+
|
|
785
|
+
def _get_form_and_files(
|
|
786
|
+
self,
|
|
787
|
+
data: Any,
|
|
788
|
+
) -> tuple[
|
|
789
|
+
dict[str, Any] | None,
|
|
790
|
+
list[tuple[str, tuple[Any, ...]]] | None,
|
|
791
|
+
]:
|
|
792
|
+
"""
|
|
793
|
+
Split a Pydantic model or mapping into form fields and uploaded files.
|
|
794
|
+
|
|
795
|
+
Repeated file fields are preserved as repeated multipart parts.
|
|
796
|
+
"""
|
|
797
|
+
if isinstance(data, BaseModel):
|
|
798
|
+
values: Mapping[str, Any] = {
|
|
799
|
+
field_name: getattr(data, field_name)
|
|
800
|
+
for field_name in type(data).model_fields
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
elif isinstance(data, Mapping):
|
|
804
|
+
values = {str(key): value for key, value in data.items()}
|
|
805
|
+
|
|
806
|
+
else:
|
|
807
|
+
raise TypeError(
|
|
808
|
+
"Non-JSON request bodies must be a Pydantic model or a mapping."
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
form_data: dict[str, Any] = {}
|
|
812
|
+
files: list[
|
|
813
|
+
tuple[
|
|
814
|
+
str,
|
|
815
|
+
tuple[Any, ...],
|
|
816
|
+
]
|
|
817
|
+
] = []
|
|
818
|
+
|
|
819
|
+
for field_name, value in values.items():
|
|
820
|
+
if isinstance(value, UploadedFile):
|
|
821
|
+
files.append(
|
|
822
|
+
(
|
|
823
|
+
field_name,
|
|
824
|
+
(
|
|
825
|
+
value.filename,
|
|
826
|
+
value.get_stream(),
|
|
827
|
+
value.content_type,
|
|
828
|
+
),
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
continue
|
|
832
|
+
|
|
833
|
+
if isinstance(value, (list, tuple)) and all(
|
|
834
|
+
isinstance(item, UploadedFile) for item in value
|
|
835
|
+
):
|
|
836
|
+
for uploaded_file in value:
|
|
837
|
+
files.append(
|
|
838
|
+
(
|
|
839
|
+
field_name,
|
|
840
|
+
(
|
|
841
|
+
uploaded_file.filename,
|
|
842
|
+
uploaded_file.get_stream(),
|
|
843
|
+
uploaded_file.content_type,
|
|
844
|
+
),
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
continue
|
|
849
|
+
|
|
850
|
+
form_data[field_name] = value
|
|
851
|
+
|
|
852
|
+
return (
|
|
853
|
+
form_data or None,
|
|
854
|
+
files or None,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
def _resolve_auth_value(
|
|
858
|
+
self,
|
|
859
|
+
value: str | None,
|
|
860
|
+
) -> str | None:
|
|
861
|
+
"""
|
|
862
|
+
Resolve an authentication value from application configuration.
|
|
863
|
+
|
|
864
|
+
If the configuration key does not exist, the supplied value is used
|
|
865
|
+
literally.
|
|
866
|
+
"""
|
|
867
|
+
if value is None:
|
|
868
|
+
return None
|
|
869
|
+
|
|
870
|
+
return cast(
|
|
871
|
+
str,
|
|
872
|
+
self._app.get_conf(
|
|
873
|
+
value,
|
|
874
|
+
value,
|
|
875
|
+
),
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
def _create_auth_header(
|
|
879
|
+
self,
|
|
880
|
+
auth_config: dict[str, Any],
|
|
881
|
+
) -> str:
|
|
882
|
+
auth_type = auth_config.get("type")
|
|
883
|
+
|
|
884
|
+
if auth_type == AuthType.BASIC_AUTH:
|
|
885
|
+
username = self._resolve_auth_value(auth_config.get("username"))
|
|
886
|
+
|
|
887
|
+
password = self._resolve_auth_value(auth_config.get("password"))
|
|
888
|
+
|
|
889
|
+
if username is None or password is None:
|
|
890
|
+
raise ValueError(
|
|
891
|
+
"Basic authentication requires both username and password."
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
credentials = f"{username}:{password}"
|
|
895
|
+
|
|
896
|
+
encoded = base64.b64encode(credentials.encode()).decode()
|
|
897
|
+
|
|
898
|
+
return f"Basic {encoded}"
|
|
899
|
+
|
|
900
|
+
if auth_type == AuthType.BEARER:
|
|
901
|
+
token = self._resolve_auth_value(auth_config.get("token"))
|
|
902
|
+
|
|
903
|
+
if token is None:
|
|
904
|
+
raise ValueError("Bearer authentication requires token.")
|
|
905
|
+
|
|
906
|
+
return f"Bearer {token}"
|
|
907
|
+
|
|
908
|
+
if auth_type == AuthType.API_KEY:
|
|
909
|
+
api_key = self._resolve_auth_value(auth_config.get("api_key"))
|
|
910
|
+
|
|
911
|
+
if api_key is None:
|
|
912
|
+
raise ValueError("API-key authentication requires api_key.")
|
|
913
|
+
|
|
914
|
+
return api_key
|
|
915
|
+
|
|
916
|
+
raise ValueError(f"Unsupported API authentication type: {auth_type!r}")
|
|
917
|
+
|
|
918
|
+
def _get_response_body(
|
|
919
|
+
self,
|
|
920
|
+
response: Response,
|
|
921
|
+
media_type: MediaType,
|
|
922
|
+
) -> Any:
|
|
923
|
+
if media_type in {
|
|
924
|
+
MediaType.APPLICATION_JSON,
|
|
925
|
+
MediaType.APPLICATION_X_NDJSON,
|
|
926
|
+
MediaType.APPLICATION_PROBLEM_JSON,
|
|
927
|
+
}:
|
|
928
|
+
if not response.content:
|
|
929
|
+
# e.g. 204 No Content
|
|
930
|
+
return None
|
|
931
|
+
return response.json()
|
|
932
|
+
|
|
933
|
+
if media_type in {
|
|
934
|
+
MediaType.TEXT_CSV,
|
|
935
|
+
MediaType.TEXT_HTML,
|
|
936
|
+
MediaType.TEXT_PLAIN,
|
|
937
|
+
MediaType.TEXT_XML,
|
|
938
|
+
MediaType.TEXT_YAML,
|
|
939
|
+
}:
|
|
940
|
+
return response.text
|
|
941
|
+
|
|
942
|
+
return response.read()
|
|
943
|
+
|
|
944
|
+
def _get_multipart_parts(
|
|
945
|
+
self,
|
|
946
|
+
data: Any,
|
|
947
|
+
) -> list[tuple[str, tuple[Any, ...]]]:
|
|
948
|
+
if isinstance(data, BaseModel):
|
|
949
|
+
values: Mapping[str, Any] = {
|
|
950
|
+
field_name: getattr(data, field_name)
|
|
951
|
+
for field_name in type(data).model_fields
|
|
952
|
+
}
|
|
953
|
+
elif isinstance(data, Mapping):
|
|
954
|
+
values = {str(key): value for key, value in data.items()}
|
|
955
|
+
else:
|
|
956
|
+
raise TypeError(
|
|
957
|
+
"Multipart request bodies must be a Pydantic model or a mapping."
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
parts: list[tuple[str, tuple[Any, ...]]] = []
|
|
961
|
+
|
|
962
|
+
for field_name, value in values.items():
|
|
963
|
+
items = value if isinstance(value, (list, tuple)) else [value]
|
|
964
|
+
|
|
965
|
+
for item in items:
|
|
966
|
+
if isinstance(item, UploadedFile):
|
|
967
|
+
parts.append(
|
|
968
|
+
(
|
|
969
|
+
field_name,
|
|
970
|
+
(
|
|
971
|
+
item.filename,
|
|
972
|
+
item.get_stream(),
|
|
973
|
+
item.content_type,
|
|
974
|
+
),
|
|
975
|
+
)
|
|
976
|
+
)
|
|
977
|
+
else:
|
|
978
|
+
parts.append(
|
|
979
|
+
(
|
|
980
|
+
field_name,
|
|
981
|
+
(
|
|
982
|
+
None,
|
|
983
|
+
str(item),
|
|
984
|
+
),
|
|
985
|
+
)
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
return parts
|
|
989
|
+
|
|
990
|
+
def __init_subclass__(
|
|
991
|
+
cls,
|
|
992
|
+
**kwargs: Any,
|
|
993
|
+
) -> None:
|
|
994
|
+
super().__init_subclass__(**kwargs)
|
|
995
|
+
|
|
996
|
+
for name, value in list(cls.__dict__.items()):
|
|
997
|
+
if name.startswith("_"):
|
|
998
|
+
continue
|
|
999
|
+
|
|
1000
|
+
if callable(value) and hasattr(
|
|
1001
|
+
value,
|
|
1002
|
+
"__api_interface__",
|
|
1003
|
+
):
|
|
1004
|
+
setattr(
|
|
1005
|
+
cls,
|
|
1006
|
+
name,
|
|
1007
|
+
ApiInterface._wrap_method(value),
|
|
1008
|
+
)
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
from .api_interface import (AuthType,
|
|
2
|
-
service,
|
|
3
|
-
method,
|
|
4
|
-
resource,
|
|
5
|
-
consumes,
|
|
6
|
-
produces,
|
|
7
|
-
headers,
|
|
8
|
-
timeout,
|
|
9
|
-
auth,
|
|
10
|
-
MissingApiInterfaceConfigurations,
|
|
11
|
-
ApiInterface)
|
|
12
|
-
|
|
13
|
-
__all__ = [
|
|
14
|
-
'AuthType',
|
|
15
|
-
'service',
|
|
16
|
-
'method',
|
|
17
|
-
'resource',
|
|
18
|
-
'consumes',
|
|
19
|
-
'produces',
|
|
20
|
-
'headers',
|
|
21
|
-
'timeout',
|
|
22
|
-
'auth',
|
|
23
|
-
'MissingApiInterfaceConfigurations',
|
|
24
|
-
'ApiInterface'
|
|
25
|
-
]
|
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Interface for API integration
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
import base64
|
|
7
|
-
from typing import (
|
|
8
|
-
Callable,
|
|
9
|
-
Dict,
|
|
10
|
-
Any,
|
|
11
|
-
Generic,
|
|
12
|
-
Optional,
|
|
13
|
-
Tuple,
|
|
14
|
-
Type,
|
|
15
|
-
cast,
|
|
16
|
-
get_type_hints,
|
|
17
|
-
TypeVar,
|
|
18
|
-
)
|
|
19
|
-
import httpx
|
|
20
|
-
from httpx import Response
|
|
21
|
-
from functools import wraps
|
|
22
|
-
from enum import StrEnum
|
|
23
|
-
from pydantic import BaseModel
|
|
24
|
-
|
|
25
|
-
from patera import Patera, HttpMethod, MediaType, BaseExtension, UploadedFile, Request
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class AuthType(StrEnum):
|
|
29
|
-
BASIC_AUTH = "basic_auth"
|
|
30
|
-
BEARER = "bearer_token"
|
|
31
|
-
API_KEY = "api_key"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
F = TypeVar("F", bound=Callable[..., Any])
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def service(service_url: str) -> "Callable":
|
|
38
|
-
def decorator(cls: "Type[ApiInterface]") -> "Type[ApiInterface]":
|
|
39
|
-
setattr(cls, "__service_url__", service_url)
|
|
40
|
-
return cls
|
|
41
|
-
|
|
42
|
-
return decorator
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def method(http_method: HttpMethod) -> Callable[[F], F]:
|
|
46
|
-
def decorator(func: F) -> F:
|
|
47
|
-
api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
|
|
48
|
-
api_int["method"] = http_method
|
|
49
|
-
setattr(func, "__api_interface__", api_int)
|
|
50
|
-
return func
|
|
51
|
-
|
|
52
|
-
return decorator
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def resource(url: str, follow_redirects: bool = True) -> Callable[[F], F]:
|
|
56
|
-
def decorator(func: F) -> F:
|
|
57
|
-
api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
|
|
58
|
-
api_int["target"] = url
|
|
59
|
-
api_int["follow_redirects"] = follow_redirects
|
|
60
|
-
setattr(func, "__api_interface__", api_int)
|
|
61
|
-
return func
|
|
62
|
-
|
|
63
|
-
return decorator
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def consumes(media: MediaType) -> Callable[[F], F]:
|
|
67
|
-
def decorator(func: F) -> F:
|
|
68
|
-
api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
|
|
69
|
-
api_int["consumes"] = media
|
|
70
|
-
setattr(func, "__api_interface__", api_int)
|
|
71
|
-
return func
|
|
72
|
-
|
|
73
|
-
return decorator
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def produces(media: MediaType) -> Callable[[F], F]:
|
|
77
|
-
def decorator(func: F) -> F:
|
|
78
|
-
api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
|
|
79
|
-
api_int["produces"] = media
|
|
80
|
-
setattr(func, "__api_interface__", api_int)
|
|
81
|
-
return func
|
|
82
|
-
|
|
83
|
-
return decorator
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def headers(custom_headers: Dict[str, Any]) -> Callable[[F], F]:
|
|
87
|
-
def decorator(func: F) -> F:
|
|
88
|
-
api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
|
|
89
|
-
api_int["custom_headers"] = custom_headers
|
|
90
|
-
setattr(func, "__api_interface__", api_int)
|
|
91
|
-
return func
|
|
92
|
-
|
|
93
|
-
return decorator
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def timeout(timeout: int) -> Callable[[F], F]:
|
|
97
|
-
def decorator(func: F) -> F:
|
|
98
|
-
api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
|
|
99
|
-
api_int["timeout"] = timeout
|
|
100
|
-
setattr(func, "__api_interface__", api_int)
|
|
101
|
-
return func
|
|
102
|
-
|
|
103
|
-
return decorator
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def auth(
|
|
107
|
-
auth_type: AuthType,
|
|
108
|
-
*,
|
|
109
|
-
username: str | None = None,
|
|
110
|
-
password: str | None = None,
|
|
111
|
-
header_name: str = "Authorization",
|
|
112
|
-
) -> Callable[[F], F]:
|
|
113
|
-
def decorator(func: F) -> F:
|
|
114
|
-
api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
|
|
115
|
-
api_int["auth"] = {
|
|
116
|
-
"type": auth_type,
|
|
117
|
-
"username": username,
|
|
118
|
-
"password": password,
|
|
119
|
-
"header_name": header_name,
|
|
120
|
-
}
|
|
121
|
-
setattr(func, "__api_interface__", api_int)
|
|
122
|
-
return func
|
|
123
|
-
|
|
124
|
-
return decorator
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
class MissingApiInterfaceConfigurations(Exception):
|
|
128
|
-
def __init__(self, msg: str) -> None:
|
|
129
|
-
super().__init__(msg)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
AppT = TypeVar("AppT", bound="Patera[Any]")
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
class ApiInterface(BaseExtension[AppT], Generic[AppT]):
|
|
136
|
-
def init(self) -> None:
|
|
137
|
-
serv_url = getattr(self, "__service_url__", None)
|
|
138
|
-
if serv_url is None:
|
|
139
|
-
raise Exception(
|
|
140
|
-
"Missing service url for API Interface. Use @path from api_interface"
|
|
141
|
-
)
|
|
142
|
-
self._service_url = self._app.get_conf(serv_url, serv_url)
|
|
143
|
-
|
|
144
|
-
@classmethod
|
|
145
|
-
def _wrap_method(cls, func):
|
|
146
|
-
@wraps(func)
|
|
147
|
-
async def inner(self, req: Request, *args, **kwargs):
|
|
148
|
-
return await self._wrapper(func, req, *args, **kwargs)
|
|
149
|
-
|
|
150
|
-
return inner
|
|
151
|
-
|
|
152
|
-
async def _wrapper(self, func, req: Request, *args, **kwargs) -> Any:
|
|
153
|
-
raw_configs: Optional[Dict[str, Any]] = getattr(func, "__api_interface__", None)
|
|
154
|
-
if raw_configs is None:
|
|
155
|
-
raise MissingApiInterfaceConfigurations(
|
|
156
|
-
f"API Interface method {func.__name__} does not have any configurations. Please add configuration decorators."
|
|
157
|
-
)
|
|
158
|
-
configs: Dict[str, Any] = dict(raw_configs)
|
|
159
|
-
return_type: Optional[Type[BaseModel]] = get_type_hints(func).get(
|
|
160
|
-
"return", None
|
|
161
|
-
)
|
|
162
|
-
async with httpx.AsyncClient() as client:
|
|
163
|
-
url: str = cast(str, self._service_url) + cast(str, configs.get("target"))
|
|
164
|
-
url = self._format_url(req, url)
|
|
165
|
-
follow_redirects = cast(bool, configs.get("follow_redirects"))
|
|
166
|
-
method = configs.get("method", HttpMethod.GET)
|
|
167
|
-
timeout = configs.get("timeout", 10)
|
|
168
|
-
headers: Dict[str, str] = self._construct_headers(configs)
|
|
169
|
-
pydantic_data: list[BaseModel | dict] = list(
|
|
170
|
-
filter(lambda d: isinstance(d, (BaseModel, dict)), list(args))
|
|
171
|
-
)
|
|
172
|
-
data: BaseModel | dict | None = None
|
|
173
|
-
if len(pydantic_data) > 0:
|
|
174
|
-
data = pydantic_data[0]
|
|
175
|
-
res: Response
|
|
176
|
-
if method == HttpMethod.GET:
|
|
177
|
-
res = await client.request(
|
|
178
|
-
method,
|
|
179
|
-
url,
|
|
180
|
-
headers=headers,
|
|
181
|
-
timeout=timeout,
|
|
182
|
-
params=req.query_parameters,
|
|
183
|
-
follow_redirects=follow_redirects,
|
|
184
|
-
)
|
|
185
|
-
else:
|
|
186
|
-
if data is None:
|
|
187
|
-
raise Exception("Missing body for request.")
|
|
188
|
-
if configs.get("consumes", MediaType.APPLICATION_JSON) in [
|
|
189
|
-
MediaType.APPLICATION_JSON,
|
|
190
|
-
MediaType.APPLICATION_PROBLEM_JSON,
|
|
191
|
-
MediaType.APPLICATION_X_NDJSON,
|
|
192
|
-
]:
|
|
193
|
-
res = await client.request(
|
|
194
|
-
method,
|
|
195
|
-
url,
|
|
196
|
-
headers=headers,
|
|
197
|
-
timeout=timeout,
|
|
198
|
-
json=cast(BaseModel, data).model_dump(),
|
|
199
|
-
params=req.query_parameters,
|
|
200
|
-
follow_redirects=follow_redirects,
|
|
201
|
-
)
|
|
202
|
-
else:
|
|
203
|
-
if isinstance(data, BaseModel):
|
|
204
|
-
form, files = self._get_form_and_files(data)
|
|
205
|
-
else:
|
|
206
|
-
form = data
|
|
207
|
-
files = None
|
|
208
|
-
res = await client.request(
|
|
209
|
-
method,
|
|
210
|
-
url,
|
|
211
|
-
headers=headers,
|
|
212
|
-
timeout=timeout,
|
|
213
|
-
data=form,
|
|
214
|
-
files=files,
|
|
215
|
-
params=req.query_parameters,
|
|
216
|
-
follow_redirects=follow_redirects,
|
|
217
|
-
)
|
|
218
|
-
|
|
219
|
-
res = self._get_response_body(
|
|
220
|
-
res, configs.get("produces", MediaType.APPLICATION_JSON)
|
|
221
|
-
)
|
|
222
|
-
if return_type and issubclass(return_type, BaseModel):
|
|
223
|
-
return return_type.model_validate(res)
|
|
224
|
-
return res
|
|
225
|
-
|
|
226
|
-
def _construct_headers(self, configs: Optional[Dict[str, Any]]) -> Dict[str, str]:
|
|
227
|
-
if configs is None:
|
|
228
|
-
configs = {}
|
|
229
|
-
|
|
230
|
-
headers: Dict[str, str] = {
|
|
231
|
-
"Content-Type": configs.get("consumes", MediaType.APPLICATION_JSON),
|
|
232
|
-
"Accept": configs.get("produces", MediaType.APPLICATION_JSON),
|
|
233
|
-
**configs.get("custom_headers", {}),
|
|
234
|
-
}
|
|
235
|
-
auth: Optional[dict[str, Any]] = configs.get("auth", None)
|
|
236
|
-
if auth is not None:
|
|
237
|
-
header_name = cast(str, auth.get("header_name"))
|
|
238
|
-
headers[header_name] = self._create_auth_headers(auth)
|
|
239
|
-
return headers
|
|
240
|
-
|
|
241
|
-
def _format_url(self, req: Request, url: str) -> str:
|
|
242
|
-
for key, value in req.route_parameters.items():
|
|
243
|
-
url = url.replace(f"<{key}>", value)
|
|
244
|
-
return url
|
|
245
|
-
|
|
246
|
-
def _get_form_and_files(
|
|
247
|
-
self, data: BaseModel
|
|
248
|
-
) -> Tuple[dict[str, Any], Optional[dict[str, tuple]]]:
|
|
249
|
-
form_data: dict[str, Any] = {}
|
|
250
|
-
files: dict[str, tuple] = {}
|
|
251
|
-
for field in dir(data):
|
|
252
|
-
if field.startswith("_"):
|
|
253
|
-
continue
|
|
254
|
-
value = getattr(data, field, None)
|
|
255
|
-
if isinstance(value, UploadedFile):
|
|
256
|
-
files[field] = (value.filename, value.get_stream(), value.content_type)
|
|
257
|
-
continue
|
|
258
|
-
form_data[field] = value
|
|
259
|
-
if len(files.keys()) == 0:
|
|
260
|
-
files = cast(dict[str, tuple], None)
|
|
261
|
-
if len(form_data.keys()) == 0:
|
|
262
|
-
form_data = cast(dict[str, Any], None)
|
|
263
|
-
return form_data, files
|
|
264
|
-
|
|
265
|
-
def _create_auth_headers(self, auth: dict[str, Any]) -> str:
|
|
266
|
-
auth_type = auth.get("type")
|
|
267
|
-
username = auth.get("username")
|
|
268
|
-
password = auth.get("password")
|
|
269
|
-
if auth_type == AuthType.BASIC_AUTH:
|
|
270
|
-
credentials = f"{username}:{password}"
|
|
271
|
-
encoded = base64.b64encode(credentials.encode()).decode()
|
|
272
|
-
return f"Basic {encoded}"
|
|
273
|
-
if auth_type == AuthType.BEARER:
|
|
274
|
-
return f"Bearer {self._app.get_conf(password, password)}" # type: ignore
|
|
275
|
-
if auth_type == AuthType.API_KEY:
|
|
276
|
-
return f"{self._app.get_conf(password, password)}" # type: ignore
|
|
277
|
-
return ""
|
|
278
|
-
|
|
279
|
-
def _get_response_body(self, res: Response, media_type: MediaType) -> Any:
|
|
280
|
-
if media_type in [
|
|
281
|
-
MediaType.APPLICATION_JSON,
|
|
282
|
-
MediaType.APPLICATION_X_NDJSON,
|
|
283
|
-
MediaType.APPLICATION_PROBLEM_JSON,
|
|
284
|
-
]:
|
|
285
|
-
return res.json()
|
|
286
|
-
if media_type in [
|
|
287
|
-
MediaType.TEXT_CSV,
|
|
288
|
-
MediaType.TEXT_HTML,
|
|
289
|
-
MediaType.TEXT_PLAIN,
|
|
290
|
-
MediaType.TEXT_XML,
|
|
291
|
-
MediaType.TEXT_YAML,
|
|
292
|
-
]:
|
|
293
|
-
return res.text
|
|
294
|
-
return res.read()
|
|
295
|
-
|
|
296
|
-
def __init_subclass__(cls, **kwargs):
|
|
297
|
-
super().__init_subclass__(**kwargs)
|
|
298
|
-
|
|
299
|
-
for name, value in list(cls.__dict__.items()):
|
|
300
|
-
if name.startswith("_"):
|
|
301
|
-
continue
|
|
302
|
-
|
|
303
|
-
if callable(value) and hasattr(value, "__api_interface__"):
|
|
304
|
-
setattr(cls, name, ApiInterface._wrap_method(value))
|