aspyx-service 0.10.3__py3-none-any.whl → 0.10.5__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.

Potentially problematic release.


This version of aspyx-service might be problematic. Click here for more details.

@@ -3,19 +3,18 @@ rest channel implementation
3
3
  """
4
4
  import inspect
5
5
  import re
6
- from dataclasses import is_dataclass, asdict
6
+ from dataclasses import is_dataclass
7
7
 
8
8
  from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get_args, Type
9
-
10
9
  from pydantic import BaseModel
11
10
 
12
11
  from aspyx.reflection import DynamicProxy, Decorators
12
+
13
+ from .channels import HTTPXChannel
13
14
  from .service import channel, ServiceCommunicationException
14
15
 
15
16
  T = TypeVar("T")
16
17
 
17
- from .channels import HTTPXChannel
18
-
19
18
  class BodyMarker:
20
19
  pass
21
20
 
@@ -169,7 +168,7 @@ class RestChannel(HTTPXChannel):
169
168
  for param_name, hint in hints.items():
170
169
  if get_origin(hint) is Annotated:
171
170
  metadata = get_args(hint)[1:]
172
-
171
+
173
172
  if BodyMarker in metadata:
174
173
  self.body_param_name = param_name
175
174
  param_names.remove(param_name)
@@ -179,7 +178,7 @@ class RestChannel(HTTPXChannel):
179
178
 
180
179
  # check if something is missing
181
180
 
182
- if len(param_names) > 0:
181
+ if param_names:
183
182
  # check body params
184
183
  if self.type in ("post", "put", "patch"):
185
184
  if self.body_param_name is None:
@@ -248,14 +247,11 @@ class RestChannel(HTTPXChannel):
248
247
 
249
248
  try:
250
249
  result = None
251
- if call.type == "get":
252
- result = await self.get_async_client().get(self.get_url() + url, params=query_params, timeout=self.timeout)
253
- elif call.type == "put":
254
- result = await self.get_async_client().put(self.get_url() + url, params=query_params, timeout=self.timeout)
255
- elif call.type == "delete":
256
- result = await self.get_async_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout)
250
+ if call.type in ["get", "put", "delete"]:
251
+ result = await self.request_async(call.type, self.get_url() + url, params=query_params, timeout=self.timeout)
252
+
257
253
  elif call.type == "post":
258
- result = await self.get_async_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
254
+ result = await self.request_async("post", self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
259
255
 
260
256
  return self.get_deserializer(invocation.type, invocation.method)(result.json())
261
257
  except ServiceCommunicationException:
@@ -283,14 +279,11 @@ class RestChannel(HTTPXChannel):
283
279
 
284
280
  try:
285
281
  result = None
286
- if call.type == "get":
287
- result = self.get_client().get(self.get_url() + url, params=query_params, timeout=self.timeout)
288
- elif call.type == "put":
289
- result = self.get_client().put(self.get_url() + url, params=query_params, timeout=self.timeout)
290
- elif call.type == "delete":
291
- result = self.get_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout)
282
+ if call.type in ["get", "put", "delete"]:
283
+ result = self.request("get", self.get_url() + url, params=query_params, timeout=self.timeout)
284
+
292
285
  elif call.type == "post":
293
- result = self.get_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
286
+ result = self.request( "post", self.get_url() + url, params=query_params, json=body, timeout=self.timeout)
294
287
 
295
288
  return self.get_deserializer(invocation.type, invocation.method)(result.json())
296
289
  except ServiceCommunicationException:
aspyx_service/server.py CHANGED
@@ -1,48 +1,155 @@
1
1
  """
2
2
  FastAPI server implementation for the aspyx service framework.
3
3
  """
4
+ from __future__ import annotations
4
5
  import atexit
6
+ import functools
5
7
  import inspect
6
8
  import threading
7
- from typing import Type, Optional, Callable
8
-
9
- from fastapi.responses import JSONResponse
9
+ from typing import Type, Optional, Callable, Any
10
+ import contextvars
10
11
  import msgpack
11
12
  import uvicorn
12
13
 
13
- from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse
14
+ from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse, HTTPException
14
15
 
15
- from aspyx.di import Environment
16
+
17
+ from fastapi.responses import JSONResponse
18
+ from starlette.middleware.base import BaseHTTPMiddleware
19
+
20
+ from aspyx.di import Environment, injectable, on_init, inject_environment, on_destroy
16
21
  from aspyx.reflection import TypeDescriptor, Decorators
22
+ from aspyx.util import get_deserializer, get_serializer
17
23
 
18
24
  from .service import ComponentRegistry
19
25
  from .healthcheck import HealthCheckManager
20
26
 
21
- from .serialization import get_deserializer
22
-
23
27
  from .service import Server, ServiceManager
24
- from .channels import Request, Response
28
+ from .channels import Request, Response, TokenContext
25
29
 
26
30
  from .restchannel import get, post, put, delete, rest
27
31
 
32
+ class ResponseContext:
33
+ response_var = contextvars.ContextVar[Optional['ResponseContext.Response']]("response", default=None)
34
+
35
+ class Response:
36
+ def __init__(self):
37
+ self.cookies = {}
38
+
39
+ def set_cookie(self, key, value):
40
+ self.cookies[key] = value
41
+
42
+ @classmethod
43
+ def get(cls) -> ResponseContext.Response:
44
+ response = ResponseContext.Response()
45
+
46
+ cls.response_var.set(response)
47
+
48
+ return response
49
+
50
+ @classmethod
51
+ def is_set(cls) -> Optional[ResponseContext.Response]:
52
+ return cls.response_var.get()
53
+
54
+ @classmethod
55
+ def reset(cls) -> None:
56
+ cls.response_var.set(None)
57
+
58
+
59
+ class RequestContext:
60
+ """
61
+ A request context is used to remember the current http request in the current thread
62
+ """
63
+ request_var = contextvars.ContextVar("request")
64
+
65
+ @classmethod
66
+ def get_request(cls) -> Request:
67
+ """
68
+ Return the current http request
69
+
70
+ Returns:
71
+ the current http request
72
+ """
73
+ return cls.request_var.get()
74
+
75
+ # constructor
76
+
77
+ def __init__(self, app):
78
+ self.app = app
79
+
80
+ async def __call__(self, scope, receive, send):
81
+ if scope["type"] != "http":
82
+ await self.app(scope, receive, send)
83
+ return
84
+
85
+ request = HttpRequest(scope)
86
+ token = self.request_var.set(request)
87
+ try:
88
+ await self.app(scope, receive, send)
89
+ finally:
90
+ self.request_var.reset(token)
91
+
92
+ class TokenContextMiddleware(BaseHTTPMiddleware):
93
+ async def dispatch(self, request: Request, call_next):
94
+ access_token = request.cookies.get("access_token") or request.headers.get("Authorization")
95
+ #refresh_token = request.cookies.get("refresh_token")
96
+
97
+ if access_token:
98
+ TokenContext.set(access_token)#, refresh_token)
99
+
100
+ try:
101
+ return await call_next(request)
102
+ finally:
103
+ TokenContext.clear()
28
104
 
29
105
  class FastAPIServer(Server):
30
106
  """
31
107
  A server utilizing fastapi framework.
32
108
  """
109
+
110
+ # class methods
111
+
112
+ @classmethod
113
+ def boot(cls, module: Type, host="0.0.0.0", port=8000, start_thread = True) -> Environment:
114
+ """
115
+ boot the DI infrastructure of the supplied module and optionally start a fastapi thread given the url
116
+ Args:
117
+ module: the module to initialize the environment
118
+ host: listen address
119
+ port: the port
120
+
121
+ Returns:
122
+ the created environment
123
+ """
124
+
125
+ cls.port = port
126
+
127
+ environment = Environment(module)
128
+
129
+ server = environment.get(FastAPIServer)
130
+
131
+ if start_thread:
132
+ server.start_server(host)
133
+
134
+ return environment
135
+
33
136
  # constructor
34
137
 
35
- def __init__(self, host="0.0.0.0", port=8000, **kwargs):
138
+ def __init__(self, fast_api: FastAPI, service_manager: ServiceManager, component_registry: ComponentRegistry):
36
139
  super().__init__()
37
140
 
38
- self.host = host
39
- Server.port = port
141
+ self.environment : Optional[Environment] = None
142
+ self.service_manager = service_manager
143
+ self.component_registry = component_registry
144
+
145
+ self.host = "localhost"
146
+ self.fast_api = fast_api
40
147
  self.server_thread = None
41
- self.service_manager : Optional[ServiceManager] = None
42
- self.component_registry: Optional[ComponentRegistry] = None
43
148
 
44
149
  self.router = APIRouter()
45
- self.fast_api = FastAPI()
150
+
151
+ self.server : Optional[uvicorn.Server] = None
152
+ self.thread : Optional[threading.Thread] = None
46
153
 
47
154
  # cache
48
155
 
@@ -52,14 +159,78 @@ class FastAPIServer(Server):
52
159
 
53
160
  self.router.post("/invoke")(self.invoke)
54
161
 
162
+ # inject
163
+
164
+ @inject_environment()
165
+ def set_environment(self, environment: Environment):
166
+ self.environment = environment
167
+
168
+ # lifecycle
169
+
170
+ @on_init()
171
+ def on_init(self):
172
+ self.service_manager.startup(self)
173
+
174
+ # add routes
175
+
176
+ self.add_routes()
177
+ self.fast_api.include_router(self.router)
178
+
179
+ #for route in self.fast_api.routes:
180
+ # print(f"{route.name}: {route.path} [{route.methods}]")
181
+
182
+ # add cleanup hook
183
+
184
+ def cleanup():
185
+ self.service_manager.shutdown()
186
+
187
+ atexit.register(cleanup)
188
+
189
+ @on_destroy()
190
+ def on_destroy(self):
191
+ if self.server is not None:
192
+ self.server.should_exit = True
193
+ self.thread.join()
194
+
55
195
  # private
56
196
 
57
197
  def add_routes(self):
58
198
  """
59
- add everything that looks like a http endpoint
199
+ add everything that looks like an http endpoint
60
200
  """
61
201
 
62
- # go
202
+ def wrap_service_method(handler, return_type):
203
+ sig = inspect.signature(handler)
204
+
205
+ @functools.wraps(handler)
206
+ async def wrapper(*args, **kwargs):
207
+ try:
208
+ result = handler(*args, **kwargs)
209
+ if inspect.iscoroutine(result):
210
+ result = await result
211
+
212
+ except HTTPException as e:
213
+ raise
214
+ except Exception as e:
215
+ result = {"error": str(e)}
216
+
217
+ json_response = JSONResponse(get_serializer(return_type)(result))
218
+
219
+ local_response = ResponseContext.is_set()
220
+ if local_response is not None:
221
+ for key, value in local_response.cookies.items():
222
+ json_response.set_cookie(key, value)
223
+
224
+ ResponseContext.reset()
225
+
226
+ return json_response
227
+
228
+ # Optionally attach response_model info for docs
229
+
230
+ wrapper.__signature__ = sig
231
+ wrapper.__annotations__ = {"return": return_type}
232
+
233
+ return wrapper
63
234
 
64
235
  for descriptor in self.service_manager.descriptors.values():
65
236
  if not descriptor.is_component() and descriptor.is_local():
@@ -76,25 +247,23 @@ class FastAPIServer(Server):
76
247
  if decorator is not None:
77
248
  self.router.add_api_route(
78
249
  path=prefix + decorator.args[0],
79
- endpoint=getattr(instance, method.get_name()),
250
+ endpoint=wrap_service_method(getattr(instance, method.get_name()), method.return_type),
80
251
  methods=[decorator.decorator.__name__],
81
252
  name=f"{descriptor.get_component_descriptor().name}.{descriptor.name}.{method.get_name()}",
82
253
  response_model=method.return_type,
83
254
  )
84
255
 
85
- def start_fastapi_thread(self):
256
+ def start_server(self, host: str):
86
257
  """
87
258
  start the fastapi server in a thread
88
259
  """
260
+ self.host = host
89
261
 
90
- config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, access_log=False) #log_level="debug"
91
- server = uvicorn.Server(config)
92
-
93
- thread = threading.Thread(target=server.run, daemon=True)
94
- thread.start()
95
-
96
- return thread
262
+ config = uvicorn.Config(self.fast_api, host=host, port=self.port, access_log=False)
97
263
 
264
+ self.server = uvicorn.Server(config)
265
+ self.thread = threading.Thread(target=self.server.run, daemon=True)
266
+ self.thread.start()
98
267
 
99
268
  def get_deserializers(self, service: Type, method):
100
269
  deserializers = self.deserializers.get(method, None)
@@ -106,8 +275,8 @@ class FastAPIServer(Server):
106
275
 
107
276
  return deserializers
108
277
 
109
- def deserialize_args(self, request: Request, type: Type, method: Callable) -> list:
110
- args = list(request.args)
278
+ def deserialize_args(self, args: list[Any], type: Type, method: Callable) -> list:
279
+ #args = list(request.args)
111
280
 
112
281
  deserializers = self.get_deserializers(type, method)
113
282
 
@@ -133,22 +302,22 @@ class FastAPIServer(Server):
133
302
  media_type="text/plain"
134
303
  )
135
304
 
136
- request = Request(**data)
305
+ request = data
137
306
 
138
307
  if content == "json":
139
- return await self.dispatch(request)
308
+ return await self.dispatch(http_request, request)
140
309
  else:
141
310
  return HttpResponse(
142
- content=msgpack.packb(await self.dispatch(request), use_bin_type=True),
311
+ content=msgpack.packb(await self.dispatch(http_request, request), use_bin_type=True),
143
312
  media_type="application/msgpack"
144
313
  )
145
314
 
146
- async def dispatch(self, request: Request) :
147
- ServiceManager.logger.debug("dispatch request %s", request.method)
315
+ async def dispatch(self, http_request: HttpRequest, request: dict) :
316
+ ServiceManager.logger.debug("dispatch request %s", request["method"])
148
317
 
149
318
  # <comp>:<service>:<method>
150
319
 
151
- parts = request.method.split(":")
320
+ parts = request["method"].split(":")
152
321
 
153
322
  #component = parts[0]
154
323
  service_name = parts[1]
@@ -159,16 +328,20 @@ class FastAPIServer(Server):
159
328
 
160
329
  method = getattr(service, method_name)
161
330
 
162
- args = self.deserialize_args(request, service_descriptor.type, method)
331
+ args = self.deserialize_args(request["args"], service_descriptor.type, method)
163
332
  try:
164
333
  if inspect.iscoroutinefunction(method):
165
334
  result = await method(*args)
166
335
  else:
167
336
  result = method(*args)
168
337
 
169
- return Response(result=result, exception=None).dict()
338
+ return Response(result=result, exception=None).model_dump()
339
+
340
+ except HTTPException as e:
341
+ raise
342
+
170
343
  except Exception as e:
171
- return Response(result=None, exception=str(e)).dict()
344
+ return Response(result=None, exception=str(e)).model_dump()
172
345
 
173
346
  # override
174
347
 
@@ -185,44 +358,3 @@ class FastAPIServer(Server):
185
358
  )
186
359
 
187
360
  self.router.get(url)(get_health_response)
188
-
189
- def boot(self, module_type: Type) -> Environment:
190
- """
191
- startup the service manager, DI framework and the fastapi server based on the supplied module
192
-
193
- Args:
194
- module_type: the module
195
-
196
- Returns:
197
-
198
- """
199
- # setup environment
200
-
201
- self.environment = Environment(module_type)
202
- self.service_manager = self.environment.get(ServiceManager)
203
- self.component_registry = self.environment.get(ComponentRegistry)
204
-
205
- self.service_manager.startup(self)
206
-
207
- # add routes
208
-
209
- self.add_routes()
210
- self.fast_api.include_router(self.router)
211
-
212
- #for route in self.fast_api.routes:
213
- # print(f"{route.name}: {route.path} [{route.methods}]")
214
-
215
- # start server thread
216
-
217
- self.start_fastapi_thread()
218
-
219
- # shutdown
220
-
221
- def cleanup():
222
- self.service_manager.shutdown()
223
-
224
- atexit.register(cleanup)
225
-
226
- # done
227
-
228
- return self.environment
aspyx_service/service.py CHANGED
@@ -15,9 +15,10 @@ from typing import Type, TypeVar, Generic, Callable, Optional, cast
15
15
 
16
16
  from aspyx.di import injectable, Environment, Providers, ClassInstanceProvider, inject_environment, order, \
17
17
  Lifecycle, LifecycleCallable, InstanceProvider
18
+ from aspyx.di.aop.aop import ClassAspectTarget
18
19
  from aspyx.reflection import Decorators, DynamicProxy, DecoratorDescriptor, TypeDescriptor
19
20
  from aspyx.util import StringBuilder
20
- from .healthcheck import HealthCheckManager
21
+ from .healthcheck import HealthCheckManager, HealthStatus
21
22
 
22
23
  T = TypeVar("T")
23
24
 
@@ -55,10 +56,6 @@ class Server(ABC):
55
56
  def get(self, type: Type[T]) -> T:
56
57
  return self.environment.get(type)
57
58
 
58
- @abstractmethod
59
- def boot(self, module_type: Type):
60
- pass
61
-
62
59
  @abstractmethod
63
60
  def route(self, url : str, callable: Callable):
64
61
  pass
@@ -121,7 +118,7 @@ class Component(Service):
121
118
  @abstractmethod
122
119
  def get_addresses(self, port: int) -> list[ChannelAddress]:
123
120
  """
124
- returns a list of channel addresses that expose this components services.
121
+ returns a list of channel addresses that expose this component's services.
125
122
 
126
123
  Args:
127
124
  port: the port of a server hosting this component
@@ -164,6 +161,9 @@ class AbstractComponent(Component, ABC):
164
161
  def get_status(self) -> ComponentStatus:
165
162
  return self.status
166
163
 
164
+ async def get_health(self) -> HealthCheckManager.Health:
165
+ return HealthCheckManager.Health(HealthStatus.OK)
166
+
167
167
  def to_snake_case(name: str) -> str:
168
168
  return re.sub(r'(?<!^)(?=[A-Z])', '-', name).lower()
169
169
 
@@ -401,6 +401,22 @@ class RemoteServiceException(ServiceException):
401
401
  base class for service exceptions occurring on the server side
402
402
  """
403
403
 
404
+
405
+ class AuthorizationException(ServiceException):
406
+ pass
407
+
408
+ class TokenException(AuthorizationException):
409
+ pass
410
+
411
+ class InvalidTokenException(TokenException):
412
+ pass
413
+
414
+ class MissingTokenException(TokenException):
415
+ pass
416
+
417
+ class TokenExpiredException(TokenException):
418
+ pass
419
+
404
420
  class Channel(DynamicProxy.InvocationHandler, ABC):
405
421
  """
406
422
  A channel is a dynamic proxy invocation handler and transparently takes care of remoting.
@@ -432,7 +448,7 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
432
448
  a url selector always retrieving the first URL given a list of possible URLS
433
449
  """
434
450
  def get(self, urls: list[str]) -> str:
435
- if len(urls) == 0:
451
+ if not urls:
436
452
  raise ServiceCommunicationException("no known url")
437
453
 
438
454
  return urls[0]
@@ -445,7 +461,7 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
445
461
  self.index = 0
446
462
 
447
463
  def get(self, urls: list[str]) -> str:
448
- if len(urls) > 0:
464
+ if urls:
449
465
  try:
450
466
  return urls[self.index]
451
467
  finally:
@@ -462,6 +478,8 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
462
478
  self.address: Optional[ChannelInstances] = None
463
479
  self.url_selector : Channel.URLSelector = Channel.FirstURLSelector()
464
480
 
481
+ self.select_round_robin()
482
+
465
483
  # public
466
484
 
467
485
  def customize(self):
@@ -692,11 +710,17 @@ class ServiceManager:
692
710
 
693
711
  # fetch health
694
712
 
695
- descriptor.health = Decorators.get_decorator(descriptor.implementation, health).args[0]
713
+ health_name = None
714
+ health_descriptor = Decorators.get_decorator(descriptor.implementation, health)
715
+
716
+ if health_descriptor is not None:
717
+ health_name = health_descriptor.args[0]
718
+
719
+ descriptor.health = health_name
696
720
 
697
721
  self.component_registry.register(descriptor.get_component_descriptor(), [ChannelAddress("local", "")])
698
722
 
699
- health_name = next((decorator.args[0] for decorator in Decorators.get(descriptor.type) if decorator.decorator is health), None)
723
+ #health_name = next((decorator.args[0] for decorator in Decorators.get(descriptor.type) if decorator.decorator is health), None)
700
724
 
701
725
  # startup
702
726
 
@@ -704,7 +728,8 @@ class ServiceManager:
704
728
 
705
729
  # add health route
706
730
 
707
- server.route_health(health_name, instance.get_health)
731
+ if health_name is not None:
732
+ server.route_health(health_name, instance.get_health)
708
733
 
709
734
  # register addresses
710
735
 
@@ -733,7 +758,7 @@ class ServiceManager:
733
758
  addresses = self.component_registry.get_addresses(component_descriptor) # component, channel + urls
734
759
  address = next((address for address in addresses if address.channel == preferred_channel), None)
735
760
  if address is None:
736
- if len(addresses) > 0:
761
+ if addresses:
737
762
  # return the first match
738
763
  address = addresses[0]
739
764
  else:
@@ -908,3 +933,13 @@ class ServiceLifecycleCallable(LifecycleCallable):
908
933
 
909
934
  def args(self, decorator: DecoratorDescriptor, method: TypeDescriptor.MethodDescriptor, environment: Environment):
910
935
  return [self.manager.get_service(method.param_types[0], preferred_channel=decorator.args[0])]
936
+
937
+ def component_services(component_type: Type) -> ClassAspectTarget:
938
+ target = ClassAspectTarget()
939
+
940
+ descriptor = TypeDescriptor.for_type(component_type)
941
+
942
+ for service_type in descriptor.get_decorator(component).args[2]:
943
+ target.of_type(service_type)
944
+
945
+ return target