kctl-react 0.6.2__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.
- kctl_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Parse React hook files for API call patterns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Matches: apiClient.get<Type>("url") or apiClient.post<Resp, Req>(`url`)
|
|
10
|
+
# Single-line variant
|
|
11
|
+
_API_CALL_RE = re.compile(r"apiClient\.(get|post|put|delete|patch)<([^>]*)>\s*\(\s*[`\"']([^`\"']*)[`\"']")
|
|
12
|
+
# Multi-line variant: captures method and generics on one line, URL on next line
|
|
13
|
+
_API_CALL_MULTILINE_RE = re.compile(r"apiClient\.(get|post|put|delete|patch)<([^>]*)>\s*\(\s*$")
|
|
14
|
+
_URL_LINE_RE = re.compile(r"^\s*[`\"']([^`\"']*)[`\"']")
|
|
15
|
+
|
|
16
|
+
# Matches template literal interpolations: ${someVar}
|
|
17
|
+
_TEMPLATE_VAR_RE = re.compile(r"\$\{[^}]+\}")
|
|
18
|
+
|
|
19
|
+
# Matches URLSearchParams .set("key", ...) or searchParams.set("key", ...)
|
|
20
|
+
_SEARCH_PARAM_RE = re.compile(r"\.set\(\s*[\"'](\w+)[\"']")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class HookCall:
|
|
25
|
+
file: str # relative path from app root
|
|
26
|
+
line: int
|
|
27
|
+
method: str # get, post, put, delete
|
|
28
|
+
url: str # /productions/${id} (raw)
|
|
29
|
+
normalized_url: str # /productions/{id}
|
|
30
|
+
response_type: str | None = None
|
|
31
|
+
request_type: str | None = None
|
|
32
|
+
query_params: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalize_url(url: str) -> str:
|
|
36
|
+
"""Replace template literal variables with OpenAPI-style path params and clean up."""
|
|
37
|
+
# Strip everything after ? (query string)
|
|
38
|
+
clean = url.split("?")[0]
|
|
39
|
+
# Strip template literal ternary/query patterns: ${qs ...}, ${qs}, etc.
|
|
40
|
+
# This handles: /path${qs ? `?${qs}` : ""} → /path
|
|
41
|
+
clean = re.sub(r"\$\{qs\b.*", "", clean)
|
|
42
|
+
# Strip any trailing template expressions that look like query strings
|
|
43
|
+
clean = re.sub(r"\$\{[^}]*qs[^}]*\}.*$", "", clean)
|
|
44
|
+
# Replace path-segment template vars (e.g., ${id}, ${bomId}) with {id}
|
|
45
|
+
clean = _TEMPLATE_VAR_RE.sub("{id}", clean)
|
|
46
|
+
# Normalize trailing slash
|
|
47
|
+
clean = clean.rstrip("/")
|
|
48
|
+
return clean
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_generics(generics: str) -> tuple[str | None, str | None]:
|
|
52
|
+
"""Parse generic type args like 'ResponseType, RequestType'."""
|
|
53
|
+
parts = [p.strip() for p in generics.split(",") if p.strip()]
|
|
54
|
+
response_type = parts[0] if len(parts) >= 1 else None
|
|
55
|
+
request_type = parts[1] if len(parts) >= 2 else None
|
|
56
|
+
return response_type, request_type
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_hooks(app_path: Path) -> list[HookCall]:
|
|
60
|
+
"""Scan hook files for API client calls and return structured results."""
|
|
61
|
+
api_dir = app_path / "src" / "api"
|
|
62
|
+
if not api_dir.is_dir():
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
results: list[HookCall] = []
|
|
66
|
+
|
|
67
|
+
for hook_file in sorted(api_dir.glob("use*.ts")):
|
|
68
|
+
if hook_file.name == "client.ts":
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
content = hook_file.read_text()
|
|
73
|
+
except OSError:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
# Extract query params used across the file
|
|
77
|
+
file_query_params = _SEARCH_PARAM_RE.findall(content)
|
|
78
|
+
|
|
79
|
+
rel_path = str(hook_file.relative_to(app_path))
|
|
80
|
+
lines = content.splitlines()
|
|
81
|
+
|
|
82
|
+
pending_multiline: tuple[int, str, str] | None = None # (line_num, method, generics)
|
|
83
|
+
|
|
84
|
+
for line_num, line in enumerate(lines, start=1):
|
|
85
|
+
# Check for multi-line continuation from previous line
|
|
86
|
+
if pending_multiline is not None:
|
|
87
|
+
url_match = _URL_LINE_RE.search(line)
|
|
88
|
+
if url_match:
|
|
89
|
+
ml_line, method, generics = pending_multiline
|
|
90
|
+
url = url_match.group(1)
|
|
91
|
+
response_type, request_type = _parse_generics(generics)
|
|
92
|
+
normalized = _normalize_url(url)
|
|
93
|
+
results.append(
|
|
94
|
+
HookCall(
|
|
95
|
+
file=rel_path,
|
|
96
|
+
line=ml_line,
|
|
97
|
+
method=method,
|
|
98
|
+
url=url,
|
|
99
|
+
normalized_url=normalized,
|
|
100
|
+
response_type=response_type,
|
|
101
|
+
request_type=request_type,
|
|
102
|
+
query_params=file_query_params,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
pending_multiline = None
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
# Single-line matches
|
|
109
|
+
for match in _API_CALL_RE.finditer(line):
|
|
110
|
+
method = match.group(1)
|
|
111
|
+
generics = match.group(2)
|
|
112
|
+
url = match.group(3)
|
|
113
|
+
response_type, request_type = _parse_generics(generics)
|
|
114
|
+
normalized = _normalize_url(url)
|
|
115
|
+
results.append(
|
|
116
|
+
HookCall(
|
|
117
|
+
file=rel_path,
|
|
118
|
+
line=line_num,
|
|
119
|
+
method=method,
|
|
120
|
+
url=url,
|
|
121
|
+
normalized_url=normalized,
|
|
122
|
+
response_type=response_type,
|
|
123
|
+
request_type=request_type,
|
|
124
|
+
query_params=file_query_params,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Check if this line starts a multi-line call
|
|
129
|
+
ml_match = _API_CALL_MULTILINE_RE.search(line)
|
|
130
|
+
if ml_match and not _API_CALL_RE.search(line):
|
|
131
|
+
pending_multiline = (line_num, ml_match.group(1), ml_match.group(2))
|
|
132
|
+
|
|
133
|
+
return results
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Match hook API calls against OpenAPI schema operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from .hooks import HookCall
|
|
9
|
+
from .schema import Operation, ParsedSchema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class MatchResult:
|
|
14
|
+
matched: list[tuple[HookCall, Operation]] = field(default_factory=list)
|
|
15
|
+
orphan_endpoints: list[Operation] = field(default_factory=list)
|
|
16
|
+
dead_endpoints: list[HookCall] = field(default_factory=list)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_PATH_PARAM_RE = re.compile(r"\{[^}]+\}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _norm(path: str) -> str:
|
|
23
|
+
"""Normalize path for comparison: strip trailing slash, normalize path params to {id}."""
|
|
24
|
+
path = path.rstrip("/")
|
|
25
|
+
path = _PATH_PARAM_RE.sub("{id}", path)
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def match_hooks_to_schema(hooks: list[HookCall], schema: ParsedSchema) -> MatchResult:
|
|
30
|
+
"""Cross-reference hook API calls against OpenAPI schema operations."""
|
|
31
|
+
# Normalize schema paths for comparison
|
|
32
|
+
schema_by_key: dict[tuple[str, str], Operation] = {(op.method, _norm(op.path)): op for op in schema.operations}
|
|
33
|
+
hook_by_key: dict[tuple[str, str], HookCall] = {(h.method, _norm(h.normalized_url)): h for h in hooks}
|
|
34
|
+
|
|
35
|
+
schema_keys = set(schema_by_key.keys())
|
|
36
|
+
hook_keys = set(hook_by_key.keys())
|
|
37
|
+
|
|
38
|
+
# Matched: hooks whose (method, normalized_url) matches a schema (method, path)
|
|
39
|
+
matched: list[tuple[HookCall, Operation]] = []
|
|
40
|
+
for key in hook_keys & schema_keys:
|
|
41
|
+
matched.append((hook_by_key[key], schema_by_key[key]))
|
|
42
|
+
|
|
43
|
+
# Orphan: schema operations not matched by any hook (GET only)
|
|
44
|
+
orphan_endpoints = [
|
|
45
|
+
op for op in schema.operations if op.method == "get" and (op.method, _norm(op.path)) not in hook_keys
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Dead: hook calls not matched by any schema operation
|
|
49
|
+
dead_endpoints = [h for h in hooks if (h.method, _norm(h.normalized_url)) not in schema_keys]
|
|
50
|
+
|
|
51
|
+
return MatchResult(
|
|
52
|
+
matched=matched,
|
|
53
|
+
orphan_endpoints=orphan_endpoints,
|
|
54
|
+
dead_endpoints=dead_endpoints,
|
|
55
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""OpenAPI schema loader and parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
APP_BACKEND_MAP = {
|
|
16
|
+
"sfa": "sfa_management",
|
|
17
|
+
"lfa": "lfa_management",
|
|
18
|
+
"shop": "shop_management",
|
|
19
|
+
"wms": "wms_management",
|
|
20
|
+
"bia": "bia_management",
|
|
21
|
+
"eam": "asset_management",
|
|
22
|
+
"mrp": "mrp_management",
|
|
23
|
+
"hrm": "hrm_management",
|
|
24
|
+
"tpm": "tpm_management",
|
|
25
|
+
"dms": "dms_management",
|
|
26
|
+
"saas": "saas_management",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Operation:
|
|
32
|
+
method: str # get, post, put, delete
|
|
33
|
+
path: str # /productions/{id}
|
|
34
|
+
tag: str # mrp-productions
|
|
35
|
+
response_schema: str | None = None # ProductionDetailResponse
|
|
36
|
+
request_schema: str | None = None # ProduceQuantityRequest
|
|
37
|
+
parameters: list[str] = field(default_factory=list) # ["state", "search"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ParsedSchema:
|
|
42
|
+
operations: list[Operation] = field(default_factory=list)
|
|
43
|
+
response_schemas: dict[str, dict] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_schema(app_path: Path, app_name: str, *, offline: bool = False) -> dict | None:
|
|
47
|
+
"""Load OpenAPI schema from cache or fetch from backend."""
|
|
48
|
+
cached = app_path / "openapi.json"
|
|
49
|
+
if cached.exists():
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(cached.read_text())
|
|
52
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
53
|
+
logger.warning("Failed to read cached schema: %s", exc)
|
|
54
|
+
|
|
55
|
+
if offline:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
return fetch_schema(app_path, app_name)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fetch_schema(app_path: Path, app_name: str) -> dict | None:
|
|
62
|
+
"""Fetch OpenAPI schema from the Odoo backend and cache it."""
|
|
63
|
+
odoo_url = "http://localhost:8069"
|
|
64
|
+
odoo_db = "odoo_18"
|
|
65
|
+
|
|
66
|
+
env_file = app_path / ".env"
|
|
67
|
+
if env_file.exists():
|
|
68
|
+
try:
|
|
69
|
+
content = env_file.read_text()
|
|
70
|
+
url_match = re.search(r"VITE_ODOO_URL\s*=\s*(.+)", content)
|
|
71
|
+
if url_match:
|
|
72
|
+
odoo_url = url_match.group(1).strip().strip("\"'")
|
|
73
|
+
db_match = re.search(r"VITE_ODOO_DB\s*=\s*(.+)", content)
|
|
74
|
+
if db_match:
|
|
75
|
+
odoo_db = db_match.group(1).strip().strip("\"'")
|
|
76
|
+
except OSError as exc:
|
|
77
|
+
logger.warning("Failed to read .env: %s", exc)
|
|
78
|
+
|
|
79
|
+
backend_name = APP_BACKEND_MAP.get(app_name)
|
|
80
|
+
if not backend_name:
|
|
81
|
+
logger.warning("Unknown app %r — no backend mapping", app_name)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
url = f"{odoo_url}/{backend_name}/api/openapi.json?db={odoo_db}"
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
resp = httpx.get(url, timeout=10)
|
|
88
|
+
resp.raise_for_status()
|
|
89
|
+
data = resp.json()
|
|
90
|
+
except (httpx.HTTPError, json.JSONDecodeError) as exc:
|
|
91
|
+
logger.warning("Failed to fetch schema from %s: %s", url, exc)
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
(app_path / "openapi.json").write_text(json.dumps(data, indent=2))
|
|
96
|
+
except OSError as exc:
|
|
97
|
+
logger.warning("Failed to cache schema: %s", exc)
|
|
98
|
+
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_ref_name(ref: str) -> str:
|
|
103
|
+
"""Extract schema name from a $ref string like '#/components/schemas/Foo'."""
|
|
104
|
+
return ref.rsplit("/", 1)[-1]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_schema(raw: dict) -> ParsedSchema:
|
|
108
|
+
"""Parse a raw OpenAPI JSON dict into structured operations."""
|
|
109
|
+
operations: list[Operation] = []
|
|
110
|
+
|
|
111
|
+
for path, methods in raw.get("paths", {}).items():
|
|
112
|
+
for method in ("get", "post", "put", "delete", "patch"):
|
|
113
|
+
operation = methods.get(method)
|
|
114
|
+
if operation is None:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
tag = operation.get("tags", ["unknown"])[0]
|
|
118
|
+
|
|
119
|
+
# Response schema
|
|
120
|
+
response_schema: str | None = None
|
|
121
|
+
resp_200 = operation.get("responses", {}).get("200", {})
|
|
122
|
+
resp_content = resp_200.get("content", {}).get("application/json", {})
|
|
123
|
+
resp_schema_obj = resp_content.get("schema", {})
|
|
124
|
+
if "$ref" in resp_schema_obj:
|
|
125
|
+
response_schema = _extract_ref_name(resp_schema_obj["$ref"])
|
|
126
|
+
|
|
127
|
+
# Request schema
|
|
128
|
+
request_schema: str | None = None
|
|
129
|
+
req_body = operation.get("requestBody", {})
|
|
130
|
+
req_content = req_body.get("content", {}).get("application/json", {})
|
|
131
|
+
req_schema_obj = req_content.get("schema", {})
|
|
132
|
+
if "$ref" in req_schema_obj:
|
|
133
|
+
request_schema = _extract_ref_name(req_schema_obj["$ref"])
|
|
134
|
+
|
|
135
|
+
# Parameters
|
|
136
|
+
parameters = [p["name"] for p in operation.get("parameters", []) if "name" in p]
|
|
137
|
+
|
|
138
|
+
operations.append(
|
|
139
|
+
Operation(
|
|
140
|
+
method=method,
|
|
141
|
+
path=path,
|
|
142
|
+
tag=tag,
|
|
143
|
+
response_schema=response_schema,
|
|
144
|
+
request_schema=request_schema,
|
|
145
|
+
parameters=parameters,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
response_schemas = raw.get("components", {}).get("schemas", {})
|
|
150
|
+
|
|
151
|
+
return ParsedSchema(operations=operations, response_schemas=response_schemas)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""API health: runtime endpoint validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from kctl_react.core.compliance.api_check.schema import Operation
|
|
8
|
+
|
|
9
|
+
from .client import HealthClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class EndpointResult:
|
|
14
|
+
"""Result of hitting a single endpoint."""
|
|
15
|
+
|
|
16
|
+
operation: Operation
|
|
17
|
+
status_code: int
|
|
18
|
+
body: dict | None
|
|
19
|
+
elapsed: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_health_checks(client: HealthClient, endpoints: list[Operation]) -> list[EndpointResult]:
|
|
23
|
+
"""Hit each endpoint once, return results for all checkers to use."""
|
|
24
|
+
results: list[EndpointResult] = []
|
|
25
|
+
for op in endpoints:
|
|
26
|
+
status, body, elapsed = client.get(op.path)
|
|
27
|
+
results.append(
|
|
28
|
+
EndpointResult(
|
|
29
|
+
operation=op,
|
|
30
|
+
status_code=status,
|
|
31
|
+
body=body,
|
|
32
|
+
elapsed=elapsed,
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
return results
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""API health check registry — all 4 checkers."""
|
|
2
|
+
|
|
3
|
+
from .auth import AuthChecker
|
|
4
|
+
from .reachable import ReachableChecker
|
|
5
|
+
from .response import ResponseChecker
|
|
6
|
+
from .timing import TimingChecker
|
|
7
|
+
|
|
8
|
+
ALL_HEALTH_CHECKERS = [AuthChecker(), ReachableChecker(), ResponseChecker(), TimingChecker()]
|
|
9
|
+
CHECKER_MAP = {c.name: c for c in ALL_HEALTH_CHECKERS}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Auth validation checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_check.schema import ParsedSchema
|
|
6
|
+
from kctl_react.core.compliance.api_health.client import HealthClient
|
|
7
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuthChecker:
|
|
11
|
+
name = "auth"
|
|
12
|
+
label = "Auth"
|
|
13
|
+
max_points = 10
|
|
14
|
+
|
|
15
|
+
def check(self, client: HealthClient, schema: ParsedSchema) -> CategoryResult:
|
|
16
|
+
"""Check /auth/me returns valid response.
|
|
17
|
+
|
|
18
|
+
If no token and authenticate() fails -> violation.
|
|
19
|
+
If /auth/me returns non-200 -> violation.
|
|
20
|
+
If /auth/me response missing success/data -> violation.
|
|
21
|
+
"""
|
|
22
|
+
violations: list[Violation] = []
|
|
23
|
+
|
|
24
|
+
if not client.token:
|
|
25
|
+
violations.append(
|
|
26
|
+
Violation(
|
|
27
|
+
file="runtime",
|
|
28
|
+
message="No auth token available — dev-login failed or was not attempted",
|
|
29
|
+
fix_hint="Ensure VITE_DEV_USER/VITE_DEV_PASSWORD are set in .env or backend accepts admin/admin",
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return CategoryResult(
|
|
33
|
+
name=self.name,
|
|
34
|
+
label=self.label,
|
|
35
|
+
max_points=self.max_points,
|
|
36
|
+
violations=violations,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
status, body, _elapsed = client.get("/auth/me")
|
|
40
|
+
|
|
41
|
+
if status != 200:
|
|
42
|
+
violations.append(
|
|
43
|
+
Violation(
|
|
44
|
+
file="runtime",
|
|
45
|
+
message=f"GET /auth/me returned {status}",
|
|
46
|
+
fix_hint="Check auth token validity and /auth/me endpoint",
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
elif body is not None:
|
|
50
|
+
if not body.get("success"):
|
|
51
|
+
violations.append(
|
|
52
|
+
Violation(
|
|
53
|
+
file="runtime",
|
|
54
|
+
message="GET /auth/me response missing 'success: true'",
|
|
55
|
+
fix_hint="Ensure /auth/me returns {success: true, data: ...}",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
if "data" not in body:
|
|
59
|
+
violations.append(
|
|
60
|
+
Violation(
|
|
61
|
+
file="runtime",
|
|
62
|
+
message="GET /auth/me response missing 'data' key",
|
|
63
|
+
fix_hint="Ensure /auth/me returns {success: true, data: ...}",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return CategoryResult(
|
|
68
|
+
name=self.name,
|
|
69
|
+
label=self.label,
|
|
70
|
+
max_points=self.max_points,
|
|
71
|
+
violations=violations,
|
|
72
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Endpoint reachability checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_health import EndpointResult
|
|
6
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReachableChecker:
|
|
10
|
+
name = "reachable"
|
|
11
|
+
label = "Endpoints Reachable"
|
|
12
|
+
max_points = 10
|
|
13
|
+
|
|
14
|
+
def check(self, results: list[EndpointResult]) -> CategoryResult:
|
|
15
|
+
"""Hit each sampled endpoint. Violations for non-200 responses.
|
|
16
|
+
|
|
17
|
+
Message: "GET {path} returned {status}" or "GET {path} connection error".
|
|
18
|
+
"""
|
|
19
|
+
violations: list[Violation] = []
|
|
20
|
+
|
|
21
|
+
for r in results:
|
|
22
|
+
if r.status_code == 0:
|
|
23
|
+
violations.append(
|
|
24
|
+
Violation(
|
|
25
|
+
file="runtime",
|
|
26
|
+
message=f"GET {r.operation.path} connection error",
|
|
27
|
+
fix_hint="Check that the backend is running and reachable",
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
elif r.status_code != 200:
|
|
31
|
+
violations.append(
|
|
32
|
+
Violation(
|
|
33
|
+
file="runtime",
|
|
34
|
+
message=f"GET {r.operation.path} returned {r.status_code}",
|
|
35
|
+
fix_hint="Check endpoint implementation and auth",
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
return CategoryResult(
|
|
40
|
+
name=self.name,
|
|
41
|
+
label=self.label,
|
|
42
|
+
max_points=self.max_points,
|
|
43
|
+
violations=violations,
|
|
44
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Response envelope validation checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_health import EndpointResult
|
|
6
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResponseChecker:
|
|
10
|
+
name = "response"
|
|
11
|
+
label = "Response Valid"
|
|
12
|
+
max_points = 5
|
|
13
|
+
|
|
14
|
+
def check(self, results: list[EndpointResult]) -> CategoryResult:
|
|
15
|
+
"""For each sampled endpoint that returned 200, check response body has {success, data}.
|
|
16
|
+
|
|
17
|
+
Violation if success is not True or data key is missing.
|
|
18
|
+
"""
|
|
19
|
+
violations: list[Violation] = []
|
|
20
|
+
|
|
21
|
+
for r in results:
|
|
22
|
+
if r.status_code != 200:
|
|
23
|
+
continue
|
|
24
|
+
if r.body is None:
|
|
25
|
+
violations.append(
|
|
26
|
+
Violation(
|
|
27
|
+
file="runtime",
|
|
28
|
+
message=f"GET {r.operation.path} returned non-JSON body",
|
|
29
|
+
fix_hint="Ensure endpoint returns JSON with {success, data}",
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
continue
|
|
33
|
+
if not r.body.get("success"):
|
|
34
|
+
violations.append(
|
|
35
|
+
Violation(
|
|
36
|
+
file="runtime",
|
|
37
|
+
message=f"GET {r.operation.path} response missing 'success: true'",
|
|
38
|
+
fix_hint="Ensure response envelope has success: true",
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
if "data" not in r.body:
|
|
42
|
+
violations.append(
|
|
43
|
+
Violation(
|
|
44
|
+
file="runtime",
|
|
45
|
+
message=f"GET {r.operation.path} response missing 'data' key",
|
|
46
|
+
fix_hint="Ensure response envelope has data key",
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return CategoryResult(
|
|
51
|
+
name=self.name,
|
|
52
|
+
label=self.label,
|
|
53
|
+
max_points=self.max_points,
|
|
54
|
+
violations=violations,
|
|
55
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Response time checker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kctl_react.core.compliance.api_health import EndpointResult
|
|
6
|
+
from kctl_react.core.compliance.models import CategoryResult, Violation
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TimingChecker:
|
|
10
|
+
name = "timing"
|
|
11
|
+
label = "Response Time"
|
|
12
|
+
max_points = 5
|
|
13
|
+
|
|
14
|
+
def check(self, results: list[EndpointResult], threshold: float = 3.0) -> CategoryResult:
|
|
15
|
+
"""Flag endpoints slower than threshold.
|
|
16
|
+
|
|
17
|
+
Message: "GET {path} took {elapsed:.1f}s (threshold: {threshold}s)".
|
|
18
|
+
"""
|
|
19
|
+
violations: list[Violation] = []
|
|
20
|
+
|
|
21
|
+
for r in results:
|
|
22
|
+
if r.status_code == 0:
|
|
23
|
+
continue # connection error — already flagged by reachable
|
|
24
|
+
if r.elapsed > threshold:
|
|
25
|
+
violations.append(
|
|
26
|
+
Violation(
|
|
27
|
+
file="runtime",
|
|
28
|
+
message=f"GET {r.operation.path} took {r.elapsed:.1f}s (threshold: {threshold}s)",
|
|
29
|
+
fix_hint="Optimize endpoint performance or increase timeout",
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
return CategoryResult(
|
|
34
|
+
name=self.name,
|
|
35
|
+
label=self.label,
|
|
36
|
+
max_points=self.max_points,
|
|
37
|
+
violations=violations,
|
|
38
|
+
)
|