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.
- aspyx_service/__init__.py +106 -0
- aspyx_service/authorization.py +126 -0
- aspyx_service/channels.py +445 -0
- aspyx_service/generator/__init__.py +16 -0
- aspyx_service/generator/json_schema_generator.py +197 -0
- aspyx_service/generator/openapi_generator.py +120 -0
- aspyx_service/healthcheck.py +194 -0
- aspyx_service/protobuf.py +1093 -0
- aspyx_service/registries.py +241 -0
- aspyx_service/restchannel.py +313 -0
- aspyx_service/server.py +576 -0
- aspyx_service/service.py +968 -0
- aspyx_service/session.py +136 -0
- aspyx_service-0.11.2.dist-info/METADATA +555 -0
- aspyx_service-0.11.2.dist-info/RECORD +17 -0
- aspyx_service-0.11.2.dist-info/WHEEL +4 -0
- aspyx_service-0.11.2.dist-info/licenses/LICENSE +21 -0
aspyx_service/server.py
ADDED
|
@@ -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)
|