DefStream 0.1.22__93-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.
- DefStream/Broker/Configuration.py +17 -0
- DefStream/Broker/Connection.py +141 -0
- DefStream/Broker/Consumer.py +59 -0
- DefStream/Broker/Errors.py +14 -0
- DefStream/Broker/Event.py +35 -0
- DefStream/Broker/Implements/RabbitMQ/Configuration.py +49 -0
- DefStream/Broker/Implements/RabbitMQ/Connection.py +550 -0
- DefStream/Broker/Implements/RabbitMQ/Consumer.py +416 -0
- DefStream/Broker/Implements/RabbitMQ/Event.py +146 -0
- DefStream/Broker/Implements/RabbitMQ/Exchange.py +277 -0
- DefStream/Broker/Implements/RabbitMQ/Queue.py +321 -0
- DefStream/Broker/Implements/RabbitMQ/Serializer.py +75 -0
- DefStream/Broker/Implements/RabbitMQ/Stream.py +181 -0
- DefStream/Broker/Implements/RabbitMQ/Subscription.py +90 -0
- DefStream/Broker/Implements/RabbitMQ/__init__.py +45 -0
- DefStream/Broker/Implements/Redis/Configuration.py +59 -0
- DefStream/Broker/Implements/Redis/Connection.py +265 -0
- DefStream/Broker/Implements/Redis/Consumer.py +368 -0
- DefStream/Broker/Implements/Redis/Event.py +212 -0
- DefStream/Broker/Implements/Redis/Queue.py +284 -0
- DefStream/Broker/Implements/Redis/Serializer.py +48 -0
- DefStream/Broker/Implements/Redis/Subscription.py +46 -0
- DefStream/Broker/Implements/Redis/__init__.py +29 -0
- DefStream/Broker/Implements/__init__.py +1 -0
- DefStream/Broker/Priority.py +26 -0
- DefStream/Broker/Protocol.py +54 -0
- DefStream/Broker/__init__.py +55 -0
- DefStream/Descriptor/DataObject.py +409 -0
- DefStream/Descriptor/Types.py +248 -0
- DefStream/Descriptor/Value.py +188 -0
- DefStream/Descriptor/__init__.py +172 -0
- DefStream/Inspector/Extension.py +69 -0
- DefStream/Inspector/Extensions/Metrics.py +316 -0
- DefStream/Inspector/Extensions/Ping.py +107 -0
- DefStream/Inspector/Extensions/Play.html +373 -0
- DefStream/Inspector/Extensions/Play.py +415 -0
- DefStream/Inspector/Extensions/Specifications.py +176 -0
- DefStream/Inspector/Extensions/Status.html +206 -0
- DefStream/Inspector/Extensions/Status.py +124 -0
- DefStream/Inspector/Extensions/__init__.py +13 -0
- DefStream/Inspector/Inspector.py +354 -0
- DefStream/Inspector/Response.py +14 -0
- DefStream/Inspector/__init__.py +9 -0
- DefStream/Validator/Pattern.py +36 -0
- DefStream/Validator/Patterns/Array.py +30 -0
- DefStream/Validator/Patterns/Common.py +92 -0
- DefStream/Validator/Patterns/Compare.py +180 -0
- DefStream/Validator/Patterns/Condition.py +60 -0
- DefStream/Validator/Patterns/DataObject.py +93 -0
- DefStream/Validator/Patterns/DateTime.py +111 -0
- DefStream/Validator/Patterns/Number.py +43 -0
- DefStream/Validator/Patterns/Object.py +158 -0
- DefStream/Validator/Patterns/Size.py +110 -0
- DefStream/Validator/Patterns/String.py +92 -0
- DefStream/Validator/Patterns/Type.py +355 -0
- DefStream/Validator/Patterns/__init__.py +137 -0
- DefStream/Validator/Validate.py +29 -0
- DefStream/Validator/Validator.py +30 -0
- DefStream/Validator/__init__.py +11 -0
- DefStream/Worker/Executor.py +525 -0
- DefStream/Worker/Message.py +180 -0
- DefStream/Worker/Pool.py +282 -0
- DefStream/Worker/Run.py +357 -0
- DefStream/Worker/Specification.py +87 -0
- DefStream/Worker/Startup.py +174 -0
- DefStream/Worker/Worker.py +571 -0
- DefStream/Worker/__init__.py +47 -0
- DefStream/__init__.py +0 -0
- defstream-0.1.22.dist-info/METADATA +92 -0
- defstream-0.1.22.dist-info/RECORD +73 -0
- defstream-0.1.22.dist-info/WHEEL +6 -0
- defstream-0.1.22.dist-info/licenses/LICENSE +674 -0
- defstream-0.1.22.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
from hashlib import sha256
|
|
2
|
+
from hmac import compare_digest
|
|
3
|
+
from html import escape
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from json import (
|
|
6
|
+
dumps,
|
|
7
|
+
loads,
|
|
8
|
+
)
|
|
9
|
+
from logging import getLogger
|
|
10
|
+
from math import isfinite
|
|
11
|
+
from re import sub
|
|
12
|
+
from threading import BoundedSemaphore
|
|
13
|
+
from time import monotonic
|
|
14
|
+
from typing import (
|
|
15
|
+
Optional,
|
|
16
|
+
TYPE_CHECKING,
|
|
17
|
+
)
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
from ...Broker.Connection import (
|
|
21
|
+
BrokerFactory,
|
|
22
|
+
Requestable,
|
|
23
|
+
)
|
|
24
|
+
from ...Broker.Errors import RemoteError
|
|
25
|
+
from ...Worker.Message import (
|
|
26
|
+
message_body,
|
|
27
|
+
request,
|
|
28
|
+
emit,
|
|
29
|
+
)
|
|
30
|
+
from ..Extension import Extension
|
|
31
|
+
from ..Response import Response
|
|
32
|
+
from .Specifications import Specifications
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from ..Inspector import Inspector
|
|
36
|
+
|
|
37
|
+
__all__ = (
|
|
38
|
+
'Play',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def json_response(
|
|
42
|
+
status: str,
|
|
43
|
+
value: dict,
|
|
44
|
+
) -> Response:
|
|
45
|
+
return Response(
|
|
46
|
+
status,
|
|
47
|
+
dumps(
|
|
48
|
+
value,
|
|
49
|
+
ensure_ascii=False,
|
|
50
|
+
allow_nan=False,
|
|
51
|
+
).encode('utf-8'),
|
|
52
|
+
'application/json; charset=utf-8',
|
|
53
|
+
(
|
|
54
|
+
(
|
|
55
|
+
'X-Content-Type-Options',
|
|
56
|
+
'nosniff',
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Play(Extension):
|
|
63
|
+
logger = getLogger('Worker.Inspector.Play')
|
|
64
|
+
|
|
65
|
+
methods = (
|
|
66
|
+
'GET',
|
|
67
|
+
'HEAD',
|
|
68
|
+
'POST',
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
path: str,
|
|
74
|
+
*,
|
|
75
|
+
broker: BrokerFactory,
|
|
76
|
+
token: Optional[str] = None,
|
|
77
|
+
timeout: float = 5,
|
|
78
|
+
title: str = 'DefStream',
|
|
79
|
+
description: Optional[str] = None,
|
|
80
|
+
):
|
|
81
|
+
super().__init__(path)
|
|
82
|
+
if not callable(broker):
|
|
83
|
+
raise TypeError('Play broker must be a factory creating a new producer connection')
|
|
84
|
+
if token is not None and (not isinstance(
|
|
85
|
+
token,
|
|
86
|
+
str,
|
|
87
|
+
) or len(token) < 16):
|
|
88
|
+
raise ValueError('Play token must contain at least 16 characters')
|
|
89
|
+
if isinstance(
|
|
90
|
+
timeout,
|
|
91
|
+
bool,
|
|
92
|
+
) or not isfinite(timeout) or timeout <= 0:
|
|
93
|
+
raise ValueError('Play RPC timeout must be finite and positive')
|
|
94
|
+
self.broker = broker
|
|
95
|
+
if not isinstance(
|
|
96
|
+
title,
|
|
97
|
+
str,
|
|
98
|
+
) or not title:
|
|
99
|
+
raise ValueError('Play title must be a nonempty string')
|
|
100
|
+
if description is not None and not isinstance(
|
|
101
|
+
description,
|
|
102
|
+
str,
|
|
103
|
+
):
|
|
104
|
+
raise TypeError('Play description must be a string or None')
|
|
105
|
+
self.title = title
|
|
106
|
+
self.description = description
|
|
107
|
+
self.token_digest = sha256(token.encode('utf-8')).digest() if token is not None else None
|
|
108
|
+
self.timeout = timeout
|
|
109
|
+
self.slots = BoundedSemaphore(4)
|
|
110
|
+
|
|
111
|
+
def __call__(
|
|
112
|
+
self,
|
|
113
|
+
inspector: 'Inspector',
|
|
114
|
+
) -> Response:
|
|
115
|
+
self.logger.debug('Building Inspector Play HTML response')
|
|
116
|
+
page = files(__package__).joinpath('Play.html').read_bytes()
|
|
117
|
+
values = {
|
|
118
|
+
'title': escape(self.title),
|
|
119
|
+
'description': escape(self.description) if self.description is not None else '',
|
|
120
|
+
}
|
|
121
|
+
page = sub(
|
|
122
|
+
r'\{\{play_(title|description)\}\}',
|
|
123
|
+
lambda match: values[match[1]],
|
|
124
|
+
page.decode('utf-8'),
|
|
125
|
+
).encode('utf-8')
|
|
126
|
+
if self.token_digest is None:
|
|
127
|
+
page = page.replace(
|
|
128
|
+
b'data-auth-required="true"',
|
|
129
|
+
b'data-auth-required="false"',
|
|
130
|
+
)
|
|
131
|
+
return Response(
|
|
132
|
+
'200 OK',
|
|
133
|
+
page,
|
|
134
|
+
'text/html; charset=utf-8',
|
|
135
|
+
(
|
|
136
|
+
(
|
|
137
|
+
'Content-Security-Policy',
|
|
138
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
139
|
+
),
|
|
140
|
+
(
|
|
141
|
+
'X-Content-Type-Options',
|
|
142
|
+
'nosniff',
|
|
143
|
+
),
|
|
144
|
+
(
|
|
145
|
+
'Referrer-Policy',
|
|
146
|
+
'no-referrer',
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def handle(
|
|
152
|
+
self,
|
|
153
|
+
inspector: 'Inspector',
|
|
154
|
+
environ: dict,
|
|
155
|
+
) -> Response:
|
|
156
|
+
method = environ['REQUEST_METHOD']
|
|
157
|
+
if method in (
|
|
158
|
+
'GET',
|
|
159
|
+
'HEAD',
|
|
160
|
+
) and environ.get('HTTP_ACCEPT') != 'application/json':
|
|
161
|
+
return self(inspector)
|
|
162
|
+
authorization = environ.get(
|
|
163
|
+
'HTTP_AUTHORIZATION',
|
|
164
|
+
'',
|
|
165
|
+
)
|
|
166
|
+
if self.token_digest is not None and (not authorization.startswith('Bearer ') or not compare_digest(
|
|
167
|
+
sha256(authorization[7:].encode('utf-8')).digest(),
|
|
168
|
+
self.token_digest,
|
|
169
|
+
)):
|
|
170
|
+
self.logger.warning('Inspector Play rejected an unauthorized request')
|
|
171
|
+
return json_response(
|
|
172
|
+
'401 Unauthorized',
|
|
173
|
+
{'error': 'A valid Bearer token is required'},
|
|
174
|
+
)
|
|
175
|
+
origin = environ.get('HTTP_ORIGIN')
|
|
176
|
+
if origin is not None:
|
|
177
|
+
try:
|
|
178
|
+
parsed = urlsplit(origin)
|
|
179
|
+
valid_origin = parsed.scheme in (
|
|
180
|
+
'http',
|
|
181
|
+
'https',
|
|
182
|
+
) and parsed.netloc == environ.get('HTTP_HOST')
|
|
183
|
+
except ValueError:
|
|
184
|
+
valid_origin = False
|
|
185
|
+
if not valid_origin:
|
|
186
|
+
self.logger.warning('Inspector Play rejected a cross-origin request')
|
|
187
|
+
return json_response(
|
|
188
|
+
'403 Forbidden',
|
|
189
|
+
{'error': 'Cross-origin requests are not allowed'},
|
|
190
|
+
)
|
|
191
|
+
if not self.slots.acquire(blocking=False):
|
|
192
|
+
self.logger.warning('Inspector Play concurrent request limit exceeded')
|
|
193
|
+
return json_response(
|
|
194
|
+
'429 Too Many Requests',
|
|
195
|
+
{'error': 'Too many concurrent Play requests'},
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
self.logger.debug(
|
|
199
|
+
'Handling Inspector Play request: method=%s',
|
|
200
|
+
method,
|
|
201
|
+
)
|
|
202
|
+
if method in (
|
|
203
|
+
'GET',
|
|
204
|
+
'HEAD',
|
|
205
|
+
):
|
|
206
|
+
return self.catalog(inspector)
|
|
207
|
+
return self.execute(
|
|
208
|
+
inspector,
|
|
209
|
+
environ,
|
|
210
|
+
)
|
|
211
|
+
except Exception as error:
|
|
212
|
+
self.logger.error(
|
|
213
|
+
'Inspector Play request failed: error=%s',
|
|
214
|
+
type(error).__name__,
|
|
215
|
+
)
|
|
216
|
+
return json_response(
|
|
217
|
+
'500 Internal Server Error',
|
|
218
|
+
{'error': 'Inspector Play failed; consult the worker logs'},
|
|
219
|
+
)
|
|
220
|
+
finally:
|
|
221
|
+
self.slots.release()
|
|
222
|
+
|
|
223
|
+
def catalog(
|
|
224
|
+
self,
|
|
225
|
+
inspector: 'Inspector',
|
|
226
|
+
) -> Response:
|
|
227
|
+
self.logger.debug('Building Inspector Play task catalog')
|
|
228
|
+
producer = self.broker()
|
|
229
|
+
try:
|
|
230
|
+
if inspector.worker is not None and producer is inspector.worker.broker:
|
|
231
|
+
raise ValueError('Play requires a separate producer, not the Worker connection')
|
|
232
|
+
rpc = isinstance(
|
|
233
|
+
producer,
|
|
234
|
+
Requestable,
|
|
235
|
+
)
|
|
236
|
+
specifications = next(
|
|
237
|
+
(
|
|
238
|
+
extension for extension in inspector.exts.values()
|
|
239
|
+
if isinstance(
|
|
240
|
+
extension,
|
|
241
|
+
Specifications,
|
|
242
|
+
)
|
|
243
|
+
),
|
|
244
|
+
None,
|
|
245
|
+
)
|
|
246
|
+
if specifications is None:
|
|
247
|
+
specifications = Specifications(self.path)
|
|
248
|
+
document = specifications.document(inspector)
|
|
249
|
+
self.logger.debug(
|
|
250
|
+
'Inspector Play task catalog built: tasks=%d rpc=%s',
|
|
251
|
+
len(document['operations']),
|
|
252
|
+
rpc,
|
|
253
|
+
)
|
|
254
|
+
return json_response(
|
|
255
|
+
'200 OK',
|
|
256
|
+
{
|
|
257
|
+
'specification': document,
|
|
258
|
+
'rpc': rpc,
|
|
259
|
+
'timeout': self.timeout,
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
finally:
|
|
263
|
+
if inspector.worker is None or producer is not inspector.worker.broker:
|
|
264
|
+
producer.close()
|
|
265
|
+
|
|
266
|
+
def execute(
|
|
267
|
+
self,
|
|
268
|
+
inspector: 'Inspector',
|
|
269
|
+
environ: dict,
|
|
270
|
+
) -> Response:
|
|
271
|
+
worker = inspector.worker
|
|
272
|
+
if worker is None or not worker.running.is_set() or worker.stopping.is_set() or worker.finished.is_set():
|
|
273
|
+
return json_response(
|
|
274
|
+
'503 Service Unavailable',
|
|
275
|
+
{'error': 'Worker is not running'},
|
|
276
|
+
)
|
|
277
|
+
if environ.get(
|
|
278
|
+
'CONTENT_TYPE',
|
|
279
|
+
'',
|
|
280
|
+
).split(';')[0].strip().lower() != 'application/json':
|
|
281
|
+
return json_response(
|
|
282
|
+
'415 Unsupported Media Type',
|
|
283
|
+
{'error': 'Use application/json'},
|
|
284
|
+
)
|
|
285
|
+
length = environ.get(
|
|
286
|
+
'CONTENT_LENGTH',
|
|
287
|
+
'',
|
|
288
|
+
)
|
|
289
|
+
if not length.isascii() or not length.isdecimal():
|
|
290
|
+
return json_response(
|
|
291
|
+
'411 Length Required',
|
|
292
|
+
{'error': 'A Content-Length is required'},
|
|
293
|
+
)
|
|
294
|
+
size = int(length)
|
|
295
|
+
if size > 65536:
|
|
296
|
+
return json_response(
|
|
297
|
+
'413 Payload Too Large',
|
|
298
|
+
{'error': 'Play requests must not exceed 64 KiB'},
|
|
299
|
+
)
|
|
300
|
+
raw = environ['wsgi.input'].read(size)
|
|
301
|
+
try:
|
|
302
|
+
if len(raw) != size:
|
|
303
|
+
raise ValueError('Incomplete request body')
|
|
304
|
+
payload = loads(raw)
|
|
305
|
+
if not isinstance(
|
|
306
|
+
payload,
|
|
307
|
+
dict,
|
|
308
|
+
) or set(payload) != {
|
|
309
|
+
'task',
|
|
310
|
+
'mode',
|
|
311
|
+
'body',
|
|
312
|
+
}:
|
|
313
|
+
raise ValueError('Expected task, mode and body fields')
|
|
314
|
+
if payload['mode'] not in (
|
|
315
|
+
'send',
|
|
316
|
+
'request',
|
|
317
|
+
):
|
|
318
|
+
raise ValueError('Mode must be send or request')
|
|
319
|
+
body = message_body(payload['body'])
|
|
320
|
+
dumps(
|
|
321
|
+
body,
|
|
322
|
+
allow_nan=False,
|
|
323
|
+
)
|
|
324
|
+
task = next(
|
|
325
|
+
(
|
|
326
|
+
task for task in worker.tasks if task.name == payload['task']
|
|
327
|
+
),
|
|
328
|
+
None,
|
|
329
|
+
)
|
|
330
|
+
if task is None:
|
|
331
|
+
raise ValueError('Unknown registered task')
|
|
332
|
+
if sum(task.name == payload['task'] for task in worker.tasks) != 1:
|
|
333
|
+
raise ValueError('Ambiguous registered task name')
|
|
334
|
+
except (
|
|
335
|
+
ValueError,
|
|
336
|
+
TypeError,
|
|
337
|
+
UnicodeDecodeError,
|
|
338
|
+
) as error:
|
|
339
|
+
self.logger.warning(
|
|
340
|
+
'Inspector Play rejected invalid input: error=%s message=%s',
|
|
341
|
+
type(error).__name__,
|
|
342
|
+
error,
|
|
343
|
+
)
|
|
344
|
+
return json_response(
|
|
345
|
+
'400 Bad Request',
|
|
346
|
+
{'error': str(error)},
|
|
347
|
+
)
|
|
348
|
+
started = monotonic()
|
|
349
|
+
self.logger.debug(
|
|
350
|
+
'Inspector Play task request validated: task=%s mode=%s',
|
|
351
|
+
task.__module__ + '.' + task.__qualname__,
|
|
352
|
+
payload['mode'],
|
|
353
|
+
)
|
|
354
|
+
producer = self.broker()
|
|
355
|
+
if producer is worker.broker:
|
|
356
|
+
raise ValueError('Play requires a separate producer, not the Worker connection')
|
|
357
|
+
try:
|
|
358
|
+
if payload['mode'] == 'request' and not isinstance(
|
|
359
|
+
producer,
|
|
360
|
+
Requestable,
|
|
361
|
+
):
|
|
362
|
+
self.logger.debug('Inspector Play request rejected: broker does not support RPC')
|
|
363
|
+
return json_response(
|
|
364
|
+
'501 Not Implemented',
|
|
365
|
+
{'error': 'This broker does not support RPC'},
|
|
366
|
+
)
|
|
367
|
+
producer.connect()
|
|
368
|
+
try:
|
|
369
|
+
if payload['mode'] == 'request':
|
|
370
|
+
result = request(
|
|
371
|
+
body,
|
|
372
|
+
task=task,
|
|
373
|
+
broker=producer,
|
|
374
|
+
timeout=self.timeout,
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
result = emit(
|
|
378
|
+
body,
|
|
379
|
+
task=task,
|
|
380
|
+
broker=producer,
|
|
381
|
+
)
|
|
382
|
+
except TimeoutError:
|
|
383
|
+
self.logger.warning('Inspector Play RPC timed out')
|
|
384
|
+
return json_response(
|
|
385
|
+
'504 Gateway Timeout',
|
|
386
|
+
{'error': 'RPC timed out; delivered work is not cancelled'},
|
|
387
|
+
)
|
|
388
|
+
except RemoteError as error:
|
|
389
|
+
self.logger.warning(
|
|
390
|
+
'Inspector Play received a remote task error: code=%s',
|
|
391
|
+
error.code,
|
|
392
|
+
)
|
|
393
|
+
return json_response(
|
|
394
|
+
'502 Bad Gateway',
|
|
395
|
+
{
|
|
396
|
+
'error': str(error),
|
|
397
|
+
'code': error.code,
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
self.logger.debug(
|
|
401
|
+
'Inspector Play task request completed: task=%s mode=%s elapsed=%.6f',
|
|
402
|
+
task.__module__ + '.' + task.__qualname__,
|
|
403
|
+
payload['mode'],
|
|
404
|
+
monotonic() - started,
|
|
405
|
+
)
|
|
406
|
+
return json_response(
|
|
407
|
+
'200 OK',
|
|
408
|
+
{
|
|
409
|
+
'mode': payload['mode'],
|
|
410
|
+
'result': result,
|
|
411
|
+
'elapsed': monotonic() - started,
|
|
412
|
+
},
|
|
413
|
+
)
|
|
414
|
+
finally:
|
|
415
|
+
producer.close()
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from json import dumps
|
|
3
|
+
from logging import getLogger
|
|
4
|
+
from re import fullmatch
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Optional,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from ..Extension import Extension
|
|
12
|
+
from ..Response import Response
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..Inspector import Inspector
|
|
16
|
+
|
|
17
|
+
__all__ = (
|
|
18
|
+
'Specifications',
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def reference(
|
|
23
|
+
*parts: str,
|
|
24
|
+
) -> dict:
|
|
25
|
+
return {'$ref': '#/' + '/'.join(part.replace(
|
|
26
|
+
'~',
|
|
27
|
+
'~0',
|
|
28
|
+
).replace(
|
|
29
|
+
'/',
|
|
30
|
+
'~1',
|
|
31
|
+
) for part in parts)}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Specifications(Extension):
|
|
35
|
+
logger = getLogger('Worker.Inspector.Specifications')
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
path: str,
|
|
40
|
+
version: str = '1.0.0',
|
|
41
|
+
description: Optional[str] = None,
|
|
42
|
+
format: str = 'application/json',
|
|
43
|
+
):
|
|
44
|
+
super().__init__(path)
|
|
45
|
+
if not isinstance(
|
|
46
|
+
version,
|
|
47
|
+
str,
|
|
48
|
+
) or not version:
|
|
49
|
+
raise ValueError('A document version is required')
|
|
50
|
+
self.version = version
|
|
51
|
+
self.description = description
|
|
52
|
+
if not isinstance(
|
|
53
|
+
format,
|
|
54
|
+
str,
|
|
55
|
+
) or fullmatch(
|
|
56
|
+
r"[!#$%&'+.^_`|~0-9A-Za-z-]+/[!#$%&'+.^_`|~0-9A-Za-z-]+(?:[ \t]*;[^\r\n]+)?",
|
|
57
|
+
format,
|
|
58
|
+
) is None:
|
|
59
|
+
raise ValueError('Specifications format must be a concrete media type')
|
|
60
|
+
self.format = format
|
|
61
|
+
|
|
62
|
+
def document(
|
|
63
|
+
self,
|
|
64
|
+
inspector: 'Inspector',
|
|
65
|
+
) -> dict:
|
|
66
|
+
self.logger.debug('Building Specifications AsyncAPI document')
|
|
67
|
+
worker = inspector.worker
|
|
68
|
+
if worker is None:
|
|
69
|
+
raise RuntimeError('Specifications requires an attached Worker')
|
|
70
|
+
info = {
|
|
71
|
+
'title': 'DefStream',
|
|
72
|
+
'version': self.version,
|
|
73
|
+
}
|
|
74
|
+
if self.description is not None:
|
|
75
|
+
info['description'] = self.description
|
|
76
|
+
channels: dict[str, Any] = {}
|
|
77
|
+
operations: dict[str, Any] = {}
|
|
78
|
+
for task in worker.tasks:
|
|
79
|
+
key = task.name
|
|
80
|
+
if key in operations:
|
|
81
|
+
raise ValueError('Duplicate task specification identifier: ' + key)
|
|
82
|
+
parameters = task.parameters
|
|
83
|
+
if isinstance(
|
|
84
|
+
parameters,
|
|
85
|
+
str,
|
|
86
|
+
):
|
|
87
|
+
queue = parameters
|
|
88
|
+
elif isinstance(
|
|
89
|
+
parameters,
|
|
90
|
+
Mapping,
|
|
91
|
+
):
|
|
92
|
+
queue = parameters.get('queue')
|
|
93
|
+
else:
|
|
94
|
+
queue = getattr(
|
|
95
|
+
parameters,
|
|
96
|
+
'queue',
|
|
97
|
+
None,
|
|
98
|
+
)
|
|
99
|
+
if not isinstance(
|
|
100
|
+
queue,
|
|
101
|
+
str,
|
|
102
|
+
) or not queue:
|
|
103
|
+
raise ValueError('Task specification requires a nonempty queue address: ' + key)
|
|
104
|
+
if queue in channels:
|
|
105
|
+
raise ValueError('Duplicate channel identifier: ' + queue)
|
|
106
|
+
message: dict[str, Any] = task.request.document() if task.request is not None else {
|
|
107
|
+
'payload': {'type': 'object'},
|
|
108
|
+
}
|
|
109
|
+
message['name'] = task.__name__ + 'Request'
|
|
110
|
+
channels[queue] = {
|
|
111
|
+
'address': queue,
|
|
112
|
+
'messages': {'request': message},
|
|
113
|
+
}
|
|
114
|
+
operation = {
|
|
115
|
+
'action': 'receive',
|
|
116
|
+
'channel': reference(
|
|
117
|
+
'channels',
|
|
118
|
+
queue,
|
|
119
|
+
),
|
|
120
|
+
'messages': [reference(
|
|
121
|
+
'channels',
|
|
122
|
+
queue,
|
|
123
|
+
'messages',
|
|
124
|
+
'request',
|
|
125
|
+
)],
|
|
126
|
+
}
|
|
127
|
+
if task.description is not None:
|
|
128
|
+
operation['description'] = task.description
|
|
129
|
+
if task.response is not None:
|
|
130
|
+
channels[queue]['messages']['response'] = task.response.document()
|
|
131
|
+
operation['reply'] = {
|
|
132
|
+
'channel': reference(
|
|
133
|
+
'channels',
|
|
134
|
+
queue,
|
|
135
|
+
),
|
|
136
|
+
'messages': [reference(
|
|
137
|
+
'channels',
|
|
138
|
+
queue,
|
|
139
|
+
'messages',
|
|
140
|
+
'response',
|
|
141
|
+
)],
|
|
142
|
+
}
|
|
143
|
+
operations[key] = operation
|
|
144
|
+
document = {
|
|
145
|
+
'asyncapi': '3.1.0',
|
|
146
|
+
'info': info,
|
|
147
|
+
'defaultContentType': self.format,
|
|
148
|
+
'channels': channels,
|
|
149
|
+
'operations': operations,
|
|
150
|
+
}
|
|
151
|
+
self.logger.debug(
|
|
152
|
+
'Specifications AsyncAPI document built: tasks=%d channels=%d',
|
|
153
|
+
len(operations),
|
|
154
|
+
len(channels),
|
|
155
|
+
)
|
|
156
|
+
return document
|
|
157
|
+
|
|
158
|
+
def __call__(
|
|
159
|
+
self,
|
|
160
|
+
inspector: 'Inspector',
|
|
161
|
+
) -> Response:
|
|
162
|
+
body = dumps(
|
|
163
|
+
self.document(inspector),
|
|
164
|
+
ensure_ascii=False,
|
|
165
|
+
allow_nan=False,
|
|
166
|
+
indent=2,
|
|
167
|
+
).encode('utf-8')
|
|
168
|
+
self.logger.debug(
|
|
169
|
+
'Specifications HTTP response built: bytes=%d',
|
|
170
|
+
len(body),
|
|
171
|
+
)
|
|
172
|
+
return Response(
|
|
173
|
+
'200 OK',
|
|
174
|
+
body,
|
|
175
|
+
'application/json; charset=utf-8',
|
|
176
|
+
)
|