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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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
|
+
]
|