aspyx-service 0.10.0__py3-none-any.whl → 0.10.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.
Potentially problematic release.
This version of aspyx-service might be problematic. Click here for more details.
- aspyx_service/channels.py +93 -32
- aspyx_service/registries.py +31 -30
- aspyx_service/restchannel.py +90 -30
- aspyx_service/serialization.py +61 -61
- aspyx_service/server.py +5 -8
- aspyx_service/service.py +23 -6
- aspyx_service-0.10.2.dist-info/METADATA +517 -0
- aspyx_service-0.10.2.dist-info/RECORD +12 -0
- aspyx_service-0.10.0.dist-info/METADATA +0 -37
- aspyx_service-0.10.0.dist-info/RECORD +0 -12
- {aspyx_service-0.10.0.dist-info → aspyx_service-0.10.2.dist-info}/WHEEL +0 -0
- {aspyx_service-0.10.0.dist-info → aspyx_service-0.10.2.dist-info}/licenses/LICENSE +0 -0
aspyx_service/channels.py
CHANGED
|
@@ -3,14 +3,17 @@ Service management and dependency injection framework.
|
|
|
3
3
|
"""
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import is_dataclass, asdict, fields
|
|
6
8
|
from typing import Type, Optional, Any, Callable
|
|
7
9
|
|
|
8
10
|
import msgpack
|
|
9
11
|
from httpx import Client, AsyncClient
|
|
10
12
|
from pydantic import BaseModel
|
|
11
13
|
|
|
14
|
+
from aspyx.di.configuration import inject_value
|
|
12
15
|
from aspyx.reflection import DynamicProxy, TypeDescriptor
|
|
13
|
-
from .service import ServiceManager
|
|
16
|
+
from .service import ServiceManager, ServiceCommunicationException
|
|
14
17
|
|
|
15
18
|
from .service import ComponentDescriptor, ChannelInstances, ServiceException, channel, Channel, RemoteServiceException
|
|
16
19
|
from .serialization import get_deserializer
|
|
@@ -21,19 +24,54 @@ class HTTPXChannel(Channel):
|
|
|
21
24
|
"client",
|
|
22
25
|
"async_client",
|
|
23
26
|
"service_names",
|
|
24
|
-
"deserializers"
|
|
27
|
+
"deserializers",
|
|
28
|
+
"timeout"
|
|
25
29
|
]
|
|
26
30
|
|
|
31
|
+
# class methods
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def to_dict(cls, obj: Any) -> Any:
|
|
35
|
+
if isinstance(obj, BaseModel):
|
|
36
|
+
return obj.dict()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
elif is_dataclass(obj):
|
|
40
|
+
return {
|
|
41
|
+
f.name: cls.to_dict(getattr(obj, f.name))
|
|
42
|
+
|
|
43
|
+
for f in fields(obj)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
elif isinstance(obj, (list, tuple)):
|
|
47
|
+
return [cls.to_dict(item) for item in obj]
|
|
48
|
+
|
|
49
|
+
elif isinstance(obj, dict):
|
|
50
|
+
return {key: cls.to_dict(value) for key, value in obj.items()}
|
|
51
|
+
|
|
52
|
+
return obj
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def to_json(cls, obj) -> str:
|
|
56
|
+
return json.dumps(cls.to_dict(obj))
|
|
57
|
+
|
|
27
58
|
# constructor
|
|
28
59
|
|
|
29
60
|
def __init__(self):
|
|
30
61
|
super().__init__()
|
|
31
62
|
|
|
63
|
+
self.timeout = 1000.0
|
|
32
64
|
self.client: Optional[Client] = None
|
|
33
65
|
self.async_client: Optional[AsyncClient] = None
|
|
34
66
|
self.service_names: dict[Type, str] = {}
|
|
35
67
|
self.deserializers: dict[Callable, Callable] = {}
|
|
36
68
|
|
|
69
|
+
# inject
|
|
70
|
+
|
|
71
|
+
@inject_value("http.timeout", default=1000.0)
|
|
72
|
+
def set_timeout(self, timeout: float) -> None:
|
|
73
|
+
self.timeout = timeout
|
|
74
|
+
|
|
37
75
|
# protected
|
|
38
76
|
|
|
39
77
|
def get_deserializer(self, type: Type, method: Callable) -> Type:
|
|
@@ -70,16 +108,16 @@ class HTTPXChannel(Channel):
|
|
|
70
108
|
|
|
71
109
|
return self.client
|
|
72
110
|
|
|
73
|
-
def get_async_client(self) ->
|
|
111
|
+
def get_async_client(self) -> AsyncClient:
|
|
74
112
|
if self.async_client is None:
|
|
75
113
|
self.async_client = self.make_async_client()
|
|
76
114
|
|
|
77
115
|
return self.async_client
|
|
78
116
|
|
|
79
|
-
def make_client(self):
|
|
117
|
+
def make_client(self) -> Client:
|
|
80
118
|
return Client() # base_url=url
|
|
81
119
|
|
|
82
|
-
def make_async_client(self):
|
|
120
|
+
def make_async_client(self) -> AsyncClient:
|
|
83
121
|
return AsyncClient() # base_url=url
|
|
84
122
|
|
|
85
123
|
class Request(BaseModel):
|
|
@@ -93,7 +131,7 @@ class Response(BaseModel):
|
|
|
93
131
|
@channel("dispatch-json")
|
|
94
132
|
class DispatchJSONChannel(HTTPXChannel):
|
|
95
133
|
"""
|
|
96
|
-
A channel that calls a POST on
|
|
134
|
+
A channel that calls a POST on the endpoint `ìnvoke` sending a request body containing information on the
|
|
97
135
|
called component, service and method and the arguments.
|
|
98
136
|
"""
|
|
99
137
|
# constructor
|
|
@@ -115,38 +153,49 @@ class DispatchJSONChannel(HTTPXChannel):
|
|
|
115
153
|
|
|
116
154
|
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
117
155
|
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
118
|
-
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
156
|
+
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
157
|
+
args=invocation.args)
|
|
119
158
|
|
|
159
|
+
dict = self.to_dict(request)
|
|
120
160
|
try:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
161
|
+
result = Response(**self.get_client().post(f"{self.get_url()}/invoke", json=dict, timeout=self.timeout).json())
|
|
162
|
+
if result.exception is not None:
|
|
163
|
+
raise RemoteServiceException(f"server side exception {result.exception}")
|
|
164
|
+
|
|
165
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.result)
|
|
166
|
+
except ServiceCommunicationException:
|
|
167
|
+
raise
|
|
168
|
+
|
|
169
|
+
except RemoteServiceException:
|
|
170
|
+
raise
|
|
171
|
+
|
|
129
172
|
except Exception as e:
|
|
130
|
-
raise
|
|
173
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
|
174
|
+
|
|
131
175
|
|
|
132
176
|
async def invoke_async(self, invocation: DynamicProxy.Invocation):
|
|
133
177
|
service_name = self.service_names[invocation.type] # map type to registered service name
|
|
134
178
|
request = Request(method=f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
|
|
135
179
|
args=invocation.args)
|
|
136
|
-
|
|
180
|
+
dict = self.to_dict(request)
|
|
137
181
|
try:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
182
|
+
data = await self.get_async_client().post(f"{self.get_url()}/invoke", json=dict, timeout=self.timeout)
|
|
183
|
+
result = Response(**data.json())
|
|
184
|
+
|
|
185
|
+
if result.exception is not None:
|
|
186
|
+
raise RemoteServiceException(f"server side exception {result.exception}")
|
|
187
|
+
|
|
188
|
+
return self.get_deserializer(invocation.type, invocation.method)(result.result)
|
|
189
|
+
|
|
190
|
+
except ServiceCommunicationException:
|
|
191
|
+
raise
|
|
192
|
+
|
|
193
|
+
except RemoteServiceException:
|
|
194
|
+
raise
|
|
195
|
+
|
|
148
196
|
except Exception as e:
|
|
149
|
-
raise
|
|
197
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
|
198
|
+
|
|
150
199
|
|
|
151
200
|
@channel("dispatch-msgpack")
|
|
152
201
|
class DispatchMSPackChannel(HTTPXChannel):
|
|
@@ -171,13 +220,13 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
171
220
|
args=invocation.args)
|
|
172
221
|
|
|
173
222
|
try:
|
|
174
|
-
packed = msgpack.packb(
|
|
223
|
+
packed = msgpack.packb(self.to_dict(request), use_bin_type=True)
|
|
175
224
|
|
|
176
225
|
response = self.get_client().post(
|
|
177
226
|
f"{self.get_url()}/invoke",
|
|
178
227
|
content=packed,
|
|
179
228
|
headers={"Content-Type": "application/msgpack"},
|
|
180
|
-
timeout=
|
|
229
|
+
timeout=self.timeout
|
|
181
230
|
)
|
|
182
231
|
|
|
183
232
|
result = msgpack.unpackb(response.content, raw=False)
|
|
@@ -187,6 +236,12 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
187
236
|
|
|
188
237
|
return self.get_deserializer(invocation.type, invocation.method)(result["result"])
|
|
189
238
|
|
|
239
|
+
except ServiceCommunicationException:
|
|
240
|
+
raise
|
|
241
|
+
|
|
242
|
+
except RemoteServiceException:
|
|
243
|
+
raise
|
|
244
|
+
|
|
190
245
|
except Exception as e:
|
|
191
246
|
raise ServiceException(f"msgpack exception: {e}") from e
|
|
192
247
|
|
|
@@ -196,13 +251,13 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
196
251
|
args=invocation.args)
|
|
197
252
|
|
|
198
253
|
try:
|
|
199
|
-
packed = msgpack.packb(
|
|
254
|
+
packed = msgpack.packb(self.to_dict(request), use_bin_type=True)
|
|
200
255
|
|
|
201
256
|
response = await self.get_async_client().post(
|
|
202
257
|
f"{self.get_url()}/invoke",
|
|
203
258
|
content=packed,
|
|
204
259
|
headers={"Content-Type": "application/msgpack"},
|
|
205
|
-
timeout=
|
|
260
|
+
timeout=self.timeout
|
|
206
261
|
)
|
|
207
262
|
|
|
208
263
|
result = msgpack.unpackb(response.content, raw=False)
|
|
@@ -212,5 +267,11 @@ class DispatchMSPackChannel(HTTPXChannel):
|
|
|
212
267
|
|
|
213
268
|
return self.get_deserializer(invocation.type, invocation.method)(result["result"])
|
|
214
269
|
|
|
270
|
+
except ServiceCommunicationException:
|
|
271
|
+
raise
|
|
272
|
+
|
|
273
|
+
except RemoteServiceException:
|
|
274
|
+
raise
|
|
275
|
+
|
|
215
276
|
except Exception as e:
|
|
216
277
|
raise ServiceException(f"msgpack exception: {e}") from e
|
aspyx_service/registries.py
CHANGED
|
@@ -6,10 +6,10 @@ import threading
|
|
|
6
6
|
from abc import abstractmethod
|
|
7
7
|
import time
|
|
8
8
|
from typing import Optional
|
|
9
|
-
from urllib.parse import urlparse
|
|
10
9
|
|
|
11
10
|
import consul
|
|
12
11
|
|
|
12
|
+
from aspyx.di.configuration import inject_value
|
|
13
13
|
from aspyx.util import StringBuilder
|
|
14
14
|
from aspyx.di import on_init
|
|
15
15
|
from .healthcheck import HealthCheckManager, HealthStatus
|
|
@@ -24,35 +24,38 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
24
24
|
"""
|
|
25
25
|
# constructor
|
|
26
26
|
|
|
27
|
-
def __init__(self, port: int,
|
|
27
|
+
def __init__(self, port: int, consul: consul.Consul):
|
|
28
28
|
self.port = port
|
|
29
29
|
self.ip = Server.get_local_ip()
|
|
30
30
|
self.running = False
|
|
31
|
-
self.consul =
|
|
31
|
+
self.consul = consul
|
|
32
32
|
self.watchdog = None
|
|
33
33
|
self.interval = 5
|
|
34
34
|
self.last_index = {}
|
|
35
35
|
self.component_addresses : dict[str, dict[str, ChannelInstances]] = {} # comp -> channel -> address
|
|
36
36
|
self.watch_channels : list[Channel] = []
|
|
37
|
+
self.watchdog_interval = 5
|
|
38
|
+
self.healthcheck_interval = "10s"
|
|
39
|
+
self.healthcheck_timeout= "5s"
|
|
40
|
+
self.healthcheck_deregister = "5m"
|
|
37
41
|
|
|
38
|
-
|
|
42
|
+
# injections
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
@inject_value("consul.watchdog.interval", default=5)
|
|
45
|
+
def set_interval(self, interval):
|
|
46
|
+
self.watchdog_interval = interval
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
@inject_value("consul.healthcheck.interval", default="10s")
|
|
49
|
+
def set_interval(self, interval):
|
|
50
|
+
self.healthcheck_interval = interval
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
@inject_value("consul.healthcheck.timeout", default="3s")
|
|
53
|
+
def set_interval(self, interval):
|
|
54
|
+
self.healthcheck_timeout = interval
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"""
|
|
55
|
-
return consul.Consul(host=host, port=port)
|
|
56
|
+
@inject_value("consul.healthcheck.deregister", default="5m")
|
|
57
|
+
def set_interval(self, interval):
|
|
58
|
+
self.healthcheck_deregister = interval
|
|
56
59
|
|
|
57
60
|
# lifecycle hooks
|
|
58
61
|
|
|
@@ -60,7 +63,6 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
60
63
|
def setup(self):
|
|
61
64
|
# create consul client
|
|
62
65
|
|
|
63
|
-
self.consul = self.make_consul(host=self.consul_host, port=self.consul_port)
|
|
64
66
|
self.running = True
|
|
65
67
|
|
|
66
68
|
# start thread
|
|
@@ -122,14 +124,12 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
122
124
|
|
|
123
125
|
# time to sleep
|
|
124
126
|
|
|
125
|
-
time.sleep(
|
|
127
|
+
time.sleep(self.watchdog_interval)
|
|
126
128
|
|
|
127
129
|
@abstractmethod
|
|
128
130
|
def watch(self, channel: Channel) -> None:
|
|
129
131
|
self.watch_channels.append(channel)
|
|
130
132
|
|
|
131
|
-
#self.component_addresses[channel.component_descriptor.name] = {}
|
|
132
|
-
|
|
133
133
|
# public
|
|
134
134
|
|
|
135
135
|
def register_service(self, name, service_id, health: str, tags=None, meta=None) -> None:
|
|
@@ -142,9 +142,9 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
142
142
|
meta=meta or {},
|
|
143
143
|
check=consul.Check().http(
|
|
144
144
|
url=f"http://{self.ip}:{self.port}{health}",
|
|
145
|
-
interval=
|
|
146
|
-
timeout=
|
|
147
|
-
deregister=
|
|
145
|
+
interval=self.healthcheck_interval,
|
|
146
|
+
timeout=self.healthcheck_timeout,
|
|
147
|
+
deregister=self.healthcheck_deregister)
|
|
148
148
|
)
|
|
149
149
|
|
|
150
150
|
def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
|
|
@@ -225,12 +225,13 @@ class ConsulComponentRegistry(ComponentRegistry):
|
|
|
225
225
|
|
|
226
226
|
return list(component_addresses.values())
|
|
227
227
|
|
|
228
|
-
#200–299 passing Service is healthy (OK, Created, No Content…)
|
|
229
|
-
#429 warning Rate limited or degraded
|
|
230
|
-
#300–399 warning Redirects interpreted as potential issues
|
|
231
|
-
#400–499 critical Client errors (Bad Request, Unauthorized…)
|
|
232
|
-
#500–599 critical Server errors (Internal Error, Timeout…)
|
|
233
|
-
#Other / No response critical Timeout, connection refused, etc.
|
|
228
|
+
# 200–299 passing Service is healthy (OK, Created, No Content…)
|
|
229
|
+
# 429 warning Rate limited or degraded
|
|
230
|
+
# 300–399 warning Redirects interpreted as potential issues
|
|
231
|
+
# 400–499 critical Client errors (Bad Request, Unauthorized…)
|
|
232
|
+
# 500–599 critical Server errors (Internal Error, Timeout…)
|
|
233
|
+
# Other / No response critical Timeout, connection refused, etc.
|
|
234
|
+
|
|
234
235
|
def map_health(self, health: HealthCheckManager.Health) -> int:
|
|
235
236
|
if health.status is HealthStatus.OK:
|
|
236
237
|
return 200
|
aspyx_service/restchannel.py
CHANGED
|
@@ -10,7 +10,7 @@ from typing import get_type_hints, TypeVar, Annotated, Callable, get_origin, get
|
|
|
10
10
|
from pydantic import BaseModel
|
|
11
11
|
|
|
12
12
|
from aspyx.reflection import DynamicProxy, Decorators
|
|
13
|
-
from .service import
|
|
13
|
+
from .service import channel, ServiceCommunicationException
|
|
14
14
|
|
|
15
15
|
T = TypeVar("T")
|
|
16
16
|
|
|
@@ -134,6 +134,9 @@ class RestChannel(HTTPXChannel):
|
|
|
134
134
|
def __init__(self, type: Type, method : Callable):
|
|
135
135
|
self.signature = inspect.signature(method)
|
|
136
136
|
|
|
137
|
+
param_names = list(self.signature.parameters.keys())
|
|
138
|
+
param_names.remove("self")
|
|
139
|
+
|
|
137
140
|
prefix = ""
|
|
138
141
|
if Decorators.has_decorator(type, rest):
|
|
139
142
|
prefix = Decorators.get_decorator(type, rest).args[0]
|
|
@@ -155,6 +158,9 @@ class RestChannel(HTTPXChannel):
|
|
|
155
158
|
|
|
156
159
|
self.path_param_names = set(re.findall(r"{(.*?)}", self.url_template))
|
|
157
160
|
|
|
161
|
+
for param_name in self.path_param_names:
|
|
162
|
+
param_names.remove(param_name)
|
|
163
|
+
|
|
158
164
|
hints = get_type_hints(method, include_extras=True)
|
|
159
165
|
|
|
160
166
|
self.body_param_name = None
|
|
@@ -163,10 +169,42 @@ class RestChannel(HTTPXChannel):
|
|
|
163
169
|
for param_name, hint in hints.items():
|
|
164
170
|
if get_origin(hint) is Annotated:
|
|
165
171
|
metadata = get_args(hint)[1:]
|
|
172
|
+
|
|
166
173
|
if BodyMarker in metadata:
|
|
167
174
|
self.body_param_name = param_name
|
|
175
|
+
param_names.remove(param_name)
|
|
168
176
|
elif QueryParamMarker in metadata:
|
|
169
177
|
self.query_param_names.add(param_name)
|
|
178
|
+
param_names.remove(param_name)
|
|
179
|
+
|
|
180
|
+
# check if something is missing
|
|
181
|
+
|
|
182
|
+
if len(param_names) > 0:
|
|
183
|
+
# check body params
|
|
184
|
+
if self.type in ("post", "put", "patch"):
|
|
185
|
+
if self.body_param_name is None:
|
|
186
|
+
candidates = [
|
|
187
|
+
(name, hint)
|
|
188
|
+
for name, hint in hints.items()
|
|
189
|
+
if name not in self.path_param_names
|
|
190
|
+
]
|
|
191
|
+
# find first dataclass or pydantic argument
|
|
192
|
+
for name, hint in candidates:
|
|
193
|
+
typ = hint
|
|
194
|
+
if get_origin(typ) is Annotated:
|
|
195
|
+
typ = get_args(typ)[0]
|
|
196
|
+
if (
|
|
197
|
+
(isinstance(typ, type) and issubclass(typ, BaseModel))
|
|
198
|
+
or is_dataclass(typ)
|
|
199
|
+
):
|
|
200
|
+
self.body_param_name = name
|
|
201
|
+
param_names.remove(name)
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
# the rest are query params
|
|
205
|
+
|
|
206
|
+
for param in param_names:
|
|
207
|
+
self.query_param_names.add(param)
|
|
170
208
|
|
|
171
209
|
# return type
|
|
172
210
|
|
|
@@ -189,21 +227,44 @@ class RestChannel(HTTPXChannel):
|
|
|
189
227
|
|
|
190
228
|
return call
|
|
191
229
|
|
|
192
|
-
def to_dict(self, obj):
|
|
193
|
-
if obj is None:
|
|
194
|
-
return None
|
|
195
|
-
if is_dataclass(obj):
|
|
196
|
-
return asdict(obj)
|
|
197
|
-
elif isinstance(obj, BaseModel):
|
|
198
|
-
return obj.dict()
|
|
199
|
-
elif hasattr(obj, "__dict__"):
|
|
200
|
-
return vars(obj)
|
|
201
|
-
else:
|
|
202
|
-
# fallback for primitives etc.
|
|
203
|
-
return obj
|
|
204
|
-
|
|
205
230
|
# override
|
|
206
231
|
|
|
232
|
+
async def invoke_async(self, invocation: 'DynamicProxy.Invocation'):
|
|
233
|
+
call = self.get_call(invocation.type, invocation.method)
|
|
234
|
+
|
|
235
|
+
bound = call.signature.bind(self, *invocation.args, **invocation.kwargs)
|
|
236
|
+
bound.apply_defaults()
|
|
237
|
+
arguments = bound.arguments
|
|
238
|
+
|
|
239
|
+
# url
|
|
240
|
+
|
|
241
|
+
url = call.url_template.format(**arguments)
|
|
242
|
+
query_params = {k: arguments[k] for k in call.query_param_names if k in arguments}
|
|
243
|
+
body = {}
|
|
244
|
+
if call.body_param_name is not None:
|
|
245
|
+
body = self.to_dict(arguments.get(call.body_param_name))
|
|
246
|
+
|
|
247
|
+
# call
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
result = None
|
|
251
|
+
if call.type == "get":
|
|
252
|
+
result = await self.get_client().get(self.get_url() + url, params=query_params, timeout=self.timeout).json()
|
|
253
|
+
elif call.type == "put":
|
|
254
|
+
result = await self.get_client().put(self.get_url() + url, params=query_params, timeout=self.timeout).json()
|
|
255
|
+
elif call.type == "delete":
|
|
256
|
+
result = await self.get_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout).json()
|
|
257
|
+
elif call.type == "post":
|
|
258
|
+
result = await self.get_client().post(self.get_url() + url, params=query_params, json=body,
|
|
259
|
+
timeout=self.timeout).json()
|
|
260
|
+
|
|
261
|
+
return self.get_deserializer(invocation.type, invocation.method)(result)
|
|
262
|
+
except ServiceCommunicationException:
|
|
263
|
+
raise
|
|
264
|
+
|
|
265
|
+
except Exception as e:
|
|
266
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
|
267
|
+
|
|
207
268
|
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
208
269
|
call = self.get_call(invocation.type, invocation.method)
|
|
209
270
|
|
|
@@ -222,20 +283,19 @@ class RestChannel(HTTPXChannel):
|
|
|
222
283
|
# call
|
|
223
284
|
|
|
224
285
|
try:
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
|
|
286
|
+
result = None
|
|
287
|
+
if call.type == "get":
|
|
288
|
+
result = self.get_client().get(self.get_url() + url, params=query_params, timeout=self.timeout).json()
|
|
289
|
+
elif call.type == "put":
|
|
290
|
+
result = self.get_client().put(self.get_url() + url, params=query_params, timeout=self.timeout).json()
|
|
291
|
+
elif call.type == "delete":
|
|
292
|
+
result = self.get_client().delete(self.get_url() + url, params=query_params, timeout=self.timeout).json()
|
|
293
|
+
elif call.type == "post":
|
|
294
|
+
result = self.get_client().post(self.get_url() + url, params=query_params, json=body, timeout=self.timeout).json()
|
|
295
|
+
|
|
296
|
+
return self.get_deserializer(invocation.type, invocation.method)(result)
|
|
297
|
+
except ServiceCommunicationException:
|
|
298
|
+
raise
|
|
299
|
+
|
|
240
300
|
except Exception as e:
|
|
241
|
-
raise
|
|
301
|
+
raise ServiceCommunicationException(f"communication exception {e}") from e
|
aspyx_service/serialization.py
CHANGED
|
@@ -7,67 +7,6 @@ from typing import get_origin, get_args, Union
|
|
|
7
7
|
|
|
8
8
|
from pydantic import BaseModel
|
|
9
9
|
|
|
10
|
-
def deserialize(value, return_type):
|
|
11
|
-
if value is None:
|
|
12
|
-
return None
|
|
13
|
-
|
|
14
|
-
origin = get_origin(return_type)
|
|
15
|
-
args = get_args(return_type)
|
|
16
|
-
|
|
17
|
-
# Handle Optional / Union
|
|
18
|
-
if origin is Union and type(None) in args:
|
|
19
|
-
real_type = [arg for arg in args if arg is not type(None)][0]
|
|
20
|
-
return deserialize(value, real_type)
|
|
21
|
-
|
|
22
|
-
# Handle pydantic
|
|
23
|
-
if isinstance(return_type, type) and issubclass(return_type, BaseModel):
|
|
24
|
-
return return_type.parse_obj(value)
|
|
25
|
-
|
|
26
|
-
# Handle dataclass
|
|
27
|
-
if is_dataclass(return_type):
|
|
28
|
-
return from_dict(return_type, value)
|
|
29
|
-
|
|
30
|
-
# Handle List[T]
|
|
31
|
-
if origin is list:
|
|
32
|
-
item_type = args[0]
|
|
33
|
-
return [deserialize(v, item_type) for v in value]
|
|
34
|
-
|
|
35
|
-
# Fallback: primitive
|
|
36
|
-
return value
|
|
37
|
-
|
|
38
|
-
def from_dict(cls, data: dict):
|
|
39
|
-
if not is_dataclass(cls):
|
|
40
|
-
return data # primitive or unknown
|
|
41
|
-
|
|
42
|
-
kwargs = {}
|
|
43
|
-
for field in fields(cls):
|
|
44
|
-
name = field.name
|
|
45
|
-
field_type = field.type
|
|
46
|
-
value = data.get(name)
|
|
47
|
-
|
|
48
|
-
if value is None:
|
|
49
|
-
kwargs[name] = None
|
|
50
|
-
continue
|
|
51
|
-
|
|
52
|
-
origin = get_origin(field_type)
|
|
53
|
-
args = get_args(field_type)
|
|
54
|
-
|
|
55
|
-
if origin is Union and type(None) in args:
|
|
56
|
-
real_type = [arg for arg in args if arg is not type(None)][0]
|
|
57
|
-
kwargs[name] = deserialize(value, real_type)
|
|
58
|
-
|
|
59
|
-
elif is_dataclass(field_type):
|
|
60
|
-
kwargs[name] = from_dict(field_type, value)
|
|
61
|
-
|
|
62
|
-
elif origin is list:
|
|
63
|
-
item_type = args[0]
|
|
64
|
-
kwargs[name] = [deserialize(v, item_type) for v in value]
|
|
65
|
-
|
|
66
|
-
else:
|
|
67
|
-
kwargs[name] = value
|
|
68
|
-
|
|
69
|
-
return cls(**kwargs)
|
|
70
|
-
|
|
71
10
|
class TypeDeserializer:
|
|
72
11
|
# constructor
|
|
73
12
|
|
|
@@ -119,6 +58,54 @@ class TypeDeserializer:
|
|
|
119
58
|
|
|
120
59
|
# Fallback
|
|
121
60
|
return lambda v: v
|
|
61
|
+
|
|
62
|
+
class TypeSerializer:
|
|
63
|
+
def __init__(self, typ):
|
|
64
|
+
self.typ = typ
|
|
65
|
+
self.serializer = self._build_serializer(typ)
|
|
66
|
+
|
|
67
|
+
def __call__(self, value):
|
|
68
|
+
return self.serializer(value)
|
|
69
|
+
|
|
70
|
+
def _build_serializer(self, typ):
|
|
71
|
+
origin = get_origin(typ)
|
|
72
|
+
args = get_args(typ)
|
|
73
|
+
|
|
74
|
+
if origin is Union:
|
|
75
|
+
serializers = [TypeSerializer(arg) for arg in args if arg is not type(None)]
|
|
76
|
+
def ser_union(value):
|
|
77
|
+
if value is None:
|
|
78
|
+
return None
|
|
79
|
+
for s in serializers:
|
|
80
|
+
try:
|
|
81
|
+
return s(value)
|
|
82
|
+
except Exception:
|
|
83
|
+
continue
|
|
84
|
+
return value
|
|
85
|
+
return ser_union
|
|
86
|
+
|
|
87
|
+
if isinstance(typ, type) and issubclass(typ, BaseModel):
|
|
88
|
+
return lambda v: v.dict() if v is not None else None
|
|
89
|
+
|
|
90
|
+
if is_dataclass(typ):
|
|
91
|
+
field_serializers = {f.name: TypeSerializer(f.type) for f in fields(typ)}
|
|
92
|
+
def ser_dataclass(obj):
|
|
93
|
+
if obj is None:
|
|
94
|
+
return None
|
|
95
|
+
return {k: field_serializers[k](getattr(obj, k)) for k in field_serializers}
|
|
96
|
+
return ser_dataclass
|
|
97
|
+
|
|
98
|
+
if origin is list:
|
|
99
|
+
item_ser = TypeSerializer(args[0]) if args else lambda x: x
|
|
100
|
+
return lambda v: [item_ser(item) for item in v] if v is not None else None
|
|
101
|
+
|
|
102
|
+
if origin is dict:
|
|
103
|
+
key_ser = TypeSerializer(args[0]) if args else lambda x: x
|
|
104
|
+
val_ser = TypeSerializer(args[1]) if len(args) > 1 else lambda x: x
|
|
105
|
+
return lambda v: {key_ser(k): val_ser(val) for k, val in v.items()} if v is not None else None
|
|
106
|
+
|
|
107
|
+
# Fallback: primitive Typen oder unbekannt
|
|
108
|
+
return lambda v: v
|
|
122
109
|
|
|
123
110
|
@lru_cache(maxsize=512)
|
|
124
111
|
def get_deserializer(typ):
|
|
@@ -132,3 +119,16 @@ def get_deserializer(typ):
|
|
|
132
119
|
|
|
133
120
|
"""
|
|
134
121
|
return TypeDeserializer(typ)
|
|
122
|
+
|
|
123
|
+
@lru_cache(maxsize=512)
|
|
124
|
+
def get_serializer(typ):
|
|
125
|
+
"""
|
|
126
|
+
return a function that is able to deserialize a value of the specified type
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
typ: the type
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
|
|
133
|
+
"""
|
|
134
|
+
return TypeSerializer(typ)
|
aspyx_service/server.py
CHANGED
|
@@ -38,7 +38,6 @@ class FastAPIServer(Server):
|
|
|
38
38
|
self.host = host
|
|
39
39
|
Server.port = port
|
|
40
40
|
self.server_thread = None
|
|
41
|
-
self.environment : Optional[Environment] = None
|
|
42
41
|
self.service_manager : Optional[ServiceManager] = None
|
|
43
42
|
self.component_registry: Optional[ComponentRegistry] = None
|
|
44
43
|
|
|
@@ -83,19 +82,17 @@ class FastAPIServer(Server):
|
|
|
83
82
|
response_model=method.return_type,
|
|
84
83
|
)
|
|
85
84
|
|
|
86
|
-
def
|
|
85
|
+
def start_fastapi_thread(self):
|
|
87
86
|
"""
|
|
88
87
|
start the fastapi server in a thread
|
|
89
88
|
"""
|
|
90
89
|
|
|
91
|
-
config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, log_level="
|
|
90
|
+
config = uvicorn.Config(self.fast_api, host=self.host, port=self.port, access_log=False) #log_level="debug"
|
|
92
91
|
server = uvicorn.Server(config)
|
|
93
92
|
|
|
94
93
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
95
94
|
thread.start()
|
|
96
95
|
|
|
97
|
-
print(f"server started on {self.host}:{self.port}")
|
|
98
|
-
|
|
99
96
|
return thread
|
|
100
97
|
|
|
101
98
|
|
|
@@ -212,12 +209,12 @@ class FastAPIServer(Server):
|
|
|
212
209
|
self.add_routes()
|
|
213
210
|
self.fast_api.include_router(self.router)
|
|
214
211
|
|
|
215
|
-
for route in self.fast_api.routes:
|
|
216
|
-
|
|
212
|
+
#for route in self.fast_api.routes:
|
|
213
|
+
# print(f"{route.name}: {route.path} [{route.methods}]")
|
|
217
214
|
|
|
218
215
|
# start server thread
|
|
219
216
|
|
|
220
|
-
self.
|
|
217
|
+
self.start_fastapi_thread()
|
|
221
218
|
|
|
222
219
|
# shutdown
|
|
223
220
|
|
aspyx_service/service.py
CHANGED
|
@@ -48,7 +48,12 @@ class Server(ABC):
|
|
|
48
48
|
# constructor
|
|
49
49
|
|
|
50
50
|
def __init__(self):
|
|
51
|
-
|
|
51
|
+
self.environment : Optional[Environment] = None
|
|
52
|
+
|
|
53
|
+
# public
|
|
54
|
+
|
|
55
|
+
def get(self, type: Type[T]) -> T:
|
|
56
|
+
return self.environment.get(type)
|
|
52
57
|
|
|
53
58
|
@abstractmethod
|
|
54
59
|
def boot(self, module_type: Type):
|
|
@@ -475,6 +480,9 @@ class Channel(DynamicProxy.InvocationHandler, ABC):
|
|
|
475
480
|
self.url_selector = Channel.FirstURLSelector()
|
|
476
481
|
|
|
477
482
|
def get_url(self) -> str:
|
|
483
|
+
if self.address is None:
|
|
484
|
+
raise ServiceCommunicationException(f"no url for channel {self.name} for component {self.component_descriptor.name} registered")
|
|
485
|
+
|
|
478
486
|
return self.url_selector.get(self.address.urls)
|
|
479
487
|
|
|
480
488
|
def set_address(self, address: Optional[ChannelInstances]):
|
|
@@ -637,7 +645,8 @@ class ServiceManager:
|
|
|
637
645
|
def __init__(self, component_registry: ComponentRegistry, channel_manager: ChannelManager):
|
|
638
646
|
self.component_registry = component_registry
|
|
639
647
|
self.channel_manager = channel_manager
|
|
640
|
-
self.environment = None
|
|
648
|
+
self.environment : Optional[Environment] = None
|
|
649
|
+
self.preferred_channel = ""
|
|
641
650
|
|
|
642
651
|
self.ip = Server.get_local_ip()
|
|
643
652
|
|
|
@@ -732,6 +741,9 @@ class ServiceManager:
|
|
|
732
741
|
|
|
733
742
|
return address
|
|
734
743
|
|
|
744
|
+
def set_preferred_channel(self, preferred_channel: str):
|
|
745
|
+
self.preferred_channel = preferred_channel
|
|
746
|
+
|
|
735
747
|
def get_service(self, service_type: Type[T], preferred_channel="") -> T:
|
|
736
748
|
"""
|
|
737
749
|
return a service proxy given a service type and preferred channel name
|
|
@@ -743,6 +755,10 @@ class ServiceManager:
|
|
|
743
755
|
Returns:
|
|
744
756
|
the proxy
|
|
745
757
|
"""
|
|
758
|
+
|
|
759
|
+
if len(preferred_channel) == 0:
|
|
760
|
+
preferred_channel = self.preferred_channel
|
|
761
|
+
|
|
746
762
|
service_descriptor = ServiceManager.get_descriptor(service_type)
|
|
747
763
|
component_descriptor = service_descriptor.get_component_descriptor()
|
|
748
764
|
|
|
@@ -850,18 +866,19 @@ class LocalChannel(Channel):
|
|
|
850
866
|
|
|
851
867
|
return getattr(instance, invocation.method.__name__)(*invocation.args, **invocation.kwargs)
|
|
852
868
|
|
|
853
|
-
|
|
854
|
-
#@injectable()
|
|
855
869
|
class LocalComponentRegistry(ComponentRegistry):
|
|
856
870
|
# constructor
|
|
857
871
|
|
|
858
872
|
def __init__(self):
|
|
859
|
-
self.component_channels : dict[ComponentDescriptor, list[
|
|
873
|
+
self.component_channels : dict[ComponentDescriptor, list[ChannelInstances]] = {}
|
|
860
874
|
|
|
861
875
|
# implement
|
|
862
876
|
|
|
863
877
|
def register(self, descriptor: ComponentDescriptor[Component], addresses: list[ChannelAddress]) -> None:
|
|
864
|
-
self.component_channels
|
|
878
|
+
if self.component_channels.get(descriptor, None) is None:
|
|
879
|
+
self.component_channels[descriptor] = []
|
|
880
|
+
|
|
881
|
+
self.component_channels[descriptor].extend([ChannelInstances(descriptor.name, address.channel, [address.uri]) for address in addresses])
|
|
865
882
|
|
|
866
883
|
def deregister(self, descriptor: ComponentDescriptor[Component]) -> None:
|
|
867
884
|
pass
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aspyx_service
|
|
3
|
+
Version: 0.10.2
|
|
4
|
+
Summary: Aspyx Service framework
|
|
5
|
+
Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Andreas Ernst
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Requires-Dist: aspyx>=1.5.1
|
|
30
|
+
Requires-Dist: fastapi~=0.115.13
|
|
31
|
+
Requires-Dist: httpx~=0.28.1
|
|
32
|
+
Requires-Dist: msgpack~=1.1.1
|
|
33
|
+
Requires-Dist: python-consul2~=0.1.5
|
|
34
|
+
Requires-Dist: uvicorn[standard]
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# aspyx
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
43
|
+

|
|
44
|
+
[](https://pypi.org/project/aspyx/)
|
|
45
|
+
[](https://coolsamson7.github.io/aspyx/index/introduction)
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
# Service
|
|
50
|
+
|
|
51
|
+
- [Introduction](#introduction)
|
|
52
|
+
- [Features](#features)
|
|
53
|
+
- [Service and Component declaration](#service-and-component-declaration)
|
|
54
|
+
- [Service and Component implementation](#service-and-component-implementation)
|
|
55
|
+
- [Health Checks](#health-checks)
|
|
56
|
+
- [Service Manager](#service-manager)
|
|
57
|
+
- [Component Registry](#component-registry)
|
|
58
|
+
- [Channels](#channels)
|
|
59
|
+
- [Performance](#performance)
|
|
60
|
+
- [Rest Calls](#rest-calls)
|
|
61
|
+
- [Intercepting calls](#intercepting-calls)
|
|
62
|
+
- [FastAPI server](#fastapi-server)
|
|
63
|
+
- [Implementing Channels](#implementing-channels)
|
|
64
|
+
- [Version History](#version-history)
|
|
65
|
+
|
|
66
|
+
## Introduction
|
|
67
|
+
|
|
68
|
+
The Aspyx service library is built on top of the DI core framework and adds a microservice based architecture,
|
|
69
|
+
that lets you deploy, discover and call services with different remoting protocols and pluggable discovery services.
|
|
70
|
+
|
|
71
|
+
The basic design consists of four different concepts:
|
|
72
|
+
|
|
73
|
+
!!! info "Service"
|
|
74
|
+
defines a group of methods that can be called either locally or remotely.
|
|
75
|
+
These methods represent the functional interface exposed to clients — similar to an interface in traditional programming
|
|
76
|
+
|
|
77
|
+
!!! info "Component"
|
|
78
|
+
a component bundles one or more services and declares the channels (protocols) used to expose them.
|
|
79
|
+
Think of a component as a deployment unit or module.
|
|
80
|
+
|
|
81
|
+
!!! info "Component Registry "
|
|
82
|
+
acts as the central directory for managing available components.
|
|
83
|
+
It allows the framework to register, discover, and resolve components and their services.
|
|
84
|
+
|
|
85
|
+
!!! info "Channel"
|
|
86
|
+
is a pluggable transport layer that defines how service method invocations are transmitted and handled.
|
|
87
|
+
|
|
88
|
+
Let's look at the "interface" layer first.
|
|
89
|
+
|
|
90
|
+
**Example**:
|
|
91
|
+
```python
|
|
92
|
+
@service(name="test-service", description="test service")
|
|
93
|
+
class TestService(Service):
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def hello(self, message: str) -> str:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
@component(name="test-component", services =[TestService])
|
|
99
|
+
class TestComponent(Component):
|
|
100
|
+
pass
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
After booting the DI infrastructure with a main module we could already call a service:
|
|
104
|
+
|
|
105
|
+
**Example**:
|
|
106
|
+
```python
|
|
107
|
+
@module(imports=[ServiceModule])
|
|
108
|
+
class Module:
|
|
109
|
+
def __init__(self):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
@create()
|
|
113
|
+
def create_registry(self) -> ConsulComponentRegistry:
|
|
114
|
+
return ConsulComponentRegistry(Server.port, "http://localhost:8500") # a consul based registry!
|
|
115
|
+
|
|
116
|
+
environment = Environment(Module)
|
|
117
|
+
service_manager = environment.get(ServiceManager)
|
|
118
|
+
|
|
119
|
+
service = service_manager.get_service(TestService)
|
|
120
|
+
|
|
121
|
+
service.hello("world")
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The technical details are completely transparent, as a dynamic proxy encapsulates the internals.
|
|
125
|
+
|
|
126
|
+
As we can also host implementations, lets look at this side as well:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
@implementation()
|
|
130
|
+
class TestComponentImpl(AbstractComponent, TestComponent):
|
|
131
|
+
# constructor
|
|
132
|
+
|
|
133
|
+
def __init__(self):
|
|
134
|
+
super().__init__()
|
|
135
|
+
|
|
136
|
+
# implement Component
|
|
137
|
+
|
|
138
|
+
def get_addresses(self, port: int) -> list[ChannelAddress]:
|
|
139
|
+
return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
|
|
140
|
+
|
|
141
|
+
@implementation()
|
|
142
|
+
class TestServiceImpl(TestService):
|
|
143
|
+
def __init__(self):
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def hello(self, message: str) -> str:
|
|
147
|
+
return f"hello {message}"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The interesting part if the `get_addresses` method that return a list of channel addresses, that can be used to execute remote calls.
|
|
151
|
+
In this case a channel is used that exposes a single http endpoint, that will dispatch to the correct service method.
|
|
152
|
+
This information is registered with the appropriate component registry and is used by other processes.
|
|
153
|
+
|
|
154
|
+
The required - `FastAPI` - infrastructure to expose those services is started with the call:
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
server = FastAPIServer(host="0.0.0.0", port=8000)
|
|
158
|
+
|
|
159
|
+
environment = server.boot(Module)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Of course, service can also be called locally. In case of multiple possible channels, a keyword argument is used to
|
|
163
|
+
determine a specific channel. As a local channel has the name "local", the appropriate call is:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
service = service_manager.get_service(TestService, preferred_channel="local")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Features
|
|
170
|
+
|
|
171
|
+
The library offers:
|
|
172
|
+
|
|
173
|
+
- sync and async support
|
|
174
|
+
- multiple - extensible - channel implementations supporting dataclasses and pydantic data models.
|
|
175
|
+
- ability to customize http calls with interceptors ( via the AOP abilities )
|
|
176
|
+
- `fastapi` based channels covering simple rest endpoints including `msgpack` support.
|
|
177
|
+
- `httpx` based clients for dispatching channels and simple rest endpoint with the help of low-level decorators.
|
|
178
|
+
- first registry implementation based on `consul`
|
|
179
|
+
- support for configurable health checks
|
|
180
|
+
|
|
181
|
+
As well as the DI and AOP core, all mechanisms are heavily optimized.
|
|
182
|
+
A simple benchmark resulted in message roundtrips in significanlty under a ms per call.
|
|
183
|
+
|
|
184
|
+
Let's see some details
|
|
185
|
+
|
|
186
|
+
## Service and Component declaration
|
|
187
|
+
|
|
188
|
+
Every service needs to inherit from the "tagging interface" `Service`
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
@service(name="test-service", description="test service")
|
|
192
|
+
class TestService(Service):
|
|
193
|
+
@abstractmethod
|
|
194
|
+
def hello(self, message: str) -> str:
|
|
195
|
+
pass
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The decorator can add a name and a description. If `name` is not set, the class name converted to snake case is used.
|
|
199
|
+
|
|
200
|
+
A component needs to derive from `Component`:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
@component(services =[TestService])
|
|
204
|
+
class TestComponent(Component):
|
|
205
|
+
pass
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The `services` argument references a list of service interfaces that are managed by this component, meaning that they all are
|
|
209
|
+
exposed by the same channels.
|
|
210
|
+
|
|
211
|
+
`Component` defines the abstract methods:
|
|
212
|
+
|
|
213
|
+
- `def startup(self) -> None`
|
|
214
|
+
called initially after booting the system
|
|
215
|
+
|
|
216
|
+
- `def shutdown(self) -> None:`
|
|
217
|
+
called before shutting fown the system
|
|
218
|
+
|
|
219
|
+
- `def get_addresses(self, port: int) -> list[ChannelAddress]:`
|
|
220
|
+
return a list of available `ChannelAddress`es that this component exposes
|
|
221
|
+
|
|
222
|
+
- `def get_status(self) -> ComponentStatus:`
|
|
223
|
+
return the status of this component ( one of the `ComponentStatus` enums `VIRGIN`, `RUNNING`, and `STOPPED`)
|
|
224
|
+
|
|
225
|
+
- `async def get_health(self) -> HealthCheckManager.Health:`
|
|
226
|
+
return the health status of a component implementation.
|
|
227
|
+
|
|
228
|
+
## Service and Component implementation
|
|
229
|
+
|
|
230
|
+
Service implementations implement the corresponding interface and are decorated with `@implementation`
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
@implementation()
|
|
234
|
+
class TestServiceImpl(TestService):
|
|
235
|
+
def __init__(self):
|
|
236
|
+
pass
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The constructor is required since the instances are managed by the DI framework.
|
|
240
|
+
|
|
241
|
+
Component implementations derive from the interface and the abstract base class `AbstractComponent`
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
@implementation()
|
|
245
|
+
class TestComponentImpl(AbstractComponent, TestComponent):
|
|
246
|
+
# constructor
|
|
247
|
+
|
|
248
|
+
def __init__(self):
|
|
249
|
+
super().__init__()
|
|
250
|
+
|
|
251
|
+
# implement Component
|
|
252
|
+
|
|
253
|
+
def get_addresses(self, port: int) -> list[ChannelAddress]:
|
|
254
|
+
return [ChannelAddress("dispatch-json", f"http://{Server.get_local_ip()}:{port}")]
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
As a minimum you have to declare the constructor and the `get_addresses` method, that exposes channel addresses
|
|
258
|
+
|
|
259
|
+
## Health Checks
|
|
260
|
+
|
|
261
|
+
Every component can declare a HTTP health endpoint and the corresponding logic to compute the current status.
|
|
262
|
+
|
|
263
|
+
Two additional things have to be done:
|
|
264
|
+
|
|
265
|
+
- adding a `@health(<endpoint>)` decorator to the class
|
|
266
|
+
- implementing the `get_health()` method that returns a `HealthCheckManager.Health`
|
|
267
|
+
|
|
268
|
+
While you can instantiate the `Health` class directly via
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
HealthCheckManager.Health(HealtStatus.OK)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
it typically makes more sense to let the system execute a number of configured checks and compute the overall result automatically.
|
|
275
|
+
|
|
276
|
+
For this purpose injectable classes can be decorated with `@health_checks()` that contain methods in turn decorated with `@health_check`
|
|
277
|
+
|
|
278
|
+
**Example**:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
@health_checks()
|
|
282
|
+
@injectable()
|
|
283
|
+
class Checks:
|
|
284
|
+
def __init__(self):
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
@health_check(fail_if_slower_than=1)
|
|
288
|
+
def check_performance(self, result: HealthCheckManager.Result):
|
|
289
|
+
... # should be done in under a second
|
|
290
|
+
|
|
291
|
+
@health_check(name="check", cache=10)
|
|
292
|
+
def check(self, result: HealthCheckManager.Result):
|
|
293
|
+
ok = ...
|
|
294
|
+
result.set_status(if ok HealthStatus.OK else HealthStatus.ERROR)
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The methods are expected to have a single parameter of type `HealthCheckManager.Result` that can be used to set the status including detail information with
|
|
298
|
+
|
|
299
|
+
```
|
|
300
|
+
set_status(status: HealthStatus, details = "")
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
When called, the default is already `OK`.
|
|
304
|
+
|
|
305
|
+
The decorator accepts a couple of parameters:
|
|
306
|
+
|
|
307
|
+
- `fail_if_slower_than=0`
|
|
308
|
+
time in `s` that the check is expected to take as a maximum. As soon as the time is exceeded, the status is set to `ERROR`
|
|
309
|
+
- `cache`
|
|
310
|
+
time in 's' that the last result is cached. This is done in order to prevent health-checks putting even more strain on a heavily used system.
|
|
311
|
+
|
|
312
|
+
## Service Manager
|
|
313
|
+
|
|
314
|
+
`ServiceManager` is the central class used to retrieve service proxies.
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
def get_service(self, service_type: Type[T], preferred_channel="") -> T
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- `type` is the requested service interface
|
|
321
|
+
- `preferred_channel` the name of the preferred channel.
|
|
322
|
+
|
|
323
|
+
If not specified, the first registered channel is used, which btw. is a local channel - called `local` - in case of implementing services.
|
|
324
|
+
|
|
325
|
+
## Component Registry
|
|
326
|
+
|
|
327
|
+
The component registry is the place where component implementations are registered and retrieved.
|
|
328
|
+
|
|
329
|
+
In addition to a `LocalComponentRegistry` ( which is used for testing purposes ) the only implementation is
|
|
330
|
+
|
|
331
|
+
`ConsulComponentRegistry`
|
|
332
|
+
|
|
333
|
+
Constructor arguments are
|
|
334
|
+
|
|
335
|
+
- `port: int` the own port
|
|
336
|
+
- `consul: Consul` the consul instance
|
|
337
|
+
|
|
338
|
+
The component registry is also responsible to execute regular health-checks to track component healths.
|
|
339
|
+
As soon as - in our case consul - decides that a component is not alive anymore, it will notify the clients via regular heartbeats about address changes
|
|
340
|
+
which will be propagated to channels talking to the appropriate component.
|
|
341
|
+
|
|
342
|
+
Currently, this only affects the list of possible URLs which are required by the channels!
|
|
343
|
+
|
|
344
|
+
## Channels
|
|
345
|
+
|
|
346
|
+
Channels implement the possible transport layer protocols. In the sense of a dynamic proxy, they are the invocation handlers!
|
|
347
|
+
|
|
348
|
+
Several channels are implemented:
|
|
349
|
+
|
|
350
|
+
- `dispatch-json`
|
|
351
|
+
channel that dispatches generic `Request` objects via a `invoke` POST-call
|
|
352
|
+
- `dispatch-msgpack`
|
|
353
|
+
channel that dispatches generic `Request` objects via a `invoke` POST-call after packing the json with msgpack
|
|
354
|
+
- `rest`
|
|
355
|
+
channel that executes regular rest-calls as defined by a couple of decorators.
|
|
356
|
+
|
|
357
|
+
All channels react on changed URLs as provided by the component registry.
|
|
358
|
+
|
|
359
|
+
A so called `URLSelector` is used internally to provide URLs for every single call. Two subclasses exist that offer a different logic
|
|
360
|
+
|
|
361
|
+
- `FirstURLSelector` always returns the first URL of the list of possible URLs
|
|
362
|
+
- `RoundRobinURLSelector` switches sequentially between all URLs.
|
|
363
|
+
|
|
364
|
+
To customize the behavior, an around advice can be implemented easily:
|
|
365
|
+
|
|
366
|
+
**Example**:
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
@advice
|
|
370
|
+
class ChannelAdvice:
|
|
371
|
+
def __init__(self):
|
|
372
|
+
pass
|
|
373
|
+
|
|
374
|
+
@advice
|
|
375
|
+
class ChannelAdvice:
|
|
376
|
+
def __init__(self):
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
@around(methods().named("customize").of_type(Channel))
|
|
380
|
+
def customize_channel(self, invocation: Invocation):
|
|
381
|
+
channel = cast(Channel, invocation.args[0])
|
|
382
|
+
|
|
383
|
+
channel.select_round_robin() # or select_first_url()
|
|
384
|
+
|
|
385
|
+
return invocation.proceed()
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Performance
|
|
389
|
+
|
|
390
|
+
I benchmarked the different implementations with a recursive dataclass as an argument and return value.
|
|
391
|
+
The avg response times - on a local server - where all below 1ms per call.
|
|
392
|
+
|
|
393
|
+
- rest calls are the slowest ( about 0.7ms )
|
|
394
|
+
- dispatching-json 20% faster
|
|
395
|
+
- dispatching-msgpack 30% faster
|
|
396
|
+
|
|
397
|
+
The biggest advantage of the dispatching flavors is, that you don't have to worry about the additional decorators!
|
|
398
|
+
|
|
399
|
+
### Rest Calls
|
|
400
|
+
|
|
401
|
+
Invoking rest calls requires decorators and some marker annotations.
|
|
402
|
+
|
|
403
|
+
**Example**:
|
|
404
|
+
|
|
405
|
+
```python
|
|
406
|
+
@service()
|
|
407
|
+
@rest("/api")
|
|
408
|
+
class TestService(Service):
|
|
409
|
+
@get("/hello/{message}")
|
|
410
|
+
def hello(self, message: str) -> str:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
@post("/post/")
|
|
414
|
+
def set_data(self, data: Body(Data)) -> Data:
|
|
415
|
+
pass
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The decorators `get`, `put`, `post` and `delete` specify the methods.
|
|
419
|
+
|
|
420
|
+
If the class is decorated with `@rest(<prefix>)`, the corresponding prefix will be appended at the beginning.
|
|
421
|
+
|
|
422
|
+
Additional annotations are
|
|
423
|
+
- `Body` the post body
|
|
424
|
+
- `QueryParam`marked for query params
|
|
425
|
+
|
|
426
|
+
### Intercepting calls
|
|
427
|
+
|
|
428
|
+
The client side HTTP calling is done with `httpx` instances of type `Httpx.Client` or `Httpx.AsyncClient`.
|
|
429
|
+
|
|
430
|
+
To add the possibility to add interceptors - for token handling, etc. - the channel base class `HTTPXChannel` defines
|
|
431
|
+
the methods `make_client()` and `make_async_client` that can be modified with an around advice.
|
|
432
|
+
|
|
433
|
+
**Example**:
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
class InterceptingClient(httpx.Client):
|
|
437
|
+
# constructor
|
|
438
|
+
|
|
439
|
+
def __init__(self, *args, **kwargs):
|
|
440
|
+
self.token_provider = ...
|
|
441
|
+
super().__init__(*args, **kwargs)
|
|
442
|
+
|
|
443
|
+
# override
|
|
444
|
+
|
|
445
|
+
def request(self, method, url, *args, **kwargs):
|
|
446
|
+
headers = kwargs.pop("headers", {})
|
|
447
|
+
headers["Authorization"] = f"Bearer {self.token_provider()}"
|
|
448
|
+
kwargs["headers"] = headers
|
|
449
|
+
|
|
450
|
+
return super().request(method, url, *args, **kwargs)
|
|
451
|
+
|
|
452
|
+
@advice
|
|
453
|
+
class ChannelAdvice:
|
|
454
|
+
def __init__(self):
|
|
455
|
+
pass
|
|
456
|
+
|
|
457
|
+
@around(methods().named("make_client").of_type(HTTPXChannel))
|
|
458
|
+
def make_client(self, invocation: Invocation):
|
|
459
|
+
return InterceptingClient()
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
## FastAPI server
|
|
463
|
+
|
|
464
|
+
In order to expose components via HTTP, the corresponding infrastructure in form of a FastAPI server needs to be setup.
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
```python
|
|
468
|
+
@module()
|
|
469
|
+
class Module():
|
|
470
|
+
def __init__(self):
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
server = FastAPIServer(host="0.0.0.0", port=8000)
|
|
474
|
+
|
|
475
|
+
environment = server.boot(Module) # will start the http server
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
This setup will also expose all service interfaces decorated with the corresponding http decorators!
|
|
479
|
+
No need to add any FastAPI decorators, since the mapping is already done internally!
|
|
480
|
+
|
|
481
|
+
## Implementing Channels
|
|
482
|
+
|
|
483
|
+
To implement a new channel, you only need to derive from one of the possible base classes ( `Channel` or `HTTPXChannel` that already has a `httpx` client)
|
|
484
|
+
and decorate it with `@channel(<name>)`
|
|
485
|
+
|
|
486
|
+
The main methods to implement are `ìnvoke` and `ìnvoke_async`
|
|
487
|
+
|
|
488
|
+
**Example**:
|
|
489
|
+
|
|
490
|
+
```python
|
|
491
|
+
@channel("fancy")
|
|
492
|
+
class FancyChannel(Channel):
|
|
493
|
+
# constructor
|
|
494
|
+
|
|
495
|
+
def __init__(self):
|
|
496
|
+
super().__init__()
|
|
497
|
+
|
|
498
|
+
# override
|
|
499
|
+
|
|
500
|
+
def invoke(self, invocation: DynamicProxy.Invocation):
|
|
501
|
+
return ...
|
|
502
|
+
|
|
503
|
+
async def invoke_async(self, invocation: DynamicProxy.Invocation):
|
|
504
|
+
return await ...
|
|
505
|
+
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
# Version History
|
|
509
|
+
|
|
510
|
+
**0.10.0**
|
|
511
|
+
|
|
512
|
+
- first release version
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
aspyx_service/__init__.py,sha256=6t24VPrSCG83EAvYlqCKdEcEbyCY3vrSb5GoAx01Ymg,1662
|
|
2
|
+
aspyx_service/channels.py,sha256=9EjnY6DV_toqeQNswYZgmAtirXM_TTLGErs6hb1U9yw,8934
|
|
3
|
+
aspyx_service/healthcheck.py,sha256=8ZPSkAx6ypoYaxDMkJT_MtL2pEN2LcUAishAWPCy-3I,5624
|
|
4
|
+
aspyx_service/registries.py,sha256=JSsD32F8VffZMHyEDuapEWtvmem5SK9kR6bgsFRLFZQ,8002
|
|
5
|
+
aspyx_service/restchannel.py,sha256=OVGyKNtORJEFnWsFB5MPkNldVFfJTGuUa4X658_x9Kg,9169
|
|
6
|
+
aspyx_service/serialization.py,sha256=lEr106yiZ0UzVxGTGdiorZSSSNI7vP4C7RyrBJku-U0,4133
|
|
7
|
+
aspyx_service/server.py,sha256=PHKx3O90jnxm8dA4z331_51znhU-HlRB2Fa1AikmFXA,7052
|
|
8
|
+
aspyx_service/service.py,sha256=-hhxRpEtMn-JP57bHNF521zbGMHUy5YaXkbXyiu7dW4,25927
|
|
9
|
+
aspyx_service-0.10.2.dist-info/METADATA,sha256=KEdh30D63sXs26FVOtCph_LF521bBJa7A6Qpc8d-9iQ,16966
|
|
10
|
+
aspyx_service-0.10.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
+
aspyx_service-0.10.2.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
|
|
12
|
+
aspyx_service-0.10.2.dist-info/RECORD,,
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: aspyx_service
|
|
3
|
-
Version: 0.10.0
|
|
4
|
-
Summary: Aspyx Service framework
|
|
5
|
-
Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
|
|
6
|
-
License: MIT License
|
|
7
|
-
|
|
8
|
-
Copyright (c) 2025 Andreas Ernst
|
|
9
|
-
|
|
10
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
-
in the Software without restriction, including without limitation the rights
|
|
13
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
-
furnished to do so, subject to the following conditions:
|
|
16
|
-
|
|
17
|
-
The above copyright notice and this permission notice shall be included in all
|
|
18
|
-
copies or substantial portions of the Software.
|
|
19
|
-
|
|
20
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
-
SOFTWARE.
|
|
27
|
-
License-File: LICENSE
|
|
28
|
-
Requires-Python: >=3.9
|
|
29
|
-
Requires-Dist: aspyx>=1.5.0
|
|
30
|
-
Requires-Dist: fastapi~=0.115.13
|
|
31
|
-
Requires-Dist: httpx~=0.28.1
|
|
32
|
-
Requires-Dist: msgpack~=1.1.1
|
|
33
|
-
Requires-Dist: python-consul2~=0.1.5
|
|
34
|
-
Requires-Dist: uvicorn[standard]
|
|
35
|
-
Description-Content-Type: text/markdown
|
|
36
|
-
|
|
37
|
-
aspyx-service
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
aspyx_service/__init__.py,sha256=6t24VPrSCG83EAvYlqCKdEcEbyCY3vrSb5GoAx01Ymg,1662
|
|
2
|
-
aspyx_service/channels.py,sha256=HZFkbZq0Q1DNpOauuPCMOEh7UlpcisN8_gQhL66zS-0,7718
|
|
3
|
-
aspyx_service/healthcheck.py,sha256=8ZPSkAx6ypoYaxDMkJT_MtL2pEN2LcUAishAWPCy-3I,5624
|
|
4
|
-
aspyx_service/registries.py,sha256=5sU9eFT_D33sAgrXyYSd9fAa8xNmFRJ6CmZGp8cQztY,7712
|
|
5
|
-
aspyx_service/restchannel.py,sha256=IbspE-hrzkpPLgBzuNncagjjGZnLVPX9jtT1PKRNenw,6453
|
|
6
|
-
aspyx_service/serialization.py,sha256=T6JDURwYd7V4240j1BnNsFKdyiMgePCsCwKa9rf8hq4,3779
|
|
7
|
-
aspyx_service/server.py,sha256=DA4bfTzYbpPrfqgddbyvTqr80Ehk9tuoqFfuo59ppN4,7118
|
|
8
|
-
aspyx_service/service.py,sha256=T7rZ8EPEzOusP_xOk-7KwTQfX_iJQ291XDL_ieKa_d8,25148
|
|
9
|
-
aspyx_service-0.10.0.dist-info/METADATA,sha256=9bVUDpfMPvHUr_NfWmOw4EYHA0rw8m84niRWxnhmslk,1674
|
|
10
|
-
aspyx_service-0.10.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
11
|
-
aspyx_service-0.10.0.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
|
|
12
|
-
aspyx_service-0.10.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|