aspyx-service 0.11.2__py3-none-any.whl

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,106 @@
1
+ """
2
+ This module provides the core Aspyx service management framework allowing for service discovery and transparent remoting including multiple possible transport protocols.
3
+ """
4
+
5
+ from aspyx.di import module
6
+
7
+ from .service import AuthorizationException, MissingTokenException, RemoteServiceException, ServiceCommunicationException, TokenException, TokenExpiredException, InvalidTokenException, component_services, ServiceException, Server, Channel, ComponentDescriptor, inject_service, ChannelAddress, ChannelInstances, ServiceManager, Component, Service, AbstractComponent, ComponentStatus, ComponentRegistry, implementation, health, component, service
8
+ from .channels import HTTPXChannel, DispatchJSONChannel, TokenContext
9
+ from .registries import ConsulComponentRegistry
10
+ from .server import FastAPIServer, RequestContext, ResponseContext, TokenContextMiddleware
11
+ from .healthcheck import health_checks, health_check, HealthCheckManager, HealthStatus
12
+ from .restchannel import RestChannel, post, get, put, delete, QueryParam, Body, rest
13
+ from .session import Session, SessionManager, SessionContext
14
+ from .authorization import AuthorizationManager, AbstractAuthorizationFactory
15
+ from .protobuf import ProtobufManager
16
+
17
+ @module()
18
+ class ServiceModule:
19
+ def __init__(self):
20
+ pass
21
+
22
+ __all__ = [
23
+ # service
24
+
25
+ "ServiceManager",
26
+ "ServiceModule",
27
+ "ServiceException",
28
+ "Server",
29
+ "Component",
30
+ "Service",
31
+ "Channel",
32
+ "AbstractComponent",
33
+ "ComponentStatus",
34
+ "ComponentDescriptor",
35
+ "ComponentRegistry",
36
+ "ChannelAddress",
37
+ "ChannelInstances",
38
+ "health",
39
+ "component",
40
+ "service",
41
+ "implementation",
42
+ "inject_service",
43
+ "component_services",
44
+ "RemoteServiceException",
45
+ "ServiceCommunicationException",
46
+ "TokenException",
47
+ "TokenExpiredException",
48
+ "InvalidTokenException",
49
+ "MissingTokenException",
50
+ "AuthorizationException",
51
+
52
+ # protobuf
53
+
54
+ "ProtobufManager",
55
+
56
+ # authorization
57
+
58
+ "AuthorizationManager",
59
+ "AbstractAuthorizationFactory",
60
+
61
+ # session
62
+
63
+ "Session",
64
+ "SessionManager",
65
+ "SessionContext",
66
+
67
+ # healthcheck
68
+
69
+ "health_checks",
70
+ "health_check",
71
+ "HealthStatus",
72
+ "HealthCheckManager",
73
+
74
+ # serialization
75
+
76
+ # "deserialize",
77
+
78
+ # channel
79
+
80
+ "HTTPXChannel",
81
+ "DispatchJSONChannel",
82
+ "TokenContext",
83
+
84
+ # rest
85
+
86
+ "RestChannel",
87
+ "post",
88
+ "get",
89
+ "put",
90
+ "delete",
91
+ "rest",
92
+ "QueryParam",
93
+ "Body",
94
+
95
+ # registries
96
+
97
+ "ConsulComponentRegistry",
98
+
99
+ # server
100
+
101
+ "FastAPIServer",
102
+ "RequestContext",
103
+ "ResponseContext",
104
+ "TokenContext",
105
+ "TokenContextMiddleware",
106
+ ]
@@ -0,0 +1,126 @@
1
+ """
2
+ authorization logic
3
+ """
4
+ import inspect
5
+ from abc import abstractmethod, ABC
6
+ from typing import Optional, Callable
7
+
8
+ from aspyx.di import injectable, inject, order
9
+ from aspyx.di.aop import Invocation
10
+ from aspyx.reflection import TypeDescriptor, Decorators
11
+
12
+ def get_method_class(method):
13
+ if inspect.ismethod(method) or inspect.isfunction(method):
14
+ qualname = method.__qualname__
15
+ parts = qualname.split('.')
16
+ if len(parts) > 1:
17
+ cls_name = parts[-2]
18
+ module = inspect.getmodule(method)
19
+ if module:
20
+ for name, obj in inspect.getmembers(module, inspect.isclass):
21
+ if name == cls_name and hasattr(obj, method.__name__):
22
+ return obj
23
+ return None
24
+
25
+ @injectable()
26
+ class AuthorizationManager:
27
+ """
28
+ The authorization manager is used to remember and execute pluggable authorization checks.
29
+ """
30
+ class Authorization():
31
+ """
32
+ Base class for authorization checks
33
+ """
34
+ def authorize(self, invocation: Invocation):
35
+ """
36
+ execute the authorization check. Throws an exception in case of violations
37
+ """
38
+
39
+ class AuthorizationFactory(ABC):
40
+ """
41
+ An authorization factory is used to create possible authorization checks given a method descriptor
42
+ """
43
+
44
+ def __init__(self, order = 0):
45
+ self.order = order
46
+
47
+ @abstractmethod
48
+ def compute_authorization(self, method_descriptor: TypeDescriptor.MethodDescriptor) -> Optional['AuthorizationManager.Authorization']:
49
+ """
50
+ return a possible authorization check given a method descriptor
51
+ Args:
52
+ method_descriptor: the corresponding method descriptor
53
+
54
+ Returns:
55
+ an authorization check or None
56
+ """
57
+
58
+ # constructor
59
+
60
+ def __init__(self):
61
+ self.factories : list[AuthorizationManager.AuthorizationFactory] = []
62
+ self.checks : dict[Callable, list[AuthorizationManager.Authorization]] = {}
63
+
64
+ # public
65
+
66
+ def register_factory(self, factory: 'AuthorizationManager.AuthorizationFactory'):
67
+ self.factories.append(factory)
68
+
69
+ self.factories.sort(key=lambda factory: factory.order)
70
+
71
+ # internal
72
+
73
+ def compute_checks(self, func: Callable) -> list[Authorization]:
74
+ checks = []
75
+
76
+ clazz = get_method_class(func)
77
+
78
+ descriptor = TypeDescriptor.for_type(clazz).get_method(func.__name__)
79
+
80
+ for factory in self.factories:
81
+ check = factory.compute_authorization(descriptor)
82
+ if check is not None:
83
+ checks.append(check)
84
+
85
+ return checks
86
+
87
+ def get_checks(self, func: Callable) -> list[Authorization]:
88
+ """
89
+ return a list of authorization checks given a function.
90
+
91
+ Args:
92
+ func: the corresponding function.
93
+
94
+ Returns:
95
+ list of authorization checks
96
+ """
97
+ checks = self.checks.get(func, None)
98
+ if checks is None:
99
+ checks = self.compute_checks(func)
100
+ self.checks[func] = checks
101
+ print(checks)
102
+
103
+ return checks
104
+
105
+ def authorize(self, invocation: Invocation):
106
+ for check in self.get_checks(invocation.func):
107
+ check.authorize(invocation)
108
+
109
+ class AbstractAuthorizationFactory(AuthorizationManager.AuthorizationFactory):
110
+ """
111
+ Abstract base class for authorization factories
112
+ """
113
+
114
+ # constructor
115
+
116
+ def __init__(self):
117
+ super().__init__(0)
118
+
119
+ if Decorators.has_decorator(type(self), order):
120
+ self.order = Decorators.get_decorator(type(self), order).args[0]
121
+
122
+ # inject
123
+
124
+ @inject()
125
+ def set_authorization_manager(self, authorization_manager: AuthorizationManager):
126
+ authorization_manager.register_factory(self)
@@ -0,0 +1,445 @@
1
+ """
2
+ Service management and dependency injection framework.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import typing
7
+ from contextlib import contextmanager
8
+ from dataclasses import is_dataclass, fields
9
+ from typing import Type, Optional, Any, Callable
10
+
11
+ import httpx
12
+ import msgpack
13
+ from httpx import Client, AsyncClient, USE_CLIENT_DEFAULT
14
+ from pydantic import BaseModel
15
+
16
+ from aspyx.di.configuration import inject_value
17
+ from aspyx.reflection import DynamicProxy, TypeDescriptor
18
+ from aspyx.threading import ThreadLocal, ContextLocal
19
+ from aspyx.util import get_deserializer, TypeDeserializer, TypeSerializer, get_serializer, CopyOnWriteCache
20
+ from .service import ServiceManager, ServiceCommunicationException, TokenExpiredException, InvalidTokenException, \
21
+ AuthorizationException, MissingTokenException
22
+
23
+ from .service import ComponentDescriptor, ChannelInstances, ServiceException, channel, Channel, RemoteServiceException
24
+
25
+ class TokenContext:
26
+ """
27
+ TokeContext covers two context locals for both the access and - optional - refresh topen
28
+ """
29
+ access_token = ContextLocal[str]("access_token", default=None)
30
+ refresh_token = ContextLocal[str]("refresh_token", default=None)
31
+
32
+ @classmethod
33
+ def get_access_token(cls) -> Optional[str]:
34
+ return cls.access_token.get()
35
+
36
+ @classmethod
37
+ def get_refresh_token(cls) -> Optional[str]:
38
+ return cls.refresh_token.get()
39
+
40
+
41
+ @classmethod
42
+ def set(cls, access_token: str, refresh_token: Optional[str] = None):
43
+ cls.access_token.set(access_token)
44
+ if refresh_token:
45
+ cls.refresh_token.set(refresh_token)
46
+
47
+ @classmethod
48
+ def clear(cls):
49
+ cls.access_token.set(None)
50
+ cls.refresh_token.set(None)
51
+
52
+ @classmethod
53
+ @contextmanager
54
+ def use(cls, access_token: str, refresh_token: Optional[str] = None):
55
+ access_token = cls.access_token.set(access_token)
56
+ refresh_token = cls.refresh_token.set(refresh_token)
57
+ try:
58
+ yield
59
+ finally:
60
+ cls.access_token.reset(access_token)
61
+ cls.refresh_token.reset(refresh_token)
62
+
63
+ class HTTPXChannel(Channel):
64
+ """
65
+ A channel using the httpx clients.
66
+ """
67
+ __slots__ = [
68
+ "client",
69
+ "async_client",
70
+ "service_names",
71
+ "deserializers",
72
+ "timeout",
73
+ "optimize_serialization"
74
+ ]
75
+
76
+ # class properties
77
+
78
+ client_local = ThreadLocal[Client]()
79
+ async_client_local = ThreadLocal[AsyncClient]()
80
+
81
+ # constructor
82
+
83
+ def __init__(self):
84
+ super().__init__()
85
+
86
+ self.timeout = 1000.0
87
+ self.service_names: dict[Type, str] = {}
88
+ self.serializers = CopyOnWriteCache[Callable, list[Callable]]()
89
+ self.deserializers = CopyOnWriteCache[Callable, Callable]()
90
+
91
+ # inject
92
+
93
+ @inject_value("http.timeout", default=1000.0)
94
+ def set_timeout(self, timeout: float) -> None:
95
+ self.timeout = timeout
96
+
97
+ # protected
98
+
99
+ def serialize_args(self, invocation: DynamicProxy.Invocation) -> list[Any]:
100
+ deserializers = self.get_serializers(invocation.type, invocation.method)
101
+
102
+ args = list(invocation.args)
103
+ for index, deserializer in enumerate(deserializers):
104
+ args[index] = deserializer(args[index])
105
+
106
+ return args
107
+
108
+ def get_serializers(self, type: Type, method: Callable) -> list[TypeSerializer]:
109
+ serializers = self.serializers.get(method, None)
110
+ if serializers is None:
111
+ param_types = TypeDescriptor.for_type(type).get_method(method.__name__).param_types
112
+
113
+ serializers = [get_serializer(type) for type in param_types]
114
+
115
+ self.serializers.put(method, serializers)
116
+
117
+ return serializers
118
+
119
+ def get_deserializer(self, type: Type, method: Callable) -> TypeDeserializer:
120
+ deserializer = self.deserializers.get(method, None)
121
+ if deserializer is None:
122
+ return_type = TypeDescriptor.for_type(type).get_method(method.__name__).return_type
123
+
124
+ deserializer = get_deserializer(return_type)
125
+
126
+ self.deserializers.put(method, deserializer)
127
+
128
+ return deserializer
129
+
130
+ # override
131
+
132
+ def setup(self, component_descriptor: ComponentDescriptor, address: ChannelInstances):
133
+ super().setup(component_descriptor, address)
134
+
135
+ # remember service names
136
+
137
+ for service in component_descriptor.services:
138
+ self.service_names[service.type] = service.name
139
+
140
+ # public
141
+
142
+ def get_client(self) -> Client:
143
+ client = self.client_local.get()
144
+
145
+ if client is None:
146
+ client = self.make_client()
147
+ self.client_local.set(client)
148
+
149
+ return client
150
+
151
+ def get_async_client(self) -> AsyncClient:
152
+ async_client = self.async_client_local.get()
153
+
154
+ if async_client is None:
155
+ async_client = self.make_async_client()
156
+ self.async_client_local.set(async_client)
157
+
158
+ return async_client
159
+
160
+ def make_client(self) -> Client:
161
+ return Client() # base_url=url
162
+
163
+ def make_async_client(self) -> AsyncClient:
164
+ return AsyncClient() # base_url=url
165
+
166
+ def request(self, http_method: str, url: str, json: Optional[typing.Any] = None,
167
+ params: Optional[Any] = None, headers: Optional[Any] = None,
168
+ timeout: Any = USE_CLIENT_DEFAULT, content: Optional[Any] = None) -> httpx.Response:
169
+
170
+ token = TokenContext.get_access_token()
171
+ if token is not None:
172
+ if headers is None: # None is also valid!
173
+ headers = {}
174
+
175
+ ## add bearer token
176
+
177
+ headers["Authorization"] = f"Bearer {token}"
178
+
179
+ try:
180
+ response = self.get_client().request(http_method, url, params=params, json=json, headers=headers, timeout=timeout, content=content)
181
+
182
+ #print("\n=== Response ===")
183
+ #print("Status Code:", response.status_code)
184
+ #try:
185
+ # print("Body:", json.dumps(response.json(), indent=2))
186
+ #except Exception:
187
+ # print("Body (raw):", response.text)
188
+
189
+ response.raise_for_status()
190
+ except httpx.RequestError as e:
191
+ raise ServiceCommunicationException(str(e)) from e
192
+
193
+ except httpx.HTTPStatusError as e:
194
+ if e.response.status_code == 401:
195
+ www_auth = e.response.headers.get("www-authenticate", "")
196
+ if "invalid_token" in www_auth:
197
+ if 'expired' in www_auth:
198
+ raise TokenExpiredException() from e
199
+
200
+ if 'missing' in www_auth:
201
+ raise MissingTokenException() from e
202
+
203
+ raise InvalidTokenException() from e
204
+
205
+ raise AuthorizationException(str(e)) from e
206
+ except httpx.HTTPError as e:
207
+ raise RemoteServiceException(str(e)) from e
208
+
209
+ return response
210
+
211
+ async def request_async(self, http_method: str, url: str, json: Optional[typing.Any] = None,
212
+ params: Optional[Any] = None, headers: Optional[Any] = None,
213
+ timeout: Any = USE_CLIENT_DEFAULT, content: Optional[Any] = None) -> httpx.Response:
214
+
215
+ token = TokenContext.get_access_token()
216
+ if token is not None:
217
+ if headers is None: # None is also valid!
218
+ headers = {}
219
+
220
+ ## add bearer token
221
+
222
+ headers["Authorization"] = f"Bearer {token}"
223
+
224
+ try:
225
+ response = await self.get_async_client().request(http_method, url, params=params, json=json, headers=headers,
226
+ timeout=timeout, content=content)
227
+ response.raise_for_status()
228
+ except httpx.RequestError as e:
229
+ raise ServiceCommunicationException(str(e)) from e
230
+
231
+ except httpx.HTTPStatusError as e:
232
+ if e.response.status_code == 401:
233
+ www_auth = e.response.headers.get("www-authenticate", "")
234
+ if "invalid_token" in www_auth:
235
+ if 'expired' in www_auth:
236
+ raise TokenExpiredException() from e
237
+ elif 'missing' in www_auth:
238
+ raise MissingTokenException() from e
239
+ else:
240
+ raise InvalidTokenException() from e
241
+
242
+ raise RemoteServiceException(str(e)) from e
243
+ except httpx.HTTPError as e:
244
+ raise RemoteServiceException(str(e)) from e
245
+
246
+ return response
247
+
248
+ class Request(BaseModel):
249
+ method: str # component:service:method
250
+ args: tuple[Any, ...]
251
+
252
+ class Response(BaseModel):
253
+ result: Optional[Any]
254
+ exception: Optional[Any]
255
+
256
+ @channel("dispatch-json")
257
+ class DispatchJSONChannel(HTTPXChannel):
258
+ """
259
+ A channel that calls a POST on the endpoint `ìnvoke` sending a request body containing information on the
260
+ called component, service and method and the arguments.
261
+ """
262
+ # constructor
263
+
264
+ def __init__(self):
265
+ super().__init__()
266
+
267
+ # internal
268
+
269
+ # implement Channel
270
+
271
+ def set_address(self, address: Optional[ChannelInstances]):
272
+ ServiceManager.logger.info("channel %s got an address %s", self.name, address)
273
+
274
+ super().set_address(address)
275
+
276
+ def setup(self, component_descriptor: ComponentDescriptor, address: ChannelInstances):
277
+ super().setup(component_descriptor, address)
278
+
279
+ def invoke(self, invocation: DynamicProxy.Invocation):
280
+ service_name = self.service_names[invocation.type] # map type to registered service name
281
+
282
+ request = {
283
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
284
+ "args": self.serialize_args(invocation)
285
+ }
286
+
287
+ try:
288
+ http_result = self.request( "post", f"{self.get_url()}/invoke", json=request, timeout=self.timeout)
289
+ result = http_result.json()
290
+ if result["exception"] is not None:
291
+ raise RemoteServiceException(f"server side exception {result['exception']}")
292
+
293
+ return self.get_deserializer(invocation.type, invocation.method)(result["result"])
294
+ except (ServiceCommunicationException, AuthorizationException, RemoteServiceException) as e:
295
+ raise
296
+
297
+ except Exception as e:
298
+ raise ServiceCommunicationException(f"communication exception {e}") from e
299
+
300
+
301
+ async def invoke_async(self, invocation: DynamicProxy.Invocation):
302
+ service_name = self.service_names[invocation.type] # map type to registered service name
303
+ request = {
304
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
305
+ "args": self.serialize_args(invocation)
306
+ }
307
+
308
+ try:
309
+ data = await self.request_async("post", f"{self.get_url()}/invoke", json=request, timeout=self.timeout)
310
+ result = data.json()
311
+
312
+ if result["exception"] is not None:
313
+ raise RemoteServiceException(f"server side exception {result['exception']}")
314
+
315
+ return self.get_deserializer(invocation.type, invocation.method)(result["result"])
316
+
317
+ except (ServiceCommunicationException, AuthorizationException, RemoteServiceException) as e:
318
+ raise
319
+
320
+ except Exception as e:
321
+ raise ServiceCommunicationException(f"communication exception {e}") from e
322
+
323
+
324
+ @channel("dispatch-msgpack")
325
+ class DispatchMSPackChannel(HTTPXChannel):
326
+ """
327
+ A channel that sends a POST on the ìnvoke `endpoint`with an msgpack encoded request body.
328
+ """
329
+ # constructor
330
+
331
+ def __init__(self):
332
+ super().__init__()
333
+
334
+ # override
335
+
336
+ def set_address(self, address: Optional[ChannelInstances]):
337
+ ServiceManager.logger.info("channel %s got an address %s", self.name, address)
338
+
339
+ super().set_address(address)
340
+
341
+ def invoke(self, invocation: DynamicProxy.Invocation):
342
+ service_name = self.service_names[invocation.type] # map type to registered service name
343
+ request = {
344
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
345
+ "args": self.serialize_args(invocation)
346
+ }
347
+
348
+ try:
349
+ packed = msgpack.packb(request, use_bin_type=True)
350
+
351
+ response = self.request("post",
352
+ f"{self.get_url()}/invoke",
353
+ content=packed,
354
+ headers={"Content-Type": "application/msgpack"},
355
+ timeout=self.timeout
356
+ )
357
+
358
+ result = msgpack.unpackb(response.content, raw=False)
359
+
360
+ if result.get("exception", None):
361
+ raise RemoteServiceException(f"server-side: {result['exception']}")
362
+
363
+ return self.get_deserializer(invocation.type, invocation.method)(result["result"])
364
+
365
+
366
+ except httpx.RequestError as e:
367
+ raise ServiceCommunicationException(str(e)) from e
368
+
369
+ except httpx.HTTPStatusError as e:
370
+ if e.response.status_code == 401:
371
+ www_auth = e.response.headers.get("www-authenticate", "")
372
+ if "invalid_token" in www_auth:
373
+ if 'expired' in www_auth:
374
+ raise TokenExpiredException() from e
375
+
376
+ if 'missing' in www_auth:
377
+ raise MissingTokenException() from e
378
+
379
+ raise InvalidTokenException() from e
380
+
381
+ raise RemoteServiceException(str(e)) from e
382
+ except httpx.HTTPError as e:
383
+ raise RemoteServiceException(str(e)) from e
384
+
385
+ except ServiceCommunicationException:
386
+ raise
387
+
388
+ except RemoteServiceException:
389
+ raise
390
+
391
+ except Exception as e:
392
+ raise ServiceException(f"msgpack exception: {e}") from e
393
+
394
+ async def invoke_async(self, invocation: DynamicProxy.Invocation):
395
+ service_name = self.service_names[invocation.type] # map type to registered service name
396
+ request = {
397
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
398
+ "args": self.serialize_args(invocation)
399
+ }
400
+
401
+ try:
402
+ packed = msgpack.packb(request, use_bin_type=True)
403
+
404
+ response = await self.request_async("post",
405
+ f"{self.get_url()}/invoke",
406
+ content=packed,
407
+ headers={"Content-Type": "application/msgpack"},
408
+ timeout=self.timeout
409
+ )
410
+
411
+ result = msgpack.unpackb(response.content, raw=False)
412
+
413
+ if result.get("exception", None):
414
+ raise RemoteServiceException(f"server-side: {result['exception']}")
415
+
416
+ return self.get_deserializer(invocation.type, invocation.method)(result["result"])
417
+
418
+ except httpx.RequestError as e:
419
+ raise ServiceCommunicationException(str(e)) from e
420
+
421
+ except httpx.HTTPStatusError as e:
422
+ if e.response.status_code == 401:
423
+ www_auth = e.response.headers.get("www-authenticate", "")
424
+ if "invalid_token" in www_auth:
425
+ if 'expired' in www_auth:
426
+ raise TokenExpiredException() from e
427
+
428
+ if 'missing' in www_auth:
429
+ raise MissingTokenException() from e
430
+
431
+ raise InvalidTokenException() from e
432
+
433
+ raise RemoteServiceException(str(e)) from e
434
+
435
+ except httpx.HTTPError as e:
436
+ raise RemoteServiceException(str(e)) from e
437
+
438
+ except ServiceCommunicationException:
439
+ raise
440
+
441
+ except RemoteServiceException:
442
+ raise
443
+
444
+ except Exception as e:
445
+ raise ServiceException(f"msgpack exception: {e}") from e
@@ -0,0 +1,16 @@
1
+ """
2
+ This module provides the core Aspyx event management framework .
3
+ """
4
+
5
+ from .json_schema_generator import JSONSchemaGenerator
6
+ from .openapi_generator import OpenAPIGenerator
7
+
8
+ __all__ = [
9
+ # json_schema_generator
10
+
11
+ "JSONSchemaGenerator",
12
+
13
+ # openapi_generator
14
+
15
+ "OpenAPIGenerator"
16
+ ]