nat-engine 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.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,526 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """WebSocket / AsyncAPI ingestor plugin.
7
+
8
+ Parses AsyncAPI 2.x and 3.x specification files (YAML or JSON) describing
9
+ WebSocket channels and message schemas, and produces normalised
10
+ :class:`~mannf.product.ingestors.models.IngestedEndpoint` and
11
+ :class:`~mannf.product.ingestors.models.IngestedTestCase` objects.
12
+
13
+ Two input formats are supported:
14
+
15
+ * **AsyncAPI YAML** — ``.yaml`` / ``.yml`` files with ``asyncapi: "2.x"`` or
16
+ ``asyncapi: "3.x"`` at the top level.
17
+ * **AsyncAPI JSON** — equivalent JSON files.
18
+
19
+ For each WebSocket channel, one :class:`~mannf.product.ingestors.models.IngestedEndpoint`
20
+ is emitted representing the channel's publish/subscribe operations.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from mannf.product.ingestors.base import IngestorPlugin
32
+ from mannf.product.ingestors.models import (
33
+ IngestedEndpoint,
34
+ IngestedTestCase,
35
+ IngestorSource,
36
+ IngestResult,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Constants
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _OPERATION_PRIORITY: dict[str, str] = {
46
+ "subscribe": "high",
47
+ "publish": "medium",
48
+ }
49
+
50
+ _ASYNCAPI_WS_SCHEMES = {"ws", "wss", "websocket", "websockets"}
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # AsyncAPI spec helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _is_asyncapi(data: dict[str, Any]) -> bool:
58
+ """Return ``True`` if *data* looks like an AsyncAPI spec."""
59
+ return "asyncapi" in data and ("channels" in data or "operations" in data)
60
+
61
+
62
+ def _asyncapi_version(data: dict[str, Any]) -> tuple[int, int]:
63
+ """Return the (major, minor) version of an AsyncAPI spec."""
64
+ raw = str(data.get("asyncapi", "2.0"))
65
+ parts = raw.split(".")
66
+ try:
67
+ major = int(parts[0])
68
+ minor = int(parts[1]) if len(parts) > 1 else 0
69
+ except (ValueError, IndexError):
70
+ major, minor = 2, 0
71
+ return major, minor
72
+
73
+
74
+ def _resolve_schema_ref(ref: str, spec: dict[str, Any]) -> dict[str, Any]:
75
+ """Follow a JSON reference ``#/components/schemas/Foo`` in *spec*."""
76
+ if not ref.startswith("#/"):
77
+ return {}
78
+ parts = ref[2:].split("/")
79
+ node: Any = spec
80
+ for part in parts:
81
+ if isinstance(node, dict):
82
+ node = node.get(part, {})
83
+ else:
84
+ return {}
85
+ return node if isinstance(node, dict) else {}
86
+
87
+
88
+ def _resolve_message(message: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]:
89
+ """Resolve a message object, following ``$ref`` if present."""
90
+ if "$ref" in message:
91
+ return _resolve_schema_ref(message["$ref"], spec)
92
+ return message
93
+
94
+
95
+ def _extract_schema_properties(schema: dict[str, Any]) -> list[dict[str, Any]]:
96
+ """Extract a flat list of property descriptors from a JSON Schema object."""
97
+ properties: list[dict[str, Any]] = []
98
+ required = schema.get("required", [])
99
+ for name, prop_schema in (schema.get("properties") or {}).items():
100
+ properties.append({
101
+ "name": name,
102
+ "type": prop_schema.get("type", "string"),
103
+ "required": name in required,
104
+ "description": prop_schema.get("description", ""),
105
+ })
106
+ return properties
107
+
108
+
109
+ def _build_example_from_schema(schema: dict[str, Any]) -> dict[str, Any]:
110
+ """Build a minimal example dict from a JSON Schema object."""
111
+ if "example" in schema:
112
+ return schema["example"]
113
+ example: dict[str, Any] = {}
114
+ for name, prop_schema in (schema.get("properties") or {}).items():
115
+ ptype = prop_schema.get("type", "string")
116
+ if ptype == "string":
117
+ example[name] = ""
118
+ elif ptype in ("integer", "number"):
119
+ example[name] = 0
120
+ elif ptype == "boolean":
121
+ example[name] = False
122
+ elif ptype == "array":
123
+ example[name] = []
124
+ elif ptype == "object":
125
+ example[name] = {}
126
+ else:
127
+ example[name] = None
128
+ return example
129
+
130
+
131
+ def _extract_channels_v2(
132
+ spec: dict[str, Any],
133
+ include_subscribe: bool,
134
+ include_publish: bool,
135
+ ) -> list[IngestedEndpoint]:
136
+ """Extract endpoints from AsyncAPI 2.x ``channels`` map."""
137
+ endpoints: list[IngestedEndpoint] = []
138
+ servers = spec.get("servers") or {}
139
+ ws_urls: list[str] = []
140
+ for srv in servers.values():
141
+ protocol = (srv.get("protocol") or "").lower()
142
+ if protocol in _ASYNCAPI_WS_SCHEMES:
143
+ ws_urls.append(srv.get("url", ""))
144
+
145
+ ws_base = ws_urls[0] if ws_urls else ""
146
+
147
+ for channel_path, channel_def in (spec.get("channels") or {}).items():
148
+ if not isinstance(channel_def, dict):
149
+ continue
150
+
151
+ for operation in ("subscribe", "publish"):
152
+ if operation == "subscribe" and not include_subscribe:
153
+ continue
154
+ if operation == "publish" and not include_publish:
155
+ continue
156
+
157
+ op_def = channel_def.get(operation)
158
+ if not op_def:
159
+ continue
160
+
161
+ summary = op_def.get("summary") or channel_def.get("description", "")
162
+ tags: list[str] = ["websocket", operation]
163
+ for tag in (op_def.get("tags") or []):
164
+ tags.append(tag.get("name", "") if isinstance(tag, dict) else str(tag))
165
+
166
+ # Resolve message schema
167
+ raw_message = op_def.get("message") or {}
168
+ message = _resolve_message(raw_message, spec)
169
+ payload_schema = _resolve_message(
170
+ message.get("payload") or {}, spec
171
+ )
172
+ if "$ref" in payload_schema:
173
+ payload_schema = _resolve_schema_ref(payload_schema["$ref"], spec)
174
+
175
+ parameters = _extract_schema_properties(payload_schema)
176
+ request_body: dict[str, Any] | None = None
177
+ if payload_schema:
178
+ request_body = {
179
+ "schema": payload_schema,
180
+ "example": _build_example_from_schema(payload_schema),
181
+ "message_name": message.get("name", ""),
182
+ }
183
+
184
+ path = channel_path if channel_path.startswith("/") else f"/{channel_path}"
185
+ ws_url = f"{ws_base}{path}" if ws_base else path
186
+
187
+ endpoint = IngestedEndpoint(
188
+ method=operation.upper(),
189
+ path=path,
190
+ summary=summary,
191
+ description=op_def.get("description", ""),
192
+ parameters=parameters,
193
+ request_body=request_body,
194
+ responses={},
195
+ auth_requirements=[],
196
+ tags=tags,
197
+ metadata={
198
+ "protocol": "websocket",
199
+ "operation": operation,
200
+ "channel": channel_path,
201
+ "ws_url": ws_url,
202
+ "asyncapi_version": "2.x",
203
+ "message_name": message.get("name", ""),
204
+ "content_type": message.get("contentType", "application/json"),
205
+ },
206
+ )
207
+ endpoints.append(endpoint)
208
+
209
+ return endpoints
210
+
211
+
212
+ def _extract_channels_v3(
213
+ spec: dict[str, Any],
214
+ include_subscribe: bool,
215
+ include_publish: bool,
216
+ ) -> list[IngestedEndpoint]:
217
+ """Extract endpoints from AsyncAPI 3.x ``channels`` + ``operations`` maps."""
218
+ endpoints: list[IngestedEndpoint] = []
219
+ servers = spec.get("servers") or {}
220
+ ws_urls: list[str] = []
221
+ for srv in servers.values():
222
+ protocol = (srv.get("protocol") or "").lower()
223
+ if protocol in _ASYNCAPI_WS_SCHEMES:
224
+ host = srv.get("host", "")
225
+ path = srv.get("pathname", "")
226
+ ws_urls.append(f"{host}{path}")
227
+
228
+ ws_base = ws_urls[0] if ws_urls else ""
229
+
230
+ # In AsyncAPI 3.x, operations reference channels
231
+ operations = spec.get("operations") or {}
232
+ channels_map = spec.get("channels") or {}
233
+
234
+ for _op_id, op_def in operations.items():
235
+ if not isinstance(op_def, dict):
236
+ continue
237
+
238
+ action = (op_def.get("action") or "").lower()
239
+ operation = "subscribe" if action in ("receive", "subscribe") else "publish"
240
+
241
+ if operation == "subscribe" and not include_subscribe:
242
+ continue
243
+ if operation == "publish" and not include_publish:
244
+ continue
245
+
246
+ # Resolve channel reference
247
+ channel_ref = op_def.get("channel") or {}
248
+ if "$ref" in channel_ref:
249
+ channel_key = channel_ref["$ref"].split("/")[-1]
250
+ channel_def = channels_map.get(channel_key, {})
251
+ channel_path = channel_def.get("address", channel_key)
252
+ else:
253
+ channel_path = channel_ref.get("address", "")
254
+
255
+ summary = op_def.get("summary") or op_def.get("description", "")
256
+ tags: list[str] = ["websocket", operation]
257
+
258
+ # Resolve message
259
+ raw_messages = op_def.get("messages") or []
260
+ message: dict[str, Any] = {}
261
+ if raw_messages:
262
+ first = raw_messages[0]
263
+ if "$ref" in first:
264
+ message = _resolve_schema_ref(first["$ref"], spec)
265
+ else:
266
+ message = first
267
+
268
+ payload_schema = message.get("payload") or {}
269
+ if "$ref" in payload_schema:
270
+ payload_schema = _resolve_schema_ref(payload_schema["$ref"], spec)
271
+
272
+ parameters = _extract_schema_properties(payload_schema)
273
+ request_body: dict[str, Any] | None = None
274
+ if payload_schema:
275
+ request_body = {
276
+ "schema": payload_schema,
277
+ "example": _build_example_from_schema(payload_schema),
278
+ "message_name": message.get("name", ""),
279
+ }
280
+
281
+ path = channel_path if channel_path.startswith("/") else f"/{channel_path}"
282
+ ws_url = f"{ws_base}{path}" if ws_base else path
283
+
284
+ endpoint = IngestedEndpoint(
285
+ method=operation.upper(),
286
+ path=path,
287
+ summary=summary,
288
+ description=op_def.get("description", ""),
289
+ parameters=parameters,
290
+ request_body=request_body,
291
+ responses={},
292
+ auth_requirements=[],
293
+ tags=tags,
294
+ metadata={
295
+ "protocol": "websocket",
296
+ "operation": operation,
297
+ "channel": channel_path,
298
+ "ws_url": ws_url,
299
+ "asyncapi_version": "3.x",
300
+ "message_name": message.get("name", ""),
301
+ "content_type": message.get("contentType", "application/json"),
302
+ },
303
+ )
304
+ endpoints.append(endpoint)
305
+
306
+ return endpoints
307
+
308
+
309
+ def _generate_test_cases(endpoint: IngestedEndpoint) -> list[IngestedTestCase]:
310
+ """Generate basic test cases for a WebSocket endpoint."""
311
+ cases: list[IngestedTestCase] = []
312
+ meta = endpoint.metadata
313
+ channel = meta.get("channel", endpoint.path)
314
+ operation = meta.get("operation", "subscribe")
315
+ ws_url = meta.get("ws_url", "")
316
+ priority = _OPERATION_PRIORITY.get(operation, "medium")
317
+ example = (endpoint.request_body or {}).get("example", {})
318
+
319
+ # Happy-path connection + message test
320
+ cases.append(
321
+ IngestedTestCase(
322
+ name=f"{channel} — {operation} happy path",
323
+ description=f"Connect to {channel} and {operation} a well-formed message",
324
+ steps=[
325
+ {"action": "ws_connect", "url": ws_url},
326
+ {"action": "ws_send", "payload": example} if operation == "publish" else
327
+ {"action": "ws_receive", "timeout": 5.0},
328
+ {"action": "ws_close"},
329
+ ],
330
+ expected_result="WebSocket connection established; message received without error",
331
+ priority=priority,
332
+ tags=["websocket", operation, "happy_path"],
333
+ source_ref=channel,
334
+ metadata={
335
+ "case_type": "happy_path",
336
+ "channel": channel,
337
+ "operation": operation,
338
+ "ws_url": ws_url,
339
+ },
340
+ )
341
+ )
342
+
343
+ # Connection disconnect / reconnect scenario
344
+ cases.append(
345
+ IngestedTestCase(
346
+ name=f"{channel} — reconnection after disconnect",
347
+ description="Verify client can reconnect after an unexpected disconnect",
348
+ steps=[
349
+ {"action": "ws_connect", "url": ws_url},
350
+ {"action": "ws_disconnect_abrupt"},
351
+ {"action": "ws_connect", "url": ws_url},
352
+ {"action": "ws_close"},
353
+ ],
354
+ expected_result="Reconnection succeeds; no lingering error state",
355
+ priority="medium",
356
+ tags=["websocket", operation, "reconnection"],
357
+ source_ref=channel,
358
+ metadata={
359
+ "case_type": "reconnection",
360
+ "channel": channel,
361
+ "operation": operation,
362
+ "ws_url": ws_url,
363
+ },
364
+ )
365
+ )
366
+
367
+ return cases
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # WebSocketIngestor
372
+ # ---------------------------------------------------------------------------
373
+
374
+
375
+ def _looks_like_asyncapi(raw: str) -> bool:
376
+ """Quick heuristic to detect AsyncAPI content."""
377
+ return "asyncapi:" in raw or '"asyncapi"' in raw
378
+
379
+
380
+ class WebSocketIngestor(IngestorPlugin):
381
+ """Parse AsyncAPI 2.x/3.x specs describing WebSocket channels.
382
+
383
+ Produces one :class:`~mannf.product.ingestors.models.IngestedEndpoint`
384
+ per channel/operation and generates happy-path and reconnection test cases.
385
+
386
+ Supported input formats:
387
+
388
+ * AsyncAPI YAML (``.yaml`` / ``.yml``) with a ``asyncapi:`` key.
389
+ * AsyncAPI JSON (``.json``) with an ``"asyncapi"`` key.
390
+
391
+ Configuration keys (passed in the *config* dict to :meth:`ingest`):
392
+
393
+ ``include_subscribe``
394
+ Include subscribe/receive operations (default: ``True``).
395
+ ``include_publish``
396
+ Include publish/send operations (default: ``True``).
397
+ ``ws_endpoint``
398
+ Override the WebSocket base URL extracted from the spec servers block.
399
+ """
400
+
401
+ name = "websocket"
402
+ display_name = "WebSocket / AsyncAPI"
403
+ description = "Parse AsyncAPI 2.x/3.x specs and extract WebSocket channel definitions."
404
+ supported_extensions = [".yaml", ".yml", ".json"]
405
+ supported_formats = ["websocket", "asyncapi", "ws"]
406
+
407
+ # ------------------------------------------------------------------
408
+ # can_handle
409
+ # ------------------------------------------------------------------
410
+
411
+ def can_handle(self, source: IngestorSource) -> bool:
412
+ if source.format in self.supported_formats:
413
+ return True
414
+ if source.raw_content and _looks_like_asyncapi(source.raw_content):
415
+ return True
416
+ if source.path is not None:
417
+ ext = Path(source.path).suffix.lower()
418
+ if ext in (".yaml", ".yml", ".json") and source.raw_content:
419
+ return _looks_like_asyncapi(source.raw_content)
420
+ return False
421
+
422
+ # ------------------------------------------------------------------
423
+ # validate_config
424
+ # ------------------------------------------------------------------
425
+
426
+ def validate_config(self, config: dict) -> list[str]:
427
+ return []
428
+
429
+ # ------------------------------------------------------------------
430
+ # ingest
431
+ # ------------------------------------------------------------------
432
+
433
+ async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
434
+ """Parse an AsyncAPI spec and return a normalised :class:`IngestResult`.
435
+
436
+ Parameters
437
+ ----------
438
+ source:
439
+ The source to ingest. Must provide either ``raw_content`` or a
440
+ valid ``path``.
441
+ config:
442
+ Plugin-specific configuration. See class docstring for keys.
443
+
444
+ Returns
445
+ -------
446
+ IngestResult
447
+ Populated result on success; ``success=False`` with ``errors`` on
448
+ failure.
449
+ """
450
+ try:
451
+ content = self._read_source(source)
452
+ except (FileNotFoundError, ValueError) as exc:
453
+ return self._make_error_result(source, str(exc))
454
+
455
+ if not content or not content.strip():
456
+ return self._make_error_result(source, "Empty content — nothing to ingest.")
457
+
458
+ include_subscribe: bool = bool(config.get("include_subscribe", True))
459
+ include_publish: bool = bool(config.get("include_publish", True))
460
+ ws_endpoint_override: str = config.get("ws_endpoint", "") or ""
461
+
462
+ # Parse YAML or JSON
463
+ stripped = content.strip()
464
+ spec: dict[str, Any]
465
+ try:
466
+ if stripped.startswith("{"):
467
+ spec = json.loads(content)
468
+ else:
469
+ import yaml # pyyaml
470
+ spec = yaml.safe_load(content) or {}
471
+ except Exception as exc: # noqa: BLE001
472
+ return self._make_error_result(source, f"Failed to parse spec: {exc}")
473
+
474
+ if not isinstance(spec, dict) or not _is_asyncapi(spec):
475
+ return self._make_error_result(
476
+ source,
477
+ "Content does not appear to be an AsyncAPI specification "
478
+ "(missing 'asyncapi' key or 'channels' / 'operations' block).",
479
+ )
480
+
481
+ major, _minor = _asyncapi_version(spec)
482
+ warnings: list[str] = []
483
+
484
+ if major >= 3:
485
+ endpoints = _extract_channels_v3(spec, include_subscribe, include_publish)
486
+ else:
487
+ endpoints = _extract_channels_v2(spec, include_subscribe, include_publish)
488
+
489
+ # Apply ws_endpoint override
490
+ if ws_endpoint_override:
491
+ for ep in endpoints:
492
+ channel = ep.metadata.get("channel", ep.path)
493
+ ep.metadata["ws_url"] = f"{ws_endpoint_override}{channel}"
494
+
495
+ # Generate test cases
496
+ test_cases: list[IngestedTestCase] = []
497
+ for ep in endpoints:
498
+ test_cases.extend(_generate_test_cases(ep))
499
+
500
+ if not endpoints:
501
+ warnings.append(
502
+ "No WebSocket channels found. Ensure the AsyncAPI spec defines "
503
+ "channels with subscribe or publish operations and that the server "
504
+ "uses a WebSocket protocol (ws, wss, websocket, websockets)."
505
+ )
506
+
507
+ stats: dict[str, Any] = {
508
+ "endpoints_count": len(endpoints),
509
+ "test_cases_count": len(test_cases),
510
+ "asyncapi_version": f"{major}.x",
511
+ "subscribe_count": sum(
512
+ 1 for e in endpoints if e.metadata.get("operation") == "subscribe"
513
+ ),
514
+ "publish_count": sum(
515
+ 1 for e in endpoints if e.metadata.get("operation") == "publish"
516
+ ),
517
+ }
518
+
519
+ return IngestResult(
520
+ success=True,
521
+ source=source,
522
+ endpoints=endpoints,
523
+ test_cases=test_cases,
524
+ warnings=warnings,
525
+ stats=stats,
526
+ )
@@ -0,0 +1,21 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Integrations sub-package — HTTP SUT, OpenAPI parser, Postman parser, and auth strategies."""
7
+
8
+ from mannf.product.integrations.auth import ApiKeyAuth, AuthStrategy, BearerTokenAuth, NoAuth
9
+ from mannf.product.integrations.http_sut import HttpApiSUT
10
+ from mannf.product.integrations.openapi_parser import OpenApiParser
11
+ from mannf.product.integrations.postman_parser import PostmanParser
12
+
13
+ __all__ = [
14
+ "AuthStrategy",
15
+ "NoAuth",
16
+ "ApiKeyAuth",
17
+ "BearerTokenAuth",
18
+ "HttpApiSUT",
19
+ "OpenApiParser",
20
+ "PostmanParser",
21
+ ]