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,606 @@
|
|
|
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
|
+
"""OpenAPI/Swagger ingestor plugin.
|
|
7
|
+
|
|
8
|
+
Parses OpenAPI 3.x and Swagger 2.0 specifications (YAML or JSON) and
|
|
9
|
+
produces normalised :class:`~mannf.product.ingestors.models.IngestedEndpoint`
|
|
10
|
+
and :class:`~mannf.product.ingestors.models.IngestedTestCase` objects.
|
|
11
|
+
|
|
12
|
+
Parsing helpers (``_load_spec``, ``_resolve_ref``, ``_schema_example``,
|
|
13
|
+
``_detect_version``, ``_base_url_from_spec``) are imported directly from the
|
|
14
|
+
existing :mod:`mannf.product.integrations.openapi_parser` module so that spec
|
|
15
|
+
traversal logic is not duplicated.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from mannf.product.ingestors.base import IngestorPlugin
|
|
28
|
+
from mannf.product.ingestors.models import (
|
|
29
|
+
IngestedEndpoint,
|
|
30
|
+
IngestedTestCase,
|
|
31
|
+
IngestorSource,
|
|
32
|
+
IngestResult,
|
|
33
|
+
)
|
|
34
|
+
from mannf.product.integrations.openapi_parser import (
|
|
35
|
+
_base_url_from_spec,
|
|
36
|
+
_detect_version,
|
|
37
|
+
_resolve_ref,
|
|
38
|
+
_schema_example,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# HTTP methods that OpenAPI specs enumerate at path-item level
|
|
44
|
+
_HTTP_METHODS = ("get", "post", "put", "patch", "delete", "head", "options", "trace")
|
|
45
|
+
|
|
46
|
+
# Priority mapping by HTTP method
|
|
47
|
+
_METHOD_PRIORITY: dict[str, str] = {
|
|
48
|
+
"POST": "high",
|
|
49
|
+
"PUT": "high",
|
|
50
|
+
"DELETE": "high",
|
|
51
|
+
"PATCH": "high",
|
|
52
|
+
"GET": "medium",
|
|
53
|
+
"OPTIONS": "low",
|
|
54
|
+
"HEAD": "low",
|
|
55
|
+
"TRACE": "low",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Maximum $ref resolution depth to prevent infinite loops
|
|
59
|
+
_MAX_REF_DEPTH = 10
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _resolve_deep(
|
|
63
|
+
spec: dict[str, Any],
|
|
64
|
+
obj: Any,
|
|
65
|
+
depth: int = 0,
|
|
66
|
+
) -> Any:
|
|
67
|
+
"""Recursively resolve all ``$ref`` pointers in *obj* up to *_MAX_REF_DEPTH*.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
spec:
|
|
72
|
+
The root spec document (used for ``$ref`` resolution).
|
|
73
|
+
obj:
|
|
74
|
+
The object to resolve (dict, list, or scalar).
|
|
75
|
+
depth:
|
|
76
|
+
Current recursion depth (guards against cycles).
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
Any
|
|
81
|
+
The resolved object.
|
|
82
|
+
"""
|
|
83
|
+
if depth > _MAX_REF_DEPTH:
|
|
84
|
+
return obj
|
|
85
|
+
|
|
86
|
+
if isinstance(obj, dict):
|
|
87
|
+
if "$ref" in obj:
|
|
88
|
+
resolved = _resolve_ref(spec, obj["$ref"])
|
|
89
|
+
return _resolve_deep(spec, resolved, depth + 1)
|
|
90
|
+
return {k: _resolve_deep(spec, v, depth + 1) for k, v in obj.items()}
|
|
91
|
+
|
|
92
|
+
if isinstance(obj, list):
|
|
93
|
+
return [_resolve_deep(spec, item, depth + 1) for item in obj]
|
|
94
|
+
|
|
95
|
+
return obj
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_content(content: str, path: str | None) -> dict[str, Any]:
|
|
99
|
+
"""Parse *content* as YAML or JSON, inferring format from *path* extension.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
content:
|
|
104
|
+
Raw spec text.
|
|
105
|
+
path:
|
|
106
|
+
Optional file path used to detect whether content is YAML or JSON.
|
|
107
|
+
|
|
108
|
+
Returns
|
|
109
|
+
-------
|
|
110
|
+
dict[str, Any]
|
|
111
|
+
Parsed spec document.
|
|
112
|
+
|
|
113
|
+
Raises
|
|
114
|
+
------
|
|
115
|
+
ValueError
|
|
116
|
+
If content cannot be parsed as either YAML or JSON.
|
|
117
|
+
"""
|
|
118
|
+
ext = Path(path).suffix.lower() if path else ""
|
|
119
|
+
if ext in {".yaml", ".yml"}:
|
|
120
|
+
result = yaml.safe_load(content)
|
|
121
|
+
elif ext == ".json":
|
|
122
|
+
result = json.loads(content)
|
|
123
|
+
else:
|
|
124
|
+
# Try YAML first (superset of JSON), fall back to JSON
|
|
125
|
+
try:
|
|
126
|
+
result = yaml.safe_load(content)
|
|
127
|
+
except yaml.YAMLError:
|
|
128
|
+
result = json.loads(content)
|
|
129
|
+
|
|
130
|
+
if not isinstance(result, dict):
|
|
131
|
+
raise ValueError("Parsed spec is not a mapping/object.")
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _extract_auth_requirements(
|
|
136
|
+
spec: dict[str, Any],
|
|
137
|
+
operation: dict[str, Any],
|
|
138
|
+
) -> list[str]:
|
|
139
|
+
"""Return a list of security scheme names required for *operation*.
|
|
140
|
+
|
|
141
|
+
Falls back to the global ``security`` requirement when the operation does
|
|
142
|
+
not define its own.
|
|
143
|
+
"""
|
|
144
|
+
security_list: list[dict[str, Any]] | None = operation.get("security")
|
|
145
|
+
if security_list is None:
|
|
146
|
+
security_list = spec.get("security") # type: ignore[assignment]
|
|
147
|
+
|
|
148
|
+
if not security_list:
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
schemes: list[str] = []
|
|
152
|
+
for entry in security_list:
|
|
153
|
+
if isinstance(entry, dict):
|
|
154
|
+
schemes.extend(entry.keys())
|
|
155
|
+
return schemes
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _merge_parameters(
|
|
159
|
+
spec: dict[str, Any],
|
|
160
|
+
path_level: list[dict[str, Any]],
|
|
161
|
+
operation_level: list[dict[str, Any]],
|
|
162
|
+
) -> list[dict[str, Any]]:
|
|
163
|
+
"""Merge path-level and operation-level parameters.
|
|
164
|
+
|
|
165
|
+
Operation-level parameters override path-level parameters with the same
|
|
166
|
+
``name`` + ``in`` pair. All ``$ref`` pointers are resolved.
|
|
167
|
+
|
|
168
|
+
Parameters
|
|
169
|
+
----------
|
|
170
|
+
spec:
|
|
171
|
+
Root spec document.
|
|
172
|
+
path_level:
|
|
173
|
+
Parameters defined at the path-item level.
|
|
174
|
+
operation_level:
|
|
175
|
+
Parameters defined at the operation level.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
list[dict[str, Any]]
|
|
180
|
+
Merged, resolved parameter list.
|
|
181
|
+
"""
|
|
182
|
+
def _key(param: dict[str, Any]) -> tuple[str, str]:
|
|
183
|
+
return (param.get("name", ""), param.get("in", ""))
|
|
184
|
+
|
|
185
|
+
resolved_path = [
|
|
186
|
+
_resolve_deep(spec, p) for p in path_level if isinstance(p, dict)
|
|
187
|
+
]
|
|
188
|
+
resolved_op = [
|
|
189
|
+
_resolve_deep(spec, p) for p in operation_level if isinstance(p, dict)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
merged: dict[tuple[str, str], dict[str, Any]] = {}
|
|
193
|
+
for param in resolved_path:
|
|
194
|
+
merged[_key(param)] = param
|
|
195
|
+
for param in resolved_op:
|
|
196
|
+
merged[_key(param)] = param # operation-level wins
|
|
197
|
+
|
|
198
|
+
return list(merged.values())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _extract_request_body(
|
|
202
|
+
spec: dict[str, Any],
|
|
203
|
+
operation: dict[str, Any],
|
|
204
|
+
version: str,
|
|
205
|
+
) -> dict[str, Any] | None:
|
|
206
|
+
"""Return the resolved request body schema (OAS3) or body parameter (Swagger 2).
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
spec:
|
|
211
|
+
Root spec document.
|
|
212
|
+
operation:
|
|
213
|
+
The operation object.
|
|
214
|
+
version:
|
|
215
|
+
``"openapi3"`` or ``"swagger2"``.
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
dict[str, Any] | None
|
|
220
|
+
The resolved request body dict, or ``None`` if absent.
|
|
221
|
+
"""
|
|
222
|
+
if version == "openapi3":
|
|
223
|
+
request_body = operation.get("requestBody")
|
|
224
|
+
if request_body:
|
|
225
|
+
return _resolve_deep(spec, request_body)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
# Swagger 2 — body parameter
|
|
229
|
+
for param in operation.get("parameters", []):
|
|
230
|
+
resolved = _resolve_deep(spec, param) if isinstance(param, dict) else param
|
|
231
|
+
if isinstance(resolved, dict) and resolved.get("in") == "body":
|
|
232
|
+
return resolved
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _extract_responses(
|
|
237
|
+
spec: dict[str, Any],
|
|
238
|
+
operation: dict[str, Any],
|
|
239
|
+
) -> dict[str, Any]:
|
|
240
|
+
"""Return a resolved responses dict keyed by HTTP status code."""
|
|
241
|
+
raw = operation.get("responses", {})
|
|
242
|
+
return {str(code): _resolve_deep(spec, resp) for code, resp in raw.items()}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _generate_test_cases(
|
|
246
|
+
endpoint: IngestedEndpoint,
|
|
247
|
+
spec: dict[str, Any],
|
|
248
|
+
) -> list[IngestedTestCase]:
|
|
249
|
+
"""Generate synthetic test cases for *endpoint*.
|
|
250
|
+
|
|
251
|
+
Produces:
|
|
252
|
+
- A **happy-path** test case (valid request, expect 2xx).
|
|
253
|
+
- A **missing required params** test case (if required params exist).
|
|
254
|
+
- A **wrong type** test case (if typed params exist).
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
endpoint:
|
|
259
|
+
The endpoint to generate test cases for.
|
|
260
|
+
spec:
|
|
261
|
+
Root spec document (used to resolve schemas).
|
|
262
|
+
|
|
263
|
+
Returns
|
|
264
|
+
-------
|
|
265
|
+
list[IngestedTestCase]
|
|
266
|
+
Generated test cases.
|
|
267
|
+
"""
|
|
268
|
+
cases: list[IngestedTestCase] = []
|
|
269
|
+
method = endpoint.method
|
|
270
|
+
path = endpoint.path
|
|
271
|
+
priority = _METHOD_PRIORITY.get(method, "medium")
|
|
272
|
+
source_ref = f"{method} {path}"
|
|
273
|
+
|
|
274
|
+
# --- Happy-path test case ---
|
|
275
|
+
cases.append(
|
|
276
|
+
IngestedTestCase(
|
|
277
|
+
name=f"[happy path] {method} {path}",
|
|
278
|
+
description=f"Send a valid {method} request to {path} and expect a 2xx response.",
|
|
279
|
+
steps=[
|
|
280
|
+
{
|
|
281
|
+
"action": f"{method} {path}",
|
|
282
|
+
"description": "Send valid request",
|
|
283
|
+
}
|
|
284
|
+
],
|
|
285
|
+
expected_result="2xx response",
|
|
286
|
+
priority=priority,
|
|
287
|
+
tags=["happy-path", "functional"],
|
|
288
|
+
source_ref=source_ref,
|
|
289
|
+
metadata={
|
|
290
|
+
"intent": "functional",
|
|
291
|
+
"test_type": "positive",
|
|
292
|
+
"operationId": endpoint.metadata.get("operationId", ""),
|
|
293
|
+
},
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Collect required parameters
|
|
298
|
+
required_params = [
|
|
299
|
+
p for p in endpoint.parameters
|
|
300
|
+
if isinstance(p, dict) and p.get("required")
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
# --- Missing required params ---
|
|
304
|
+
if required_params:
|
|
305
|
+
first_required = required_params[0]
|
|
306
|
+
param_name = first_required.get("name", "param")
|
|
307
|
+
cases.append(
|
|
308
|
+
IngestedTestCase(
|
|
309
|
+
name=f"[missing required param] {method} {path} — omit '{param_name}'",
|
|
310
|
+
description=(
|
|
311
|
+
f"Send a {method} request to {path} without required "
|
|
312
|
+
f"parameter '{param_name}' and expect a 4xx response."
|
|
313
|
+
),
|
|
314
|
+
steps=[
|
|
315
|
+
{
|
|
316
|
+
"action": f"{method} {path}",
|
|
317
|
+
"description": f"Omit required parameter '{param_name}'",
|
|
318
|
+
}
|
|
319
|
+
],
|
|
320
|
+
expected_result="4xx response (400 or 422)",
|
|
321
|
+
priority=priority,
|
|
322
|
+
tags=["negative", "functional"],
|
|
323
|
+
source_ref=source_ref,
|
|
324
|
+
metadata={
|
|
325
|
+
"intent": "functional",
|
|
326
|
+
"test_type": "negative",
|
|
327
|
+
"neg_type": "missing_required_param",
|
|
328
|
+
"missing_param": param_name,
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# --- Wrong type ---
|
|
334
|
+
typed_params = [
|
|
335
|
+
p for p in endpoint.parameters
|
|
336
|
+
if isinstance(p, dict) and p.get("schema", {}).get("type")
|
|
337
|
+
]
|
|
338
|
+
if not typed_params and endpoint.request_body:
|
|
339
|
+
# Check request body for typed schema properties
|
|
340
|
+
content = endpoint.request_body.get("content", {})
|
|
341
|
+
for media_schema in content.values():
|
|
342
|
+
schema = media_schema.get("schema", {}) if isinstance(media_schema, dict) else {}
|
|
343
|
+
if schema.get("properties"):
|
|
344
|
+
typed_params = [{"_from_body": True, "schema": schema}]
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
if typed_params:
|
|
348
|
+
param = typed_params[0]
|
|
349
|
+
param_name = param.get("name", "body field")
|
|
350
|
+
cases.append(
|
|
351
|
+
IngestedTestCase(
|
|
352
|
+
name=f"[wrong type] {method} {path} — wrong type for '{param_name}'",
|
|
353
|
+
description=(
|
|
354
|
+
f"Send a {method} request to {path} with wrong type for "
|
|
355
|
+
f"'{param_name}' and expect a 4xx response."
|
|
356
|
+
),
|
|
357
|
+
steps=[
|
|
358
|
+
{
|
|
359
|
+
"action": f"{method} {path}",
|
|
360
|
+
"description": f"Use wrong type for '{param_name}'",
|
|
361
|
+
}
|
|
362
|
+
],
|
|
363
|
+
expected_result="4xx response (400 or 422)",
|
|
364
|
+
priority=priority,
|
|
365
|
+
tags=["negative", "functional"],
|
|
366
|
+
source_ref=source_ref,
|
|
367
|
+
metadata={
|
|
368
|
+
"intent": "functional",
|
|
369
|
+
"test_type": "negative",
|
|
370
|
+
"neg_type": "wrong_type",
|
|
371
|
+
"param": param_name,
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
# --- Security test case (if auth is required) ---
|
|
377
|
+
if endpoint.auth_requirements:
|
|
378
|
+
cases.append(
|
|
379
|
+
IngestedTestCase(
|
|
380
|
+
name=f"[security] {method} {path} — missing auth",
|
|
381
|
+
description=(
|
|
382
|
+
f"Send a {method} request to {path} without authentication "
|
|
383
|
+
f"and expect a 401 or 403 response."
|
|
384
|
+
),
|
|
385
|
+
steps=[
|
|
386
|
+
{
|
|
387
|
+
"action": f"{method} {path}",
|
|
388
|
+
"description": "Send request without auth credentials",
|
|
389
|
+
}
|
|
390
|
+
],
|
|
391
|
+
expected_result="401 or 403 response",
|
|
392
|
+
priority="high",
|
|
393
|
+
tags=["security", "auth"],
|
|
394
|
+
source_ref=source_ref,
|
|
395
|
+
metadata={
|
|
396
|
+
"intent": "security",
|
|
397
|
+
"test_type": "negative",
|
|
398
|
+
"neg_type": "missing_auth",
|
|
399
|
+
"auth_schemes": endpoint.auth_requirements,
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return cases
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class OpenAPIIngestor(IngestorPlugin):
|
|
408
|
+
"""Ingest OpenAPI 3.x and Swagger 2.0 specifications (YAML/JSON).
|
|
409
|
+
|
|
410
|
+
Produces normalised :class:`~mannf.product.ingestors.models.IngestedEndpoint`
|
|
411
|
+
and :class:`~mannf.product.ingestors.models.IngestedTestCase` objects from
|
|
412
|
+
OpenAPI 3.x (``openapi`` key) and Swagger 2.0 (``swagger`` key) specs in
|
|
413
|
+
either YAML or JSON format.
|
|
414
|
+
|
|
415
|
+
Configuration keys
|
|
416
|
+
------------------
|
|
417
|
+
base_url : str, optional
|
|
418
|
+
Override the server URL extracted from the spec.
|
|
419
|
+
include_deprecated : bool, optional
|
|
420
|
+
When ``True``, deprecated operations are included in the output.
|
|
421
|
+
Defaults to ``False``.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
name = "openapi"
|
|
425
|
+
display_name = "OpenAPI/Swagger"
|
|
426
|
+
description = "Ingest OpenAPI 3.x and Swagger 2.0 specifications (YAML/JSON)."
|
|
427
|
+
supported_extensions = [".yaml", ".yml", ".json"]
|
|
428
|
+
supported_formats = ["openapi", "swagger"]
|
|
429
|
+
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
# validate_config
|
|
432
|
+
# ------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
435
|
+
"""Validate the plugin configuration.
|
|
436
|
+
|
|
437
|
+
Parameters
|
|
438
|
+
----------
|
|
439
|
+
config:
|
|
440
|
+
Supported keys: ``base_url`` (str, optional),
|
|
441
|
+
``include_deprecated`` (bool, optional).
|
|
442
|
+
|
|
443
|
+
Returns
|
|
444
|
+
-------
|
|
445
|
+
list[str]
|
|
446
|
+
Empty list — no keys are required.
|
|
447
|
+
"""
|
|
448
|
+
errors: list[str] = []
|
|
449
|
+
if "base_url" in config and not isinstance(config["base_url"], str):
|
|
450
|
+
errors.append("'base_url' must be a string.")
|
|
451
|
+
if "include_deprecated" in config and not isinstance(
|
|
452
|
+
config["include_deprecated"], bool
|
|
453
|
+
):
|
|
454
|
+
errors.append("'include_deprecated' must be a boolean.")
|
|
455
|
+
return errors
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# can_handle
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
def can_handle(self, source: IngestorSource) -> bool:
|
|
462
|
+
"""Return ``True`` for openapi/swagger formats and YAML/JSON extensions.
|
|
463
|
+
|
|
464
|
+
Parameters
|
|
465
|
+
----------
|
|
466
|
+
source:
|
|
467
|
+
The source to evaluate.
|
|
468
|
+
|
|
469
|
+
Returns
|
|
470
|
+
-------
|
|
471
|
+
bool
|
|
472
|
+
``True`` if this plugin can ingest the source.
|
|
473
|
+
"""
|
|
474
|
+
if source.format and source.format in self.supported_formats:
|
|
475
|
+
return True
|
|
476
|
+
if not source.format and source.path is not None:
|
|
477
|
+
ext = Path(source.path).suffix.lower()
|
|
478
|
+
return ext in self.supported_extensions
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
# ------------------------------------------------------------------
|
|
482
|
+
# ingest
|
|
483
|
+
# ------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
|
|
486
|
+
"""Parse an OpenAPI/Swagger spec and return a normalised :class:`IngestResult`.
|
|
487
|
+
|
|
488
|
+
Parameters
|
|
489
|
+
----------
|
|
490
|
+
source:
|
|
491
|
+
The source to ingest. Must provide either ``raw_content`` or a
|
|
492
|
+
valid ``path``.
|
|
493
|
+
config:
|
|
494
|
+
Plugin-specific configuration. See :meth:`validate_config` for
|
|
495
|
+
supported keys.
|
|
496
|
+
|
|
497
|
+
Returns
|
|
498
|
+
-------
|
|
499
|
+
IngestResult
|
|
500
|
+
Populated result on success; ``success=False`` with ``errors`` on
|
|
501
|
+
failure.
|
|
502
|
+
"""
|
|
503
|
+
# --- Read content ---
|
|
504
|
+
try:
|
|
505
|
+
content = self._read_source(source)
|
|
506
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
507
|
+
return self._make_error_result(source, str(exc))
|
|
508
|
+
|
|
509
|
+
# --- Parse YAML/JSON ---
|
|
510
|
+
try:
|
|
511
|
+
spec: dict[str, Any] = _parse_content(content, source.path)
|
|
512
|
+
except Exception as exc: # noqa: BLE001
|
|
513
|
+
return self._make_error_result(
|
|
514
|
+
source, f"Failed to parse spec: {exc}"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# --- Detect version ---
|
|
518
|
+
version = _detect_version(spec)
|
|
519
|
+
|
|
520
|
+
# --- Extract base URL ---
|
|
521
|
+
spec_base_url: str = _base_url_from_spec(spec) or ""
|
|
522
|
+
base_url: str = config.get("base_url", spec_base_url) or spec_base_url
|
|
523
|
+
|
|
524
|
+
include_deprecated: bool = bool(config.get("include_deprecated", False))
|
|
525
|
+
|
|
526
|
+
# --- Walk paths ---
|
|
527
|
+
endpoints: list[IngestedEndpoint] = []
|
|
528
|
+
test_cases: list[IngestedTestCase] = []
|
|
529
|
+
paths: dict[str, Any] = spec.get("paths") or {}
|
|
530
|
+
|
|
531
|
+
for url_path, path_item in paths.items():
|
|
532
|
+
if not isinstance(path_item, dict):
|
|
533
|
+
continue
|
|
534
|
+
|
|
535
|
+
path_level_params: list[dict[str, Any]] = [
|
|
536
|
+
p for p in path_item.get("parameters", []) if isinstance(p, dict)
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
for method_lower in _HTTP_METHODS:
|
|
540
|
+
operation: dict[str, Any] | None = path_item.get(method_lower)
|
|
541
|
+
if not isinstance(operation, dict):
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
# Skip deprecated unless explicitly included
|
|
545
|
+
if operation.get("deprecated") and not include_deprecated:
|
|
546
|
+
continue
|
|
547
|
+
|
|
548
|
+
method = method_lower.upper()
|
|
549
|
+
|
|
550
|
+
# Merge parameters (path-level + operation-level), resolve $ref
|
|
551
|
+
merged_params = _merge_parameters(
|
|
552
|
+
spec,
|
|
553
|
+
path_level_params,
|
|
554
|
+
operation.get("parameters", []),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# Request body
|
|
558
|
+
request_body = _extract_request_body(spec, operation, version)
|
|
559
|
+
|
|
560
|
+
# Responses
|
|
561
|
+
responses = _extract_responses(spec, operation)
|
|
562
|
+
|
|
563
|
+
# Auth requirements
|
|
564
|
+
auth_requirements = _extract_auth_requirements(spec, operation)
|
|
565
|
+
|
|
566
|
+
# Tags
|
|
567
|
+
tags: list[str] = operation.get("tags") or []
|
|
568
|
+
|
|
569
|
+
# Metadata
|
|
570
|
+
metadata: dict[str, Any] = {
|
|
571
|
+
"operationId": operation.get("operationId") or "",
|
|
572
|
+
"deprecated": bool(operation.get("deprecated", False)),
|
|
573
|
+
"base_url": base_url,
|
|
574
|
+
"spec_version": version,
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
endpoint = IngestedEndpoint(
|
|
578
|
+
method=method,
|
|
579
|
+
path=url_path,
|
|
580
|
+
summary=operation.get("summary") or "",
|
|
581
|
+
description=operation.get("description") or "",
|
|
582
|
+
parameters=merged_params,
|
|
583
|
+
request_body=request_body,
|
|
584
|
+
responses=responses,
|
|
585
|
+
auth_requirements=auth_requirements,
|
|
586
|
+
tags=list(tags),
|
|
587
|
+
metadata=metadata,
|
|
588
|
+
)
|
|
589
|
+
endpoints.append(endpoint)
|
|
590
|
+
test_cases.extend(_generate_test_cases(endpoint, spec))
|
|
591
|
+
|
|
592
|
+
stats: dict[str, Any] = {
|
|
593
|
+
"endpoints_count": len(endpoints),
|
|
594
|
+
"test_cases_count": len(test_cases),
|
|
595
|
+
"spec_version": version,
|
|
596
|
+
"base_url": base_url,
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return IngestResult(
|
|
600
|
+
success=True,
|
|
601
|
+
source=source,
|
|
602
|
+
endpoints=endpoints,
|
|
603
|
+
test_cases=test_cases,
|
|
604
|
+
errors=[],
|
|
605
|
+
stats=stats,
|
|
606
|
+
)
|