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,576 @@
1
+ """
2
+ FastAPI server implementation for the aspyx service framework.
3
+ """
4
+ from __future__ import annotations
5
+ import atexit
6
+ import functools
7
+ import inspect
8
+ import threading
9
+ import typing
10
+ from typing import get_origin, get_args, get_type_hints, Annotated
11
+ from dataclasses import is_dataclass
12
+ from datetime import datetime
13
+ from typing import Type, Optional, Callable, Any
14
+ import contextvars
15
+ import msgpack
16
+ import uvicorn
17
+ import re
18
+ from fastapi import Body as FastAPI_Body
19
+ from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse, HTTPException
20
+ from fastapi.datastructures import DefaultPlaceholder, Default
21
+
22
+ from fastapi.responses import JSONResponse
23
+ from starlette.middleware.base import BaseHTTPMiddleware
24
+
25
+ from aspyx.di import Environment, on_init, inject_environment, on_destroy
26
+ from aspyx.reflection import TypeDescriptor, Decorators
27
+ from aspyx.util import get_deserializer, get_serializer, CopyOnWriteCache
28
+
29
+ from .protobuf import ProtobufManager
30
+ from .service import ComponentRegistry, ServiceDescriptor
31
+ from .healthcheck import HealthCheckManager
32
+
33
+ from .service import Server, ServiceManager
34
+ from .channels import Request, Response, TokenContext
35
+
36
+ from .restchannel import get, post, put, delete, rest
37
+
38
+ class ResponseContext:
39
+ response_var = contextvars.ContextVar[Optional['ResponseContext.Response']]("response", default=None)
40
+
41
+ class Response:
42
+ def __init__(self):
43
+ self.cookies = {}
44
+ self.delete_cookies = {}
45
+
46
+ def delete_cookie(self,
47
+ key: str,
48
+ path: str = "/",
49
+ domain: str | None = None,
50
+ secure: bool = False,
51
+ httponly: bool = False,
52
+ samesite: typing.Literal["lax", "strict", "none"] | None = "lax",
53
+ ):
54
+ self.delete_cookies[key] = {
55
+ "path": path,
56
+ "domain": domain,
57
+ "secure": secure,
58
+ "httponly": httponly,
59
+ "samesite": samesite
60
+ }
61
+
62
+ def set_cookie(self,
63
+ key: str,
64
+ value: str = "",
65
+ max_age: int | None = None,
66
+ expires: datetime | str | int | None = None,
67
+ path: str | None = "/",
68
+ domain: str | None = None,
69
+ secure: bool = False,
70
+ httponly: bool = False,
71
+ samesite: typing.Literal["lax", "strict", "none"] | None = "lax"):
72
+ self.cookies[key] = {
73
+ "value": value,
74
+ "max_age": max_age,
75
+ "expires": expires,
76
+ "path": path,
77
+ "domain": domain,
78
+ "secure": secure,
79
+ "httponly": httponly,
80
+ "samesite": samesite
81
+ }
82
+
83
+ @classmethod
84
+ def create(cls) -> ResponseContext.Response:
85
+ response = ResponseContext.Response()
86
+
87
+ cls.response_var.set(response)
88
+
89
+ return response
90
+
91
+ @classmethod
92
+ def get(cls) -> Optional[ResponseContext.Response]:
93
+ return cls.response_var.get()
94
+
95
+ @classmethod
96
+ def reset(cls) -> None:
97
+ cls.response_var.set(None)
98
+
99
+
100
+ class RequestContext:
101
+ """
102
+ A request context is used to remember the current http request in the current thread
103
+ """
104
+ request_var = contextvars.ContextVar("request")
105
+
106
+ @classmethod
107
+ def get_request(cls) -> Request:
108
+ """
109
+ Return the current http request
110
+
111
+ Returns:
112
+ the current http request
113
+ """
114
+ return cls.request_var.get()
115
+
116
+ # constructor
117
+
118
+ def __init__(self, app):
119
+ self.app = app
120
+
121
+ async def __call__(self, scope, receive, send):
122
+ if scope["type"] != "http":
123
+ await self.app(scope, receive, send)
124
+ return
125
+
126
+ request = HttpRequest(scope)
127
+ token = self.request_var.set(request)
128
+ try:
129
+ await self.app(scope, receive, send)
130
+ finally:
131
+ self.request_var.reset(token)
132
+
133
+ class TokenContextMiddleware(BaseHTTPMiddleware):
134
+ async def dispatch(self, request: Request, call_next):
135
+ access_token = request.cookies.get("access_token") or request.headers.get("Authorization")
136
+ #refresh_token = request.cookies.get("refresh_token")
137
+
138
+ if access_token:
139
+ TokenContext.set(access_token)#, refresh_token)
140
+
141
+ try:
142
+ return await call_next(request)
143
+ finally:
144
+ TokenContext.clear()
145
+
146
+ class FastAPIServer(Server):
147
+ """
148
+ A server utilizing fastapi framework.
149
+ """
150
+
151
+ # class methods
152
+
153
+ @classmethod
154
+ def boot(cls, module: Type, host="0.0.0.0", port=8000, start_thread = True) -> Environment:
155
+ """
156
+ boot the DI infrastructure of the supplied module and optionally start a fastapi thread given the url
157
+ Args:
158
+ module: the module to initialize the environment
159
+ host: listen address
160
+ port: the port
161
+
162
+ Returns:
163
+ the created environment
164
+ """
165
+
166
+ cls.port = port
167
+
168
+ environment = Environment(module)
169
+
170
+ server = environment.get(FastAPIServer)
171
+
172
+ if start_thread:
173
+ server.start_server(host)
174
+
175
+ return environment
176
+
177
+ # constructor
178
+
179
+ def __init__(self, fast_api: FastAPI, service_manager: ServiceManager, component_registry: ComponentRegistry):
180
+ super().__init__()
181
+
182
+ self.environment : Optional[Environment] = None
183
+ self.protobuf_manager : Optional[ProtobufManager] = None
184
+ self.service_manager = service_manager
185
+ self.component_registry = component_registry
186
+
187
+ self.host = "localhost"
188
+ self.fast_api = fast_api
189
+ self.server_thread = None
190
+
191
+ self.router = APIRouter()
192
+
193
+ self.server : Optional[uvicorn.Server] = None
194
+ self.thread : Optional[threading.Thread] = None
195
+
196
+ # cache
197
+
198
+ self.deserializers = CopyOnWriteCache[str, list[Callable]]()
199
+
200
+ # that's the overall dispatcher
201
+
202
+ self.router.post("/invoke")(self.invoke)
203
+
204
+ # inject
205
+
206
+ @inject_environment()
207
+ def set_environment(self, environment: Environment):
208
+ self.environment = environment
209
+
210
+ # lifecycle
211
+
212
+ @on_init()
213
+ def on_init(self):
214
+ self.service_manager.startup(self)
215
+
216
+ # add routes
217
+
218
+ self.add_routes()
219
+ self.fast_api.include_router(self.router)
220
+
221
+ # TODO: trace routes
222
+ #for route in self.fast_api.routes:
223
+ # print(f"{route.name}: {route.path} [{route.methods}]")
224
+
225
+ # add cleanup hook
226
+
227
+ def cleanup():
228
+ self.service_manager.shutdown()
229
+
230
+ atexit.register(cleanup)
231
+
232
+ @on_destroy()
233
+ def on_destroy(self):
234
+ if self.server is not None:
235
+ self.server.should_exit = True
236
+ self.thread.join()
237
+
238
+ # private
239
+
240
+ def add_routes(self):
241
+ """
242
+ Add everything that looks like an HTTP endpoint
243
+ """
244
+
245
+ from fastapi import Body as FastAPI_Body
246
+ from pydantic import BaseModel
247
+
248
+ def wrap_service_method(handler, return_type, url_template=""):
249
+ """
250
+ Wrap a service method for FastAPI:
251
+ - Detects body params (BodyMarker or single dataclass/Pydantic)
252
+ - Path params inferred from {param} in URL
253
+ - Query params = everything else
254
+ - Handles ResponseContext cookies
255
+ - Supports async/sync methods
256
+ - Preserves signature and annotations for docs
257
+ """
258
+ sig = inspect.signature(handler)
259
+ # copy original annotations and we'll patch them below where needed
260
+ annotations = dict(getattr(handler, "__annotations__", {}))
261
+
262
+ # We'll use get_type_hints with include_extras to resolve forward refs and keep Annotated metadata
263
+ try:
264
+ type_hints_with_extras = get_type_hints(handler, include_extras=True)
265
+ except Exception:
266
+ # fallback: some handlers may fail to resolve; use empty dict to continue
267
+ type_hints_with_extras = {}
268
+
269
+ param_names = [n for n in sig.parameters.keys() if n != "self"]
270
+
271
+ # path params from url
272
+ path_param_names = set(re.findall(r"{(.*?)}", url_template))
273
+ for p in path_param_names:
274
+ if p in param_names:
275
+ param_names.remove(p)
276
+
277
+ # detect body param
278
+ body_param_name = None
279
+
280
+ # 1) explicit Annotated[..., BodyMarker]
281
+ for name, param in sig.parameters.items():
282
+ typ = param.annotation
283
+ if get_origin(typ) is Annotated:
284
+ args = get_args(typ)
285
+ # metadata are args[1:]
286
+ for meta in args[1:]:
287
+ # compare by class name to avoid import / identity issues
288
+ if getattr(meta, "__class__", None).__name__ == "BodyMarker":
289
+ if name in param_names:
290
+ param_names.remove(name)
291
+ body_param_name = name
292
+ break
293
+ if body_param_name:
294
+ break
295
+
296
+ # 2) if none, first Pydantic model or dataclass (resolve forward refs)
297
+ if body_param_name is None:
298
+ # resolve forward refs via type_hints_with_extras (keeps Annotated too)
299
+ for name in param_names[:]:
300
+ # try to get resolved hint first
301
+ typ = type_hints_with_extras.get(name, sig.parameters[name].annotation)
302
+ # if Annotated, unwrap
303
+ if get_origin(typ) is Annotated:
304
+ typ = get_args(typ)[0]
305
+ # if still a string, try fallback to handler's module globals
306
+ if isinstance(typ, str):
307
+ # skip unresolved forward refs
308
+ continue
309
+ # now test
310
+ if inspect.isclass(typ) and (issubclass(typ, BaseModel) or is_dataclass(typ)):
311
+ body_param_name = name
312
+ param_names.remove(name)
313
+ break
314
+
315
+ query_param_names = set(param_names)
316
+
317
+ # Build new signature: unwrapped body param annotated to actual type and default=FastAPI Body(...)
318
+ new_params = []
319
+ for name, param in sig.parameters.items():
320
+ ann = param.annotation
321
+ if name == body_param_name:
322
+ typ = ann
323
+ if get_origin(typ) is Annotated:
324
+ typ = get_args(typ)[0] # unwrap Annotated
325
+ # if this was a forward-ref string, try to pull resolved hint
326
+ if isinstance(typ, str):
327
+ typ = type_hints_with_extras.get(name, typ)
328
+ # set FastAPI body default
329
+ default = FastAPI_Body(...) if param.default is inspect.Parameter.empty else FastAPI_Body(
330
+ param.default)
331
+ new_param = param.replace(annotation=typ, default=default)
332
+ # also update annotations dict so wrapper.__annotations__ matches the unwrapped type
333
+ annotations[name] = typ
334
+ else:
335
+ # ensure annotation reflects resolved type if available (optional)
336
+ resolved = type_hints_with_extras.get(name)
337
+ if resolved is not None:
338
+ annotations[name] = resolved
339
+ new_param = param
340
+ new_params.append(new_param)
341
+
342
+ # set/ensure return annotation
343
+ if "return" in annotations:
344
+ # ensure resolved return if available
345
+ annotations["return"] = type_hints_with_extras.get("return", annotations["return"])
346
+
347
+ new_sig = sig.replace(parameters=new_params)
348
+
349
+ @functools.wraps(handler)
350
+ async def wrapper(*args, **kwargs):
351
+ bound = new_sig.bind(*args, **kwargs)
352
+ bound.apply_defaults()
353
+
354
+ # Call original handler (handler is usually a bound method)
355
+ result = handler(*bound.args, **bound.kwargs)
356
+ if inspect.iscoroutine(result):
357
+ result = await result
358
+
359
+ # Serialize result
360
+ json_response = JSONResponse(get_serializer(return_type)(result))
361
+
362
+ # ResponseContext cookie handling (unchanged)
363
+ local_response = ResponseContext.get()
364
+ if local_response:
365
+ for key, value in local_response.delete_cookies.items():
366
+ json_response.delete_cookie(
367
+ key,
368
+ path=value["path"],
369
+ domain=value["domain"],
370
+ secure=value["secure"],
371
+ httponly=value["httponly"]
372
+ )
373
+ for key, value in local_response.cookies.items():
374
+ json_response.set_cookie(
375
+ key,
376
+ value=value["value"],
377
+ max_age=value["max_age"],
378
+ expires=value["expires"],
379
+ path=value["path"],
380
+ domain=value["domain"],
381
+ secure=value["secure"],
382
+ httponly=value["httponly"],
383
+ samesite=value.get("samesite", "lax")
384
+ )
385
+ ResponseContext.reset()
386
+
387
+ return json_response
388
+
389
+ # ensure wrapper annotations match the unwrapped/resolved types
390
+ wrapper.__signature__ = new_sig
391
+ wrapper.__annotations__ = annotations
392
+
393
+ print("WRAPPER SIGNATURE:", wrapper.__name__, wrapper.__signature__)
394
+ print("WRAPPER ANNOTATIONS:", wrapper.__annotations__)
395
+
396
+ return wrapper
397
+
398
+ # iterate over all service descriptors
399
+ for descriptor in self.service_manager.descriptors.values():
400
+ if not descriptor.is_component() and descriptor.is_local():
401
+ prefix = ""
402
+
403
+ type_descriptor = TypeDescriptor.for_type(descriptor.type)
404
+ instance = self.environment.get(descriptor.implementation)
405
+
406
+ if type_descriptor.has_decorator(rest):
407
+ prefix = type_descriptor.get_decorator(rest).args[0]
408
+
409
+ for method in type_descriptor.get_methods():
410
+ decorator = next(
411
+ (
412
+ decorator
413
+ for decorator in Decorators.get(method.method)
414
+ if decorator.decorator in [get, put, post, delete]
415
+ ),
416
+ None,
417
+ )
418
+ if decorator is not None:
419
+ self.router.add_api_route(
420
+ path=prefix + decorator.args[0],
421
+ endpoint=wrap_service_method(
422
+ getattr(instance, method.get_name()), method.return_type, decorator.args[0]
423
+ ),
424
+ methods=[decorator.decorator.__name__],
425
+ name=f"{descriptor.get_component_descriptor().name}.{descriptor.name}.{method.get_name()}",
426
+ response_model=method.return_type,
427
+ )
428
+
429
+ def start_server(self, host: str):
430
+ """
431
+ start the fastapi server in a thread
432
+ """
433
+ self.host = host
434
+
435
+ config = uvicorn.Config(self.fast_api, host=host, port=self.port, access_log=False)
436
+
437
+ self.server = uvicorn.Server(config)
438
+ self.thread = threading.Thread(target=self.server.run, daemon=True)
439
+ self.thread.start()
440
+
441
+ def get_deserializers(self, service: Type, method):
442
+ deserializers = self.deserializers.get(method)
443
+ if deserializers is None:
444
+ descriptor = TypeDescriptor.for_type(service).get_method(method.__name__)
445
+
446
+ deserializers = [get_deserializer(type) for type in descriptor.param_types]
447
+ self.deserializers.put(method, deserializers)
448
+
449
+ return deserializers
450
+
451
+ def deserialize_args(self, args: list[Any], type: Type, method: Callable) -> list:
452
+ deserializers = self.get_deserializers(type, method)
453
+
454
+ for i, arg in enumerate(args):
455
+ args[i] = deserializers[i](arg)
456
+
457
+ return args
458
+
459
+ def get_descriptor_and_method(self, method_name: str) -> typing.Tuple[ServiceDescriptor, Callable]:
460
+ parts = method_name.split(":")
461
+
462
+ # component = parts[0]
463
+ service_name = parts[1]
464
+ method_name = parts[2]
465
+
466
+ service_descriptor = typing.cast(ServiceDescriptor, ServiceManager.descriptors_by_name[service_name])
467
+ service = self.service_manager.get_service(service_descriptor.type, preferred_channel="local")
468
+
469
+ return service_descriptor, getattr(service, method_name)
470
+
471
+ async def invoke_json(self, http_request: HttpRequest):
472
+ data = await http_request.json()
473
+ service_descriptor, method = self.get_descriptor_and_method(data["method"])
474
+ args = self.deserialize_args(data["args"], service_descriptor.type, method)
475
+
476
+ try:
477
+ result = await self.dispatch(service_descriptor, method, args)
478
+
479
+ return Response(result=result, exception=None).model_dump()
480
+ except Exception as e:
481
+ return Response(result=None, exception=str(e)).model_dump()
482
+
483
+ async def invoke_msgpack(self, http_request: HttpRequest):
484
+ data = msgpack.unpackb(await http_request.body(), raw=False)
485
+ service_descriptor, method = self.get_descriptor_and_method(data["method"])
486
+ args = self.deserialize_args(data["args"], service_descriptor.type, method)
487
+
488
+ try:
489
+ response = Response(result=await self.dispatch(service_descriptor, method, args), exception=None).model_dump()
490
+ except Exception as e:
491
+ response = Response(result=None, exception=str(e)).model_dump()
492
+
493
+ return HttpResponse(
494
+ content=msgpack.packb(response, use_bin_type=True),
495
+ media_type="application/msgpack"
496
+ )
497
+
498
+ async def invoke_protobuf(self, http_request: HttpRequest):
499
+ if self.protobuf_manager is None:
500
+ self.protobuf_manager = self.environment.get(ProtobufManager)
501
+
502
+ service_descriptor, method = self.get_descriptor_and_method(http_request.headers.get("x-rpc-method"))
503
+
504
+ data = await http_request.body()
505
+
506
+ # create message
507
+
508
+ request = self.protobuf_manager.get_request_message(service_descriptor.type, method)()
509
+ request.ParseFromString(data)
510
+
511
+ # and parse
512
+
513
+ args = self.protobuf_manager.create_deserializer(request.DESCRIPTOR, method).deserialize(request)
514
+
515
+ response_type = self.protobuf_manager.get_response_message(service_descriptor.type,method)
516
+ result_serializer = self.protobuf_manager.create_result_serializer(response_type, method)
517
+ try:
518
+ result = await self.dispatch(service_descriptor, method, args)
519
+
520
+ result_message = result_serializer.serialize_result(result, None)
521
+
522
+ return HttpResponse(
523
+ content=result_message.SerializeToString(),
524
+ media_type="application/x-protobuf"
525
+ )
526
+
527
+ except Exception as e:
528
+ result_message = result_serializer.serialize_result(None, str(e))
529
+
530
+ return HttpResponse(
531
+ content=result_message.SerializeToString(),
532
+ media_type="application/x-protobuf"
533
+ )
534
+
535
+ async def invoke(self, http_request: HttpRequest):
536
+ content_type = http_request.headers.get("content-type", "")
537
+
538
+ if content_type == "application/x-protobuf":
539
+ return await self.invoke_protobuf(http_request)
540
+
541
+ elif content_type == "application/msgpack":
542
+ return await self.invoke_msgpack(http_request)
543
+
544
+ elif content_type == "application/json":
545
+ return await self.invoke_json(http_request)
546
+
547
+ else:
548
+ return HttpResponse(
549
+ content="Unsupported Content-Type",
550
+ status_code=415,
551
+ media_type="text/plain"
552
+ )
553
+
554
+ async def dispatch(self, service_descriptor: ServiceDescriptor, method: Callable, args: list[Any]) :
555
+ ServiceManager.logger.debug("dispatch request %s.%s", service_descriptor, method.__name__)
556
+
557
+ if inspect.iscoroutinefunction(method):
558
+ return await method(*args)
559
+ else:
560
+ return method(*args)
561
+
562
+ # override
563
+
564
+ def add_route(self, path: str, endpoint: Callable, methods: list[str], response_class: typing.Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse)):
565
+ self.router.add_api_route(path=path, endpoint=endpoint, methods=methods, response_class=response_class)
566
+
567
+ def route_health(self, url: str, callable: Callable):
568
+ async def get_health_response():
569
+ health : HealthCheckManager.Health = await callable()
570
+
571
+ return JSONResponse(
572
+ status_code= self.component_registry.map_health(health),
573
+ content = health.to_dict()
574
+ )
575
+
576
+ self.router.get(url)(get_health_response)