aspyx-service 0.10.4__py3-none-any.whl → 0.10.6__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.
- aspyx_service/__init__.py +8 -7
- aspyx_service/authorization.py +4 -13
- aspyx_service/channels.py +9 -7
- aspyx_service/restchannel.py +2 -6
- aspyx_service/server.py +153 -32
- aspyx_service/session.py +69 -30
- {aspyx_service-0.10.4.dist-info → aspyx_service-0.10.6.dist-info}/METADATA +28 -11
- aspyx_service-0.10.6.dist-info/RECORD +13 -0
- aspyx_service/serialization.py +0 -137
- aspyx_service-0.10.4.dist-info/RECORD +0 -14
- {aspyx_service-0.10.4.dist-info → aspyx_service-0.10.6.dist-info}/WHEEL +0 -0
- {aspyx_service-0.10.4.dist-info → aspyx_service-0.10.6.dist-info}/licenses/LICENSE +0 -0
aspyx_service/__init__.py
CHANGED
|
@@ -7,12 +7,11 @@ from aspyx.di import module
|
|
|
7
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
8
|
from .channels import HTTPXChannel, DispatchJSONChannel, TokenContext
|
|
9
9
|
from .registries import ConsulComponentRegistry
|
|
10
|
-
from .server import FastAPIServer, RequestContext
|
|
10
|
+
from .server import FastAPIServer, RequestContext, ResponseContext, TokenContextMiddleware
|
|
11
11
|
from .healthcheck import health_checks, health_check, HealthCheckManager, HealthStatus
|
|
12
12
|
from .restchannel import RestChannel, post, get, put, delete, QueryParam, Body, rest
|
|
13
|
-
from .session import Session, SessionManager
|
|
14
|
-
from .authorization import AuthorizationManager, AbstractAuthorizationFactory
|
|
15
|
-
|
|
13
|
+
from .session import Session, SessionManager, SessionContext
|
|
14
|
+
from .authorization import AuthorizationManager, AbstractAuthorizationFactory
|
|
16
15
|
|
|
17
16
|
@module()
|
|
18
17
|
class ServiceModule:
|
|
@@ -53,12 +52,12 @@ __all__ = [
|
|
|
53
52
|
|
|
54
53
|
"AuthorizationManager",
|
|
55
54
|
"AbstractAuthorizationFactory",
|
|
56
|
-
"AuthorizationException",
|
|
57
55
|
|
|
58
56
|
# session
|
|
59
57
|
|
|
60
58
|
"Session",
|
|
61
59
|
"SessionManager",
|
|
60
|
+
"SessionContext",
|
|
62
61
|
|
|
63
62
|
# healthcheck
|
|
64
63
|
|
|
@@ -91,10 +90,12 @@ __all__ = [
|
|
|
91
90
|
# registries
|
|
92
91
|
|
|
93
92
|
"ConsulComponentRegistry",
|
|
94
|
-
"RequestContext",
|
|
95
93
|
|
|
96
94
|
# server
|
|
97
95
|
|
|
98
96
|
"FastAPIServer",
|
|
99
|
-
"RequestContext"
|
|
97
|
+
"RequestContext",
|
|
98
|
+
"ResponseContext",
|
|
99
|
+
"TokenContext",
|
|
100
|
+
"TokenContextMiddleware",
|
|
100
101
|
]
|
aspyx_service/authorization.py
CHANGED
|
@@ -9,13 +9,6 @@ from aspyx.di import injectable, inject, order
|
|
|
9
9
|
from aspyx.di.aop import Invocation
|
|
10
10
|
from aspyx.reflection import TypeDescriptor, Decorators
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
class AuthorizationException(Exception):
|
|
14
|
-
"""
|
|
15
|
-
Any authorization exception
|
|
16
|
-
"""
|
|
17
|
-
pass
|
|
18
|
-
|
|
19
12
|
def get_method_class(method):
|
|
20
13
|
if inspect.ismethod(method) or inspect.isfunction(method):
|
|
21
14
|
qualname = method.__qualname__
|
|
@@ -38,11 +31,10 @@ class AuthorizationManager:
|
|
|
38
31
|
"""
|
|
39
32
|
Base class for authorization checks
|
|
40
33
|
"""
|
|
41
|
-
def
|
|
34
|
+
def authorize(self, invocation: Invocation):
|
|
42
35
|
"""
|
|
43
|
-
execute the authorization check. Throws an exception in case of violations
|
|
36
|
+
execute the authorization check. Throws an exception in case of violations
|
|
44
37
|
"""
|
|
45
|
-
pass
|
|
46
38
|
|
|
47
39
|
class AuthorizationFactory(ABC):
|
|
48
40
|
"""
|
|
@@ -62,7 +54,6 @@ class AuthorizationManager:
|
|
|
62
54
|
Returns:
|
|
63
55
|
an authorization check or None
|
|
64
56
|
"""
|
|
65
|
-
pass
|
|
66
57
|
|
|
67
58
|
# constructor
|
|
68
59
|
|
|
@@ -111,9 +102,9 @@ class AuthorizationManager:
|
|
|
111
102
|
|
|
112
103
|
return checks
|
|
113
104
|
|
|
114
|
-
def
|
|
105
|
+
def authorize(self, invocation: Invocation):
|
|
115
106
|
for check in self.get_checks(invocation.func):
|
|
116
|
-
check.
|
|
107
|
+
check.authorize(invocation)
|
|
117
108
|
|
|
118
109
|
class AbstractAuthorizationFactory(AuthorizationManager.AuthorizationFactory):
|
|
119
110
|
"""
|
aspyx_service/channels.py
CHANGED
|
@@ -16,11 +16,11 @@ from pydantic import BaseModel
|
|
|
16
16
|
from aspyx.di.configuration import inject_value
|
|
17
17
|
from aspyx.reflection import DynamicProxy, TypeDescriptor
|
|
18
18
|
from aspyx.threading import ThreadLocal, ContextLocal
|
|
19
|
+
from aspyx.util import get_deserializer, TypeDeserializer, TypeSerializer, get_serializer
|
|
19
20
|
from .service import ServiceManager, ServiceCommunicationException, TokenExpiredException, InvalidTokenException, \
|
|
20
21
|
AuthorizationException, MissingTokenException
|
|
21
22
|
|
|
22
23
|
from .service import ComponentDescriptor, ChannelInstances, ServiceException, channel, Channel, RemoteServiceException
|
|
23
|
-
from .serialization import get_deserializer, TypeDeserializer, TypeSerializer, get_serializer
|
|
24
24
|
|
|
25
25
|
class TokenContext:
|
|
26
26
|
"""
|
|
@@ -208,10 +208,11 @@ class HTTPXChannel(Channel):
|
|
|
208
208
|
if "invalid_token" in www_auth:
|
|
209
209
|
if 'expired' in www_auth:
|
|
210
210
|
raise TokenExpiredException() from e
|
|
211
|
-
|
|
211
|
+
|
|
212
|
+
if 'missing' in www_auth:
|
|
212
213
|
raise MissingTokenException() from e
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
|
|
215
|
+
raise InvalidTokenException() from e
|
|
215
216
|
|
|
216
217
|
raise AuthorizationException(str(e)) from e
|
|
217
218
|
except httpx.HTTPError as e:
|
|
@@ -451,10 +452,11 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
451
452
|
if "invalid_token" in www_auth:
|
|
452
453
|
if 'expired' in www_auth:
|
|
453
454
|
raise TokenExpiredException() from e
|
|
454
|
-
|
|
455
|
+
|
|
456
|
+
if 'missing' in www_auth:
|
|
455
457
|
raise MissingTokenException() from e
|
|
456
|
-
|
|
457
|
-
|
|
458
|
+
|
|
459
|
+
raise InvalidTokenException() from e
|
|
458
460
|
|
|
459
461
|
raise RemoteServiceException(str(e)) from e
|
|
460
462
|
|
aspyx_service/restchannel.py
CHANGED
|
@@ -6,19 +6,15 @@ import re
|
|
|
6
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
|
-
|
|
11
9
|
from pydantic import BaseModel
|
|
12
10
|
|
|
13
|
-
from .channels import HTTPXChannel
|
|
14
|
-
|
|
15
11
|
from aspyx.reflection import DynamicProxy, Decorators
|
|
12
|
+
|
|
13
|
+
from .channels import HTTPXChannel
|
|
16
14
|
from .service import channel, ServiceCommunicationException
|
|
17
15
|
|
|
18
16
|
T = TypeVar("T")
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
18
|
class BodyMarker:
|
|
23
19
|
pass
|
|
24
20
|
|
aspyx_service/server.py
CHANGED
|
@@ -1,33 +1,98 @@
|
|
|
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
|
|
9
|
+
import typing
|
|
10
|
+
from datetime import datetime
|
|
7
11
|
from typing import Type, Optional, Callable, Any
|
|
8
|
-
|
|
9
|
-
from fastapi.responses import JSONResponse
|
|
12
|
+
import contextvars
|
|
10
13
|
import msgpack
|
|
11
14
|
import uvicorn
|
|
12
15
|
|
|
13
16
|
from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse, HTTPException
|
|
14
|
-
import contextvars
|
|
15
17
|
|
|
18
|
+
|
|
19
|
+
from fastapi.responses import JSONResponse
|
|
16
20
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
17
21
|
|
|
18
|
-
from aspyx.di import Environment, injectable, on_init, inject_environment
|
|
22
|
+
from aspyx.di import Environment, injectable, on_init, inject_environment, on_destroy
|
|
19
23
|
from aspyx.reflection import TypeDescriptor, Decorators
|
|
24
|
+
from aspyx.util import get_deserializer, get_serializer
|
|
20
25
|
|
|
21
26
|
from .service import ComponentRegistry
|
|
22
27
|
from .healthcheck import HealthCheckManager
|
|
23
28
|
|
|
24
|
-
from .serialization import get_deserializer
|
|
25
|
-
|
|
26
29
|
from .service import Server, ServiceManager
|
|
27
30
|
from .channels import Request, Response, TokenContext
|
|
28
31
|
|
|
29
32
|
from .restchannel import get, post, put, delete, rest
|
|
30
33
|
|
|
34
|
+
class ResponseContext:
|
|
35
|
+
response_var = contextvars.ContextVar[Optional['ResponseContext.Response']]("response", default=None)
|
|
36
|
+
|
|
37
|
+
class Response:
|
|
38
|
+
def __init__(self):
|
|
39
|
+
self.cookies = {}
|
|
40
|
+
self.delete_cookies = {}
|
|
41
|
+
|
|
42
|
+
def delete_cookie(self,
|
|
43
|
+
key: str,
|
|
44
|
+
path: str = "/",
|
|
45
|
+
domain: str | None = None,
|
|
46
|
+
secure: bool = False,
|
|
47
|
+
httponly: bool = False,
|
|
48
|
+
samesite: typing.Literal["lax", "strict", "none"] | None = "lax",
|
|
49
|
+
):
|
|
50
|
+
self.delete_cookies[key] = {
|
|
51
|
+
"path": path,
|
|
52
|
+
"domain": domain,
|
|
53
|
+
"secure": secure,
|
|
54
|
+
"httponly": httponly,
|
|
55
|
+
"samesite": samesite
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def set_cookie(self,
|
|
59
|
+
key: str,
|
|
60
|
+
value: str = "",
|
|
61
|
+
max_age: int | None = None,
|
|
62
|
+
expires: datetime | str | int | None = None,
|
|
63
|
+
path: str | None = "/",
|
|
64
|
+
domain: str | None = None,
|
|
65
|
+
secure: bool = False,
|
|
66
|
+
httponly: bool = False,
|
|
67
|
+
samesite: typing.Literal["lax", "strict", "none"] | None = "lax"):
|
|
68
|
+
self.cookies[key] = {
|
|
69
|
+
"value": value,
|
|
70
|
+
"max_age": max_age,
|
|
71
|
+
"expires": expires,
|
|
72
|
+
"path": path,
|
|
73
|
+
"domain": domain,
|
|
74
|
+
"secure": secure,
|
|
75
|
+
"httponly": httponly,
|
|
76
|
+
"samesite": samesite
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def create(cls) -> ResponseContext.Response:
|
|
81
|
+
response = ResponseContext.Response()
|
|
82
|
+
|
|
83
|
+
cls.response_var.set(response)
|
|
84
|
+
|
|
85
|
+
return response
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def get(cls) -> Optional[ResponseContext.Response]:
|
|
89
|
+
return cls.response_var.get()
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def reset(cls) -> None:
|
|
93
|
+
cls.response_var.set(None)
|
|
94
|
+
|
|
95
|
+
|
|
31
96
|
class RequestContext:
|
|
32
97
|
"""
|
|
33
98
|
A request context is used to remember the current http request in the current thread
|
|
@@ -61,7 +126,7 @@ class RequestContext:
|
|
|
61
126
|
finally:
|
|
62
127
|
self.request_var.reset(token)
|
|
63
128
|
|
|
64
|
-
class
|
|
129
|
+
class TokenContextMiddleware(BaseHTTPMiddleware):
|
|
65
130
|
async def dispatch(self, request: Request, call_next):
|
|
66
131
|
access_token = request.cookies.get("access_token") or request.headers.get("Authorization")
|
|
67
132
|
#refresh_token = request.cookies.get("refresh_token")
|
|
@@ -74,26 +139,15 @@ class TokenMiddleware(BaseHTTPMiddleware):
|
|
|
74
139
|
finally:
|
|
75
140
|
TokenContext.clear()
|
|
76
141
|
|
|
77
|
-
def create_server() -> FastAPI:
|
|
78
|
-
server = FastAPI()
|
|
79
|
-
|
|
80
|
-
server.add_middleware(RequestContext)
|
|
81
|
-
server.add_middleware(TokenMiddleware)
|
|
82
|
-
|
|
83
|
-
return server
|
|
84
|
-
|
|
85
|
-
@injectable()
|
|
86
142
|
class FastAPIServer(Server):
|
|
87
143
|
"""
|
|
88
144
|
A server utilizing fastapi framework.
|
|
89
145
|
"""
|
|
90
146
|
|
|
91
|
-
fast_api = create_server()
|
|
92
|
-
|
|
93
147
|
# class methods
|
|
94
148
|
|
|
95
149
|
@classmethod
|
|
96
|
-
def boot(cls, module: Type, host="0.0.0.0", port=8000,
|
|
150
|
+
def boot(cls, module: Type, host="0.0.0.0", port=8000, start_thread = True) -> Environment:
|
|
97
151
|
"""
|
|
98
152
|
boot the DI infrastructure of the supplied module and optionally start a fastapi thread given the url
|
|
99
153
|
Args:
|
|
@@ -102,22 +156,23 @@ class FastAPIServer(Server):
|
|
|
102
156
|
port: the port
|
|
103
157
|
|
|
104
158
|
Returns:
|
|
105
|
-
|
|
159
|
+
the created environment
|
|
106
160
|
"""
|
|
161
|
+
|
|
107
162
|
cls.port = port
|
|
108
163
|
|
|
109
164
|
environment = Environment(module)
|
|
110
165
|
|
|
111
166
|
server = environment.get(FastAPIServer)
|
|
112
167
|
|
|
113
|
-
if
|
|
114
|
-
server.
|
|
168
|
+
if start_thread:
|
|
169
|
+
server.start_server(host)
|
|
115
170
|
|
|
116
|
-
return
|
|
171
|
+
return environment
|
|
117
172
|
|
|
118
173
|
# constructor
|
|
119
174
|
|
|
120
|
-
def __init__(self, service_manager: ServiceManager, component_registry: ComponentRegistry):
|
|
175
|
+
def __init__(self, fast_api: FastAPI, service_manager: ServiceManager, component_registry: ComponentRegistry):
|
|
121
176
|
super().__init__()
|
|
122
177
|
|
|
123
178
|
self.environment : Optional[Environment] = None
|
|
@@ -125,10 +180,14 @@ class FastAPIServer(Server):
|
|
|
125
180
|
self.component_registry = component_registry
|
|
126
181
|
|
|
127
182
|
self.host = "localhost"
|
|
183
|
+
self.fast_api = fast_api
|
|
128
184
|
self.server_thread = None
|
|
129
185
|
|
|
130
186
|
self.router = APIRouter()
|
|
131
187
|
|
|
188
|
+
self.server : Optional[uvicorn.Server] = None
|
|
189
|
+
self.thread : Optional[threading.Thread] = None
|
|
190
|
+
|
|
132
191
|
# cache
|
|
133
192
|
|
|
134
193
|
self.deserializers: dict[str, list[Callable]] = {}
|
|
@@ -143,6 +202,8 @@ class FastAPIServer(Server):
|
|
|
143
202
|
def set_environment(self, environment: Environment):
|
|
144
203
|
self.environment = environment
|
|
145
204
|
|
|
205
|
+
# lifecycle
|
|
206
|
+
|
|
146
207
|
@on_init()
|
|
147
208
|
def on_init(self):
|
|
148
209
|
self.service_manager.startup(self)
|
|
@@ -162,12 +223,74 @@ class FastAPIServer(Server):
|
|
|
162
223
|
|
|
163
224
|
atexit.register(cleanup)
|
|
164
225
|
|
|
226
|
+
@on_destroy()
|
|
227
|
+
def on_destroy(self):
|
|
228
|
+
if self.server is not None:
|
|
229
|
+
self.server.should_exit = True
|
|
230
|
+
self.thread.join()
|
|
231
|
+
|
|
165
232
|
# private
|
|
166
233
|
|
|
167
234
|
def add_routes(self):
|
|
168
235
|
"""
|
|
169
236
|
add everything that looks like an http endpoint
|
|
170
237
|
"""
|
|
238
|
+
|
|
239
|
+
def wrap_service_method(handler, return_type):
|
|
240
|
+
sig = inspect.signature(handler)
|
|
241
|
+
|
|
242
|
+
@functools.wraps(handler)
|
|
243
|
+
async def wrapper(*args, **kwargs):
|
|
244
|
+
try:
|
|
245
|
+
result = handler(*args, **kwargs)
|
|
246
|
+
if inspect.iscoroutine(result):
|
|
247
|
+
result = await result
|
|
248
|
+
|
|
249
|
+
except HTTPException as e:
|
|
250
|
+
raise
|
|
251
|
+
except Exception as e:
|
|
252
|
+
result = {"error": str(e)}
|
|
253
|
+
|
|
254
|
+
json_response = JSONResponse(get_serializer(return_type)(result))
|
|
255
|
+
|
|
256
|
+
local_response = ResponseContext.get()
|
|
257
|
+
if local_response is not None:
|
|
258
|
+
# delete
|
|
259
|
+
|
|
260
|
+
for key, value in local_response.delete_cookies.items():
|
|
261
|
+
json_response.delete_cookie(
|
|
262
|
+
key,
|
|
263
|
+
path=value.path,
|
|
264
|
+
domain=value.domain,
|
|
265
|
+
secure=value.secure,
|
|
266
|
+
httponly=value.httponly
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# create
|
|
270
|
+
|
|
271
|
+
for key, value in local_response.cookies.items():
|
|
272
|
+
json_response.set_cookie(
|
|
273
|
+
key,
|
|
274
|
+
value=value.value,
|
|
275
|
+
max_age=value.max_age,
|
|
276
|
+
expires=value.expires,
|
|
277
|
+
path=value.path,
|
|
278
|
+
domain=value.domain,
|
|
279
|
+
secure=value.secure,
|
|
280
|
+
httponly=value.httponly
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
ResponseContext.reset()
|
|
284
|
+
|
|
285
|
+
return json_response
|
|
286
|
+
|
|
287
|
+
# Optionally attach response_model info for docs
|
|
288
|
+
|
|
289
|
+
wrapper.__signature__ = sig
|
|
290
|
+
wrapper.__annotations__ = {"return": return_type}
|
|
291
|
+
|
|
292
|
+
return wrapper
|
|
293
|
+
|
|
171
294
|
for descriptor in self.service_manager.descriptors.values():
|
|
172
295
|
if not descriptor.is_component() and descriptor.is_local():
|
|
173
296
|
prefix = ""
|
|
@@ -183,25 +306,23 @@ class FastAPIServer(Server):
|
|
|
183
306
|
if decorator is not None:
|
|
184
307
|
self.router.add_api_route(
|
|
185
308
|
path=prefix + decorator.args[0],
|
|
186
|
-
endpoint=getattr(instance, method.get_name()),
|
|
309
|
+
endpoint=wrap_service_method(getattr(instance, method.get_name()), method.return_type),
|
|
187
310
|
methods=[decorator.decorator.__name__],
|
|
188
311
|
name=f"{descriptor.get_component_descriptor().name}.{descriptor.name}.{method.get_name()}",
|
|
189
312
|
response_model=method.return_type,
|
|
190
313
|
)
|
|
191
314
|
|
|
192
|
-
def
|
|
315
|
+
def start_server(self, host: str):
|
|
193
316
|
"""
|
|
194
317
|
start the fastapi server in a thread
|
|
195
318
|
"""
|
|
196
319
|
self.host = host
|
|
197
320
|
|
|
198
|
-
config = uvicorn.Config(self.fast_api, host=
|
|
199
|
-
server = uvicorn.Server(config)
|
|
200
|
-
|
|
201
|
-
thread = threading.Thread(target=server.run, daemon=True)
|
|
202
|
-
thread.start()
|
|
321
|
+
config = uvicorn.Config(self.fast_api, host=host, port=self.port, access_log=False)
|
|
203
322
|
|
|
204
|
-
|
|
323
|
+
self.server = uvicorn.Server(config)
|
|
324
|
+
self.thread = threading.Thread(target=self.server.run, daemon=True)
|
|
325
|
+
self.thread.start()
|
|
205
326
|
|
|
206
327
|
def get_deserializers(self, service: Type, method):
|
|
207
328
|
deserializers = self.deserializers.get(method, None)
|
aspyx_service/session.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""
|
|
2
2
|
session related module
|
|
3
3
|
"""
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
4
5
|
import contextvars
|
|
5
6
|
from typing import Type, Optional, Callable, Any, TypeVar
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
|
+
from datetime import datetime, timezone, timedelta
|
|
7
8
|
from cachetools import TTLCache
|
|
8
9
|
|
|
9
10
|
from aspyx.di import injectable
|
|
10
|
-
from aspyx.threading import ThreadLocal
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
class Session:
|
|
@@ -19,18 +19,16 @@ class Session:
|
|
|
19
19
|
|
|
20
20
|
T = TypeVar("T")
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
class
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
""
|
|
27
|
-
#current_session = ThreadLocal[Session]()
|
|
28
|
-
current_session = contextvars.ContextVar("session")
|
|
22
|
+
class SessionContext:
|
|
23
|
+
# class properties
|
|
24
|
+
|
|
25
|
+
# current_session = ThreadLocal[Session]()
|
|
26
|
+
current_session = contextvars.ContextVar("session")
|
|
29
27
|
|
|
30
28
|
@classmethod
|
|
31
|
-
def
|
|
29
|
+
def get(cls, type: Type[T]) -> T:
|
|
32
30
|
"""
|
|
33
|
-
return the current session associated with the
|
|
31
|
+
return the current session associated with the context
|
|
34
32
|
Args:
|
|
35
33
|
type: the session type
|
|
36
34
|
|
|
@@ -40,40 +38,85 @@ class SessionManager:
|
|
|
40
38
|
return cls.current_session.get()
|
|
41
39
|
|
|
42
40
|
@classmethod
|
|
43
|
-
def
|
|
41
|
+
def set(cls, session: Session) -> None:
|
|
44
42
|
"""
|
|
45
|
-
set the current session in the
|
|
43
|
+
set the current session in the context
|
|
46
44
|
Args:
|
|
47
45
|
session: the session
|
|
48
46
|
"""
|
|
49
47
|
cls.current_session.set(session)
|
|
50
48
|
|
|
51
49
|
@classmethod
|
|
52
|
-
def
|
|
50
|
+
def clear(cls) -> None:
|
|
53
51
|
"""
|
|
54
52
|
delete the current session
|
|
55
53
|
"""
|
|
56
|
-
cls.current_session.set(None)#clear()
|
|
54
|
+
cls.current_session.set(None) # clear()
|
|
55
|
+
|
|
56
|
+
@injectable()
|
|
57
|
+
class SessionManager(SessionContext):
|
|
58
|
+
"""
|
|
59
|
+
A SessionManager controls the lifecycle of sessions and is responsible to establish a session context local.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# local classes
|
|
63
|
+
|
|
64
|
+
class Storage(ABC):
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def store(self, token: str, session: Session, ttl_seconds: int):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def read(self, token: str) -> Optional[Session]:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
class InMemoryStorage(Storage):
|
|
74
|
+
"""
|
|
75
|
+
InMemoryStorage is a simple in-memory storage for sessions.
|
|
76
|
+
It uses a TTLCache to store sessions with a time-to-live.
|
|
77
|
+
"""
|
|
78
|
+
# constructor
|
|
79
|
+
|
|
80
|
+
def __init__(self, max_size = 1000, ttl = 3600):
|
|
81
|
+
self.cache = TTLCache(maxsize=max_size, ttl=ttl)
|
|
82
|
+
|
|
83
|
+
# implement
|
|
84
|
+
|
|
85
|
+
def store(self, token: str, session: 'Session', ttl_seconds: int):
|
|
86
|
+
expiry_time = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
|
87
|
+
self.cache[token] = (session, expiry_time)
|
|
88
|
+
|
|
89
|
+
def read(self, token: str) -> Optional['Session']:
|
|
90
|
+
value = self.cache.get(token)
|
|
91
|
+
if value is None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
session, expiry = value
|
|
95
|
+
if expiry < datetime.now(timezone.utc):
|
|
96
|
+
del self.cache[token]
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
return session
|
|
57
100
|
|
|
58
101
|
# constructor
|
|
59
102
|
|
|
60
|
-
def __init__(self):
|
|
61
|
-
self.
|
|
62
|
-
self.
|
|
103
|
+
def __init__(self, storage: 'SessionManager.Storage'):
|
|
104
|
+
self.storage = storage
|
|
105
|
+
self.session_factory : Optional[Callable[[Any], Session]] = None
|
|
63
106
|
|
|
64
107
|
# public
|
|
65
108
|
|
|
66
|
-
def
|
|
109
|
+
def set_factory(self, factory: Callable[..., Session]) -> None:
|
|
67
110
|
"""
|
|
68
111
|
set a factory function that will be used to create a concrete session
|
|
69
112
|
Args:
|
|
70
|
-
|
|
113
|
+
factory: the function
|
|
71
114
|
"""
|
|
72
|
-
self.
|
|
115
|
+
self.session_factory = factory
|
|
73
116
|
|
|
74
117
|
def create_session(self, *args, **kwargs) -> Session:
|
|
75
118
|
"""
|
|
76
|
-
create a session given the
|
|
119
|
+
create a session given the arguments (usually a token, etc.)
|
|
77
120
|
Args:
|
|
78
121
|
args: rest args
|
|
79
122
|
kwargs: keyword args
|
|
@@ -81,17 +124,13 @@ class SessionManager:
|
|
|
81
124
|
Returns:
|
|
82
125
|
the new session
|
|
83
126
|
"""
|
|
84
|
-
return self.
|
|
127
|
+
return self.session_factory(*args, **kwargs)
|
|
85
128
|
|
|
86
129
|
def store_session(self, token: str, session: Session, expiry: datetime):
|
|
87
130
|
now = datetime.now(timezone.utc)
|
|
88
131
|
ttl_seconds = max(int((expiry - now).total_seconds()), 0)
|
|
89
|
-
self.sessions[token] = (session, ttl_seconds)
|
|
90
132
|
|
|
91
|
-
|
|
92
|
-
value = self.sessions.get(token)
|
|
93
|
-
if value is None:
|
|
94
|
-
return None
|
|
133
|
+
self.storage.store(token, session, ttl_seconds)
|
|
95
134
|
|
|
96
|
-
|
|
97
|
-
return
|
|
135
|
+
def read_session(self, token: str) -> Optional[Session]:
|
|
136
|
+
return self.storage.read(token)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aspyx_service
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.6
|
|
4
4
|
Summary: Aspyx Service framework
|
|
5
5
|
Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -26,8 +26,7 @@ License: MIT License
|
|
|
26
26
|
SOFTWARE.
|
|
27
27
|
License-File: LICENSE
|
|
28
28
|
Requires-Python: >=3.9
|
|
29
|
-
Requires-Dist: aspyx>=1.
|
|
30
|
-
Requires-Dist: cachetools~=5.5.2
|
|
29
|
+
Requires-Dist: aspyx>=1.6.0
|
|
31
30
|
Requires-Dist: fastapi~=0.115.13
|
|
32
31
|
Requires-Dist: httpx~=0.28.1
|
|
33
32
|
Requires-Dist: msgpack~=1.1.1
|
|
@@ -110,18 +109,20 @@ class TestComponent(Component):
|
|
|
110
109
|
After booting the DI infrastructure with a main module we could already call a service:
|
|
111
110
|
|
|
112
111
|
**Example**:
|
|
112
|
+
|
|
113
113
|
```python
|
|
114
114
|
@module(imports=[ServiceModule])
|
|
115
115
|
class Module:
|
|
116
116
|
def __init__(self):
|
|
117
117
|
pass
|
|
118
|
-
|
|
118
|
+
|
|
119
119
|
@create()
|
|
120
120
|
def create_registry(self) -> ConsulComponentRegistry:
|
|
121
|
-
return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500))
|
|
121
|
+
return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500)) # a consul based registry!
|
|
122
|
+
|
|
122
123
|
|
|
123
124
|
environment = Environment(Module)
|
|
124
|
-
service_manager = environment.
|
|
125
|
+
service_manager = environment.read(ServiceManager)
|
|
125
126
|
|
|
126
127
|
service = service_manager.get_service(TestService)
|
|
127
128
|
|
|
@@ -202,6 +203,14 @@ The library offers:
|
|
|
202
203
|
As well as the DI and AOP core, all mechanisms are heavily optimized.
|
|
203
204
|
A simple benchmark resulted in message roundtrips in significanlty under a ms per call.
|
|
204
205
|
|
|
206
|
+
## Installation
|
|
207
|
+
|
|
208
|
+
Just install from PyPI with
|
|
209
|
+
|
|
210
|
+
`pip install aspyx-service`
|
|
211
|
+
|
|
212
|
+
The library is tested with all Python version >= 3.9
|
|
213
|
+
|
|
205
214
|
Let's see some details
|
|
206
215
|
|
|
207
216
|
## Service and Component declaration
|
|
@@ -484,18 +493,26 @@ class ChannelAdvice:
|
|
|
484
493
|
|
|
485
494
|
## FastAPI server
|
|
486
495
|
|
|
487
|
-
|
|
496
|
+
The required - `FastAPI` - infrastructure to expose those services requires:
|
|
488
497
|
|
|
498
|
+
- a `FastAPI` instance
|
|
499
|
+
- an injectable `FastAPIServer`
|
|
500
|
+
- and a final `boot` call with the root module, which will return an `Environment`
|
|
489
501
|
|
|
490
502
|
```python
|
|
491
|
-
|
|
492
|
-
|
|
503
|
+
fast_api = FastAPI() # so you can run it with uvivorn from command-line
|
|
504
|
+
|
|
505
|
+
@module(imports=[ServiceModule])
|
|
506
|
+
class Module:
|
|
493
507
|
def __init__(self):
|
|
494
508
|
pass
|
|
509
|
+
|
|
510
|
+
@create()
|
|
511
|
+
def create_server(self, service_manager: ServiceManager, component_registry: ComponentRegistry) -> FastAPIServer:
|
|
512
|
+
return FastAPIServer(fastapi, service_manager, component_registry)
|
|
495
513
|
|
|
496
|
-
server = FastAPIServer(host="0.0.0.0", port=8000)
|
|
497
514
|
|
|
498
|
-
|
|
515
|
+
environment = FastAPIServer.boot(Moudle, host="0.0.0.0", port=8000)
|
|
499
516
|
```
|
|
500
517
|
|
|
501
518
|
This setup will also expose all service interfaces decorated with the corresponding http decorators!
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
aspyx_service/__init__.py,sha256=OWJoScdDVK1NTs9cIgImShgEdJb8TZHLBQjS11rA0Yo,2564
|
|
2
|
+
aspyx_service/authorization.py,sha256=0B1xb0WrRaj2rcGTHVUhh6i8aA0sy7BmpYA18xI9LQA,3833
|
|
3
|
+
aspyx_service/channels.py,sha256=u1afqUfcmVgLxXDXC2BYHH-dMozLcmVROuPRpypSwr8,16397
|
|
4
|
+
aspyx_service/healthcheck.py,sha256=vjfY7s5kd5mRJynVpvAJ4BvVF7QY1xrvj94Y-m041LQ,5615
|
|
5
|
+
aspyx_service/registries.py,sha256=bnTjKb40fbZXA52E2lDSEzCWI5_NBKZzQjc8ffufB5g,8039
|
|
6
|
+
aspyx_service/restchannel.py,sha256=0Xb8grEE8Dyx3g3ENl78DDMKa2WGjIKIPgOrpw5p9ak,8470
|
|
7
|
+
aspyx_service/server.py,sha256=uph9QtIvJLxq9I0JRCq1sbyF2z_oLNHDmHZGMwd1YLk,13321
|
|
8
|
+
aspyx_service/service.py,sha256=drETAZasbYJZisnmbhAqW0-mHghJ3IWyPaU-7etxvBI,27003
|
|
9
|
+
aspyx_service/session.py,sha256=HjGpnmwdislc8Ur6pQbSMi2K-lvTsb9_XyO80zupiF8,3713
|
|
10
|
+
aspyx_service-0.10.6.dist-info/METADATA,sha256=cutZq0v5pHKAvNXLUWg7MGuVs6e1KpMuAduufbMReLI,17946
|
|
11
|
+
aspyx_service-0.10.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
aspyx_service-0.10.6.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
|
|
13
|
+
aspyx_service-0.10.6.dist-info/RECORD,,
|
aspyx_service/serialization.py
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
deserialization functions
|
|
3
|
-
"""
|
|
4
|
-
from dataclasses import is_dataclass, fields
|
|
5
|
-
from functools import lru_cache
|
|
6
|
-
from typing import get_origin, get_args, Union
|
|
7
|
-
|
|
8
|
-
from pydantic import BaseModel
|
|
9
|
-
|
|
10
|
-
class TypeDeserializer:
|
|
11
|
-
# constructor
|
|
12
|
-
|
|
13
|
-
def __init__(self, typ):
|
|
14
|
-
self.typ = typ
|
|
15
|
-
self.deserializer = self._build_deserializer(typ)
|
|
16
|
-
|
|
17
|
-
def __call__(self, value):
|
|
18
|
-
return self.deserializer(value)
|
|
19
|
-
|
|
20
|
-
# internal
|
|
21
|
-
|
|
22
|
-
def _build_deserializer(self, typ):
|
|
23
|
-
origin = get_origin(typ)
|
|
24
|
-
args = get_args(typ)
|
|
25
|
-
|
|
26
|
-
if origin is Union:
|
|
27
|
-
deserializers = [TypeDeserializer(arg) for arg in args if arg is not type(None)]
|
|
28
|
-
def deser_union(value):
|
|
29
|
-
if value is None:
|
|
30
|
-
return None
|
|
31
|
-
for d in deserializers:
|
|
32
|
-
try:
|
|
33
|
-
return d(value)
|
|
34
|
-
except Exception:
|
|
35
|
-
continue
|
|
36
|
-
return value
|
|
37
|
-
return deser_union
|
|
38
|
-
|
|
39
|
-
if isinstance(typ, type) and issubclass(typ, BaseModel):
|
|
40
|
-
return typ.model_validate
|
|
41
|
-
|
|
42
|
-
if is_dataclass(typ):
|
|
43
|
-
field_deserializers = {f.name: TypeDeserializer(f.type) for f in fields(typ)}
|
|
44
|
-
def deser_dataclass(value):
|
|
45
|
-
if is_dataclass(value):
|
|
46
|
-
return value
|
|
47
|
-
|
|
48
|
-
return typ(**{
|
|
49
|
-
k: field_deserializers[k](v) for k, v in value.items()
|
|
50
|
-
})
|
|
51
|
-
return deser_dataclass
|
|
52
|
-
|
|
53
|
-
if origin is list:
|
|
54
|
-
item_deser = TypeDeserializer(args[0]) if args else lambda x: x
|
|
55
|
-
return lambda v: [item_deser(item) for item in v]
|
|
56
|
-
|
|
57
|
-
if origin is dict:
|
|
58
|
-
key_deser = TypeDeserializer(args[0]) if args else lambda x: x
|
|
59
|
-
val_deser = TypeDeserializer(args[1]) if len(args) > 1 else lambda x: x
|
|
60
|
-
return lambda v: {key_deser(k): val_deser(val) for k, val in v.items()}
|
|
61
|
-
|
|
62
|
-
# Fallback
|
|
63
|
-
return lambda v: v
|
|
64
|
-
|
|
65
|
-
class TypeSerializer:
|
|
66
|
-
def __init__(self, typ):
|
|
67
|
-
self.typ = typ
|
|
68
|
-
self.serializer = self._build_serializer(typ)
|
|
69
|
-
|
|
70
|
-
def __call__(self, value):
|
|
71
|
-
return self.serializer(value)
|
|
72
|
-
|
|
73
|
-
def _build_serializer(self, typ):
|
|
74
|
-
origin = get_origin(typ)
|
|
75
|
-
args = get_args(typ)
|
|
76
|
-
|
|
77
|
-
if origin is Union:
|
|
78
|
-
serializers = [TypeSerializer(arg) for arg in args if arg is not type(None)]
|
|
79
|
-
def ser_union(value):
|
|
80
|
-
if value is None:
|
|
81
|
-
return None
|
|
82
|
-
for s in serializers:
|
|
83
|
-
try:
|
|
84
|
-
return s(value)
|
|
85
|
-
except Exception:
|
|
86
|
-
continue
|
|
87
|
-
return value
|
|
88
|
-
return ser_union
|
|
89
|
-
|
|
90
|
-
if isinstance(typ, type) and issubclass(typ, BaseModel):
|
|
91
|
-
return lambda v: v.model_dump() if v is not None else None
|
|
92
|
-
|
|
93
|
-
if is_dataclass(typ):
|
|
94
|
-
field_serializers = {f.name: TypeSerializer(f.type) for f in fields(typ)}
|
|
95
|
-
def ser_dataclass(obj):
|
|
96
|
-
if obj is None:
|
|
97
|
-
return None
|
|
98
|
-
return {k: field_serializers[k](getattr(obj, k)) for k in field_serializers}
|
|
99
|
-
return ser_dataclass
|
|
100
|
-
|
|
101
|
-
if origin is list:
|
|
102
|
-
item_ser = TypeSerializer(args[0]) if args else lambda x: x
|
|
103
|
-
return lambda v: [item_ser(item) for item in v] if v is not None else None
|
|
104
|
-
|
|
105
|
-
if origin is dict:
|
|
106
|
-
key_ser = TypeSerializer(args[0]) if args else lambda x: x
|
|
107
|
-
val_ser = TypeSerializer(args[1]) if len(args) > 1 else lambda x: x
|
|
108
|
-
return lambda v: {key_ser(k): val_ser(val) for k, val in v.items()} if v is not None else None
|
|
109
|
-
|
|
110
|
-
# Fallback: primitive Typen oder unbekannt
|
|
111
|
-
return lambda v: v
|
|
112
|
-
|
|
113
|
-
@lru_cache(maxsize=512)
|
|
114
|
-
def get_deserializer(typ):
|
|
115
|
-
"""
|
|
116
|
-
return a function that is able to deserialize a value of the specified type
|
|
117
|
-
|
|
118
|
-
Args:
|
|
119
|
-
typ: the type
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
|
|
123
|
-
"""
|
|
124
|
-
return TypeDeserializer(typ)
|
|
125
|
-
|
|
126
|
-
@lru_cache(maxsize=512)
|
|
127
|
-
def get_serializer(typ):
|
|
128
|
-
"""
|
|
129
|
-
return a function that is able to deserialize a value of the specified type
|
|
130
|
-
|
|
131
|
-
Args:
|
|
132
|
-
typ: the type
|
|
133
|
-
|
|
134
|
-
Returns:
|
|
135
|
-
|
|
136
|
-
"""
|
|
137
|
-
return TypeSerializer(typ)
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
aspyx_service/__init__.py,sha256=h9zcGzYaVdU2_mXON-k-mgYErEJ7eIs-wjNDKtet1_s,2488
|
|
2
|
-
aspyx_service/authorization.py,sha256=vBM8uPsAZwMiTilqFZMJ101Qy37gL2Y9vdGTLp-ykFg,3983
|
|
3
|
-
aspyx_service/channels.py,sha256=3Fv6055n1hw8HQ6MKu2BROsq6gmPdKIhayUhQiTRLic,16461
|
|
4
|
-
aspyx_service/healthcheck.py,sha256=vjfY7s5kd5mRJynVpvAJ4BvVF7QY1xrvj94Y-m041LQ,5615
|
|
5
|
-
aspyx_service/registries.py,sha256=bnTjKb40fbZXA52E2lDSEzCWI5_NBKZzQjc8ffufB5g,8039
|
|
6
|
-
aspyx_service/restchannel.py,sha256=wutLGnxqMAS1oX7cc1pvN8qIIpjeBEvPz_hsKeWHVZs,8474
|
|
7
|
-
aspyx_service/serialization.py,sha256=OrwOAUsHQyGDyhYTkTc-0v8urYMbh0_3fgkpTvNOl0o,4214
|
|
8
|
-
aspyx_service/server.py,sha256=_LFRy1XIXTbu7CLoXsoPGQwKHkpSPDh82VV4ppahzr0,9057
|
|
9
|
-
aspyx_service/service.py,sha256=drETAZasbYJZisnmbhAqW0-mHghJ3IWyPaU-7etxvBI,27003
|
|
10
|
-
aspyx_service/session.py,sha256=ytWRTlnu1kDpTkLBCy_WF2i-mdffG-exIqsUQZ1Udo0,2592
|
|
11
|
-
aspyx_service-0.10.4.dist-info/METADATA,sha256=-WA5_ta5gP3AXy8EA0JhFzoFstaS_RgeGjyglOAYqXw,17499
|
|
12
|
-
aspyx_service-0.10.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
-
aspyx_service-0.10.4.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
|
|
14
|
-
aspyx_service-0.10.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|