patera-apiinterface 0.4.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.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.3
2
+ Name: patera-apiinterface
3
+ Version: 0.4.0
4
+ Requires-Dist: patera
5
+ Requires-Python: >=3.13
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "patera-apiinterface"
3
+ version = "0.4.0"
4
+ requires-python = ">=3.13"
5
+ dependencies = [
6
+ "patera",
7
+ ]
8
+
9
+ [tool.uv.sources]
10
+ patera = { workspace = true }
11
+ patera-database = { workspace = true }
12
+
13
+ [tool.uv.build-backend]
14
+ module-name = "patera.apiinterface"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.10.9,<0.11.0"]
18
+ build-backend = "uv_build"
@@ -0,0 +1,25 @@
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
+ ]
@@ -0,0 +1,311 @@
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
+ TYPE_CHECKING,
19
+ )
20
+ import httpx
21
+ from httpx import Response
22
+ from functools import wraps
23
+ from enum import StrEnum
24
+ from pydantic import BaseModel
25
+
26
+ from .http_methods import HttpMethod
27
+ from .media_types import MediaType
28
+ from .base_extension import BaseExtension
29
+ from .request import UploadedFile, Request
30
+
31
+ if TYPE_CHECKING:
32
+ from .patera import Patera
33
+
34
+
35
+ class AuthType(StrEnum):
36
+ BASIC_AUTH = "basic_auth"
37
+ BEARER = "bearer_token"
38
+ API_KEY = "api_key"
39
+
40
+
41
+ F = TypeVar("F", bound=Callable[..., Any])
42
+
43
+
44
+ def service(service_url: str) -> "Callable":
45
+ def decorator(cls: "Type[ApiInterface]") -> "Type[ApiInterface]":
46
+ setattr(cls, "__service_url__", service_url)
47
+ return cls
48
+
49
+ return decorator
50
+
51
+
52
+ def method(http_method: HttpMethod) -> Callable[[F], F]:
53
+ def decorator(func: F) -> F:
54
+ api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
55
+ api_int["method"] = http_method
56
+ setattr(func, "__api_interface__", api_int)
57
+ return func
58
+
59
+ return decorator
60
+
61
+
62
+ def resource(url: str, follow_redirects: bool = True) -> Callable[[F], F]:
63
+ def decorator(func: F) -> F:
64
+ api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
65
+ api_int["target"] = url
66
+ api_int["follow_redirects"] = follow_redirects
67
+ setattr(func, "__api_interface__", api_int)
68
+ return func
69
+
70
+ return decorator
71
+
72
+
73
+ def consumes(media: MediaType) -> Callable[[F], F]:
74
+ def decorator(func: F) -> F:
75
+ api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
76
+ api_int["consumes"] = media
77
+ setattr(func, "__api_interface__", api_int)
78
+ return func
79
+
80
+ return decorator
81
+
82
+
83
+ def produces(media: MediaType) -> Callable[[F], F]:
84
+ def decorator(func: F) -> F:
85
+ api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
86
+ api_int["produces"] = media
87
+ setattr(func, "__api_interface__", api_int)
88
+ return func
89
+
90
+ return decorator
91
+
92
+
93
+ def headers(custom_headers: Dict[str, Any]) -> Callable[[F], F]:
94
+ def decorator(func: F) -> F:
95
+ api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
96
+ api_int["custom_headers"] = custom_headers
97
+ setattr(func, "__api_interface__", api_int)
98
+ return func
99
+
100
+ return decorator
101
+
102
+
103
+ def timeout(timeout: int) -> Callable[[F], F]:
104
+ def decorator(func: F) -> F:
105
+ api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
106
+ api_int["timeout"] = timeout
107
+ setattr(func, "__api_interface__", api_int)
108
+ return func
109
+
110
+ return decorator
111
+
112
+
113
+ def auth(
114
+ auth_type: AuthType,
115
+ *,
116
+ username: str | None = None,
117
+ password: str | None = None,
118
+ header_name: str = "Authorization",
119
+ ) -> Callable[[F], F]:
120
+ def decorator(func: F) -> F:
121
+ api_int: Dict[str, Any] = getattr(func, "__api_interface__", {}) or {}
122
+ api_int["auth"] = {
123
+ "type": auth_type,
124
+ "username": username,
125
+ "password": password,
126
+ "header_name": header_name,
127
+ }
128
+ setattr(func, "__api_interface__", api_int)
129
+ return func
130
+
131
+ return decorator
132
+
133
+
134
+ class MissingApiInterfaceConfigurations(Exception):
135
+ def __init__(self, msg: str) -> None:
136
+ super().__init__(msg)
137
+
138
+
139
+ AppT = TypeVar("AppT", bound="Patera[Any]")
140
+
141
+
142
+ class ApiInterface(BaseExtension[AppT], Generic[AppT]):
143
+ def init(self) -> None:
144
+ serv_url = getattr(self, "__service_url__", None)
145
+ if serv_url is None:
146
+ raise Exception(
147
+ "Missing service url for API Interface. Use @path from api_interface"
148
+ )
149
+ self._service_url = self._app.get_conf(serv_url, serv_url)
150
+
151
+ @classmethod
152
+ def _wrap_method(cls, func):
153
+ @wraps(func)
154
+ async def inner(self, req: Request, *args, **kwargs):
155
+ return await self._wrapper(func, req, *args, **kwargs)
156
+
157
+ return inner
158
+
159
+ async def _wrapper(self, func, req: Request, *args, **kwargs) -> Any:
160
+ raw_configs: Optional[Dict[str, Any]] = getattr(func, "__api_interface__", None)
161
+ if raw_configs is None:
162
+ raise MissingApiInterfaceConfigurations(
163
+ f"API Interface method {func.__name__} does not have any configurations. Please add configuration decorators."
164
+ )
165
+ configs: Dict[str, Any] = dict(raw_configs)
166
+ return_type: Optional[Type[BaseModel]] = get_type_hints(func).get(
167
+ "return", None
168
+ )
169
+ async with httpx.AsyncClient() as client:
170
+ url: str = cast(str, self._service_url) + cast(str, configs.get("target"))
171
+ url = self._format_url(req, url)
172
+ follow_redirects = cast(bool, configs.get("follow_redirects"))
173
+ method = configs.get("method", HttpMethod.GET)
174
+ timeout = configs.get("timeout", 10)
175
+ headers: Dict[str, str] = self._construct_headers(configs)
176
+ pydantic_data: list[BaseModel | dict] = list(
177
+ filter(lambda d: isinstance(d, (BaseModel, dict)), list(args))
178
+ )
179
+ data: BaseModel | dict | None = None
180
+ if len(pydantic_data) > 0:
181
+ data = pydantic_data[0]
182
+ res: Response
183
+ if method == HttpMethod.GET:
184
+ res = await client.request(
185
+ method,
186
+ url,
187
+ headers=headers,
188
+ timeout=timeout,
189
+ params=req.query_parameters,
190
+ follow_redirects=follow_redirects,
191
+ )
192
+ else:
193
+ if data is None:
194
+ raise Exception("Missing body for request.")
195
+ if configs.get("consumes", MediaType.APPLICATION_JSON) in [
196
+ MediaType.APPLICATION_JSON,
197
+ MediaType.APPLICATION_PROBLEM_JSON,
198
+ MediaType.APPLICATION_X_NDJSON,
199
+ ]:
200
+ res = await client.request(
201
+ method,
202
+ url,
203
+ headers=headers,
204
+ timeout=timeout,
205
+ json=cast(BaseModel, data).model_dump(),
206
+ params=req.query_parameters,
207
+ follow_redirects=follow_redirects,
208
+ )
209
+ else:
210
+ if isinstance(data, BaseModel):
211
+ form, files = self._get_form_and_files(data)
212
+ else:
213
+ form = data
214
+ files = None
215
+ res = await client.request(
216
+ method,
217
+ url,
218
+ headers=headers,
219
+ timeout=timeout,
220
+ data=form,
221
+ files=files,
222
+ params=req.query_parameters,
223
+ follow_redirects=follow_redirects,
224
+ )
225
+
226
+ res = self._get_response_body(
227
+ res, configs.get("produces", MediaType.APPLICATION_JSON)
228
+ )
229
+ if return_type and issubclass(return_type, BaseModel):
230
+ return return_type.model_validate(res)
231
+ return res
232
+
233
+ def _construct_headers(self, configs: Optional[Dict[str, Any]]) -> Dict[str, str]:
234
+ if configs is None:
235
+ configs = {}
236
+
237
+ headers: Dict[str, str] = {
238
+ "Content-Type": configs.get("consumes", MediaType.APPLICATION_JSON),
239
+ "Accept": configs.get("produces", MediaType.APPLICATION_JSON),
240
+ **configs.get("custom_headers", {}),
241
+ }
242
+ auth: Optional[dict[str, Any]] = configs.get("auth", None)
243
+ if auth is not None:
244
+ header_name = cast(str, auth.get("header_name"))
245
+ headers[header_name] = self._create_auth_headers(auth)
246
+ return headers
247
+
248
+ def _format_url(self, req: Request, url: str) -> str:
249
+ for key, value in req.route_parameters.items():
250
+ url = url.replace(f"<{key}>", value)
251
+ return url
252
+
253
+ def _get_form_and_files(
254
+ self, data: BaseModel
255
+ ) -> Tuple[dict[str, Any], Optional[dict[str, tuple]]]:
256
+ form_data: dict[str, Any] = {}
257
+ files: dict[str, tuple] = {}
258
+ for field in dir(data):
259
+ if field.startswith("_"):
260
+ continue
261
+ value = getattr(data, field, None)
262
+ if isinstance(value, UploadedFile):
263
+ files[field] = (value.filename, value.get_stream(), value.content_type)
264
+ continue
265
+ form_data[field] = value
266
+ if len(files.keys()) == 0:
267
+ files = cast(dict[str, tuple], None)
268
+ if len(form_data.keys()) == 0:
269
+ form_data = cast(dict[str, Any], None)
270
+ return form_data, files
271
+
272
+ def _create_auth_headers(self, auth: dict[str, Any]) -> str:
273
+ auth_type = auth.get("type")
274
+ username = auth.get("username")
275
+ password = auth.get("password")
276
+ if auth_type == AuthType.BASIC_AUTH:
277
+ credentials = f"{username}:{password}"
278
+ encoded = base64.b64encode(credentials.encode()).decode()
279
+ return f"Basic {encoded}"
280
+ if auth_type == AuthType.BEARER:
281
+ return f"Bearer {self._app.get_conf(password, password)}" # type: ignore
282
+ if auth_type == AuthType.API_KEY:
283
+ return f"{self._app.get_conf(password, password)}" # type: ignore
284
+ return ""
285
+
286
+ def _get_response_body(self, res: Response, media_type: MediaType) -> Any:
287
+ if media_type in [
288
+ MediaType.APPLICATION_JSON,
289
+ MediaType.APPLICATION_X_NDJSON,
290
+ MediaType.APPLICATION_PROBLEM_JSON,
291
+ ]:
292
+ return res.json()
293
+ if media_type in [
294
+ MediaType.TEXT_CSV,
295
+ MediaType.TEXT_HTML,
296
+ MediaType.TEXT_PLAIN,
297
+ MediaType.TEXT_XML,
298
+ MediaType.TEXT_YAML,
299
+ ]:
300
+ return res.text
301
+ return res.read()
302
+
303
+ def __init_subclass__(cls, **kwargs):
304
+ super().__init_subclass__(**kwargs)
305
+
306
+ for name, value in list(cls.__dict__.items()):
307
+ if name.startswith("_"):
308
+ continue
309
+
310
+ if callable(value) and hasattr(value, "__api_interface__"):
311
+ setattr(cls, name, ApiInterface._wrap_method(value))