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.
@@ -1,5 +1,5 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: patera-apiinterface
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Requires-Dist: patera
5
5
  Requires-Python: >=3.13
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "patera-apiinterface"
3
- version = "0.9.0"
3
+ version = "0.10.0"
4
4
  requires-python = ">=3.13"
5
5
  dependencies = [
6
6
  "patera",
@@ -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))