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,845 @@
|
|
|
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
|
+
"""GraphQL Schema ingestor plugin.
|
|
7
|
+
|
|
8
|
+
Parses GraphQL schemas from introspection JSON or SDL (Schema Definition
|
|
9
|
+
Language) files 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
|
+
* **Introspection JSON** — the ``{"data": {"__schema": {...}}}`` or plain
|
|
16
|
+
``{"__schema": {...}}`` payload returned by a GraphQL introspection query.
|
|
17
|
+
* **SDL** — ``.graphql`` / ``.gql`` source text with ``type Query``,
|
|
18
|
+
``type Mutation``, and/or ``type Subscription`` root-type definitions.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import re
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from mannf.product.ingestors.base import IngestorPlugin
|
|
30
|
+
from mannf.product.ingestors.models import (
|
|
31
|
+
IngestedEndpoint,
|
|
32
|
+
IngestedTestCase,
|
|
33
|
+
IngestorSource,
|
|
34
|
+
IngestResult,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Constants
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Priority mapping by GraphQL operation type
|
|
44
|
+
_OPERATION_PRIORITY: dict[str, str] = {
|
|
45
|
+
"QUERY": "medium",
|
|
46
|
+
"MUTATION": "high",
|
|
47
|
+
"SUBSCRIPTION": "low",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Tags added to every endpoint by operation type
|
|
51
|
+
_OPERATION_TAGS: dict[str, list[str]] = {
|
|
52
|
+
"QUERY": ["graphql", "query"],
|
|
53
|
+
"MUTATION": ["graphql", "mutation"],
|
|
54
|
+
"SUBSCRIPTION": ["graphql", "subscription"],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Regex patterns for the lightweight SDL parser
|
|
58
|
+
_RE_BLOCK_COMMENT = re.compile(r'"""(.*?)"""', re.DOTALL)
|
|
59
|
+
_RE_LINE_COMMENT = re.compile(r'#[^\n]*')
|
|
60
|
+
_RE_TYPE_BLOCK = re.compile(
|
|
61
|
+
r'type\s+(\w+)\s*(?:implements[^{]*)?\{([^}]*)\}',
|
|
62
|
+
re.DOTALL,
|
|
63
|
+
)
|
|
64
|
+
_RE_FIELD = re.compile(
|
|
65
|
+
r'(\w+)\s*(\([^)]*\))?\s*:\s*([\w!\[\]]+)',
|
|
66
|
+
)
|
|
67
|
+
_RE_ARG = re.compile(
|
|
68
|
+
r'(\w+)\s*:\s*([\w!\[\]]+)(?:\s*=\s*[^\s,)]+)?',
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Introspection JSON helpers
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _unwrap_introspection(data: dict[str, Any]) -> dict[str, Any] | None:
|
|
78
|
+
"""Return the ``__schema`` dict from an introspection payload, or ``None``.
|
|
79
|
+
|
|
80
|
+
Handles both the raw ``{"__schema": {...}}`` shape and the wrapped
|
|
81
|
+
``{"data": {"__schema": {...}}}`` shape returned by some clients.
|
|
82
|
+
"""
|
|
83
|
+
if "__schema" in data:
|
|
84
|
+
return data["__schema"] # type: ignore[return-value]
|
|
85
|
+
nested = data.get("data") or {}
|
|
86
|
+
if isinstance(nested, dict) and "__schema" in nested:
|
|
87
|
+
return nested["__schema"] # type: ignore[return-value]
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_introspection_json(data: dict[str, Any]) -> bool:
|
|
92
|
+
"""Return ``True`` if *data* looks like a GraphQL introspection result."""
|
|
93
|
+
return _unwrap_introspection(data) is not None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _type_name(type_ref: dict[str, Any] | None) -> str:
|
|
97
|
+
"""Unwrap a nested ``{"kind": ..., "name": ..., "ofType": ...}`` type ref."""
|
|
98
|
+
if not type_ref:
|
|
99
|
+
return ""
|
|
100
|
+
if type_ref.get("name"):
|
|
101
|
+
return type_ref["name"]
|
|
102
|
+
return _type_name(type_ref.get("ofType"))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_non_null(type_ref: dict[str, Any] | None) -> bool:
|
|
106
|
+
"""Return ``True`` if the type ref represents a non-null (required) type."""
|
|
107
|
+
if not type_ref:
|
|
108
|
+
return False
|
|
109
|
+
return type_ref.get("kind") == "NON_NULL"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _args_from_introspection(
|
|
113
|
+
args: list[dict[str, Any]],
|
|
114
|
+
) -> list[dict[str, Any]]:
|
|
115
|
+
"""Convert an introspection ``args`` array to ingestor parameter dicts."""
|
|
116
|
+
params: list[dict[str, Any]] = []
|
|
117
|
+
for arg in args:
|
|
118
|
+
if not isinstance(arg, dict):
|
|
119
|
+
continue
|
|
120
|
+
params.append(
|
|
121
|
+
{
|
|
122
|
+
"name": arg.get("name", ""),
|
|
123
|
+
"description": arg.get("description") or "",
|
|
124
|
+
"required": _is_non_null(arg.get("type")),
|
|
125
|
+
"schema": {"type": _type_name(arg.get("type"))},
|
|
126
|
+
"defaultValue": arg.get("defaultValue"),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
return params
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _endpoints_from_introspection(
|
|
133
|
+
schema: dict[str, Any],
|
|
134
|
+
base_url: str,
|
|
135
|
+
include_deprecated: bool,
|
|
136
|
+
include_subscriptions: bool,
|
|
137
|
+
) -> list[IngestedEndpoint]:
|
|
138
|
+
"""Extract endpoints from a parsed ``__schema`` dict."""
|
|
139
|
+
# Build a lookup of all types by name
|
|
140
|
+
types_by_name: dict[str, dict[str, Any]] = {
|
|
141
|
+
t["name"]: t
|
|
142
|
+
for t in schema.get("types", [])
|
|
143
|
+
if isinstance(t, dict) and t.get("name")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
root_type_map: dict[str, str] = {}
|
|
147
|
+
for role in ("queryType", "mutationType", "subscriptionType"):
|
|
148
|
+
ref = schema.get(role)
|
|
149
|
+
if isinstance(ref, dict) and ref.get("name"):
|
|
150
|
+
root_type_map[role] = ref["name"]
|
|
151
|
+
|
|
152
|
+
operation_kind: dict[str, str] = {
|
|
153
|
+
"queryType": "QUERY",
|
|
154
|
+
"mutationType": "MUTATION",
|
|
155
|
+
"subscriptionType": "SUBSCRIPTION",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
endpoints: list[IngestedEndpoint] = []
|
|
159
|
+
|
|
160
|
+
for role, type_name in root_type_map.items():
|
|
161
|
+
if role == "subscriptionType" and not include_subscriptions:
|
|
162
|
+
continue
|
|
163
|
+
kind = operation_kind[role]
|
|
164
|
+
root_type = types_by_name.get(type_name)
|
|
165
|
+
if not root_type:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
for field in root_type.get("fields") or []:
|
|
169
|
+
if not isinstance(field, dict):
|
|
170
|
+
continue
|
|
171
|
+
if field.get("isDeprecated") and not include_deprecated:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
field_name: str = field.get("name", "")
|
|
175
|
+
description: str = field.get("description") or ""
|
|
176
|
+
return_type: str = _type_name(field.get("type"))
|
|
177
|
+
args: list[dict[str, Any]] = field.get("args") or []
|
|
178
|
+
|
|
179
|
+
# Collect directives for auth hints
|
|
180
|
+
auth_requirements: list[str] = _auth_from_directives(
|
|
181
|
+
field.get("directives") or []
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
path = f"/graphql#{kind.lower()}.{field_name}"
|
|
185
|
+
|
|
186
|
+
endpoint = IngestedEndpoint(
|
|
187
|
+
method=kind,
|
|
188
|
+
path=path,
|
|
189
|
+
summary=description,
|
|
190
|
+
description=description,
|
|
191
|
+
parameters=_args_from_introspection(args),
|
|
192
|
+
request_body=_build_request_body(args),
|
|
193
|
+
responses={
|
|
194
|
+
"200": {
|
|
195
|
+
"description": "Successful response",
|
|
196
|
+
"type": return_type,
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
auth_requirements=auth_requirements,
|
|
200
|
+
tags=list(_OPERATION_TAGS[kind]),
|
|
201
|
+
metadata={
|
|
202
|
+
"operationName": field_name,
|
|
203
|
+
"returnType": return_type,
|
|
204
|
+
"typeName": type_name,
|
|
205
|
+
"deprecated": bool(field.get("isDeprecated", False)),
|
|
206
|
+
"base_url": base_url,
|
|
207
|
+
"source": "introspection",
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
endpoints.append(endpoint)
|
|
211
|
+
|
|
212
|
+
return endpoints
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _auth_from_directives(directives: list[dict[str, Any]]) -> list[str]:
|
|
216
|
+
"""Return auth scheme names hinted by directive names."""
|
|
217
|
+
auth_hints: list[str] = []
|
|
218
|
+
auth_directive_names = {"auth", "authenticated", "requiresAuth", "authorize", "jwt"}
|
|
219
|
+
for d in directives:
|
|
220
|
+
if not isinstance(d, dict):
|
|
221
|
+
continue
|
|
222
|
+
name = d.get("name", "")
|
|
223
|
+
if name in auth_directive_names:
|
|
224
|
+
auth_hints.append(name)
|
|
225
|
+
return auth_hints
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _build_request_body(
|
|
229
|
+
args: list[dict[str, Any]],
|
|
230
|
+
) -> dict[str, Any] | None:
|
|
231
|
+
"""Build a minimal request body descriptor from introspection args."""
|
|
232
|
+
if not args:
|
|
233
|
+
return None
|
|
234
|
+
properties: dict[str, Any] = {}
|
|
235
|
+
required: list[str] = []
|
|
236
|
+
for arg in args:
|
|
237
|
+
if not isinstance(arg, dict):
|
|
238
|
+
continue
|
|
239
|
+
name = arg.get("name", "")
|
|
240
|
+
type_name_str = _type_name(arg.get("type"))
|
|
241
|
+
properties[name] = {"type": type_name_str}
|
|
242
|
+
if _is_non_null(arg.get("type")):
|
|
243
|
+
required.append(name)
|
|
244
|
+
|
|
245
|
+
body: dict[str, Any] = {
|
|
246
|
+
"content": {
|
|
247
|
+
"application/json": {
|
|
248
|
+
"schema": {
|
|
249
|
+
"type": "object",
|
|
250
|
+
"properties": properties,
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if required:
|
|
256
|
+
body["content"]["application/json"]["schema"]["required"] = required
|
|
257
|
+
return body
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
# SDL parser helpers
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _is_sdl(content: str) -> bool:
|
|
266
|
+
"""Return ``True`` if *content* appears to be GraphQL SDL source text."""
|
|
267
|
+
stripped = content.strip()
|
|
268
|
+
# SDL files often start with comments, schema/type keywords, or triple-quoted docs
|
|
269
|
+
return bool(
|
|
270
|
+
re.search(r'\btype\s+\w+', stripped)
|
|
271
|
+
or re.search(r'\bschema\s*\{', stripped)
|
|
272
|
+
or stripped.startswith('"""')
|
|
273
|
+
or stripped.startswith('#')
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _strip_comments(content: str) -> str:
|
|
278
|
+
"""Strip ``#`` line comments from SDL content (preserving triple-quoted strings)."""
|
|
279
|
+
return _RE_LINE_COMMENT.sub('', content)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class _SDLField:
|
|
283
|
+
"""Lightweight representation of a parsed SDL field/operation."""
|
|
284
|
+
|
|
285
|
+
__slots__ = ("name", "description", "args", "return_type")
|
|
286
|
+
|
|
287
|
+
def __init__(
|
|
288
|
+
self,
|
|
289
|
+
name: str,
|
|
290
|
+
description: str,
|
|
291
|
+
args: list[dict[str, Any]],
|
|
292
|
+
return_type: str,
|
|
293
|
+
) -> None:
|
|
294
|
+
self.name = name
|
|
295
|
+
self.description = description
|
|
296
|
+
self.args = args
|
|
297
|
+
self.return_type = return_type
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_sdl_args(args_str: str) -> list[dict[str, Any]]:
|
|
301
|
+
"""Parse a parenthesised argument list string into parameter dicts."""
|
|
302
|
+
if not args_str:
|
|
303
|
+
return []
|
|
304
|
+
params: list[dict[str, Any]] = []
|
|
305
|
+
# Strip outer parentheses if present
|
|
306
|
+
args_str = args_str.strip().lstrip('(').rstrip(')')
|
|
307
|
+
for m in _RE_ARG.finditer(args_str):
|
|
308
|
+
arg_name = m.group(1)
|
|
309
|
+
arg_type = m.group(2)
|
|
310
|
+
required = arg_type.endswith('!')
|
|
311
|
+
params.append(
|
|
312
|
+
{
|
|
313
|
+
"name": arg_name,
|
|
314
|
+
"description": "",
|
|
315
|
+
"required": required,
|
|
316
|
+
"schema": {"type": arg_type.rstrip('!')},
|
|
317
|
+
"defaultValue": None,
|
|
318
|
+
}
|
|
319
|
+
)
|
|
320
|
+
return params
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _parse_sdl_type_block(block_body: str) -> list[_SDLField]:
|
|
324
|
+
"""Parse the body of a GraphQL type block into :class:`_SDLField` objects.
|
|
325
|
+
|
|
326
|
+
Handles triple-quoted description strings attached to fields.
|
|
327
|
+
"""
|
|
328
|
+
fields: list[_SDLField] = []
|
|
329
|
+
# Extract triple-quoted descriptions alongside fields
|
|
330
|
+
# Replace triple-quoted docs with placeholder to assist field parsing
|
|
331
|
+
descriptions: dict[int, str] = {}
|
|
332
|
+
idx = 0
|
|
333
|
+
cleaned_parts: list[str] = []
|
|
334
|
+
last = 0
|
|
335
|
+
for m in _RE_BLOCK_COMMENT.finditer(block_body):
|
|
336
|
+
cleaned_parts.append(block_body[last:m.start()])
|
|
337
|
+
desc_text = m.group(1).strip()
|
|
338
|
+
placeholder = f"__DOC_{idx}__"
|
|
339
|
+
cleaned_parts.append(placeholder)
|
|
340
|
+
descriptions[idx] = desc_text
|
|
341
|
+
idx += 1
|
|
342
|
+
last = m.end()
|
|
343
|
+
cleaned_parts.append(block_body[last:])
|
|
344
|
+
cleaned = ''.join(cleaned_parts)
|
|
345
|
+
cleaned = _strip_comments(cleaned)
|
|
346
|
+
|
|
347
|
+
# Split lines for field detection
|
|
348
|
+
lines = [ln.strip() for ln in cleaned.splitlines() if ln.strip()]
|
|
349
|
+
pending_desc = ""
|
|
350
|
+
for line in lines:
|
|
351
|
+
# Check if this line is a doc placeholder
|
|
352
|
+
doc_match = re.match(r'^__DOC_(\d+)__$', line)
|
|
353
|
+
if doc_match:
|
|
354
|
+
pending_desc = descriptions[int(doc_match.group(1))]
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
field_match = _RE_FIELD.match(line)
|
|
358
|
+
if field_match:
|
|
359
|
+
f_name = field_match.group(1)
|
|
360
|
+
args_str = field_match.group(2) or ""
|
|
361
|
+
f_return_type = field_match.group(3)
|
|
362
|
+
args = _parse_sdl_args(args_str)
|
|
363
|
+
fields.append(
|
|
364
|
+
_SDLField(
|
|
365
|
+
name=f_name,
|
|
366
|
+
description=pending_desc,
|
|
367
|
+
args=args,
|
|
368
|
+
return_type=f_return_type,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
pending_desc = ""
|
|
372
|
+
|
|
373
|
+
return fields
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _endpoints_from_sdl(
|
|
377
|
+
content: str,
|
|
378
|
+
base_url: str,
|
|
379
|
+
include_deprecated: bool,
|
|
380
|
+
include_subscriptions: bool,
|
|
381
|
+
) -> tuple[list[IngestedEndpoint], list[str]]:
|
|
382
|
+
"""Parse SDL text and return ``(endpoints, warnings)``."""
|
|
383
|
+
endpoints: list[IngestedEndpoint] = []
|
|
384
|
+
warnings: list[str] = []
|
|
385
|
+
|
|
386
|
+
root_type_names: dict[str, str] = {
|
|
387
|
+
"Query": "QUERY",
|
|
388
|
+
"Mutation": "MUTATION",
|
|
389
|
+
"Subscription": "SUBSCRIPTION",
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for type_block_match in _RE_TYPE_BLOCK.finditer(content):
|
|
393
|
+
type_name = type_block_match.group(1)
|
|
394
|
+
if type_name not in root_type_names:
|
|
395
|
+
continue
|
|
396
|
+
kind = root_type_names[type_name]
|
|
397
|
+
if kind == "SUBSCRIPTION" and not include_subscriptions:
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
block_body = type_block_match.group(2)
|
|
401
|
+
sdl_fields = _parse_sdl_type_block(block_body)
|
|
402
|
+
|
|
403
|
+
for sdl_field in sdl_fields:
|
|
404
|
+
# SDL has no built-in deprecated marker in a simple regex parser;
|
|
405
|
+
# skip fields only if include_deprecated is explicitly handled
|
|
406
|
+
path = f"/graphql#{kind.lower()}.{sdl_field.name}"
|
|
407
|
+
|
|
408
|
+
# Build parameter list from args
|
|
409
|
+
parameters = [
|
|
410
|
+
{
|
|
411
|
+
"name": a["name"],
|
|
412
|
+
"description": a["description"],
|
|
413
|
+
"required": a["required"],
|
|
414
|
+
"schema": a["schema"],
|
|
415
|
+
"defaultValue": a["defaultValue"],
|
|
416
|
+
}
|
|
417
|
+
for a in sdl_field.args
|
|
418
|
+
]
|
|
419
|
+
|
|
420
|
+
request_body: dict[str, Any] | None = None
|
|
421
|
+
if sdl_field.args:
|
|
422
|
+
props = {
|
|
423
|
+
a["name"]: {"type": a["schema"].get("type", "")}
|
|
424
|
+
for a in sdl_field.args
|
|
425
|
+
}
|
|
426
|
+
required_args = [a["name"] for a in sdl_field.args if a["required"]]
|
|
427
|
+
schema_obj: dict[str, Any] = {
|
|
428
|
+
"type": "object",
|
|
429
|
+
"properties": props,
|
|
430
|
+
}
|
|
431
|
+
if required_args:
|
|
432
|
+
schema_obj["required"] = required_args
|
|
433
|
+
request_body = {
|
|
434
|
+
"content": {
|
|
435
|
+
"application/json": {"schema": schema_obj}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
endpoint = IngestedEndpoint(
|
|
440
|
+
method=kind,
|
|
441
|
+
path=path,
|
|
442
|
+
summary=sdl_field.description,
|
|
443
|
+
description=sdl_field.description,
|
|
444
|
+
parameters=parameters,
|
|
445
|
+
request_body=request_body,
|
|
446
|
+
responses={
|
|
447
|
+
"200": {
|
|
448
|
+
"description": "Successful response",
|
|
449
|
+
"type": sdl_field.return_type,
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
auth_requirements=[],
|
|
453
|
+
tags=list(_OPERATION_TAGS[kind]),
|
|
454
|
+
metadata={
|
|
455
|
+
"operationName": sdl_field.name,
|
|
456
|
+
"returnType": sdl_field.return_type,
|
|
457
|
+
"typeName": type_name,
|
|
458
|
+
"deprecated": False,
|
|
459
|
+
"base_url": base_url,
|
|
460
|
+
"source": "sdl",
|
|
461
|
+
},
|
|
462
|
+
)
|
|
463
|
+
endpoints.append(endpoint)
|
|
464
|
+
|
|
465
|
+
return endpoints, warnings
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
# Test case generation
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _generate_test_cases(endpoint: IngestedEndpoint) -> list[IngestedTestCase]:
|
|
474
|
+
"""Generate synthetic test cases for a GraphQL *endpoint*.
|
|
475
|
+
|
|
476
|
+
Produces:
|
|
477
|
+
|
|
478
|
+
* **Happy-path** — valid operation, expect a 200 response.
|
|
479
|
+
* **Missing required args** — omit a required argument, expect an error.
|
|
480
|
+
* **Security** — if auth is required, omit credentials (expect 401/403).
|
|
481
|
+
* **Invalid input type** — for MUTATION operations with typed args.
|
|
482
|
+
"""
|
|
483
|
+
cases: list[IngestedTestCase] = []
|
|
484
|
+
kind = endpoint.method # "QUERY", "MUTATION", or "SUBSCRIPTION"
|
|
485
|
+
op_name = endpoint.metadata.get("operationName", endpoint.path)
|
|
486
|
+
priority = _OPERATION_PRIORITY.get(kind, "medium")
|
|
487
|
+
source_ref = f"{kind} {op_name}"
|
|
488
|
+
|
|
489
|
+
# --- Happy-path ---
|
|
490
|
+
cases.append(
|
|
491
|
+
IngestedTestCase(
|
|
492
|
+
name=f"[happy path] {kind} {op_name}",
|
|
493
|
+
description=(
|
|
494
|
+
f"Execute a valid {kind.lower()} '{op_name}' and expect a "
|
|
495
|
+
f"successful response."
|
|
496
|
+
),
|
|
497
|
+
steps=[
|
|
498
|
+
{
|
|
499
|
+
"action": f"{kind} {op_name}",
|
|
500
|
+
"description": "Send valid GraphQL operation",
|
|
501
|
+
}
|
|
502
|
+
],
|
|
503
|
+
expected_result="200 response with data field",
|
|
504
|
+
priority=priority,
|
|
505
|
+
tags=["happy-path", "functional"],
|
|
506
|
+
source_ref=source_ref,
|
|
507
|
+
metadata={
|
|
508
|
+
"intent": "functional",
|
|
509
|
+
"test_type": "positive",
|
|
510
|
+
"operationName": op_name,
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# --- Missing required args ---
|
|
516
|
+
required_params = [
|
|
517
|
+
p for p in endpoint.parameters
|
|
518
|
+
if isinstance(p, dict) and p.get("required")
|
|
519
|
+
]
|
|
520
|
+
if required_params:
|
|
521
|
+
first_required = required_params[0]
|
|
522
|
+
param_name = first_required.get("name", "arg")
|
|
523
|
+
cases.append(
|
|
524
|
+
IngestedTestCase(
|
|
525
|
+
name=f"[missing required arg] {kind} {op_name} — omit '{param_name}'",
|
|
526
|
+
description=(
|
|
527
|
+
f"Execute '{op_name}' without required argument "
|
|
528
|
+
f"'{param_name}' and expect an error response."
|
|
529
|
+
),
|
|
530
|
+
steps=[
|
|
531
|
+
{
|
|
532
|
+
"action": f"{kind} {op_name}",
|
|
533
|
+
"description": f"Omit required argument '{param_name}'",
|
|
534
|
+
}
|
|
535
|
+
],
|
|
536
|
+
expected_result="GraphQL error or 400 response",
|
|
537
|
+
priority=priority,
|
|
538
|
+
tags=["negative", "functional"],
|
|
539
|
+
source_ref=source_ref,
|
|
540
|
+
metadata={
|
|
541
|
+
"intent": "functional",
|
|
542
|
+
"test_type": "negative",
|
|
543
|
+
"neg_type": "missing_required_arg",
|
|
544
|
+
"missing_arg": param_name,
|
|
545
|
+
},
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
# --- Security test case ---
|
|
550
|
+
if endpoint.auth_requirements:
|
|
551
|
+
cases.append(
|
|
552
|
+
IngestedTestCase(
|
|
553
|
+
name=f"[security] {kind} {op_name} — missing auth",
|
|
554
|
+
description=(
|
|
555
|
+
f"Execute '{op_name}' without authentication and expect "
|
|
556
|
+
f"a 401 or 403 response."
|
|
557
|
+
),
|
|
558
|
+
steps=[
|
|
559
|
+
{
|
|
560
|
+
"action": f"{kind} {op_name}",
|
|
561
|
+
"description": "Send request without auth credentials",
|
|
562
|
+
}
|
|
563
|
+
],
|
|
564
|
+
expected_result="401 or 403 response",
|
|
565
|
+
priority="high",
|
|
566
|
+
tags=["security", "auth"],
|
|
567
|
+
source_ref=source_ref,
|
|
568
|
+
metadata={
|
|
569
|
+
"intent": "security",
|
|
570
|
+
"test_type": "negative",
|
|
571
|
+
"neg_type": "missing_auth",
|
|
572
|
+
"auth_schemes": endpoint.auth_requirements,
|
|
573
|
+
},
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# --- Invalid input type (mutations only) ---
|
|
578
|
+
if kind == "MUTATION" and endpoint.parameters:
|
|
579
|
+
typed_params = [
|
|
580
|
+
p for p in endpoint.parameters
|
|
581
|
+
if isinstance(p, dict) and p.get("schema", {}).get("type")
|
|
582
|
+
]
|
|
583
|
+
if typed_params:
|
|
584
|
+
param = typed_params[0]
|
|
585
|
+
param_name = param.get("name", "input")
|
|
586
|
+
cases.append(
|
|
587
|
+
IngestedTestCase(
|
|
588
|
+
name=f"[invalid input type] MUTATION {op_name} — wrong type for '{param_name}'",
|
|
589
|
+
description=(
|
|
590
|
+
f"Execute mutation '{op_name}' with an invalid type "
|
|
591
|
+
f"for '{param_name}' and expect an error response."
|
|
592
|
+
),
|
|
593
|
+
steps=[
|
|
594
|
+
{
|
|
595
|
+
"action": f"MUTATION {op_name}",
|
|
596
|
+
"description": f"Use wrong type for '{param_name}'",
|
|
597
|
+
}
|
|
598
|
+
],
|
|
599
|
+
expected_result="GraphQL error or 400 response",
|
|
600
|
+
priority=priority,
|
|
601
|
+
tags=["negative", "functional"],
|
|
602
|
+
source_ref=source_ref,
|
|
603
|
+
metadata={
|
|
604
|
+
"intent": "functional",
|
|
605
|
+
"test_type": "negative",
|
|
606
|
+
"neg_type": "invalid_input_type",
|
|
607
|
+
"param": param_name,
|
|
608
|
+
},
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
return cases
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
# GraphQLIngestor
|
|
617
|
+
# ---------------------------------------------------------------------------
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
class GraphQLIngestor(IngestorPlugin):
|
|
621
|
+
"""Ingest GraphQL schemas from introspection JSON or SDL files.
|
|
622
|
+
|
|
623
|
+
Produces normalised
|
|
624
|
+
:class:`~mannf.product.ingestors.models.IngestedEndpoint` and
|
|
625
|
+
:class:`~mannf.product.ingestors.models.IngestedTestCase` objects from
|
|
626
|
+
GraphQL introspection results (JSON) or SDL schema files (``.graphql``,
|
|
627
|
+
``.gql``).
|
|
628
|
+
|
|
629
|
+
Configuration keys
|
|
630
|
+
------------------
|
|
631
|
+
base_url : str, optional
|
|
632
|
+
The GraphQL endpoint URL to associate with extracted endpoints.
|
|
633
|
+
include_deprecated : bool, optional
|
|
634
|
+
When ``True``, deprecated fields/operations are included in the
|
|
635
|
+
output. Defaults to ``False``.
|
|
636
|
+
include_subscriptions : bool, optional
|
|
637
|
+
When ``False``, subscription operations are omitted. Defaults to
|
|
638
|
+
``True``.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
name = "graphql"
|
|
642
|
+
display_name = "GraphQL Schema"
|
|
643
|
+
description = "Ingest GraphQL schemas from introspection JSON or SDL files."
|
|
644
|
+
supported_extensions = [".graphql", ".gql", ".json"]
|
|
645
|
+
supported_formats = ["graphql"]
|
|
646
|
+
|
|
647
|
+
# ------------------------------------------------------------------
|
|
648
|
+
# validate_config
|
|
649
|
+
# ------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
652
|
+
"""Validate the plugin configuration.
|
|
653
|
+
|
|
654
|
+
Parameters
|
|
655
|
+
----------
|
|
656
|
+
config:
|
|
657
|
+
Supported keys: ``base_url`` (str), ``include_deprecated`` (bool),
|
|
658
|
+
``include_subscriptions`` (bool).
|
|
659
|
+
|
|
660
|
+
Returns
|
|
661
|
+
-------
|
|
662
|
+
list[str]
|
|
663
|
+
Empty list when the config is valid; error strings otherwise.
|
|
664
|
+
"""
|
|
665
|
+
errors: list[str] = []
|
|
666
|
+
if "base_url" in config and not isinstance(config["base_url"], str):
|
|
667
|
+
errors.append("'base_url' must be a string.")
|
|
668
|
+
if "include_deprecated" in config and not isinstance(
|
|
669
|
+
config["include_deprecated"], bool
|
|
670
|
+
):
|
|
671
|
+
errors.append("'include_deprecated' must be a boolean.")
|
|
672
|
+
if "include_subscriptions" in config and not isinstance(
|
|
673
|
+
config["include_subscriptions"], bool
|
|
674
|
+
):
|
|
675
|
+
errors.append("'include_subscriptions' must be a boolean.")
|
|
676
|
+
return errors
|
|
677
|
+
|
|
678
|
+
# ------------------------------------------------------------------
|
|
679
|
+
# can_handle
|
|
680
|
+
# ------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
def can_handle(self, source: IngestorSource) -> bool:
|
|
683
|
+
"""Return ``True`` for graphql format, supported extensions, or SDL/introspection content.
|
|
684
|
+
|
|
685
|
+
Parameters
|
|
686
|
+
----------
|
|
687
|
+
source:
|
|
688
|
+
The source to evaluate.
|
|
689
|
+
|
|
690
|
+
Returns
|
|
691
|
+
-------
|
|
692
|
+
bool
|
|
693
|
+
``True`` if this plugin can ingest the source.
|
|
694
|
+
"""
|
|
695
|
+
if source.format and source.format in self.supported_formats:
|
|
696
|
+
return True
|
|
697
|
+
|
|
698
|
+
if source.path is not None:
|
|
699
|
+
ext = Path(source.path).suffix.lower()
|
|
700
|
+
if ext in {".graphql", ".gql"}:
|
|
701
|
+
return True
|
|
702
|
+
# .json could be introspection — peek at content
|
|
703
|
+
if ext == ".json" and source.raw_content:
|
|
704
|
+
try:
|
|
705
|
+
data = json.loads(source.raw_content)
|
|
706
|
+
return _is_introspection_json(data)
|
|
707
|
+
except (json.JSONDecodeError, ValueError):
|
|
708
|
+
return False
|
|
709
|
+
|
|
710
|
+
# No path — check raw content
|
|
711
|
+
if source.raw_content:
|
|
712
|
+
stripped = source.raw_content.strip()
|
|
713
|
+
if stripped.startswith("{"):
|
|
714
|
+
try:
|
|
715
|
+
data = json.loads(stripped)
|
|
716
|
+
return _is_introspection_json(data)
|
|
717
|
+
except (json.JSONDecodeError, ValueError):
|
|
718
|
+
pass
|
|
719
|
+
return _is_sdl(stripped)
|
|
720
|
+
|
|
721
|
+
return False
|
|
722
|
+
|
|
723
|
+
# ------------------------------------------------------------------
|
|
724
|
+
# ingest
|
|
725
|
+
# ------------------------------------------------------------------
|
|
726
|
+
|
|
727
|
+
async def ingest(self, source: IngestorSource, config: dict) -> IngestResult:
|
|
728
|
+
"""Parse a GraphQL schema and return a normalised :class:`IngestResult`.
|
|
729
|
+
|
|
730
|
+
Parameters
|
|
731
|
+
----------
|
|
732
|
+
source:
|
|
733
|
+
The source to ingest. Must provide either ``raw_content`` or a
|
|
734
|
+
valid ``path``.
|
|
735
|
+
config:
|
|
736
|
+
Plugin-specific configuration. See :meth:`validate_config` for
|
|
737
|
+
supported keys.
|
|
738
|
+
|
|
739
|
+
Returns
|
|
740
|
+
-------
|
|
741
|
+
IngestResult
|
|
742
|
+
Populated result on success; ``success=False`` with ``errors`` on
|
|
743
|
+
failure.
|
|
744
|
+
"""
|
|
745
|
+
# --- Read content ---
|
|
746
|
+
try:
|
|
747
|
+
content = self._read_source(source)
|
|
748
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
749
|
+
return self._make_error_result(source, str(exc))
|
|
750
|
+
|
|
751
|
+
if not content or not content.strip():
|
|
752
|
+
return self._make_error_result(source, "Empty content — nothing to ingest.")
|
|
753
|
+
|
|
754
|
+
base_url: str = config.get("base_url", "") or ""
|
|
755
|
+
include_deprecated: bool = bool(config.get("include_deprecated", False))
|
|
756
|
+
include_subscriptions: bool = bool(config.get("include_subscriptions", True))
|
|
757
|
+
|
|
758
|
+
warnings: list[str] = []
|
|
759
|
+
endpoints: list[IngestedEndpoint] = []
|
|
760
|
+
|
|
761
|
+
# --- Detect and parse format ---
|
|
762
|
+
stripped = content.strip()
|
|
763
|
+
is_json = stripped.startswith("{") or stripped.startswith("[")
|
|
764
|
+
|
|
765
|
+
if is_json:
|
|
766
|
+
try:
|
|
767
|
+
data: dict[str, Any] = json.loads(content)
|
|
768
|
+
except json.JSONDecodeError as exc:
|
|
769
|
+
return self._make_error_result(
|
|
770
|
+
source, f"Failed to parse JSON: {exc}"
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
schema = _unwrap_introspection(data)
|
|
774
|
+
if schema is None:
|
|
775
|
+
return self._make_error_result(
|
|
776
|
+
source,
|
|
777
|
+
"JSON content does not contain a GraphQL introspection "
|
|
778
|
+
"result (__schema key not found).",
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
try:
|
|
782
|
+
endpoints = _endpoints_from_introspection(
|
|
783
|
+
schema,
|
|
784
|
+
base_url=base_url,
|
|
785
|
+
include_deprecated=include_deprecated,
|
|
786
|
+
include_subscriptions=include_subscriptions,
|
|
787
|
+
)
|
|
788
|
+
except Exception as exc: # noqa: BLE001
|
|
789
|
+
return self._make_error_result(
|
|
790
|
+
source, f"Failed to process introspection JSON: {exc}"
|
|
791
|
+
)
|
|
792
|
+
else:
|
|
793
|
+
# Treat as SDL
|
|
794
|
+
if not _is_sdl(content):
|
|
795
|
+
warnings.append(
|
|
796
|
+
"Content does not appear to be valid GraphQL SDL; "
|
|
797
|
+
"attempting to parse anyway."
|
|
798
|
+
)
|
|
799
|
+
try:
|
|
800
|
+
endpoints, sdl_warnings = _endpoints_from_sdl(
|
|
801
|
+
content,
|
|
802
|
+
base_url=base_url,
|
|
803
|
+
include_deprecated=include_deprecated,
|
|
804
|
+
include_subscriptions=include_subscriptions,
|
|
805
|
+
)
|
|
806
|
+
warnings.extend(sdl_warnings)
|
|
807
|
+
except Exception as exc: # noqa: BLE001
|
|
808
|
+
return self._make_error_result(
|
|
809
|
+
source, f"Failed to parse SDL schema: {exc}"
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
if not endpoints:
|
|
813
|
+
warnings.append(
|
|
814
|
+
"No operations found. Ensure the schema defines at least one "
|
|
815
|
+
"of: type Query, type Mutation, type Subscription."
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# --- Generate test cases ---
|
|
819
|
+
test_cases: list[IngestedTestCase] = []
|
|
820
|
+
for ep in endpoints:
|
|
821
|
+
test_cases.extend(_generate_test_cases(ep))
|
|
822
|
+
|
|
823
|
+
# --- Stats ---
|
|
824
|
+
query_count = sum(1 for e in endpoints if e.method == "QUERY")
|
|
825
|
+
mutation_count = sum(1 for e in endpoints if e.method == "MUTATION")
|
|
826
|
+
subscription_count = sum(1 for e in endpoints if e.method == "SUBSCRIPTION")
|
|
827
|
+
|
|
828
|
+
stats: dict[str, Any] = {
|
|
829
|
+
"endpoints_count": len(endpoints),
|
|
830
|
+
"test_cases_count": len(test_cases),
|
|
831
|
+
"queries_count": query_count,
|
|
832
|
+
"mutations_count": mutation_count,
|
|
833
|
+
"subscriptions_count": subscription_count,
|
|
834
|
+
"base_url": base_url,
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return IngestResult(
|
|
838
|
+
success=True,
|
|
839
|
+
source=source,
|
|
840
|
+
endpoints=endpoints,
|
|
841
|
+
test_cases=test_cases,
|
|
842
|
+
errors=[],
|
|
843
|
+
warnings=warnings,
|
|
844
|
+
stats=stats,
|
|
845
|
+
)
|