stinger-ipc 0.0.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.
stingeripc/asyncapi.py ADDED
@@ -0,0 +1,501 @@
1
+ """
2
+ Provides the functionality needed to create an AsyncAPI service specification from a Stinger file.
3
+ """
4
+
5
+
6
+ import sys
7
+ from jacobsjinjatoo import templator as jj2
8
+ from jacobsjinjatoo import stringmanip
9
+ import os.path
10
+ from enum import Enum
11
+ from typing import Any
12
+ from collections import OrderedDict
13
+ from .components import StingerSpec, Arg, ArgPrimitive, ArgEnum, ArgStruct
14
+ from .args import ArgType, ArgPrimitiveType
15
+
16
+ class Direction(Enum):
17
+ SERVER_PUBLISHES = 1
18
+ SERVER_SUBSCRIBES = 2
19
+
20
+
21
+ class SpecType(Enum):
22
+ SERVER = 1
23
+ CLIENT = 2
24
+ LIB = 3
25
+
26
+
27
+ class ObjectSchema:
28
+
29
+ def __init__(self):
30
+ self._properties: dict[str, Any] = dict()
31
+ self._required = set()
32
+ self._dependent_schemas = {}
33
+
34
+ def add_value_property(self, name: str, arg_primitive_type: ArgPrimitiveType, required=True):
35
+ schema = {
36
+ "type": ArgPrimitiveType.to_json_type(arg_primitive_type)
37
+ }
38
+ self._properties[name] = schema
39
+ if required:
40
+ self._required.add(name)
41
+
42
+ def add_value_dependency(self, name: str, required_on_name: str, required_on_value):
43
+ self._dependent_schemas[name] = {
44
+ "properties": {
45
+ required_on_name: {
46
+ "const": required_on_value
47
+ }
48
+ }
49
+ }
50
+
51
+ def add_const_value_property(self, name: str, arg_type: ArgPrimitiveType, const_value, required=True):
52
+ self.add_value_property(name, arg_type, required)
53
+ self._properties[name]['const'] = const_value
54
+
55
+ def add_enum_value_property(self, name: str, arg_type: ArgPrimitiveType, possible_values, required=True):
56
+ self.add_value_property(name, arg_type, required)
57
+ self._properties[name]['enum'] = possible_values
58
+
59
+ def add_reference_property(self, name: str, dollar_ref: str, required=True):
60
+ schema = {
61
+ "$ref": dollar_ref
62
+ }
63
+ self._properties[name] = schema
64
+ if required:
65
+ self._required.add(name)
66
+
67
+
68
+ def to_schema(self) -> dict[str, str|dict[str, Any]|list[str]]:
69
+ props: dict[str, Any] = dict()
70
+ schema: dict[str, str|dict[str, Any]|list[str]] = {
71
+ "type": "object",
72
+ "properties": props,
73
+ "required": sorted(list(self._required)),
74
+ }
75
+ for prop_name, prop_schema in self._properties.items():
76
+ props[prop_name] = prop_schema
77
+ return schema
78
+
79
+ class Message(object):
80
+ """The information needed to create an AsyncAPI Message structure."""
81
+
82
+ def __init__(self, message_name: str, schema: str|None = None):
83
+ self.name = message_name
84
+ self.schema = schema or {"type": "null"}
85
+ self._traits: list[dict[str, Any]] = list()
86
+ self._headers: dict[str, tuple[bool, Any]] = dict()
87
+
88
+ def set_schema(self, schema: dict[str, Any]):
89
+ self.schema = schema
90
+ return self
91
+
92
+ def set_reference(self, reference):
93
+ return self.set_schema({"$ref": reference})
94
+
95
+ def add_trait(self, trait):
96
+ self._traits.append(trait)
97
+
98
+ def add_header(self, name: str, schema: dict[str, Any], required: bool=False):
99
+ self._headers[name] = (required, schema)
100
+
101
+ def get_message(self) -> dict[str, Any]:
102
+ msg: dict[str, Any] = {
103
+ "name": self.name,
104
+ "payload": self.schema,
105
+ }
106
+ if len(self._traits) > 0:
107
+ msg["traits"] = self._traits
108
+ if len(self._headers) > 0:
109
+ msg["headers"] = OrderedDict({
110
+ "properties": OrderedDict(),
111
+ "required": list(),
112
+ })
113
+ for header_name, (required, schema) in self._headers.items():
114
+ msg["headers"]["properties"][header_name] = schema
115
+ if required:
116
+ msg["headers"]["required"].append(header_name)
117
+ return msg
118
+
119
+
120
+ class Channel(object):
121
+ """The data needed to create an AsyncAPI Channel structure."""
122
+
123
+ def __init__(
124
+ self,
125
+ topic: str,
126
+ name: str,
127
+ direction: Direction,
128
+ message_name: str|None = None,
129
+ ):
130
+ self.topic = topic
131
+ self.name = name
132
+ self.direction = direction
133
+ self.message_name = message_name or name
134
+ self.mqtt = {"qos": 1, "retain": False}
135
+ self.description: str|None = None
136
+ self.parameters: dict[str, str] = dict()
137
+ self._operation_traits: list[dict[str, Any]] = list()
138
+
139
+ def set_mqtt(self, qos: int, retain: bool):
140
+ self.mqtt = {"qos": qos, "retain": retain}
141
+ return self
142
+
143
+ def set_description(self, description: str):
144
+ self.description = description
145
+ return self
146
+
147
+ def add_topic_parameters(self, name: str, json_schema_type: str):
148
+ self.parameters[name] = json_schema_type
149
+ return self
150
+
151
+ def add_operation_trait(self, trait: dict[str, Any]):
152
+ self._operation_traits.append(trait)
153
+ return self
154
+
155
+ def get_operation(self, client_type: SpecType, use_common=False) -> dict[str, dict[str, Any]]:
156
+ channel_item: dict[str, dict[str, Any]] = dict()
157
+ op_item: OrderedDict[str, Any] = OrderedDict({
158
+ "operationId": self.name,
159
+ "message": {
160
+ "$ref": f"{use_common or ''}#/components/messages/{self.message_name}"
161
+ }
162
+ })
163
+ if use_common is not False:
164
+ op_item["traits"] = [
165
+ {"$ref": f"{use_common}#/components/operationTraits/{self.name}"}
166
+ ]
167
+ elif len(self._operation_traits) > 0:
168
+ op_item.update(OrderedDict({
169
+ "traits": self._operation_traits,
170
+ }))
171
+ if (
172
+ client_type == SpecType.SERVER
173
+ and self.direction == Direction.SERVER_PUBLISHES
174
+ ) or (
175
+ client_type == SpecType.CLIENT
176
+ and self.direction == Direction.SERVER_SUBSCRIBES
177
+ ):
178
+ channel_item.update({"publish": op_item})
179
+ else:
180
+ channel_item.update({"subscribe": op_item})
181
+ if len(self.parameters) > 0:
182
+ params_obj = dict()
183
+ for param_name, param_type in self.parameters.items():
184
+ params_obj[param_name] = {
185
+ "schema": {
186
+ "type": param_type
187
+ }
188
+ }
189
+ channel_item.update({"parameters": params_obj})
190
+ return channel_item
191
+
192
+
193
+ class Server(object):
194
+ def __init__(self, name: str):
195
+ self.name = name
196
+ self._protocol = "mqtt"
197
+ self._host: str|None = None
198
+ self._port: int|None = None
199
+ self._lwt_topic: str|None = None
200
+
201
+ def set_host(self, host: str, port: int):
202
+ self._host = host
203
+ self._port = port
204
+ return self
205
+
206
+ def set_lwt_topic(self, topic: str):
207
+ self._lwt_topic = topic
208
+ return self
209
+
210
+ @property
211
+ def url(self) -> str:
212
+ return "{}:{}".format(
213
+ self._host or "{hostname}",
214
+ self._port or "{port}"
215
+ )
216
+
217
+ def get_server(self) -> dict[str, Any]:
218
+ spec: dict[str, Any] = {
219
+ "protocol": self._protocol,
220
+ "protocolVersion": "5",
221
+ "url": self.url,
222
+ }
223
+ if self._lwt_topic is not None:
224
+ spec['bindings'] = OrderedDict({
225
+ "mqtt": OrderedDict({
226
+ "lastWill": OrderedDict({
227
+ "retain": False,
228
+ "message": None,
229
+ "qos": 1,
230
+ "topic": self._lwt_topic,
231
+ })
232
+ })
233
+ })
234
+ if self._host is None or self._port is None:
235
+ spec['variables'] = {}
236
+ if self._host is None:
237
+ spec['variables']['hostname'] = {
238
+ "description": "The hosthame or IP address of the MQTT broker."
239
+ }
240
+ if self._port is None:
241
+ spec['variables']['port'] = {
242
+ "description": "The port for the MQTT server"
243
+ }
244
+ return spec
245
+
246
+ class AsyncApiCreator(object):
247
+ """A class to create a AsyncAPI specification from several AsyncAPI structures.
248
+
249
+ It also accepts a Stinger spec for creating all the structures.
250
+ """
251
+
252
+ def __init__(self):
253
+ self.info = dict()
254
+ self.asyncapi: OrderedDict[str, Any] = OrderedDict({
255
+ "asyncapi": "2.4.0",
256
+ "id": "",
257
+ "info": OrderedDict(),
258
+ "channels": OrderedDict(),
259
+ "components": OrderedDict({
260
+ "operationTraits": OrderedDict({
261
+ "methodCall": OrderedDict({
262
+ "bindings": OrderedDict({
263
+ "mqtt": OrderedDict({
264
+ "bindingVersion": "0.2.0",
265
+ "qos": 2,
266
+ "retain": False,
267
+ }),
268
+ }),
269
+ }),
270
+ "methodCallback": OrderedDict({
271
+ "bindings": OrderedDict({
272
+ "mqtt": OrderedDict({
273
+ "bindingVersion": "0.2.0",
274
+ "qos": 1,
275
+ "retain": False,
276
+ }),
277
+ }),
278
+ }),
279
+ "signal": OrderedDict({
280
+ "bindings": OrderedDict({
281
+ "mqtt": OrderedDict({
282
+ "bindingVersion": "0.2.0",
283
+ "qos": 2,
284
+ "retain": False,
285
+ }),
286
+ }),
287
+ }),
288
+ }),
289
+ "messageTraits": OrderedDict({
290
+ "methodJsonArguments": OrderedDict({
291
+ "bindings": OrderedDict({
292
+ "mqtt": OrderedDict({
293
+ "bindingVersion": "0.2.0",
294
+ "contentType": "application/json",
295
+ "correlationData": OrderedDict({
296
+ "type": "string",
297
+ "format": "uuid",
298
+ }),
299
+ }),
300
+ }),
301
+ }),
302
+ "methodJsonResponse": OrderedDict({
303
+ "bindings": OrderedDict({
304
+ "mqtt": OrderedDict({
305
+ "bindingVersion": "0.2.0",
306
+ "contentType": "application/json",
307
+ "correlationData": OrderedDict({
308
+ "type": "string",
309
+ "format": "uuid",
310
+ }),
311
+ "responseTopic": {
312
+ "type": "string",
313
+ }
314
+ }),
315
+ }),
316
+ }),
317
+ "signalJson": OrderedDict({
318
+ "bindings": OrderedDict({
319
+ "mqtt": OrderedDict({
320
+ "bindingVersion": "0.2.0",
321
+ "contentType": "application/json",
322
+ }),
323
+ }),
324
+ }),
325
+ }),
326
+ "messages": OrderedDict(),
327
+ "schemas": OrderedDict(),
328
+ }),
329
+ })
330
+ self.channels = []
331
+ self.messages = []
332
+ self.servers = []
333
+ self.name = "interface"
334
+
335
+ def add_schema(self, schema_name: str, schema_spec: dict[str, Any]):
336
+ schema_dict: dict[str, Any] = self.asyncapi['components']['schemas']
337
+ schema_dict[schema_name] = schema_spec
338
+
339
+ def add_channel(self, channel: Channel):
340
+ self.channels.append(channel)
341
+
342
+ def add_message(self, message: Message):
343
+ self.messages.append(message)
344
+
345
+ def add_server(self, server: Server):
346
+ self.servers.append(server)
347
+
348
+ def set_interface_name(self, name):
349
+ self.name = name
350
+ self.asyncapi["id"] = f"urn:stingeripc:{name}"
351
+
352
+ def add_to_info(self, key, value):
353
+ self.asyncapi["info"][key] = value
354
+
355
+ def get_asyncapi(self, client_type: SpecType, use_common=None):
356
+ spec = self.asyncapi.copy()
357
+ if len(self.servers) > 0:
358
+ spec['servers'] = {}
359
+ for svr in self.servers:
360
+ spec['servers'][svr.name] = svr.get_server()
361
+ for ch in self.channels:
362
+ spec["channels"][ch.topic] = ch.get_operation(
363
+ client_type, use_common or False
364
+ )
365
+ if use_common is None:
366
+ for msg in self.messages:
367
+ spec["components"]["messages"][msg.name] = msg.get_message()
368
+ return spec
369
+
370
+
371
+ class StingerToAsyncApi:
372
+
373
+ def __init__(self, stinger: StingerSpec):
374
+ self._asyncapi: AsyncApiCreator = AsyncApiCreator()
375
+ self._stinger: StingerSpec = stinger
376
+ self._convert()
377
+
378
+ def _convert(self):
379
+ self._asyncapi.set_interface_name(self._stinger.name)
380
+ self._add_interface_info()
381
+ self._add_servers()
382
+ self._add_enums()
383
+ self._add_signals()
384
+ self._add_methods()
385
+ if len(self._stinger.methods) > 0:
386
+ schema_name = f"stinger_method_return_codes"
387
+ description = [
388
+ f"The stinger_method_return_codes enum has the following values:"
389
+ ]
390
+ accepted_values = []
391
+ for i, enum_value in self._stinger.method_return_codes.items():
392
+ description.append(f"{i} - {enum_value}")
393
+ accepted_values.append(i)
394
+ json_schema = {
395
+ "type": "integer",
396
+ "description": "\n ".join(description),
397
+ "enum": accepted_values
398
+ }
399
+ self._asyncapi.add_schema(schema_name, json_schema)
400
+ return self
401
+
402
+ def _add_interface_info(self):
403
+ topic, info = self._stinger.interface_info
404
+ self._asyncapi.add_to_info("version", info['version'])
405
+ self._asyncapi.add_to_info("title", info['title'])
406
+ ch = Channel(topic, "interfaceInfo", Direction.SERVER_PUBLISHES)
407
+ ch.set_mqtt(qos=1, retain=True)
408
+ self._asyncapi.add_channel(ch)
409
+ msg = Message("interfaceInfo")
410
+ schema = ObjectSchema()
411
+ for k,v in info.items():
412
+ schema.add_const_value_property(k, ArgPrimitiveType.STRING, v)
413
+ msg.set_schema(schema.to_schema())
414
+ self._asyncapi.add_message(msg)
415
+
416
+ def _add_servers(self):
417
+ info_topic, _ = self._stinger.interface_info
418
+ for broker_name, broker_spec in self._stinger.brokers.items():
419
+ svr = Server(broker_name)
420
+ if broker_spec.hostname is not None and broker_spec.port is not None:
421
+ svr.set_host(broker_spec.hostname, broker_spec.port)
422
+ svr.set_lwt_topic(info_topic)
423
+ self._asyncapi.add_server(svr)
424
+
425
+ def _add_enums(self):
426
+ for enum_name, enum_spec in self._stinger.enums.items():
427
+ schema_name = f"enum_{enum_name}"
428
+ description = [
429
+ f"The {enum_name} enum has the following values:"
430
+ ]
431
+ accepted_values = []
432
+ for i, enum_value in enumerate(enum_spec.values):
433
+ description.append(f"{i} - {enum_value}")
434
+ accepted_values.append(i)
435
+ json_schema = {
436
+ "type": "integer",
437
+ "description": "\n ".join(description),
438
+ "enum": accepted_values
439
+ }
440
+ self._asyncapi.add_schema(schema_name, json_schema)
441
+
442
+ def _add_signals(self):
443
+ for sig_name, sig_spec in self._stinger.signals.items():
444
+ ch = Channel(sig_spec.topic, sig_name, Direction.SERVER_PUBLISHES)
445
+ ch.add_operation_trait({"$ref": "#/components/operationTraits/signal"})
446
+ self._asyncapi.add_channel(ch)
447
+ msg = Message(sig_name)
448
+ msg.add_trait({"$ref": "#/components/messageTraits/signalJson"})
449
+ schema = ObjectSchema()
450
+ for arg_spec in sig_spec.arg_list:
451
+ if isinstance(arg_spec, ArgPrimitive):
452
+ schema.add_value_property(arg_spec.name, arg_spec.type)
453
+ elif isinstance(arg_spec, ArgEnum):
454
+ schema.add_reference_property(arg_spec.name, f"#/components/schemas/enum_{arg_spec.enum.name}")
455
+ msg.set_schema(schema.to_schema())
456
+ self._asyncapi.add_message(msg)
457
+
458
+ def _add_methods(self):
459
+ for method_name, method_spec in self._stinger.methods.items():
460
+ call_ch = Channel(method_spec.topic, method_name, Direction.SERVER_SUBSCRIBES)
461
+ call_ch.add_operation_trait({"$ref": "#/components/operationTraits/methodCall"})
462
+ self._asyncapi.add_channel(call_ch)
463
+ call_msg = Message(method_name)
464
+ call_msg.add_trait({"$ref": "#/components/messageTraits/methodJsonArguments"})
465
+ call_msg_schema = ObjectSchema()
466
+ for arg_spec in method_spec.arg_list:
467
+ if isinstance(arg_spec, ArgPrimitive):
468
+ call_msg_schema.add_value_property(arg_spec.name, arg_spec.type)
469
+ elif isinstance(arg_spec, ArgEnum):
470
+ call_msg_schema.add_reference_property(arg_spec.name, f"#/components/schemas/enum_{arg_spec.name}")
471
+ call_msg.set_schema(call_msg_schema.to_schema())
472
+ self._asyncapi.add_message(call_msg)
473
+
474
+ resp_ch = Channel(method_spec.response_topic("{client_id}"), f"{method_name}Response", Direction.SERVER_PUBLISHES)
475
+ resp_ch.add_operation_trait({"$ref": "#/components/operationTraits/methodCall"})
476
+ self._asyncapi.add_channel(resp_ch)
477
+ resp_msg = Message(f"{method_name}Response")
478
+ resp_msg.add_trait({"$ref": "#/components/messageTraits/methodJsonArguments"})
479
+ resp_msg.add_header("result", {"$ref": "#/components/schemas/stinger_method_return_codes"}, required=True)
480
+ resp_msg.add_header("debug", {"type": "string"}, required=False)
481
+ resp_msg_schema = ObjectSchema()
482
+
483
+ def add_arg(arg: Arg):
484
+ if isinstance(arg, ArgPrimitive):
485
+ resp_msg_schema.add_value_property(arg.name, arg.type, required=True)
486
+ elif isinstance(arg, ArgEnum):
487
+ resp_msg_schema.add_reference_property(arg.name, f"#/components/schemas/enum_{arg.enum.name}", required=True)
488
+
489
+ if isinstance(method_spec.return_value, ArgStruct):
490
+ for arg_spec in method_spec.return_value.members:
491
+ add_arg(arg_spec)
492
+ resp_msg_schema.add_value_dependency(arg_spec.name, "result", 0)
493
+ elif method_spec.return_value is not None:
494
+ add_arg(method_spec.return_value)
495
+ resp_msg_schema.add_value_dependency(method_spec.return_value_name, "result", 0)
496
+
497
+ resp_msg.set_schema(resp_msg_schema.to_schema())
498
+ self._asyncapi.add_message(resp_msg)
499
+
500
+ def get_asyncapi(self):
501
+ return self._asyncapi.get_asyncapi(SpecType.CLIENT)