aspyx-service 0.11.0__py3-none-any.whl → 0.11.1__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 CHANGED
@@ -16,7 +16,7 @@ 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
+ from aspyx.util import get_deserializer, TypeDeserializer, TypeSerializer, get_serializer, CopyOnWriteCache
20
20
  from .service import ServiceManager, ServiceCommunicationException, TokenExpiredException, InvalidTokenException, \
21
21
  AuthorizationException, MissingTokenException
22
22
 
@@ -61,6 +61,9 @@ class TokenContext:
61
61
  cls.refresh_token.reset(refresh_token)
62
62
 
63
63
  class HTTPXChannel(Channel):
64
+ """
65
+ A channel using the httpx clients.
66
+ """
64
67
  __slots__ = [
65
68
  "client",
66
69
  "async_client",
@@ -75,28 +78,6 @@ class HTTPXChannel(Channel):
75
78
  client_local = ThreadLocal[Client]()
76
79
  async_client_local = ThreadLocal[AsyncClient]()
77
80
 
78
- # class methods
79
-
80
- @classmethod
81
- def to_dict(cls, obj: Any) -> Any:
82
- if isinstance(obj, BaseModel):
83
- return obj.model_dump()
84
-
85
- elif is_dataclass(obj):
86
- return {
87
- f.name: cls.to_dict(getattr(obj, f.name))
88
-
89
- for f in fields(obj)
90
- }
91
-
92
- elif isinstance(obj, (list, tuple)):
93
- return [cls.to_dict(item) for item in obj]
94
-
95
- elif isinstance(obj, dict):
96
- return {key: cls.to_dict(value) for key, value in obj.items()}
97
-
98
- return obj
99
-
100
81
  # constructor
101
82
 
102
83
  def __init__(self):
@@ -104,9 +85,8 @@ class HTTPXChannel(Channel):
104
85
 
105
86
  self.timeout = 1000.0
106
87
  self.service_names: dict[Type, str] = {}
107
- self.serializers: dict[Callable, list[Callable]] = {}
108
- self.deserializers: dict[Callable, Callable] = {}
109
- self.optimize_serialization = True
88
+ self.serializers = CopyOnWriteCache[Callable, list[Callable]]()
89
+ self.deserializers = CopyOnWriteCache[Callable, Callable]()
110
90
 
111
91
  # inject
112
92
 
@@ -132,7 +112,7 @@ class HTTPXChannel(Channel):
132
112
 
133
113
  serializers = [get_serializer(type) for type in param_types]
134
114
 
135
- self.serializers[method] = serializers
115
+ self.serializers.put(method, serializers)
136
116
 
137
117
  return serializers
138
118
 
@@ -143,7 +123,7 @@ class HTTPXChannel(Channel):
143
123
 
144
124
  deserializer = get_deserializer(return_type)
145
125
 
146
- self.deserializers[method] = deserializer
126
+ self.deserializers.put(method, deserializer)
147
127
 
148
128
  return deserializer
149
129
 
@@ -196,32 +176,6 @@ class HTTPXChannel(Channel):
196
176
 
197
177
  headers["Authorization"] = f"Bearer {token}"
198
178
 
199
- ### TEST
200
-
201
- print_size = False
202
- if print_size:
203
- request = self.get_client().build_request(http_method, url, params=params, json=json, headers=headers, timeout=timeout, content=content)
204
- # Measure body
205
- body_size = len(request.content or b"")
206
-
207
- # Measure headers (as raw bytes)
208
- headers_size = sum(
209
- len(k.encode()) + len(v.encode()) + 4 # ": " + "\r\n"
210
- for k, v in request.headers.items()
211
- ) + 2 # final \r\n
212
-
213
- # Optional: estimate request line
214
- request_line = f"{request.method} {request.url.raw_path.decode()} HTTP/1.1\r\n".encode()
215
- request_line_size = len(request_line)
216
-
217
- # Total estimated size
218
- total_size = request_line_size + headers_size + body_size
219
-
220
- print(f"Request line: {request_line_size} bytes")
221
- print(f"Headers: {headers_size} bytes")
222
- print(f"Body: {body_size} bytes")
223
- print(f"Total request size: {total_size} bytes")
224
-
225
179
  try:
226
180
  response = self.get_client().request(http_method, url, params=params, json=json, headers=headers, timeout=timeout, content=content)
227
181
  response.raise_for_status()
@@ -317,16 +271,11 @@ class DispatchJSONChannel(HTTPXChannel):
317
271
  def invoke(self, invocation: DynamicProxy.Invocation):
318
272
  service_name = self.service_names[invocation.type] # map type to registered service name
319
273
 
320
- request : dict = {
321
- "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}"
322
- #"args": invocation.args
274
+ request = {
275
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
276
+ "args": self.serialize_args(invocation)
323
277
  }
324
278
 
325
- if self.optimize_serialization:
326
- request["args"] = self.serialize_args(invocation)
327
- else:
328
- request["args"] = self.to_dict(invocation.args)
329
-
330
279
  try:
331
280
  http_result = self.request( "post", f"{self.get_url()}/invoke", json=request, timeout=self.timeout)
332
281
  result = http_result.json()
@@ -343,15 +292,11 @@ class DispatchJSONChannel(HTTPXChannel):
343
292
 
344
293
  async def invoke_async(self, invocation: DynamicProxy.Invocation):
345
294
  service_name = self.service_names[invocation.type] # map type to registered service name
346
- request : dict = {
347
- "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}"
295
+ request = {
296
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
297
+ "args": self.serialize_args(invocation)
348
298
  }
349
299
 
350
- if self.optimize_serialization:
351
- request["args"] = self.serialize_args(invocation)
352
- else:
353
- request["args"] = self.to_dict(invocation.args)
354
-
355
300
  try:
356
301
  data = await self.request_async("post", f"{self.get_url()}/invoke", json=request, timeout=self.timeout)
357
302
  result = data.json()
@@ -387,15 +332,11 @@ class DispatchMSPackChannel(HTTPXChannel):
387
332
 
388
333
  def invoke(self, invocation: DynamicProxy.Invocation):
389
334
  service_name = self.service_names[invocation.type] # map type to registered service name
390
- request: dict = {
391
- "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}"
335
+ request = {
336
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
337
+ "args": self.serialize_args(invocation)
392
338
  }
393
339
 
394
- if self.optimize_serialization:
395
- request["args"] = self.serialize_args(invocation)
396
- else:
397
- request["args"] = self.to_dict(invocation.args)
398
-
399
340
  try:
400
341
  packed = msgpack.packb(request, use_bin_type=True)
401
342
 
@@ -444,15 +385,11 @@ class DispatchMSPackChannel(HTTPXChannel):
444
385
 
445
386
  async def invoke_async(self, invocation: DynamicProxy.Invocation):
446
387
  service_name = self.service_names[invocation.type] # map type to registered service name
447
- request: dict = {
448
- "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}"
388
+ request = {
389
+ "method": f"{self.component_descriptor.name}:{service_name}:{invocation.method.__name__}",
390
+ "args": self.serialize_args(invocation)
449
391
  }
450
392
 
451
- if self.optimize_serialization:
452
- request["args"] = self.serialize_args(invocation)
453
- else:
454
- request["args"] = self.to_dict(invocation.args)
455
-
456
393
  try:
457
394
  packed = msgpack.packb(request, use_bin_type=True)
458
395
 
aspyx_service/protobuf.py CHANGED
@@ -4,10 +4,11 @@ Protobuf channel and utilities
4
4
  from __future__ import annotations
5
5
 
6
6
  import inspect
7
+ import logging
7
8
  import threading
8
9
  from dataclasses import is_dataclass, fields as dc_fields
9
10
  from typing import Type, get_type_hints, Callable, Tuple, get_origin, get_args, List, Dict, Any, Union, Sequence, \
10
- Optional
11
+ Optional, cast
11
12
 
12
13
  import httpx
13
14
  from google.protobuf.message_factory import GetMessageClass
@@ -17,12 +18,13 @@ from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
17
18
  from google.protobuf.descriptor_pool import DescriptorPool
18
19
  from google.protobuf.message import Message
19
20
  from google.protobuf.descriptor import FieldDescriptor, Descriptor
21
+ from starlette.responses import PlainTextResponse
20
22
 
21
- from aspyx.di import injectable
23
+ from aspyx.di import injectable, Environment
22
24
  from aspyx.reflection import DynamicProxy, TypeDescriptor
23
- from aspyx.util import CopyOnWriteCache
25
+ from aspyx.util import CopyOnWriteCache, StringBuilder
24
26
 
25
- from .service import channel, ServiceException
27
+ from .service import channel, ServiceException, Server, ComponentDescriptor
26
28
  from .channels import HTTPXChannel
27
29
  from .service import ServiceManager, ServiceCommunicationException, AuthorizationException, RemoteServiceException
28
30
 
@@ -53,6 +55,11 @@ def defaults_dict(model_cls: Type[BaseModel]) -> dict[str, Any]:
53
55
  return result
54
56
 
55
57
  class ProtobufBuilder:
58
+ """
59
+ used to infer protobuf services and messages given component and service structures.
60
+ """
61
+ logger = logging.getLogger("aspyx.service.protobuf") #
62
+
56
63
  # slots
57
64
 
58
65
  __slots__ = [
@@ -72,11 +79,11 @@ class ProtobufBuilder:
72
79
 
73
80
  @classmethod
74
81
  def get_request_message_name(cls, type: Type, method: Callable) -> str:
75
- return cls.get_message_name(type, f"{method.__name__}Request")
82
+ return cls.get_message_name(type, f"_{method.__name__}_Request")
76
83
 
77
84
  @classmethod
78
85
  def get_response_message_name(cls, type: Type, method: Callable) -> str:
79
- return cls.get_message_name(type, f"{method.__name__}Response")
86
+ return cls.get_message_name(type, f"_{method.__name__}_Response")
80
87
 
81
88
  # local classes
82
89
 
@@ -90,7 +97,7 @@ class ProtobufBuilder:
90
97
  self.file_desc_proto.name = f"{self.name}.proto"
91
98
  self.file_desc_proto.package = self.name
92
99
  self.types : dict[Type, Any] = {}
93
- self.finalized = False
100
+ self.sealed = False
94
101
  self.lock = threading.RLock()
95
102
 
96
103
  # public
@@ -107,12 +114,16 @@ class ProtobufBuilder:
107
114
  raise TypeError("Expected a dataclass or Pydantic model class.")
108
115
 
109
116
  def add_message(self, cls: Type) -> str:
110
- if self.finalized:
117
+ if self.sealed:
111
118
  raise ServiceException(f"module {self.name} is already sealed")
112
119
 
120
+
121
+
113
122
  name = cls.__name__
114
123
  full_name = f"{self.name}.{name}"
115
124
 
125
+ ProtobufBuilder.logger.debug(f"adding message %s", full_name)
126
+
116
127
  # Check if a message type is already defined
117
128
 
118
129
  if any(m.name == name for m in self.file_desc_proto.message_type):
@@ -154,12 +165,14 @@ class ProtobufBuilder:
154
165
  return self.types[type]
155
166
 
156
167
  def build_request_message(self, method: TypeDescriptor.MethodDescriptor, request_name: str):
157
- if self.finalized:
168
+ if self.sealed:
158
169
  raise ServiceException(f"module {self.name} is already sealed")
159
170
 
160
171
  request_msg = descriptor_pb2.DescriptorProto() # type: ignore
161
172
  request_msg.name = request_name.split(".")[-1]
162
173
 
174
+ ProtobufBuilder.logger.debug(f"adding request message %s", request_msg.name)
175
+
163
176
  # loop over parameters
164
177
 
165
178
  field_index = 1
@@ -182,12 +195,14 @@ class ProtobufBuilder:
182
195
  self.file_desc_proto.message_type.add().CopyFrom(request_msg)
183
196
 
184
197
  def build_response_message(self, method: TypeDescriptor.MethodDescriptor, response_name: str):
185
- if self.finalized:
198
+ if self.sealed:
186
199
  raise ServiceException(f"module {self.name} is already sealed")
187
200
 
188
201
  response_msg = descriptor_pb2.DescriptorProto() # type: ignore
189
202
  response_msg.name = response_name.split(".")[-1]
190
203
 
204
+ ProtobufBuilder.logger.debug(f"adding response message %s", response_msg.name)
205
+
191
206
  # return
192
207
 
193
208
  return_type = method.return_type
@@ -218,13 +233,13 @@ class ProtobufBuilder:
218
233
  self.file_desc_proto.message_type.add().CopyFrom(response_msg)
219
234
 
220
235
  def build_service_method(self, service_desc: descriptor_pb2.ServiceDescriptorProto, service_type: TypeDescriptor, method: TypeDescriptor.MethodDescriptor):
221
- name = f"{service_type.cls.__name__}{method.get_name()}"
236
+ name = f"{service_type.cls.__name__}_{method.get_name()}"
222
237
  package = self.name
223
238
 
224
239
  method_desc = descriptor_pb2.MethodDescriptorProto()
225
240
 
226
- request_name = f".{package}.{name}Request"
227
- response_name = f".{package}.{name}Response"
241
+ request_name = f".{package}.{name}_Request"
242
+ response_name = f".{package}.{name}_Response"
228
243
 
229
244
  method_desc.name = method.get_name()
230
245
  method_desc.input_type = request_name
@@ -240,12 +255,14 @@ class ProtobufBuilder:
240
255
  service_desc.method.add().CopyFrom(method_desc)
241
256
 
242
257
  def add_service(self, service_type: TypeDescriptor):
243
- if self.finalized:
258
+ if self.sealed:
244
259
  raise ServiceException(f"module {self.name} is already sealed")
245
260
 
246
261
  service_desc = descriptor_pb2.ServiceDescriptorProto() # type: ignore
247
262
  service_desc.name = service_type.cls.__name__
248
263
 
264
+ ProtobufBuilder.logger.debug(f"add service %s", service_desc.name)
265
+
249
266
  # check methods
250
267
 
251
268
  for method in service_type.get_methods():
@@ -255,17 +272,16 @@ class ProtobufBuilder:
255
272
 
256
273
  self.file_desc_proto.service.add().CopyFrom(service_desc)
257
274
 
258
- def finalize(self, builder: ProtobufBuilder):
259
- if not self.finalized:
260
- self.finalized = True
275
+ def seal(self, builder: ProtobufBuilder):
276
+ if not self.sealed:
277
+ ProtobufBuilder.logger.debug(f"create protobuf {self.file_desc_proto.name}")
261
278
 
262
- #for m in self.file_desc_proto.message_type:
263
- # print(m)
279
+ self.sealed = True
264
280
 
265
281
  # add dependency first
266
282
 
267
283
  for dependency in self.file_desc_proto.dependency:
268
- builder.modules[dependency].finalize(builder)
284
+ builder.modules[dependency].seal(builder)
269
285
 
270
286
  builder.pool.Add(self.file_desc_proto)
271
287
 
@@ -340,7 +356,6 @@ class ProtobufBuilder:
340
356
 
341
357
  def get_message_type(self, full_name: str):
342
358
  return GetMessageClass(self.pool.FindMessageTypeByName(full_name))
343
- #return self.factory.GetPrototype(self.pool.FindMessageTypeByName(full_name))
344
359
 
345
360
  def get_request_message(self, type: Type, method: Callable):
346
361
  return self.get_message_type(self.get_request_message_name(type, method))
@@ -364,11 +379,7 @@ class ProtobufBuilder:
364
379
  # public
365
380
 
366
381
  #@synchronized()
367
- def check(self, service_type: Type):
368
- descriptor = getattr(service_type, "__descriptor__")
369
-
370
- component_descriptor = descriptor.component_descriptor
371
-
382
+ def prepare_component(self, component_descriptor: ComponentDescriptor):
372
383
  with self.lock:
373
384
  if component_descriptor not in self.components:
374
385
  for service in component_descriptor.services:
@@ -377,7 +388,7 @@ class ProtobufBuilder:
377
388
  # finalize
378
389
 
379
390
  for module in self.modules.values():
380
- module.finalize(self)
391
+ module.seal(self)
381
392
 
382
393
  # done
383
394
 
@@ -799,54 +810,41 @@ class ProtobufManager(ProtobufBuilder):
799
810
  # public
800
811
 
801
812
  def create_serializer(self, type: Type, method: Callable) -> ProtobufManager.MethodSerializer:
802
- # is it cached?
803
-
804
- serializer = self.serializer_cache.get(method)
805
- if serializer is None:
806
- self.check(type) # make sure all messages are created
807
-
808
- serializer = ProtobufManager.MethodSerializer(self, self.get_request_message(type, method)).args(method)
809
-
810
- self.serializer_cache.put(method, serializer)
811
-
812
- return serializer
813
+ return self.serializer_cache.get(method, lambda m: ProtobufManager.MethodSerializer(self, self.get_request_message(type, m)).args(m) )
813
814
 
814
815
  def create_deserializer(self, descriptor: Descriptor, method: Callable) -> ProtobufManager.MethodDeserializer:
815
- # is it cached?
816
-
817
- deserializer = self.deserializer_cache.get(descriptor)
818
- if deserializer is None:
819
- deserializer = ProtobufManager.MethodDeserializer(self, descriptor).args(method)
820
-
821
- self.deserializer_cache.put(descriptor, deserializer)
822
-
823
- return deserializer
816
+ return self.deserializer_cache.get(descriptor, lambda d: ProtobufManager.MethodDeserializer(self, d).args(method))
824
817
 
825
818
  def create_result_serializer(self, descriptor: Descriptor, method: Callable) -> ProtobufManager.MethodSerializer:
826
- # is it cached?
819
+ return self.result_serializer_cache.get(descriptor, lambda d: ProtobufManager.MethodSerializer(self, d).result(method))
827
820
 
828
- serializer = self.result_serializer_cache.get(descriptor)
829
- if serializer is None:
830
- serializer = ProtobufManager.MethodSerializer(self, descriptor).result(method)
821
+ def create_result_deserializer(self, descriptor: Descriptor, method: Callable) -> ProtobufManager.MethodDeserializer:
822
+ return self.result_deserializer_cache.get(descriptor, lambda d: ProtobufManager.MethodDeserializer(self, d).result(method))
831
823
 
832
- self.result_serializer_cache.put(descriptor, serializer)
824
+ def report(self) -> str:
825
+ builder = StringBuilder()
826
+ for module in self.modules.values():
827
+ builder.append(ProtobufDumper.dump_proto(module.file_desc_proto))
833
828
 
834
- return serializer
829
+ return str(builder)
835
830
 
836
- def create_result_deserializer(self, descriptor: Descriptor,
837
- method: Callable) -> ProtobufManager.MethodDeserializer:
838
- # is it cached?
831
+ @channel("dispatch-protobuf")
832
+ class ProtobufChannel(HTTPXChannel):
833
+ """
834
+ channel, encoding requests and responses with protobuf
835
+ """
836
+ # class methods
839
837
 
840
- deserializer = self.result_deserializer_cache.get(descriptor)
841
- if deserializer is None:
842
- deserializer = ProtobufManager.MethodDeserializer(self, descriptor).result(method)
838
+ @classmethod
839
+ def prepare(cls, server: Server, component_descriptor: ComponentDescriptor):
840
+ protobuf_manager = server.get(ProtobufManager)
841
+ protobuf_manager.prepare_component(component_descriptor)
843
842
 
844
- self.result_deserializer_cache.put(descriptor, deserializer)
843
+ def report_protobuf():
844
+ return protobuf_manager.report()
845
845
 
846
- return deserializer
846
+ server.add_route(path="/report-protobuf", endpoint=report_protobuf, methods=["GET"], response_class=PlainTextResponse)
847
847
 
848
- @channel("dispatch-protobuf")
849
- class ProtobufChannel(HTTPXChannel):
850
848
  # local classes
851
849
 
852
850
  class Call:
@@ -896,6 +894,12 @@ class ProtobufChannel(HTTPXChannel):
896
894
  self.protobuf_manager = protobuf_manager
897
895
  self.cache = CopyOnWriteCache[Callable, ProtobufChannel.Call]()
898
896
 
897
+ # make sure, all protobuf messages are created
898
+
899
+ for descriptor in manager.descriptors.values():
900
+ if descriptor.is_component():
901
+ protobuf_manager.prepare_component(cast(ComponentDescriptor, descriptor))
902
+
899
903
  # internal
900
904
 
901
905
  def get_call(self, type: Type, method: Callable) -> ProtobufChannel.Call:
@@ -973,6 +977,7 @@ class ProtobufDumper:
973
977
  lines.append('') # blank line
974
978
 
975
979
  # Options (basic)
980
+
976
981
  for opt in fd.options.ListFields() if fd.HasField('options') else []:
977
982
  # Just a simple string option dump; for complex options you'd need more logic
978
983
  name = opt[0].name
@@ -982,6 +987,7 @@ class ProtobufDumper:
982
987
  lines.append('')
983
988
 
984
989
  # Enums
990
+
985
991
  def dump_enum(enum: descriptor_pb2.EnumDescriptorProto, indent=''):
986
992
  enum_lines = [f"{indent}enum {enum.name} {{"]
987
993
  for value in enum.value:
@@ -990,6 +996,7 @@ class ProtobufDumper:
990
996
  return enum_lines
991
997
 
992
998
  # Messages (recursive)
999
+
993
1000
  def dump_message(msg: descriptor_pb2.DescriptorProto, indent=''):
994
1001
  msg_lines = [f"{indent}message {msg.name} {{"]
995
1002
  # Nested enums
@@ -997,6 +1004,7 @@ class ProtobufDumper:
997
1004
  msg_lines.extend(dump_enum(enum, indent + ' '))
998
1005
 
999
1006
  # Nested messages
1007
+
1000
1008
  for nested in msg.nested_type:
1001
1009
  # skip map entry messages (synthetic)
1002
1010
  if nested.options.map_entry:
@@ -1004,6 +1012,7 @@ class ProtobufDumper:
1004
1012
  msg_lines.extend(dump_message(nested, indent + ' '))
1005
1013
 
1006
1014
  # Fields
1015
+
1007
1016
  for field in msg.field:
1008
1017
  label = {
1009
1018
  1: 'optional',
@@ -1054,6 +1063,7 @@ class ProtobufDumper:
1054
1063
  return msg_lines
1055
1064
 
1056
1065
  # Services
1066
+
1057
1067
  def dump_service(svc: descriptor_pb2.ServiceDescriptorProto, indent=''):
1058
1068
  svc_lines = [f"{indent}service {svc.name} {{"]
1059
1069
  for method in svc.method:
aspyx_service/server.py CHANGED
@@ -14,14 +14,14 @@ import msgpack
14
14
  import uvicorn
15
15
 
16
16
  from fastapi import FastAPI, APIRouter, Request as HttpRequest, Response as HttpResponse, HTTPException
17
-
17
+ from fastapi.datastructures import DefaultPlaceholder, Default
18
18
 
19
19
  from fastapi.responses import JSONResponse
20
20
  from starlette.middleware.base import BaseHTTPMiddleware
21
21
 
22
22
  from aspyx.di import Environment, on_init, inject_environment, on_destroy
23
23
  from aspyx.reflection import TypeDescriptor, Decorators
24
- from aspyx.util import get_deserializer, get_serializer
24
+ from aspyx.util import get_deserializer, get_serializer, CopyOnWriteCache
25
25
 
26
26
  from .protobuf import ProtobufManager
27
27
  from .service import ComponentRegistry, ServiceDescriptor
@@ -192,7 +192,7 @@ class FastAPIServer(Server):
192
192
 
193
193
  # cache
194
194
 
195
- self.deserializers: dict[str, list[Callable]] = {}
195
+ self.deserializers = CopyOnWriteCache[str, list[Callable]]()
196
196
 
197
197
  # that's the overall dispatcher
198
198
 
@@ -328,18 +328,16 @@ class FastAPIServer(Server):
328
328
  self.thread.start()
329
329
 
330
330
  def get_deserializers(self, service: Type, method):
331
- deserializers = self.deserializers.get(method, None)
331
+ deserializers = self.deserializers.get(method)
332
332
  if deserializers is None:
333
333
  descriptor = TypeDescriptor.for_type(service).get_method(method.__name__)
334
334
 
335
335
  deserializers = [get_deserializer(type) for type in descriptor.param_types]
336
- self.deserializers[method] = deserializers
336
+ self.deserializers.put(method, deserializers)
337
337
 
338
338
  return deserializers
339
339
 
340
340
  def deserialize_args(self, args: list[Any], type: Type, method: Callable) -> list:
341
- #args = list(request.args)
342
-
343
341
  deserializers = self.get_deserializers(type, method)
344
342
 
345
343
  for i, arg in enumerate(args):
@@ -354,98 +352,93 @@ class FastAPIServer(Server):
354
352
  service_name = parts[1]
355
353
  method_name = parts[2]
356
354
 
357
- service_descriptor : ServiceDescriptor = typing.cast(ServiceDescriptor, ServiceManager.descriptors_by_name[service_name])
355
+ service_descriptor = typing.cast(ServiceDescriptor, ServiceManager.descriptors_by_name[service_name])
358
356
  service = self.service_manager.get_service(service_descriptor.type, preferred_channel="local")
359
357
 
360
358
  return service_descriptor, getattr(service, method_name)
361
359
 
362
- async def invoke(self, http_request: HttpRequest):
363
- content_type = http_request.headers.get("content-type", "")
364
- response_name = ""
360
+ async def invoke_json(self, http_request: HttpRequest):
361
+ data = await http_request.json()
362
+ service_descriptor, method = self.get_descriptor_and_method(data["method"])
363
+ args = self.deserialize_args(data["args"], service_descriptor.type, method)
365
364
 
366
- service_descriptor : ServiceDescriptor
367
- method : Callable
368
- args : list[Any]
365
+ try:
366
+ result = await self.dispatch(service_descriptor, method, args)
369
367
 
370
- content = "json"
371
- if "application/msgpack" in content_type:
372
- content = "msgpack"
373
- data = msgpack.unpackb(await http_request.body(), raw=False)
374
- service_descriptor, method = self.get_descriptor_and_method(data["method"])
375
- args = self.deserialize_args(data["args"], service_descriptor.type, method)
368
+ return Response(result=result, exception=None).model_dump()
369
+ except Exception as e:
370
+ return Response(result=None, exception=str(e)).model_dump()
376
371
 
377
- elif "application/json" in content_type:
378
- data = await http_request.json()
379
- service_descriptor, method = self.get_descriptor_and_method(data["method"])
380
- args = self.deserialize_args(data["args"], service_descriptor.type, method)
372
+ async def invoke_msgpack(self, http_request: HttpRequest):
373
+ data = msgpack.unpackb(await http_request.body(), raw=False)
374
+ service_descriptor, method = self.get_descriptor_and_method(data["method"])
375
+ args = self.deserialize_args(data["args"], service_descriptor.type, method)
381
376
 
382
- elif "application/x-protobuf" in content_type:
383
- content = "protobuf"
384
- service_descriptor, method = self.get_descriptor_and_method(http_request.headers.get("x-rpc-method") )
385
- self.protobuf_manager.check(service_descriptor.type)
386
- data = await http_request.body()
377
+ try:
378
+ response = Response(result=await self.dispatch(service_descriptor, method, args), exception=None).model_dump()
379
+ except Exception as e:
380
+ response = Response(result=None, exception=str(e)).model_dump()
387
381
 
388
- request_name = ProtobufManager.get_message_name(service_descriptor.type, f"{method.__name__}Request")
382
+ return HttpResponse(
383
+ content=msgpack.packb(response, use_bin_type=True),
384
+ media_type="application/msgpack"
385
+ )
389
386
 
390
- request = self.protobuf_manager.get_message_type(request_name)()
391
- request.ParseFromString(data)
387
+ async def invoke_protobuf(self, http_request: HttpRequest):
388
+ service_descriptor, method = self.get_descriptor_and_method(http_request.headers.get("x-rpc-method"))
392
389
 
393
- args = self.protobuf_manager.create_deserializer(request.DESCRIPTOR, method).deserialize(request)
390
+ data = await http_request.body()
394
391
 
395
- else:
396
- return HttpResponse(
397
- content="Unsupported Content-Type",
398
- status_code=415,
399
- media_type="text/plain"
400
- )
392
+ # create message
401
393
 
402
- request = data
394
+ request = self.protobuf_manager.get_request_message(service_descriptor.type, method)()
395
+ request.ParseFromString(data)
403
396
 
404
- if content == "json":
405
- try:
406
- result = await self.dispatch(service_descriptor, method, args)
397
+ # and parse
407
398
 
408
- return Response(result=result, exception=None).model_dump()
409
- except Exception as e:
410
- return Response(result=None, exception=str(e)).model_dump()
399
+ args = self.protobuf_manager.create_deserializer(request.DESCRIPTOR, method).deserialize(request)
411
400
 
412
- elif content == "protobuf":
413
- response_name = ProtobufManager.get_message_name(service_descriptor.type, f"{method.__name__}Response")
414
- response_type = self.protobuf_manager.get_message_type(response_name)
401
+ response_type = self.protobuf_manager.get_response_message(service_descriptor.type,method)
402
+ result_serializer = self.protobuf_manager.create_result_serializer(response_type, method)
403
+ try:
404
+ result = await self.dispatch(service_descriptor, method, args)
415
405
 
416
- result_serializer = self.protobuf_manager.create_result_serializer(response_type, method)
406
+ result_message = result_serializer.serialize_result(result, None)
417
407
 
418
- try:
419
- result = await self.dispatch(service_descriptor, method, args)
408
+ return HttpResponse(
409
+ content=result_message.SerializeToString(),
410
+ media_type="application/x-protobuf"
411
+ )
412
+
413
+ except Exception as e:
414
+ result_message = result_serializer.serialize_result(None, str(e))
415
+
416
+ return HttpResponse(
417
+ content=result_message.SerializeToString(),
418
+ media_type="application/x-protobuf"
419
+ )
420
420
 
421
- result_message = result_serializer.serialize_result(result, None)
421
+ async def invoke(self, http_request: HttpRequest):
422
+ content_type = http_request.headers.get("content-type", "")
422
423
 
423
- return HttpResponse(
424
- content=result_message.SerializeToString(),
425
- media_type="application/x-protobuf"
426
- )
424
+ if content_type == "application/x-protobuf":
425
+ return await self.invoke_protobuf(http_request)
427
426
 
428
- except Exception as e:
429
- result_message = result_serializer.serialize_result(None, str(e))
427
+ elif content_type == "application/msgpack":
428
+ return await self.invoke_msgpack(http_request)
430
429
 
431
- return HttpResponse(
432
- content=result_message.SerializeToString(),
433
- media_type="application/x-protobuf"
434
- )
430
+ elif content_type == "application/json":
431
+ return await self.invoke_json(http_request)
435
432
 
436
433
  else:
437
- try:
438
- response = Response(result=await self.dispatch(service_descriptor, method, args), exception=None).model_dump()
439
- except Exception as e:
440
- response = Response(result=None, exception=str(e)).model_dump()
441
-
442
434
  return HttpResponse(
443
- content=msgpack.packb(response, use_bin_type=True),
444
- media_type="application/msgpack"
435
+ content="Unsupported Content-Type",
436
+ status_code=415,
437
+ media_type="text/plain"
445
438
  )
446
439
 
447
440
  async def dispatch(self, service_descriptor: ServiceDescriptor, method: Callable, args: list[Any]) :
448
- ServiceManager.logger.debug("dispatch request %s.%s", service_descriptor, method.__name__)
441
+ #ServiceManager.logger.debug("dispatch request %s.%s", service_descriptor, method.__name__)
449
442
 
450
443
  if inspect.iscoroutinefunction(method):
451
444
  return await method(*args)
@@ -454,8 +447,8 @@ class FastAPIServer(Server):
454
447
 
455
448
  # override
456
449
 
457
- def route(self, url: str, callable: Callable):
458
- self.router.get(url)(callable)
450
+ def add_route(self, path: str, endpoint: Callable, methods: list[str], response_class: typing.Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse)):
451
+ self.router.add_api_route(path=path, endpoint=endpoint, methods=methods, response_class=response_class)
459
452
 
460
453
  def route_health(self, url: str, callable: Callable):
461
454
  async def get_health_response():
aspyx_service/service.py CHANGED
@@ -7,12 +7,17 @@ import re
7
7
  import socket
8
8
  import logging
9
9
  import threading
10
+ import typing
10
11
  from abc import abstractmethod, ABC
11
12
  from dataclasses import dataclass
12
13
  from enum import Enum, auto
13
14
 
14
15
  from typing import Type, TypeVar, Generic, Callable, Optional, cast
15
16
 
17
+ from fastapi.datastructures import DefaultPlaceholder, Default
18
+ from httpx import Response
19
+ from starlette.responses import JSONResponse, PlainTextResponse
20
+
16
21
  from aspyx.di import injectable, Environment, Providers, ClassInstanceProvider, inject_environment, order, \
17
22
  Lifecycle, LifecycleCallable, InstanceProvider
18
23
  from aspyx.di.aop.aop import ClassAspectTarget
@@ -42,7 +47,7 @@ class ComponentStatus(Enum):
42
47
 
43
48
  class Server(ABC):
44
49
  """
45
- A server is a central entity that boots a main module and initializes the ServiceManager.
50
+ A server is a central class that boots a main module and initializes the ServiceManager.
46
51
  It also is the place where http servers get initialized.
47
52
  """
48
53
  port = 0
@@ -51,6 +56,7 @@ class Server(ABC):
51
56
 
52
57
  def __init__(self):
53
58
  self.environment : Optional[Environment] = None
59
+ self.instance = self
54
60
 
55
61
  # public
56
62
 
@@ -58,7 +64,7 @@ class Server(ABC):
58
64
  return self.environment.get(type)
59
65
 
60
66
  @abstractmethod
61
- def route(self, url : str, callable: Callable):
67
+ def add_route(self, path : str, endpoint : Callable, methods : list[str], response_class : typing.Union[Type[Response], DefaultPlaceholder] = Default(JSONResponse)):
62
68
  pass
63
69
 
64
70
  @abstractmethod
@@ -573,7 +579,7 @@ class ChannelFactory:
573
579
  # constructor
574
580
 
575
581
  def __init__(self):
576
- self.environment = None
582
+ self.environment : Optional[Environment] = None
577
583
 
578
584
  # lifecycle hooks
579
585
 
@@ -583,6 +589,12 @@ class ChannelFactory:
583
589
 
584
590
  # public
585
591
 
592
+ def prepare_channel(self, server: Server, channel: str, component_descriptor: ComponentDescriptor):
593
+ type = self.factories[channel]
594
+
595
+ if getattr(type, "prepare", None) is not None:
596
+ getattr(type, "prepare", None)(server, component_descriptor)
597
+
586
598
  def make(self, name: str, descriptor: ComponentDescriptor, address: ChannelInstances) -> Channel:
587
599
  ServiceManager.logger.info("create channel %s: %s", name, self.factories.get(name).__name__)
588
600
 
@@ -665,9 +677,9 @@ class ServiceManager:
665
677
 
666
678
  # constructor
667
679
 
668
- def __init__(self, component_registry: ComponentRegistry, channel_manager: ChannelFactory):
680
+ def __init__(self, component_registry: ComponentRegistry, channel_factory: ChannelFactory):
669
681
  self.component_registry = component_registry
670
- self.channel_manager = channel_manager
682
+ self.channel_factory = channel_factory
671
683
  self.environment : Optional[Environment] = None
672
684
  self.preferred_channel = ""
673
685
 
@@ -691,7 +703,7 @@ class ServiceManager:
691
703
  def get_instance(self, type: Type[T]) -> T:
692
704
  instance = self.instances.get(type)
693
705
  if instance is None:
694
- ServiceManager.logger.info("create implementation %s", type.__name__)
706
+ ServiceManager.logger.debug("create implementation %s", type.__name__)
695
707
 
696
708
  instance = self.environment.get(type)
697
709
  self.instances[type] = instance
@@ -700,9 +712,16 @@ class ServiceManager:
700
712
 
701
713
  # lifecycle
702
714
 
715
+
703
716
  def startup(self, server: Server) -> None:
704
717
  self.logger.info("startup on port %s", server.port)
705
718
 
719
+ # add some introspection endpoints
720
+
721
+ server.add_route(path="/report", endpoint=lambda: self.report(), methods=["GET"], response_class=PlainTextResponse)
722
+
723
+ # boot components
724
+
706
725
  for descriptor in self.descriptors.values():
707
726
  if descriptor.is_component():
708
727
  # register local address
@@ -725,8 +744,6 @@ class ServiceManager:
725
744
 
726
745
  self.component_registry.register(descriptor.get_component_descriptor(), [ChannelAddress("local", "")])
727
746
 
728
- #health_name = next((decorator.args[0] for decorator in Decorators.get(descriptor.type) if decorator.decorator is health), None)
729
-
730
747
  # startup
731
748
 
732
749
  instance.startup()
@@ -738,9 +755,10 @@ class ServiceManager:
738
755
 
739
756
  # register addresses
740
757
 
741
- self.component_registry.register(descriptor.get_component_descriptor(), descriptor.addresses)
758
+ for address in descriptor.addresses:
759
+ self.channel_factory.prepare_channel(server, address.channel, descriptor.get_component_descriptor())
742
760
 
743
- print(self.report())
761
+ self.component_registry.register(descriptor.get_component_descriptor(), descriptor.addresses)
744
762
 
745
763
  def shutdown(self):
746
764
  self.logger.info("shutdown")
@@ -823,7 +841,7 @@ class ServiceManager:
823
841
  if channel_instance is None:
824
842
  # create channel
825
843
 
826
- channel_instance = self.channel_manager.make(address.channel, component_descriptor, address)
844
+ channel_instance = self.channel_factory.make(address.channel, component_descriptor, address)
827
845
 
828
846
  # cache
829
847
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aspyx_service
3
- Version: 0.11.0
3
+ Version: 0.11.1
4
4
  Summary: Aspyx Service framework
5
5
  Author-email: Andreas Ernst <andreas.ernst7@gmail.com>
6
6
  License: MIT License
@@ -114,9 +114,6 @@ After booting the DI infrastructure with a main module we could already call a s
114
114
  ```python
115
115
  @module(imports=[ServiceModule])
116
116
  class Module:
117
- def __init__(self):
118
- pass
119
-
120
117
  @create()
121
118
  def create_registry(self) -> ConsulComponentRegistry:
122
119
  return ConsulComponentRegistry(Server.port, Consul(host="localhost", port=8500)) # a consul based registry!
@@ -137,11 +134,6 @@ As we can also host implementations, lets look at this side as well:
137
134
  ```python
138
135
  @implementation()
139
136
  class TestComponentImpl(AbstractComponent, TestComponent):
140
- # constructor
141
-
142
- def __init__(self):
143
- super().__init__()
144
-
145
137
  # implement Component
146
138
 
147
139
  def get_addresses(self, port: int) -> list[ChannelAddress]:
@@ -149,9 +141,6 @@ class TestComponentImpl(AbstractComponent, TestComponent):
149
141
 
150
142
  @implementation()
151
143
  class TestServiceImpl(TestService):
152
- def __init__(self):
153
- pass
154
-
155
144
  def hello(self, message: str) -> str:
156
145
  return f"hello {message}"
157
146
  ```
@@ -263,8 +252,7 @@ Service implementations implement the corresponding interface and are decorated
263
252
  ```python
264
253
  @implementation()
265
254
  class TestServiceImpl(TestService):
266
- def __init__(self):
267
- pass
255
+ pass
268
256
  ```
269
257
 
270
258
  The constructor is required since the instances are managed by the DI framework.
@@ -274,11 +262,6 @@ Component implementations derive from the interface and the abstract base class
274
262
  ```python
275
263
  @implementation()
276
264
  class TestComponentImpl(AbstractComponent, TestComponent):
277
- # constructor
278
-
279
- def __init__(self):
280
- super().__init__()
281
-
282
265
  # implement Component
283
266
 
284
267
  def get_addresses(self, port: int) -> list[ChannelAddress]:
@@ -313,7 +296,9 @@ For this purpose injectable classes can be decorated with `@health_checks()` tha
313
296
  @injectable()
314
297
  class Checks:
315
298
  def __init__(self):
316
- pass
299
+ pass # normally, we would inject stuff here
300
+
301
+ # checks
317
302
 
318
303
  @health_check(fail_if_slower_than=1)
319
304
  def check_performance(self, result: HealthCheckManager.Result):
@@ -382,6 +367,8 @@ Several channels are implemented:
382
367
  channel that posts generic `Request` objects via a `invoke` POST-call
383
368
  - `dispatch-msgpack`
384
369
  channel that posts generic `Request` objects via a `invoke` POST-call after packing the json with msgpack
370
+ - `dispatch-protobuf`
371
+ channel that posts parameters via a `invoke` POST-call after packing the arguments with protobuf
385
372
  - `rest`
386
373
  channel that executes regular rest-calls as defined by a couple of decorators.
387
374
 
@@ -401,14 +388,6 @@ To customize the behavior, an `around` advice can be implemented easily:
401
388
  ```python
402
389
  @advice
403
390
  class ChannelAdvice:
404
- def __init__(self):
405
- pass
406
-
407
- @advice
408
- class ChannelAdvice:
409
- def __init__(self):
410
- pass
411
-
412
391
  @around(methods().named("customize").of_type(Channel))
413
392
  def customize_channel(self, invocation: Invocation):
414
393
  channel = cast(Channel, invocation.args[0])
@@ -426,6 +405,7 @@ The avg response times - on a local server - where all below 1ms per call.
426
405
  - rest calls are the slowest ( about 0.7ms )
427
406
  - dispatching-json 20% faster
428
407
  - dispatching-msgpack 30% faster
408
+ - dispatching protobuf
429
409
 
430
410
  The biggest advantage of the dispatching flavors is, that you don't have to worry about the additional decorators!
431
411
 
@@ -456,6 +436,11 @@ Additional annotations are
456
436
  - `Body` the post body
457
437
  - `QueryParam`marked for query params
458
438
 
439
+ You can skip the annotations, assuming the following heuristic:
440
+
441
+ - if no body is marked it will pick the first parameter which is a dataclass or a pydantic model
442
+ - all parameters which are not in the path or equal to the body are assumed to be query params.
443
+
459
444
  ### Intercepting calls
460
445
 
461
446
  The client side HTTP calling is done with `httpx` instances of type `Httpx.Client` or `Httpx.AsyncClient`.
@@ -501,7 +486,7 @@ The required - `FastAPI` - infrastructure to expose those services requires:
501
486
  - and a final `boot` call with the root module, which will return an `Environment`
502
487
 
503
488
  ```python
504
- fast_api = FastAPI() # so you can run it with uvivorn from command-line
489
+ fast_api = FastAPI() # so you can run it with uvicorn from command-line
505
490
 
506
491
  @module(imports=[ServiceModule])
507
492
  class Module:
@@ -513,7 +498,7 @@ class Module:
513
498
  return FastAPIServer(fastapi, service_manager, component_registry)
514
499
 
515
500
 
516
- environment = FastAPIServer.boot(Moudle, host="0.0.0.0", port=8000)
501
+ environment = FastAPIServer.boot(Module, host="0.0.0.0", port=8000)
517
502
  ```
518
503
 
519
504
  This setup will also expose all service interfaces decorated with the corresponding http decorators!
@@ -560,6 +545,10 @@ class FancyChannel(Channel):
560
545
 
561
546
  - first release version
562
547
 
548
+ **0.11.0**
549
+
550
+ - added protobuf support
551
+
563
552
 
564
553
 
565
554
 
@@ -0,0 +1,14 @@
1
+ aspyx_service/__init__.py,sha256=Mzt6pBhME_qDij2timEZT0emTTXRues_xXu3muhk3Jc,2642
2
+ aspyx_service/authorization.py,sha256=0B1xb0WrRaj2rcGTHVUhh6i8aA0sy7BmpYA18xI9LQA,3833
3
+ aspyx_service/channels.py,sha256=ujJnyWijNNNsl_kJu39bFV2MDq8OjBGOs7XXDWdpx9w,15314
4
+ aspyx_service/healthcheck.py,sha256=XiQx1T0DP0kcCyK_sYBuE-JHs5N285HotLVycFCgzBU,5612
5
+ aspyx_service/protobuf.py,sha256=w2wZymTSObGhgevIRZJ9kRxiEel8f6BycMPQiTYNMzI,39595
6
+ aspyx_service/registries.py,sha256=bnTjKb40fbZXA52E2lDSEzCWI5_NBKZzQjc8ffufB5g,8039
7
+ aspyx_service/restchannel.py,sha256=aCpNCvv1eIRa9IJW8Os4bQQErZKrEeyID65QhnD9QJw,9134
8
+ aspyx_service/server.py,sha256=yI_croAT77PUh2_z1-EGklgzxjMvuUtHomckN3piaBo,15794
9
+ aspyx_service/service.py,sha256=gOX5rbOOLJ-cSIvxa_5lqo1DzcPmDoJCBbMP-ZYKYxs,27972
10
+ aspyx_service/session.py,sha256=HjGpnmwdislc8Ur6pQbSMi2K-lvTsb9_XyO80zupiF8,3713
11
+ aspyx_service-0.11.1.dist-info/METADATA,sha256=nYCeGXZYBRnaEIFYNJYgIY_b5p0qEIOHZI30hwEUwr4,18127
12
+ aspyx_service-0.11.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
+ aspyx_service-0.11.1.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
14
+ aspyx_service-0.11.1.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- aspyx_service/__init__.py,sha256=Mzt6pBhME_qDij2timEZT0emTTXRues_xXu3muhk3Jc,2642
2
- aspyx_service/authorization.py,sha256=0B1xb0WrRaj2rcGTHVUhh6i8aA0sy7BmpYA18xI9LQA,3833
3
- aspyx_service/channels.py,sha256=6R9WeC8JO-TI2FPtWBm7ad-jivn-gmkHoB7z6xosw6A,17423
4
- aspyx_service/healthcheck.py,sha256=XiQx1T0DP0kcCyK_sYBuE-JHs5N285HotLVycFCgzBU,5612
5
- aspyx_service/protobuf.py,sha256=OTOPRxpzPZEK0lX2BIH3R-FNnxiBtsWvypuyzMla5bU,39010
6
- aspyx_service/registries.py,sha256=bnTjKb40fbZXA52E2lDSEzCWI5_NBKZzQjc8ffufB5g,8039
7
- aspyx_service/restchannel.py,sha256=aCpNCvv1eIRa9IJW8Os4bQQErZKrEeyID65QhnD9QJw,9134
8
- aspyx_service/server.py,sha256=dl3UWawQRPNy7h9WzHQge3dF8nG_3oYWBVgjfN-iqQg,15891
9
- aspyx_service/service.py,sha256=0sFBpjRxEtMjRhwI1f51qsUCcTzbqoMx7gA3gZNge7A,27159
10
- aspyx_service/session.py,sha256=HjGpnmwdislc8Ur6pQbSMi2K-lvTsb9_XyO80zupiF8,3713
11
- aspyx_service-0.11.0.dist-info/METADATA,sha256=ff-Pa-1neQhtVGBX4ByuVLQ8ujH54AjND7unm1H8F2U,17978
12
- aspyx_service-0.11.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
13
- aspyx_service-0.11.0.dist-info/licenses/LICENSE,sha256=n4jfx_MNj7cBtPhhI7MCoB_K35cj1icP9yJ4Rh4vlvY,1070
14
- aspyx_service-0.11.0.dist-info/RECORD,,