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,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
+ )