diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c0bf69..17a3049 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 with: - node-version: "20" + node-version: "22" cache: npm cache-dependency-path: web/package-lock.json - run: npm ci || npm install diff --git a/.gitignore b/.gitignore index 2965d4b..946edf8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,10 @@ Thumbs.db # Env .env .env.* -!.env.example \ No newline at end of file +!.env.example +# temporary dev/debug helpers +*_fix*.py +tests/_dbg*.py +build/ +web/dist/ +.pr-summary.md diff --git a/apiverity/__init__.py b/apiverity/__init__.py index 2d61d5f..4e479a0 100644 --- a/apiverity/__init__.py +++ b/apiverity/__init__.py @@ -2,4 +2,4 @@ __version__ = "0.1.0" -PLUGIN_API_VERSION = "1" \ No newline at end of file +PLUGIN_API_VERSION = "1" diff --git a/apiverity/cli/__main__.py b/apiverity/cli/__main__.py index d7887c1..825b50b 100644 --- a/apiverity/cli/__main__.py +++ b/apiverity/cli/__main__.py @@ -1,6 +1,7 @@ """Allow ``python -m apiverity.cli`` invocation.""" + import sys from apiverity.cli.main import main -sys.exit(main()) \ No newline at end of file +sys.exit(main()) diff --git a/apiverity/cli/main.py b/apiverity/cli/main.py index 218f92a..b44da8b 100644 --- a/apiverity/cli/main.py +++ b/apiverity/cli/main.py @@ -4,12 +4,14 @@ 0 ok · 1 findings at/above threshold · 2 usage error · 3 target unreachable · 4 internal error. """ + from __future__ import annotations import argparse import json import sys from pathlib import Path +from typing import TYPE_CHECKING, Any NL = chr(10) @@ -19,12 +21,18 @@ EXIT_UNREACHABLE = 3 EXIT_INTERNAL = 4 +if TYPE_CHECKING: + from apiverity.core.model import Finding, Service + from apiverity.mock import MockServer + from apiverity.specs import SpecPlugin + _LAST_SPEC: str | None = None _LAST_TARGET: str | None = None _LAST_SEED: int | None = None -def _load(path: str): + +def _load(path: str) -> tuple[Service, list[Finding], SpecPlugin]: from apiverity.specs.loader import detect_and_load global _LAST_SPEC @@ -34,16 +42,15 @@ def _load(path: str): except FileNotFoundError: print(f"error: file not found: {path}", file=sys.stderr) sys.exit(EXIT_USAGE) - except Exception as exc: # noqa: BLE001 + except Exception as exc: print(f"error: failed to load spec: {exc}", file=sys.stderr) sys.exit(EXIT_USAGE) -def _emit(data: dict, as_json: bool) -> None: +def _emit(data: dict[str, Any], as_json: bool) -> None: from apiverity.core.artifact import enrich - data = enrich(data, spec_path=_LAST_SPEC, - target=_LAST_TARGET, seed=_LAST_SEED) + data = enrich(data, spec_path=_LAST_SPEC, target=_LAST_TARGET, seed=_LAST_SEED) if as_json: print(json.dumps(data, indent=2, default=str)) else: @@ -52,9 +59,11 @@ def _emit(data: dict, as_json: bool) -> None: print(f"{key}:") for item in value: d = item.model_dump() - print(f" [{d.get('severity', d.get('status', ''))}] " - f"{d.get('rule_id', d.get('case_id', d.get('step', '')))} " - f"{d.get('message', d.get('description', ''))}") + print( + f" [{d.get('severity', d.get('status', ''))}] " + f"{d.get('rule_id', d.get('case_id', d.get('step', '')))} " + f"{d.get('message', d.get('description', ''))}" + ) else: print(f"{key}: {value}") @@ -67,16 +76,21 @@ def cmd_validate(args: argparse.Namespace) -> int: all_findings = findings + sec errors = sum(1 for f in all_findings if f.severity.value == "ERROR") data = { - "tool": "apiverity", "command": "validate", "spec": args.spec, - "protocol": plugin.protocol().value, "title": service.title, - "version": service.version, "operations": len(service.operations), - "findings": all_findings, "errors": errors, + "tool": "apiverity", + "command": "validate", + "spec": args.spec, + "protocol": plugin.protocol().value, + "title": service.title, + "version": service.version, + "operations": len(service.operations), + "findings": all_findings, + "errors": errors, } _emit(data, args.json) return EXIT_FINDINGS if errors else EXIT_OK -def _pair(args) -> tuple: +def _pair(args: argparse.Namespace) -> tuple[Service, Service]: old_service, _, _ = _load(args.old) new_service, _, _ = _load(args.new) return old_service, new_service @@ -87,9 +101,16 @@ def cmd_diff(args: argparse.Namespace) -> int: old, new = _pair(args) changes = diff_services(old, new) - _emit({"tool": "apiverity", "command": "diff", - "old_version": old.version, "new_version": new.version, - "changes": changes}, args.json) + _emit( + { + "tool": "apiverity", + "command": "diff", + "old_version": old.version, + "new_version": new.version, + "changes": changes, + }, + args.json, + ) return EXIT_OK @@ -108,14 +129,24 @@ def cmd_breaking(args: argparse.Namespace) -> int: findings = evaluate_breaking(changes, overrides or None) if args.check_semver: policy = SemverPolicy( - args.old_version or old.version, args.new_version or new.version, - require_minor_for_warnings=args.require_minor_for_warnings) + args.old_version or old.version, + args.new_version or new.version, + require_minor_for_warnings=args.require_minor_for_warnings, + ) findings = findings + policy.evaluate(findings, changes) errors = sum(1 for f in findings if f.severity.value == "ERROR") - _emit({"tool": "apiverity", "command": "breaking", - "old_version": old.version, "new_version": new.version, - "changes": len(changes), "findings": findings, "errors": errors}, - args.json) + _emit( + { + "tool": "apiverity", + "command": "breaking", + "old_version": old.version, + "new_version": new.version, + "changes": len(changes), + "findings": findings, + "errors": errors, + }, + args.json, + ) return EXIT_FINDINGS if errors else EXIT_OK @@ -127,8 +158,14 @@ def cmd_changelog(args: argparse.Namespace) -> int: old, new = _pair(args) changes = diff_services(old, new) findings = evaluate_breaking(changes) - text = generate_changelog(old.title, old.version, new.version, changes, findings, - fmt="html" if args.html else "markdown") + text = generate_changelog( + old.title, + old.version, + new.version, + changes, + findings, + fmt="html" if args.html else "markdown", + ) if args.output: Path(args.output).write_text(text, encoding="utf-8") else: @@ -136,7 +173,7 @@ def cmd_changelog(args: argparse.Namespace) -> int: return EXIT_OK -def _start_mock(spec_path: str, port: int): +def _start_mock(spec_path: str, port: int) -> MockServer: from apiverity.mock import MockServer service, _, _ = _load(spec_path) @@ -156,36 +193,46 @@ def cmd_test(args: argparse.Namespace) -> int: cases = build_cases(service, seed=args.seed) try: results = run_cases(service, args.base_url, cases, timeout=args.timeout) - except Exception as exc: # noqa: BLE001 + except Exception as exc: print(f"error: target unreachable: {exc}", file=sys.stderr) return EXIT_UNREACHABLE if args.minimize: results = minimize_failures(service, args.base_url, results, cases) failures = [r for r in results if r.status != "pass"] passed = len(results) - len(failures) - _emit({"tool": "apiverity", "command": "test", "base_url": args.base_url, - "total": len(results), "passed": passed, "failed": len(failures), - "results": results}, args.json) + _emit( + { + "tool": "apiverity", + "command": "test", + "base_url": args.base_url, + "total": len(results), + "passed": passed, + "failed": len(failures), + "results": results, + }, + args.json, + ) return EXIT_FINDINGS if failures else EXIT_OK def cmd_workflow(args: argparse.Namespace) -> int: - from apiverity.stateful.engine import load_workflow_manifest, WorkflowEngine + from apiverity.stateful.engine import WorkflowEngine, load_workflow_manifest wf = load_workflow_manifest(args.manifest) base_url = args.base_url or wf.base_url if not base_url: - print("error: no base URL (pass --base-url or set base_url in manifest)", - file=sys.stderr) + print("error: no base URL (pass --base-url or set base_url in manifest)", file=sys.stderr) return EXIT_USAGE try: result = WorkflowEngine(wf, base_url).run() except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return EXIT_USAGE - except Exception: # noqa: BLE001 - _emit({"tool": "apiverity", "command": "workflow", - "workflow": wf.name, "status": "error"}, args.json) + except Exception: + _emit( + {"tool": "apiverity", "command": "workflow", "workflow": wf.name, "status": "error"}, + args.json, + ) return EXIT_UNREACHABLE _emit({"tool": "apiverity", "command": "workflow", "result": result}, args.json) return EXIT_FINDINGS if result.status != "pass" else EXIT_OK @@ -195,8 +242,12 @@ def cmd_mock(args: argparse.Namespace) -> int: from apiverity.mock import FaultConfig, serve service, _, _ = _load(args.spec) - faults = FaultConfig(latency_ms=args.latency_ms, force_status=args.force_status, - malformed_json=args.malformed, rate_limit_after=args.rate_limit_after) + faults = FaultConfig( + latency_ms=args.latency_ms, + force_status=args.force_status, + malformed_json=args.malformed, + rate_limit_after=args.rate_limit_after, + ) host = "127.0.0.1" # always localhost by default serve(service, host=host, port=args.port, faults=faults) return EXIT_OK @@ -208,8 +259,15 @@ def cmd_coverage(args: argparse.Namespace) -> int: service, _, _ = _load(args.spec) exercised = set(args.exercised or []) report = measure_coverage(service, exercised_operations=exercised) - _emit({"tool": "apiverity", "command": "coverage", - "overall_percent": report.overall_percent(), "report": report}, args.json) + _emit( + { + "tool": "apiverity", + "command": "coverage", + "overall_percent": report.overall_percent(), + "report": report, + }, + args.json, + ) return EXIT_OK @@ -221,7 +279,7 @@ def cmd_drift(args: argparse.Namespace) -> int: _LAST_TARGET = args.base_url try: report = detect_drift(service, args.base_url, timeout=args.timeout) - except Exception as exc: # noqa: BLE001 + except Exception as exc: print(f"error: target unreachable: {exc}", file=sys.stderr) return EXIT_UNREACHABLE _emit({"tool": "apiverity", "command": "drift", "report": report}, args.json) @@ -229,23 +287,34 @@ def cmd_drift(args: argparse.Namespace) -> int: def cmd_replay(args: argparse.Namespace) -> int: + from urllib.parse import urlparse + from apiverity.traffic.redact import RedactionConfig, import_har from apiverity.traffic.replay import ReplayEntry, replay_corpus - from urllib.parse import urlparse cfg = RedactionConfig() entries_raw = import_har(args.har, cfg) entries = [] for e in entries_raw: parsed = urlparse(e["url"] or "") - entries.append(ReplayEntry(method=e["method"] or "GET", path=parsed.path or "/", - query=e["query"], headers=e["request_headers"], - body=e["request_body"])) + entries.append( + ReplayEntry( + method=e["method"] or "GET", + path=parsed.path or "/", + query=e["query"], + headers=e["request_headers"], + body=e["request_body"], + ) + ) try: - report = replay_corpus(entries, args.base_url, allowed_hosts=args.allow_host, - dry_run=not args.execute, - rate_per_second=args.rate, - allow_production=args.i_know_this_is_production) + report = replay_corpus( + entries, + args.base_url, + allowed_hosts=args.allow_host, + dry_run=not args.execute, + rate_per_second=args.rate, + allow_production=args.i_know_this_is_production, + ) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return EXIT_USAGE @@ -259,15 +328,17 @@ def cmd_baseline(args: argparse.Namespace) -> int: service, _, _ = _load(args.spec) try: report = measure(service, args.base_url, iterations=args.iterations) - except Exception as exc: # noqa: BLE001 + except Exception as exc: print(f"error: target unreachable: {exc}", file=sys.stderr) return EXIT_UNREACHABLE global _LAST_TARGET _LAST_TARGET = args.base_url payload = json.loads(report.model_dump_json()) Path(args.output).write_text(json.dumps(payload, indent=2), encoding="utf-8") - _emit({"tool": "apiverity", "command": "baseline", "output": args.output, - "report": report}, args.json) + _emit( + {"tool": "apiverity", "command": "baseline", "output": args.output, "report": report}, + args.json, + ) return EXIT_OK @@ -277,7 +348,7 @@ def cmd_regression(args: argparse.Namespace) -> int: service, _, _ = _load(args.spec) try: report = measure(service, args.base_url, iterations=args.iterations) - except Exception as exc: # noqa: BLE001 + except Exception as exc: print(f"error: target unreachable: {exc}", file=sys.stderr) return EXIT_UNREACHABLE violations = evaluate_policies(report, args.policy or []) @@ -285,8 +356,10 @@ def cmd_regression(args: argparse.Namespace) -> int: baseline = json.loads(Path(args.baseline).read_text(encoding="utf-8")) violations += compare_baseline(report, baseline, tolerance_pct=args.tolerance) report.policy_violations = violations - _emit({"tool": "apiverity", "command": "regression", "violations": violations, - "report": report}, args.json) + _emit( + {"tool": "apiverity", "command": "regression", "violations": violations, "report": report}, + args.json, + ) return EXIT_FINDINGS if violations else EXIT_OK @@ -324,7 +397,8 @@ def cmd_report(args: argparse.Namespace) -> int: rows += ( f"
{f.get('rule_id', '')}{data.get('command', '')} — {data.get('spec', data.get('base_url', ''))}
" f"| Rule | Severity | Message |
|---|