asap-protocol 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,444 @@
1
+ """Handler registry for ASAP protocol payload processing.
2
+
3
+ This module provides a handler registry for dispatching ASAP envelopes
4
+ to appropriate handlers based on payload type.
5
+
6
+ The HandlerRegistry allows:
7
+ - Registration of handlers for specific payload types
8
+ - Dispatch of envelopes to registered handlers
9
+ - Discovery of registered payload types
10
+ - Structured logging for observability
11
+
12
+ Thread Safety:
13
+ All operations on HandlerRegistry are thread-safe. The registry uses
14
+ an internal RLock to protect concurrent access to the handler mapping.
15
+ This allows safe usage in multi-threaded environments.
16
+
17
+ Example:
18
+ >>> from asap.transport.handlers import HandlerRegistry, create_echo_handler
19
+ >>> from asap.models.envelope import Envelope
20
+ >>>
21
+ >>> # Create registry and register handler
22
+ >>> registry = HandlerRegistry()
23
+ >>> registry.register("task.request", create_echo_handler())
24
+ >>>
25
+ >>> # Dispatch envelope to handler
26
+ >>> response = registry.dispatch(envelope, manifest)
27
+ """
28
+
29
+ import asyncio
30
+ import inspect
31
+ import time
32
+ from collections.abc import Awaitable
33
+ from threading import RLock
34
+ from typing import Protocol
35
+
36
+ from asap.errors import ASAPError
37
+ from asap.models.entities import Manifest
38
+ from asap.models.enums import TaskStatus
39
+ from asap.models.envelope import Envelope
40
+ from asap.models.ids import generate_id
41
+ from asap.models.payloads import TaskRequest, TaskResponse
42
+ from asap.observability import get_logger
43
+
44
+ # Module logger
45
+ logger = get_logger(__name__)
46
+
47
+
48
+ class SyncHandler(Protocol):
49
+ """Protocol for synchronous handlers."""
50
+
51
+ def __call__(self, envelope: Envelope, manifest: Manifest) -> Envelope:
52
+ """Process envelope synchronously.
53
+
54
+ Args:
55
+ envelope: The incoming ASAP envelope to process
56
+ manifest: The server's manifest for context
57
+
58
+ Returns:
59
+ Response envelope to send back
60
+ """
61
+ ...
62
+
63
+
64
+ class AsyncHandler(Protocol):
65
+ """Protocol for asynchronous handlers."""
66
+
67
+ def __call__(self, envelope: Envelope, manifest: Manifest) -> Awaitable[Envelope]:
68
+ """Process envelope asynchronously.
69
+
70
+ Args:
71
+ envelope: The incoming ASAP envelope to process
72
+ manifest: The server's manifest for context
73
+
74
+ Returns:
75
+ Awaitable that resolves to response envelope
76
+ """
77
+ ...
78
+
79
+
80
+ # Type alias for handler functions (supports both sync and async)
81
+ Handler = SyncHandler | AsyncHandler
82
+ """Type alias for ASAP message handlers.
83
+
84
+ A handler is a callable that receives an Envelope and a Manifest,
85
+ and returns a response Envelope (sync) or an awaitable that resolves
86
+ to a response Envelope (async).
87
+
88
+ Args:
89
+ envelope: The incoming ASAP envelope to process
90
+ manifest: The server's manifest for context
91
+
92
+ Returns:
93
+ Response envelope to send back (sync) or awaitable (async)
94
+ """
95
+
96
+
97
+ class HandlerNotFoundError(ASAPError):
98
+ """Raised when no handler is registered for a payload type.
99
+
100
+ This error occurs when attempting to dispatch an envelope with
101
+ a payload_type that has no registered handler.
102
+
103
+ Attributes:
104
+ payload_type: The payload type that has no handler
105
+
106
+ Example:
107
+ >>> try:
108
+ ... raise HandlerNotFoundError("task.request")
109
+ ... except HandlerNotFoundError as exc:
110
+ ... exc.payload_type
111
+ 'task.request'
112
+ """
113
+
114
+ def __init__(self, payload_type: str) -> None:
115
+ """Initialize handler not found error.
116
+
117
+ Args:
118
+ payload_type: The payload type that has no handler
119
+ """
120
+ message = f"No handler registered for payload type: {payload_type}"
121
+ super().__init__(
122
+ code="asap:transport/handler_not_found",
123
+ message=message,
124
+ details={"payload_type": payload_type},
125
+ )
126
+ self.payload_type = payload_type
127
+
128
+
129
+ class HandlerRegistry:
130
+ """Registry for ASAP payload handlers.
131
+
132
+ HandlerRegistry manages the mapping between payload types and their
133
+ corresponding handlers. It provides methods for registration, dispatch,
134
+ and discovery of handlers.
135
+
136
+ Thread Safety:
137
+ All public methods are thread-safe. The registry uses an internal
138
+ RLock to protect concurrent access to the handler mapping. This
139
+ allows safe concurrent registration and dispatch operations from
140
+ multiple threads.
141
+
142
+ Attributes:
143
+ _handlers: Internal mapping of payload_type to handler function
144
+ _lock: Reentrant lock for thread-safe operations
145
+
146
+ Example:
147
+ >>> registry = HandlerRegistry()
148
+ >>> registry.register("task.request", my_handler)
149
+ >>> registry.has_handler("task.request")
150
+ True
151
+ >>> response = registry.dispatch(envelope, manifest)
152
+ """
153
+
154
+ def __init__(self) -> None:
155
+ """Initialize empty handler registry with thread-safe lock."""
156
+ self._handlers: dict[str, Handler] = {}
157
+ self._lock = RLock()
158
+
159
+ def register(self, payload_type: str, handler: Handler) -> None:
160
+ """Register a handler for a payload type.
161
+
162
+ If a handler is already registered for the payload type,
163
+ it will be replaced with the new handler.
164
+
165
+ This method is thread-safe.
166
+
167
+ Args:
168
+ payload_type: The payload type to handle (e.g., "task.request")
169
+ handler: Callable that processes envelopes of this type
170
+
171
+ Example:
172
+ >>> registry = HandlerRegistry()
173
+ >>> registry.register("task.request", create_echo_handler())
174
+ """
175
+ with self._lock:
176
+ is_override = payload_type in self._handlers
177
+ self._handlers[payload_type] = handler
178
+ logger.debug(
179
+ "asap.handler.registered",
180
+ payload_type=payload_type,
181
+ handler_name=handler.__name__ if hasattr(handler, "__name__") else str(handler),
182
+ is_override=is_override,
183
+ )
184
+
185
+ def has_handler(self, payload_type: str) -> bool:
186
+ """Check if a handler is registered for a payload type.
187
+
188
+ This method is thread-safe.
189
+
190
+ Args:
191
+ payload_type: The payload type to check
192
+
193
+ Returns:
194
+ True if a handler is registered, False otherwise
195
+
196
+ Example:
197
+ >>> registry = HandlerRegistry()
198
+ >>> registry.has_handler("task.request")
199
+ False
200
+ """
201
+ with self._lock:
202
+ return payload_type in self._handlers
203
+
204
+ def dispatch(self, envelope: Envelope, manifest: Manifest) -> Envelope:
205
+ """Dispatch an envelope to its registered handler.
206
+
207
+ Looks up the handler for the envelope's payload_type and
208
+ invokes it with the envelope and manifest.
209
+
210
+ This method is thread-safe for handler lookup. The handler
211
+ execution itself is not protected by the lock.
212
+
213
+ Args:
214
+ envelope: The incoming ASAP envelope
215
+ manifest: The server's manifest for context
216
+
217
+ Returns:
218
+ Response envelope from the handler
219
+
220
+ Raises:
221
+ HandlerNotFoundError: If no handler is registered for the payload type
222
+
223
+ Example:
224
+ >>> registry = create_default_registry()
225
+ >>> response = registry.dispatch(envelope, manifest)
226
+ """
227
+ payload_type = envelope.payload_type
228
+ start_time = time.perf_counter()
229
+
230
+ with self._lock:
231
+ if payload_type not in self._handlers:
232
+ logger.warning(
233
+ "asap.handler.not_found",
234
+ payload_type=payload_type,
235
+ envelope_id=envelope.id,
236
+ )
237
+ raise HandlerNotFoundError(payload_type)
238
+ handler = self._handlers[payload_type]
239
+
240
+ # Log dispatch start
241
+ logger.debug(
242
+ "asap.handler.dispatch",
243
+ payload_type=payload_type,
244
+ envelope_id=envelope.id,
245
+ handler_name=handler.__name__ if hasattr(handler, "__name__") else str(handler),
246
+ )
247
+
248
+ # Execute handler outside the lock to allow concurrent dispatches
249
+ try:
250
+ # Note: dispatch() only works with sync handlers that return Envelope directly
251
+ # For async handlers, use dispatch_async() instead
252
+ # Type narrowing: we expect sync handlers here
253
+ result = handler(envelope, manifest)
254
+ # For sync handlers, result is Envelope directly
255
+ if inspect.isawaitable(result):
256
+ raise TypeError(
257
+ f"Handler {handler} returned awaitable in sync dispatch(). "
258
+ "Use dispatch_async() for async handlers."
259
+ )
260
+ response: Envelope = result
261
+ duration_ms = (time.perf_counter() - start_time) * 1000
262
+ logger.debug(
263
+ "asap.handler.completed",
264
+ payload_type=payload_type,
265
+ envelope_id=envelope.id,
266
+ response_id=response.id,
267
+ duration_ms=round(duration_ms, 2),
268
+ )
269
+ return response
270
+ except Exception as e:
271
+ duration_ms = (time.perf_counter() - start_time) * 1000
272
+ logger.exception(
273
+ "asap.handler.error",
274
+ payload_type=payload_type,
275
+ envelope_id=envelope.id,
276
+ error=str(e),
277
+ error_type=type(e).__name__,
278
+ duration_ms=round(duration_ms, 2),
279
+ )
280
+ raise
281
+
282
+ async def dispatch_async(self, envelope: Envelope, manifest: Manifest) -> Envelope:
283
+ """Dispatch an envelope to its registered handler (async version).
284
+
285
+ This method supports both synchronous and asynchronous handlers.
286
+ When called from an async context (e.g., FastAPI endpoint), this
287
+ method will properly await async handlers and run sync handlers
288
+ in a thread pool to avoid blocking the event loop.
289
+
290
+ Args:
291
+ envelope: The ASAP envelope to dispatch
292
+ manifest: The server's manifest for context
293
+
294
+ Returns:
295
+ Response envelope from the handler
296
+
297
+ Raises:
298
+ HandlerNotFoundError: If no handler is registered for the payload type
299
+
300
+ Example:
301
+ >>> registry = create_default_registry()
302
+ >>> response = await registry.dispatch_async(envelope, manifest)
303
+ """
304
+ payload_type = envelope.payload_type
305
+ start_time = time.perf_counter()
306
+
307
+ with self._lock:
308
+ if payload_type not in self._handlers:
309
+ logger.warning(
310
+ "asap.handler.not_found",
311
+ payload_type=payload_type,
312
+ envelope_id=envelope.id,
313
+ )
314
+ raise HandlerNotFoundError(payload_type)
315
+ handler = self._handlers[payload_type]
316
+
317
+ # Log dispatch start
318
+ logger.debug(
319
+ "asap.handler.dispatch",
320
+ payload_type=payload_type,
321
+ envelope_id=envelope.id,
322
+ handler_name=handler.__name__ if hasattr(handler, "__name__") else str(handler),
323
+ )
324
+
325
+ # Execute handler outside the lock to allow concurrent dispatches
326
+ try:
327
+ # Support both sync and async handlers
328
+ response: Envelope
329
+ if inspect.iscoroutinefunction(handler):
330
+ # Async handler - await it directly
331
+ response = await handler(envelope, manifest)
332
+ else:
333
+ # Sync handler - run in thread pool to avoid blocking event loop
334
+ # Also handle async callable objects that return awaitables
335
+ loop = asyncio.get_event_loop()
336
+ result: object = await loop.run_in_executor(None, handler, envelope, manifest)
337
+ # Check if result is awaitable (handles async __call__ methods)
338
+ if inspect.isawaitable(result):
339
+ response = await result
340
+ else:
341
+ # Type narrowing: result is Envelope for sync handlers
342
+ response = result # type: ignore[assignment]
343
+
344
+ duration_ms = (time.perf_counter() - start_time) * 1000
345
+ logger.debug(
346
+ "asap.handler.completed",
347
+ payload_type=payload_type,
348
+ envelope_id=envelope.id,
349
+ response_id=response.id,
350
+ duration_ms=round(duration_ms, 2),
351
+ )
352
+ return response
353
+ except Exception as e:
354
+ duration_ms = (time.perf_counter() - start_time) * 1000
355
+ logger.exception(
356
+ "asap.handler.error",
357
+ payload_type=payload_type,
358
+ envelope_id=envelope.id,
359
+ error=str(e),
360
+ error_type=type(e).__name__,
361
+ duration_ms=round(duration_ms, 2),
362
+ )
363
+ raise
364
+
365
+ def list_handlers(self) -> list[str]:
366
+ """List all registered payload types.
367
+
368
+ This method is thread-safe. Returns a copy of the keys list.
369
+
370
+ Returns:
371
+ List of payload type strings that have registered handlers
372
+
373
+ Example:
374
+ >>> registry = create_default_registry()
375
+ >>> registry.list_handlers()
376
+ ['task.request']
377
+ """
378
+ with self._lock:
379
+ return list(self._handlers.keys())
380
+
381
+
382
+ def create_echo_handler() -> Handler:
383
+ """Create an echo handler that echoes TaskRequest input.
384
+
385
+ The echo handler is a simple implementation that:
386
+ - Receives a TaskRequest envelope
387
+ - Returns a TaskResponse with the input echoed back
388
+ - Preserves trace_id and sets correlation_id
389
+
390
+ This is useful for testing and as a base for custom handlers.
391
+
392
+ Returns:
393
+ Handler function that echoes TaskRequest input
394
+
395
+ Example:
396
+ >>> handler = create_echo_handler()
397
+ >>> response = handler(request_envelope, manifest)
398
+ """
399
+
400
+ def echo_handler(envelope: Envelope, manifest: Manifest) -> Envelope:
401
+ """Echo handler implementation."""
402
+ # Parse the TaskRequest payload
403
+ task_request = TaskRequest(**envelope.payload)
404
+
405
+ # Create response with echoed input
406
+ response_payload = TaskResponse(
407
+ task_id=f"task_{generate_id()}",
408
+ status=TaskStatus.COMPLETED,
409
+ result={"echoed": task_request.input},
410
+ )
411
+
412
+ # Create response envelope
413
+ return Envelope(
414
+ asap_version=envelope.asap_version,
415
+ sender=manifest.id,
416
+ recipient=envelope.sender,
417
+ payload_type="task.response",
418
+ payload=response_payload.model_dump(),
419
+ correlation_id=envelope.id,
420
+ trace_id=envelope.trace_id,
421
+ )
422
+
423
+ return echo_handler
424
+
425
+
426
+ def create_default_registry() -> HandlerRegistry:
427
+ """Create a registry with default handlers.
428
+
429
+ Creates a HandlerRegistry pre-configured with standard handlers:
430
+ - task.request: Echo handler (for basic testing)
431
+
432
+ Additional handlers can be registered after creation.
433
+
434
+ Returns:
435
+ HandlerRegistry with default handlers registered
436
+
437
+ Example:
438
+ >>> registry = create_default_registry()
439
+ >>> registry.has_handler("task.request")
440
+ True
441
+ """
442
+ registry = HandlerRegistry()
443
+ registry.register("task.request", create_echo_handler())
444
+ return registry
@@ -0,0 +1,190 @@
1
+ """JSON-RPC 2.0 wrapper models for ASAP protocol transport.
2
+
3
+ This module implements JSON-RPC 2.0 specification (https://www.jsonrpc.org/specification)
4
+ to wrap ASAP Envelope messages for HTTP transport.
5
+
6
+ The JSON-RPC layer provides:
7
+ - Standard request/response structure
8
+ - Error handling with standard error codes
9
+ - Request/response correlation via id field
10
+
11
+ Standard JSON-RPC Error Codes:
12
+ -32700: Parse error (invalid JSON)
13
+ -32600: Invalid request (malformed JSON-RPC)
14
+ -32601: Method not found
15
+ -32602: Invalid params
16
+ -32603: Internal error
17
+
18
+ Example:
19
+ >>> from asap.models import Envelope, TaskRequest
20
+ >>> from asap.transport.jsonrpc import JsonRpcRequest
21
+ >>>
22
+ >>> # Create ASAP envelope
23
+ >>> envelope = Envelope(
24
+ ... sender="urn:asap:agent:client",
25
+ ... recipient="urn:asap:agent:server",
26
+ ... payload_type="task.request",
27
+ ... payload=TaskRequest(...)
28
+ ... )
29
+ >>>
30
+ >>> # Wrap in JSON-RPC
31
+ >>> rpc_request = JsonRpcRequest(
32
+ ... method="asap.send",
33
+ ... params={"envelope": envelope.model_dump()},
34
+ ... id="req-1"
35
+ ... )
36
+ """
37
+
38
+ from typing import Any, Literal
39
+
40
+ from pydantic import Field
41
+
42
+ from asap.models.base import ASAPBaseModel
43
+
44
+ # JSON-RPC 2.0 Standard Error Codes
45
+ PARSE_ERROR = -32700
46
+ INVALID_REQUEST = -32600
47
+ METHOD_NOT_FOUND = -32601
48
+ INVALID_PARAMS = -32602
49
+ INTERNAL_ERROR = -32603
50
+
51
+ # ASAP Protocol JSON-RPC Method Name
52
+ ASAP_METHOD = "asap.send"
53
+
54
+ # Error code descriptions
55
+ ERROR_MESSAGES: dict[int, str] = {
56
+ PARSE_ERROR: "Parse error",
57
+ INVALID_REQUEST: "Invalid request",
58
+ METHOD_NOT_FOUND: "Method not found",
59
+ INVALID_PARAMS: "Invalid params",
60
+ INTERNAL_ERROR: "Internal error",
61
+ }
62
+
63
+
64
+ class JsonRpcError(ASAPBaseModel):
65
+ """JSON-RPC 2.0 error object.
66
+
67
+ Represents an error that occurred during request processing.
68
+ Follows JSON-RPC 2.0 specification for error responses.
69
+
70
+ Attributes:
71
+ code: Integer error code (standard or application-defined)
72
+ message: Short error description
73
+ data: Optional additional error information
74
+
75
+ Example:
76
+ >>> error = JsonRpcError(
77
+ ... code=-32602,
78
+ ... message="Invalid params",
79
+ ... data={"missing_field": "task_id"}
80
+ ... )
81
+ >>> error.code
82
+ -32602
83
+ """
84
+
85
+ code: int = Field(description="Error code (negative integer)")
86
+ message: str = Field(description="Short error description")
87
+ data: dict[str, Any] | None = Field(
88
+ default=None, description="Optional additional error information"
89
+ )
90
+
91
+ @staticmethod
92
+ def from_code(code: int, data: dict[str, Any] | None = None) -> "JsonRpcError":
93
+ """Create error from standard error code.
94
+
95
+ Args:
96
+ code: Standard JSON-RPC error code
97
+ data: Optional additional error information
98
+
99
+ Returns:
100
+ JsonRpcError instance with standard message
101
+
102
+ Example:
103
+ >>> error = JsonRpcError.from_code(INVALID_PARAMS, data={"field": "task_id"})
104
+ >>> error.message
105
+ 'Invalid params'
106
+ """
107
+ message = ERROR_MESSAGES.get(code, "Unknown error")
108
+ return JsonRpcError(code=code, message=message, data=data)
109
+
110
+
111
+ class JsonRpcRequest(ASAPBaseModel):
112
+ """JSON-RPC 2.0 request.
113
+
114
+ Wraps an ASAP Envelope in a JSON-RPC request structure.
115
+ The envelope is passed in the params field.
116
+
117
+ Attributes:
118
+ jsonrpc: Protocol version (always "2.0")
119
+ method: RPC method name (typically "asap.send")
120
+ params: Request parameters (contains ASAP envelope)
121
+ id: Request identifier for correlation
122
+
123
+ Example:
124
+ >>> request = JsonRpcRequest(
125
+ ... method="asap.send",
126
+ ... params={"envelope": {...}},
127
+ ... id="req-123"
128
+ ... )
129
+ >>> request.jsonrpc
130
+ '2.0'
131
+ """
132
+
133
+ jsonrpc: Literal["2.0"] = Field(
134
+ default="2.0", description="JSON-RPC protocol version (always '2.0')"
135
+ )
136
+ method: str = Field(description="RPC method name")
137
+ params: dict[str, Any] = Field(description="Request parameters")
138
+ id: str | int = Field(description="Request identifier for correlation")
139
+
140
+
141
+ class JsonRpcResponse(ASAPBaseModel):
142
+ """JSON-RPC 2.0 successful response.
143
+
144
+ Wraps an ASAP Envelope response or other result data.
145
+
146
+ Attributes:
147
+ jsonrpc: Protocol version (always "2.0")
148
+ result: Response data (ASAP envelope or other result)
149
+ id: Request identifier (matches original request)
150
+
151
+ Example:
152
+ >>> response = JsonRpcResponse(
153
+ ... result={"envelope": {...}},
154
+ ... id="req-123"
155
+ ... )
156
+ >>> response.jsonrpc
157
+ '2.0'
158
+ """
159
+
160
+ jsonrpc: Literal["2.0"] = Field(
161
+ default="2.0", description="JSON-RPC protocol version (always '2.0')"
162
+ )
163
+ result: dict[str, Any] = Field(description="Response data")
164
+ id: str | int = Field(description="Request identifier (matches request)")
165
+
166
+
167
+ class JsonRpcErrorResponse(ASAPBaseModel):
168
+ """JSON-RPC 2.0 error response.
169
+
170
+ Returned when a request fails or cannot be processed.
171
+
172
+ Attributes:
173
+ jsonrpc: Protocol version (always "2.0")
174
+ error: Error object with code, message, and optional data
175
+ id: Request identifier (matches request, or null if id unavailable)
176
+
177
+ Example:
178
+ >>> error_response = JsonRpcErrorResponse(
179
+ ... error=JsonRpcError(code=-32602, message="Invalid params"),
180
+ ... id="req-123"
181
+ ... )
182
+ >>> error_response.error.code
183
+ -32602
184
+ """
185
+
186
+ jsonrpc: Literal["2.0"] = Field(
187
+ default="2.0", description="JSON-RPC protocol version (always '2.0')"
188
+ )
189
+ error: JsonRpcError = Field(description="Error object")
190
+ id: str | int | None = Field(description="Request identifier (or null)")