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', '')}" f"{sev}" - f"{f.get('message', '')}") + f"{f.get('message', '')}" + ) print( "" "apiverity report" @@ -334,18 +408,33 @@ def cmd_report(args: argparse.Namespace) -> int: "

apiverity report

" f"

{data.get('command', '')} — {data.get('spec', data.get('base_url', ''))}

" f"{rows}" - "
RuleSeverityMessage
") + "" + ) elif fmt == "sarif": - sarif = {"$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "version": "2.1.0", - "runs": [{"tool": {"driver": {"name": "apiverity", - "informationUri": "https://github.com/webdevsamran/api-verity-lab"}}, - "results": [ - {"ruleId": f.get("rule_id", "APIVERITY"), - "level": {"ERROR": "error", "WARN": "warning"}.get( - str(f.get("severity")), "note"), - "message": {"text": f.get("message", "")}} - for f in data.get("findings", [])]}]} + sarif = { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "apiverity", + "informationUri": "https://github.com/webdevsamran/api-verity-lab", + } + }, + "results": [ + { + "ruleId": f.get("rule_id", "APIVERITY"), + "level": {"ERROR": "error", "WARN": "warning"}.get( + str(f.get("severity")), "note" + ), + "message": {"text": f.get("message", "")}, + } + for f in data.get("findings", []) + ], + } + ], + } print(json.dumps(sarif, indent=2)) else: print(f"error: unknown format '{fmt}'", file=sys.stderr) @@ -359,12 +448,13 @@ def cmd_export(args: argparse.Namespace) -> int: summary and SHA256 checksums.""" import hashlib - from apiverity.core.artifact import contract_hash - out = Path(args.output) out.mkdir(parents=True, exist_ok=True) - payload = json.loads(args.data) if args.data.startswith("{") else { - "tool": "apiverity", "note": args.data} + payload = ( + json.loads(args.data) + if args.data.startswith("{") + else {"tool": "apiverity", "note": args.data} + ) if args.spec: spec_bytes = Path(args.spec).read_bytes() @@ -373,20 +463,23 @@ def cmd_export(args: argparse.Namespace) -> int: payload["contract_snapshot"] = "contract-snapshot" if args.config: (out / "config.yaml").write_text( - Path(args.config).read_text(encoding="utf-8"), encoding="utf-8") + Path(args.config).read_text(encoding="utf-8"), encoding="utf-8" + ) if args.workflow: (out / "workflow-manifest.yaml").write_text( - Path(args.workflow).read_text(encoding="utf-8"), encoding="utf-8") + Path(args.workflow).read_text(encoding="utf-8"), encoding="utf-8" + ) if args.perf: (out / "performance-summary.json").write_text( - Path(args.perf).read_text(encoding="utf-8"), encoding="utf-8") + Path(args.perf).read_text(encoding="utf-8"), encoding="utf-8" + ) # sanitized failing cases only (violations + reproduction, no bodies) if isinstance(payload.get("results"), list): - failing = [r for r in payload["results"] - if isinstance(r, dict) and r.get("status") != "pass"] - (out / "failing-cases.json").write_text( - json.dumps(failing, indent=2), encoding="utf-8") + failing = [ + r for r in payload["results"] if isinstance(r, dict) and r.get("status") != "pass" + ] + (out / "failing-cases.json").write_text(json.dumps(failing, indent=2), encoding="utf-8") (out / "result.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") checksums = {} @@ -394,9 +487,12 @@ def cmd_export(args: argparse.Namespace) -> int: if f.is_file(): checksums[f.name] = hashlib.sha256(f.read_bytes()).hexdigest() (out / "SHA256SUMS").write_text( - NL.join(f"{v} {k}" for k, v in checksums.items()) + NL, encoding="utf-8") - _emit({"tool": "apiverity", "command": "export", "bundle": str(out), - "files": sorted(checksums)}, args.json) + NL.join(f"{v} {k}" for k, v in checksums.items()) + NL, encoding="utf-8" + ) + _emit( + {"tool": "apiverity", "command": "export", "bundle": str(out), "files": sorted(checksums)}, + args.json, + ) return EXIT_OK @@ -429,10 +525,11 @@ def cmd_plugins(args: argparse.Namespace) -> int: def cmd_rules(args: argparse.Namespace) -> int: from apiverity.rules.breaking import CATALOG - rules = [{"rule_id": rid, "severity": spec.severity.value, "description": spec.description} - for rid, spec in sorted(CATALOG.items())] - _emit({"tool": "apiverity", "command": "rules", "count": len(rules), "rules": rules}, - args.json) + rules = [ + {"rule_id": rid, "severity": spec.severity.value, "description": spec.description} + for rid, spec in sorted(CATALOG.items()) + ] + _emit({"tool": "apiverity", "command": "rules", "count": len(rules), "rules": rules}, args.json) return EXIT_OK @@ -444,9 +541,16 @@ def cmd_self_test(args: argparse.Namespace) -> int: return EXIT_INTERNAL service, findings, plugin = _load(str(fixture)) ok = plugin.protocol().value == "openapi" and len(service.operations) > 0 - _emit({"tool": "apiverity", "command": "self-test", "ok": ok, - "operations": len(service.operations), "spec_findings": len(findings)}, - args.json) + _emit( + { + "tool": "apiverity", + "command": "self-test", + "ok": ok, + "operations": len(service.operations), + "spec_findings": len(findings), + }, + args.json, + ) return EXIT_OK if ok else EXIT_INTERNAL @@ -454,56 +558,114 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="apiverity", description=__doc__) sub = parser.add_subparsers(dest="command", required=True) - p = sub.add_parser("validate"); p.add_argument("spec"); p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_validate) - p = sub.add_parser("diff"); p.add_argument("old"); p.add_argument("new"); p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_diff) - p = sub.add_parser("breaking"); p.add_argument("old"); p.add_argument("new") + p = sub.add_parser("validate") + p.add_argument("spec") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_validate) + p = sub.add_parser("diff") + p.add_argument("old") + p.add_argument("new") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_diff) + p = sub.add_parser("breaking") + p.add_argument("old") + p.add_argument("new") p.add_argument("--check-semver", action="store_true") - p.add_argument("--old-version"); p.add_argument("--new-version") + p.add_argument("--old-version") + p.add_argument("--new-version") p.add_argument("--require-minor-for-warnings", action="store_true") p.add_argument("--severity-override", action="append") - p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_breaking) - p = sub.add_parser("changelog"); p.add_argument("old"); p.add_argument("new") - p.add_argument("--html", action="store_true"); p.add_argument("--output"); p.set_defaults(func=cmd_changelog) - p = sub.add_parser("test"); p.add_argument("spec"); p.add_argument("--base-url", required=True) - p.add_argument("--seed", type=int, default=0); p.add_argument("--timeout", type=float, default=10.0) - p.add_argument("--minimize", action="store_true"); p.add_argument("--json", action="store_true") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_breaking) + p = sub.add_parser("changelog") + p.add_argument("old") + p.add_argument("new") + p.add_argument("--html", action="store_true") + p.add_argument("--output") + p.set_defaults(func=cmd_changelog) + p = sub.add_parser("test") + p.add_argument("spec") + p.add_argument("--base-url", required=True) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--timeout", type=float, default=10.0) + p.add_argument("--minimize", action="store_true") + p.add_argument("--json", action="store_true") p.set_defaults(func=cmd_test) - p = sub.add_parser("workflow"); p.add_argument("manifest"); p.add_argument("--base-url") - p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_workflow) - p = sub.add_parser("mock"); p.add_argument("spec"); p.add_argument("--port", type=int, default=8090) - p.add_argument("--latency-ms", type=int, default=0); p.add_argument("--force-status", type=int) - p.add_argument("--malformed", action="store_true"); p.add_argument("--rate-limit-after", type=int) + p = sub.add_parser("workflow") + p.add_argument("manifest") + p.add_argument("--base-url") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_workflow) + p = sub.add_parser("mock") + p.add_argument("spec") + p.add_argument("--port", type=int, default=8090) + p.add_argument("--latency-ms", type=int, default=0) + p.add_argument("--force-status", type=int) + p.add_argument("--malformed", action="store_true") + p.add_argument("--rate-limit-after", type=int) p.set_defaults(func=cmd_mock) - p = sub.add_parser("coverage"); p.add_argument("spec") - p.add_argument("--exercised", nargs="*"); p.add_argument("--json", action="store_true") + p = sub.add_parser("coverage") + p.add_argument("spec") + p.add_argument("--exercised", nargs="*") + p.add_argument("--json", action="store_true") p.set_defaults(func=cmd_coverage) - p = sub.add_parser("drift"); p.add_argument("spec"); p.add_argument("--base-url", required=True) - p.add_argument("--timeout", type=float, default=10.0); p.add_argument("--json", action="store_true") + p = sub.add_parser("drift") + p.add_argument("spec") + p.add_argument("--base-url", required=True) + p.add_argument("--timeout", type=float, default=10.0) + p.add_argument("--json", action="store_true") p.set_defaults(func=cmd_drift) - p = sub.add_parser("replay"); p.add_argument("har"); p.add_argument("--base-url", required=True) + p = sub.add_parser("replay") + p.add_argument("har") + p.add_argument("--base-url", required=True) p.add_argument("--allow-host", action="append", required=True) - p.add_argument("--execute", action="store_true"); p.add_argument("--rate", type=float, default=10.0) + p.add_argument("--execute", action="store_true") + p.add_argument("--rate", type=float, default=10.0) p.add_argument("--i-know-this-is-production", action="store_true") - p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_replay) - p = sub.add_parser("baseline"); p.add_argument("spec"); p.add_argument("--base-url", required=True) + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_replay) + p = sub.add_parser("baseline") + p.add_argument("spec") + p.add_argument("--base-url", required=True) p.add_argument("-o", "--output", default="perf-baseline.json") - p.add_argument("--iterations", type=int, default=20); p.add_argument("--json", action="store_true") + p.add_argument("--iterations", type=int, default=20) + p.add_argument("--json", action="store_true") p.set_defaults(func=cmd_baseline) - p = sub.add_parser("regression"); p.add_argument("spec"); p.add_argument("--base-url", required=True) - p.add_argument("--baseline"); p.add_argument("--policy", action="append") + p = sub.add_parser("regression") + p.add_argument("spec") + p.add_argument("--base-url", required=True) + p.add_argument("--baseline") + p.add_argument("--policy", action="append") p.add_argument("--tolerance", type=float, default=20.0) - p.add_argument("--iterations", type=int, default=20); p.add_argument("--json", action="store_true") + p.add_argument("--iterations", type=int, default=20) + p.add_argument("--json", action="store_true") p.set_defaults(func=cmd_regression) - p = sub.add_parser("report"); p.add_argument("bundle"); p.add_argument("--format", default="json") + p = sub.add_parser("report") + p.add_argument("bundle") + p.add_argument("--format", default="json") p.set_defaults(func=cmd_report) - p = sub.add_parser("export"); p.add_argument("--data", required=True); p.add_argument("-o", "--output", required=True) - p.add_argument("--spec"); p.add_argument("--config"); p.add_argument("--workflow"); p.add_argument("--perf") - p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_export) - p = sub.add_parser("serve"); p.add_argument("directory"); p.add_argument("--port", type=int, default=8080) + p = sub.add_parser("export") + p.add_argument("--data", required=True) + p.add_argument("-o", "--output", required=True) + p.add_argument("--spec") + p.add_argument("--config") + p.add_argument("--workflow") + p.add_argument("--perf") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_export) + p = sub.add_parser("serve") + p.add_argument("directory") + p.add_argument("--port", type=int, default=8080) p.set_defaults(func=cmd_serve) - p = sub.add_parser("plugins"); p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_plugins) - p = sub.add_parser("rules"); p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_rules) - p = sub.add_parser("self-test"); p.add_argument("--json", action="store_true"); p.set_defaults(func=cmd_self_test) + p = sub.add_parser("plugins") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_plugins) + p = sub.add_parser("rules") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_rules) + p = sub.add_parser("self-test") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_self_test) return parser @@ -511,15 +673,16 @@ def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: - return args.func(args) + result: Any = args.func(args) + return int(result) except SystemExit: raise except KeyboardInterrupt: return EXIT_OK - except Exception as exc: # noqa: BLE001 + except Exception as exc: print(f"internal error: {exc}", file=sys.stderr) return EXIT_INTERNAL if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/apiverity/core/__init__.py b/apiverity/core/__init__.py index 48fcf20..2a0f4c9 100644 --- a/apiverity/core/__init__.py +++ b/apiverity/core/__init__.py @@ -1 +1 @@ -"""Core normalized contract model shared by all engines.""" \ No newline at end of file +"""Core normalized contract model shared by all engines.""" diff --git a/apiverity/core/artifact.py b/apiverity/core/artifact.py index 6428e71..87a4aa1 100644 --- a/apiverity/core/artifact.py +++ b/apiverity/core/artifact.py @@ -4,6 +4,7 @@ version, protocol version, contract hash (sha256 of the spec file), target metadata, seed, timing and redaction state. """ + from __future__ import annotations import hashlib @@ -59,4 +60,4 @@ def enrich( for key, value in meta.model_dump().items(): enriched.setdefault(key, value) enriched["tool"] = meta.tool - return enriched \ No newline at end of file + return enriched diff --git a/apiverity/core/hash.py b/apiverity/core/hash.py index 66cc91e..8519ed4 100644 --- a/apiverity/core/hash.py +++ b/apiverity/core/hash.py @@ -22,4 +22,4 @@ def sha256_hex(value: Any) -> str: def contract_hash(service: Any) -> str: """Hash of a normalized contract, excluding volatile metadata.""" - return f"sha256:{sha256_hex(service)}" \ No newline at end of file + return f"sha256:{sha256_hex(service)}" diff --git a/apiverity/core/model.py b/apiverity/core/model.py index 2435545..a3b2b8a 100644 --- a/apiverity/core/model.py +++ b/apiverity/core/model.py @@ -10,13 +10,13 @@ from __future__ import annotations -from enum import Enum -from typing import Any, Optional, Union +from enum import StrEnum +from typing import Any from pydantic import BaseModel, ConfigDict, Field -class Protocol(str, Enum): +class Protocol(StrEnum): """Wire protocol of a contract.""" OPENAPI = "openapi" @@ -44,44 +44,44 @@ def __str__(self) -> str: # pragma: no cover - trivial class SchemaNode(BaseModel): """Recursive, JSON-Schema-like type tree used for all protocols.""" - type: Optional[str] = None # object|array|string|integer|number|boolean|null - format: Optional[str] = None - title: Optional[str] = None - description: Optional[str] = None + type: str | None = None # object|array|string|integer|number|boolean|null + format: str | None = None + title: str | None = None + description: str | None = None nullable: bool = False deprecated: bool = False - enum: Optional[list[Any]] = None - const: Optional[Any] = None - default: Optional[Any] = None - example: Optional[Any] = None + enum: list[Any] | None = None + const: Any | None = None + default: Any | None = None + example: Any | None = None # object constraints - properties: dict[str, "SchemaNode"] = Field(default_factory=dict) + properties: dict[str, SchemaNode] = Field(default_factory=dict) required: list[str] = Field(default_factory=list) - additional_properties: Optional[Union[bool, "SchemaNode"]] = None - min_properties: Optional[int] = None - max_properties: Optional[int] = None + additional_properties: bool | SchemaNode | None = None + min_properties: int | None = None + max_properties: int | None = None # array constraints - items: Optional["SchemaNode"] = None - min_items: Optional[int] = None - max_items: Optional[int] = None - unique_items: Optional[bool] = None + items: SchemaNode | None = None + min_items: int | None = None + max_items: int | None = None + unique_items: bool | None = None # string constraints - min_length: Optional[int] = None - max_length: Optional[int] = None - pattern: Optional[str] = None + min_length: int | None = None + max_length: int | None = None + pattern: str | None = None # numeric constraints - minimum: Optional[float] = None - maximum: Optional[float] = None - exclusive_minimum: Optional[float] = None - exclusive_maximum: Optional[float] = None - multiple_of: Optional[float] = None + minimum: float | None = None + maximum: float | None = None + exclusive_minimum: float | None = None + exclusive_maximum: float | None = None + multiple_of: float | None = None # composition - one_of: Optional[list["SchemaNode"]] = None - any_of: Optional[list["SchemaNode"]] = None - all_of: Optional[list["SchemaNode"]] = None - not_: Optional["SchemaNode"] = Field(default=None, alias="not") + one_of: list[SchemaNode] | None = None + any_of: list[SchemaNode] | None = None + all_of: list[SchemaNode] | None = None + not_: SchemaNode | None = Field(default=None, alias="not") # provenance - source_location: Optional[SourceLocation] = None + source_location: SourceLocation | None = None model_config = ConfigDict(populate_by_name=True) @@ -89,7 +89,7 @@ def iter_property_names(self) -> list[str]: return list(self.properties.keys()) -class ParameterLocation(str, Enum): +class ParameterLocation(StrEnum): PATH = "path" QUERY = "query" HEADER = "header" @@ -101,25 +101,25 @@ class Parameter(BaseModel): location: ParameterLocation required: bool = False deprecated: bool = False - description: Optional[str] = None - schema_node: Optional[SchemaNode] = None - example: Optional[Any] = None - source_location: Optional[SourceLocation] = None + description: str | None = None + schema_node: SchemaNode | None = None + example: Any | None = None + source_location: SourceLocation | None = None class RequestBody(BaseModel): required: bool = False - description: Optional[str] = None + description: str | None = None content: dict[str, SchemaNode] = Field(default_factory=dict) # media type -> schema - source_location: Optional[SourceLocation] = None + source_location: SourceLocation | None = None class Response(BaseModel): status: str # "200", "4XX", "default" - description: Optional[str] = None + description: str | None = None headers: dict[str, SchemaNode] = Field(default_factory=dict) content: dict[str, SchemaNode] = Field(default_factory=dict) - source_location: Optional[SourceLocation] = None + source_location: SourceLocation | None = None class SecurityRequirement(BaseModel): @@ -132,21 +132,21 @@ class SecurityRequirement(BaseModel): class SecurityScheme(BaseModel): name: str type: str # apiKey | http | oauth2 | openIdConnect | mutualTLS - location: Optional[ParameterLocation] = None # for apiKey - scheme: Optional[str] = None # bearer, basic, digest for http - bearer_format: Optional[str] = None + location: ParameterLocation | None = None # for apiKey + scheme: str | None = None # bearer, basic, digest for http + bearer_format: str | None = None deprecated: bool = False - source_location: Optional[SourceLocation] = None + source_location: SourceLocation | None = None class Example(BaseModel): name: str - value: Optional[Any] = None - summary: Optional[str] = None - source_location: Optional[SourceLocation] = None + value: Any | None = None + summary: str | None = None + source_location: SourceLocation | None = None -class OperationKind(str, Enum): +class OperationKind(StrEnum): HTTP = "http" GRAPHQL_FIELD = "graphql_field" GRPC_RPC = "grpc_rpc" @@ -160,21 +160,21 @@ class Operation(BaseModel): """ kind: OperationKind = OperationKind.HTTP - operation_id: Optional[str] = None - method: Optional[str] = None # GET/POST/... (HTTP) - path: Optional[str] = None # /users/{id} (HTTP) - rpc_name: Optional[str] = None # gRPC - service_name: Optional[str] = None # gRPC / GraphQL root type - summary: Optional[str] = None - description: Optional[str] = None + operation_id: str | None = None + method: str | None = None # GET/POST/... (HTTP) + path: str | None = None # /users/{id} (HTTP) + rpc_name: str | None = None # gRPC + service_name: str | None = None # gRPC / GraphQL root type + summary: str | None = None + description: str | None = None deprecated: bool = False tags: list[str] = Field(default_factory=list) parameters: list[Parameter] = Field(default_factory=list) - request_body: Optional[RequestBody] = None + request_body: RequestBody | None = None responses: list[Response] = Field(default_factory=list) - security: Optional[list[SecurityRequirement]] = None # None = inherit global + security: list[SecurityRequirement] | None = None # None = inherit global examples: list[Example] = Field(default_factory=list) - source_location: Optional[SourceLocation] = None + source_location: SourceLocation | None = None @property def key(self) -> str: @@ -188,7 +188,7 @@ def key(self) -> str: class Server(BaseModel): url: str - description: Optional[str] = None + description: str | None = None class Service(BaseModel): @@ -197,18 +197,18 @@ class Service(BaseModel): title: str version: str protocol: Protocol - description: Optional[str] = None + description: str | None = None servers: list[Server] = Field(default_factory=list) operations: list[Operation] = Field(default_factory=list) security_schemes: dict[str, SecurityScheme] = Field(default_factory=dict) global_security: list[SecurityRequirement] = Field(default_factory=list) - source_file: Optional[str] = None - source_location: Optional[SourceLocation] = None + source_file: str | None = None + source_location: SourceLocation | None = None def operation_keys(self) -> list[str]: return [op.key for op in self.operations] - def find_operation(self, key: str) -> Optional[Operation]: + def find_operation(self, key: str) -> Operation | None: for op in self.operations: if op.key == key: return op @@ -218,7 +218,7 @@ def find_operation(self, key: str) -> Optional[Operation]: # --- Findings --------------------------------------------------------------- -class Severity(str, Enum): +class Severity(StrEnum): ERROR = "ERROR" WARN = "WARN" INFO = "INFO" @@ -230,18 +230,18 @@ class Finding(BaseModel): rule_id: str severity: Severity message: str - operation_key: Optional[str] = None - location: Optional[SourceLocation] = None - new_location: Optional[SourceLocation] = None - change_id: Optional[str] = None - hint: Optional[str] = None + operation_key: str | None = None + location: SourceLocation | None = None + new_location: SourceLocation | None = None + change_id: str | None = None + hint: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) # --- Changes ---------------------------------------------------------------- -class ChangeKind(str, Enum): +class ChangeKind(StrEnum): OPERATION_ADDED = "operation_added" OPERATION_REMOVED = "operation_removed" PARAMETER_ADDED = "parameter_added" @@ -283,11 +283,12 @@ class Change(BaseModel): direction: str # "request" | "response" | "meta" | "security" operation_key: str description: str - old_location: Optional[SourceLocation] = None - new_location: Optional[SourceLocation] = None - old_value: Optional[Any] = None - new_value: Optional[Any] = None - breaking_hint: Optional[str] = None + old_location: SourceLocation | None = None + new_location: SourceLocation | None = None + old_value: Any | None = None + new_value: Any | None = None + breaking_hint: str | None = None + SchemaNode.model_rebuild() diff --git a/apiverity/core/validation.py b/apiverity/core/validation.py index 54448b2..defb1de 100644 --- a/apiverity/core/validation.py +++ b/apiverity/core/validation.py @@ -83,9 +83,12 @@ def validate_value( errors.append(f"{path}: {value} <= exclusiveMinimum {schema.exclusive_minimum}") if schema.exclusive_maximum is not None and value >= schema.exclusive_maximum: errors.append(f"{path}: {value} >= exclusiveMaximum {schema.exclusive_maximum}") - if schema.multiple_of is not None and schema.multiple_of != 0: - if abs(value / schema.multiple_of - round(value / schema.multiple_of)) > 1e-9: - errors.append(f"{path}: {value} not a multiple of {schema.multiple_of}") + if ( + schema.multiple_of is not None + and schema.multiple_of != 0 + and abs(value / schema.multiple_of - round(value / schema.multiple_of)) > 1e-9 + ): + errors.append(f"{path}: {value} not a multiple of {schema.multiple_of}") elif schema.type == "array": assert isinstance(value, list) @@ -112,15 +115,14 @@ def validate_value( if extra: addl = schema.additional_properties if addl is False: - errors.append(f"{path}: undeclared field(s) {sorted(extra)} " - "(additionalProperties: false)") + errors.append( + f"{path}: undeclared field(s) {sorted(extra)} (additionalProperties: false)" + ) elif forbid_undeclared_fields and addl is None: errors.append(f"{path}: undeclared field(s) {sorted(extra)}") elif isinstance(addl, SchemaNode): for k in extra: - errors.extend( - validate_value(addl, value[k], path=f"{path}.{k}") - ) + errors.extend(validate_value(addl, value[k], path=f"{path}.{k}")) # composition: anyOf/oneOf must match at least one variant for attr, label in (("any_of", "anyOf"), ("one_of", "oneOf")): @@ -130,4 +132,4 @@ def validate_value( if not ok: errors.append(f"{path}: does not match any {label} variant") - return errors \ No newline at end of file + return errors diff --git a/apiverity/coverage/__init__.py b/apiverity/coverage/__init__.py index a03f318..c62c815 100644 --- a/apiverity/coverage/__init__.py +++ b/apiverity/coverage/__init__.py @@ -7,4 +7,4 @@ from apiverity.coverage.coverage import CoverageReport, measure_coverage -__all__ = ["CoverageReport", "measure_coverage"] \ No newline at end of file +__all__ = ["CoverageReport", "measure_coverage"] diff --git a/apiverity/coverage/coverage.py b/apiverity/coverage/coverage.py index de36b9b..a881d60 100644 --- a/apiverity/coverage/coverage.py +++ b/apiverity/coverage/coverage.py @@ -2,11 +2,11 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, Field -from apiverity.core.model import Operation, Service +from apiverity.core.model import Service class OperationCoverage(BaseModel): @@ -52,9 +52,17 @@ def _count_constraints(schema: Any) -> int: if schema.enum is not None: count += 1 for attr in ( - "minimum", "maximum", "exclusive_minimum", "exclusive_maximum", - "multiple_of", "min_length", "max_length", "pattern", - "min_items", "max_items", "unique_items", + "minimum", + "maximum", + "exclusive_minimum", + "exclusive_maximum", + "multiple_of", + "min_length", + "max_length", + "pattern", + "min_items", + "max_items", + "unique_items", ): if getattr(schema, attr) is not None: count += 1 @@ -68,12 +76,12 @@ def _count_constraints(schema: Any) -> int: def measure_coverage( service: Service, *, - exercised_operations: Optional[set[str]] = None, - statuses_by_operation: Optional[dict[str, set[int]]] = None, - parameters_by_operation: Optional[dict[str, set[str]]] = None, - negative_cases_by_operation: Optional[dict[str, int]] = None, + exercised_operations: set[str] | None = None, + statuses_by_operation: dict[str, set[int]] | None = None, + parameters_by_operation: dict[str, set[str]] | None = None, + negative_cases_by_operation: dict[str, int] | None = None, workflow_edges: tuple[int, int] = (0, 0), - security_schemes_exercised: Optional[set[str]] = None, + security_schemes_exercised: set[str] | None = None, ) -> CoverageReport: """Build a coverage report. @@ -102,13 +110,13 @@ def measure_coverage( ), security_schemes=[ r.scheme_name - for r in (op.security if op.security is not None else service.global_security) - or [] + for r in (op.security if op.security is not None else service.global_security) or [] ], ) oc.statuses_seen = sorted(statuses.get(op.key, set())) oc.body_constraints_declared = sum( - _count_constraints(s) for s in (op.request_body.content.values() if op.request_body else []) + _count_constraints(s) + for s in (op.request_body.content.values() if op.request_body else []) ) # each executed negative case exercises roughly one constraint oc.body_constraints_exercised = min(negatives.get(op.key, 0), oc.body_constraints_declared) @@ -125,4 +133,4 @@ def measure_coverage( report.security_schemes_declared = sorted(service.security_schemes) report.security_schemes_exercised = sorted(schemes_used & set(service.security_schemes)) - return report \ No newline at end of file + return report diff --git a/apiverity/diff/__init__.py b/apiverity/diff/__init__.py index bcf59f4..1ffe07c 100644 --- a/apiverity/diff/__init__.py +++ b/apiverity/diff/__init__.py @@ -1 +1 @@ -"""Semantic diff engine producing stable change IDs.""" \ No newline at end of file +"""Semantic diff engine producing stable change IDs.""" diff --git a/apiverity/diff/engine.py b/apiverity/diff/engine.py index ca27ef2..1bb6cde 100644 --- a/apiverity/diff/engine.py +++ b/apiverity/diff/engine.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from apiverity.core.model import ( Change, @@ -38,7 +38,7 @@ ) -def _schema_summary(schema: Optional[SchemaNode]) -> str: +def _schema_summary(schema: SchemaNode | None) -> str: if schema is None: return "absent" base = schema.type or "any" @@ -71,7 +71,7 @@ def _add( new_value: Any = None, old_location: Any = None, new_location: Any = None, - breaking_hint: Optional[str] = None, + breaking_hint: str | None = None, ) -> Change: key = kind.value.upper() self._counters[key] = self._counters.get(key, 0) + 1 @@ -99,9 +99,7 @@ def run(self) -> list[Change]: for key in sorted(set(old_ops) - set(new_ops)): op = old_ops[key] kind = ( - ChangeKind.RPC_REMOVED - if op.kind.value != "http" - else ChangeKind.OPERATION_REMOVED + ChangeKind.RPC_REMOVED if op.kind.value != "http" else ChangeKind.OPERATION_REMOVED ) self._add( kind, @@ -114,11 +112,7 @@ def run(self) -> list[Change]: for key in sorted(set(new_ops) - set(old_ops)): op = new_ops[key] - kind = ( - ChangeKind.RPC_ADDED - if op.kind.value != "http" - else ChangeKind.OPERATION_ADDED - ) + kind = ChangeKind.RPC_ADDED if op.kind.value != "http" else ChangeKind.OPERATION_ADDED self._add( kind, key, @@ -227,9 +221,7 @@ def index(params: list[Parameter]) -> dict[tuple[str, str], Parameter]: + (" (required)" if p.required else ""), new_location=p.source_location, breaking_hint=( - "new required parameter: existing clients will fail" - if p.required - else None + "new required parameter: existing clients will fail" if p.required else None ), ) for ident in sorted(set(old_p) & set(new_p)): @@ -240,8 +232,7 @@ def index(params: list[Parameter]) -> dict[tuple[str, str], Parameter]: ChangeKind.PARAMETER_REQUIREDNESS, key, "request", - f"parameter '{name}' ({loc}) requiredness changed " - f"{o.required} -> {n.required}", + f"parameter '{name}' ({loc}) requiredness changed {o.required} -> {n.required}", old_value=o.required, new_value=n.required, old_location=o.source_location, @@ -282,15 +273,13 @@ def _diff_request_body(self, old: Operation, new: Operation, key: str) -> None: ChangeKind.REQUEST_SCHEMA_CHANGED, key, "request", - "request body " - + ("removed" if new_body is None else "added"), + "request body " + ("removed" if new_body is None else "added"), old_location=old_body.source_location if old_body else None, new_location=new_body.source_location if new_body else None, breaking_hint=( "request body removed: clients sending bodies may break" if new_body is None - else "request body added" - + (" and required" if new_body.required else "") + else "request body added" + (" and required" if new_body.required else "") if new_body.required else None ), @@ -312,9 +301,7 @@ def _diff_request_body(self, old: Operation, new: Operation, key: str) -> None: key, "request", f"request body requiredness changed {old_body.required} -> {new_body.required}", - breaking_hint=( - "request body became required" if new_body.required else None - ), + breaking_hint=("request body became required" if new_body.required else None), ) def _diff_responses(self, old: Operation, new: Operation, key: str) -> None: @@ -373,11 +360,11 @@ def _diff_responses(self, old: Operation, new: Operation, key: str) -> None: ) def _diff_security(self, old: Operation, new: Operation, key: str) -> None: - def sec_str(op: Operation) -> list[list[str]]: - reqs = op.security if op.security is not None else None + def sec_str(op: Operation) -> list[str]: + reqs = op.security if reqs is None: reqs = self.new.global_security if op is new else self.old.global_security - return sorted([sorted(r.scheme_name for r in req)] for req in reqs) if reqs else [] + return sorted(r.scheme_name for r in reqs) o_sec, n_sec = sec_str(old), sec_str(new) if o_sec != n_sec: @@ -442,9 +429,7 @@ def _diff_schema( new_value=new.enum, old_location=old.source_location, new_location=new.source_location, - breaking_hint=( - f"enum values removed: {removed}" if removed else None - ), + breaking_hint=(f"enum values removed: {removed}" if removed else None), ) for attr in _CONSTRAINT_ATTRS: @@ -537,11 +522,14 @@ def _diff_schema( for attr in ("one_of", "any_of", "all_of"): o_variants, n_variants = getattr(old, attr), getattr(new, attr) if o_variants and n_variants and len(o_variants) == len(n_variants): - for i, (o_v, n_v) in enumerate(zip(o_variants, n_variants)): + for i, (o_v, n_v) in enumerate(zip(o_variants, n_variants, strict=True)): self._diff_schema( o_v, n_v, operation_key, where, direction, path=f"{path}/{attr}[{i}]" ) def diff_services(old: Service, new: Service) -> list[Change]: - return DiffEngine(old, new).run() \ No newline at end of file + return DiffEngine(old, new).run() + + +__all__ = ["Change", "diff_services"] diff --git a/apiverity/fuzz/__init__.py b/apiverity/fuzz/__init__.py index fc6ca87..d88b1c8 100644 --- a/apiverity/fuzz/__init__.py +++ b/apiverity/fuzz/__init__.py @@ -1 +1 @@ -"""Schema-driven test generation, execution and failure minimization.""" \ No newline at end of file +"""Schema-driven test generation, execution and failure minimization.""" diff --git a/apiverity/fuzz/generate.py b/apiverity/fuzz/generate.py index 204ccf7..977dbf9 100644 --- a/apiverity/fuzz/generate.py +++ b/apiverity/fuzz/generate.py @@ -9,7 +9,7 @@ import random import string -from typing import Any, Optional +from typing import Any from apiverity.core.model import Operation, SchemaNode @@ -29,9 +29,7 @@ def _random_string(rng: random.Random, length: int) -> str: return "".join(rng.choices(string.ascii_lowercase, k=length)) -def generate_valid( - schema: Optional[SchemaNode], rng: random.Random, depth: int = 0 -) -> Any: +def generate_valid(schema: SchemaNode | None, rng: random.Random, depth: int = 0) -> Any: """Generate a value satisfying the schema constraints.""" if schema is None or depth > 8: return None @@ -67,9 +65,9 @@ def generate_valid( return rng.randint(lo, max(lo, hi)) if stype == "number": - lo = schema.minimum if schema.minimum is not None else 1.0 - hi = schema.maximum if schema.maximum is not None else lo + 100.0 - return round(rng.uniform(lo, hi), 2) + flo = schema.minimum if schema.minimum is not None else 1.0 + fhi = schema.maximum if schema.maximum is not None else flo + 100.0 + return round(rng.uniform(flo, fhi), 2) if stype == "boolean": return True @@ -91,7 +89,7 @@ def generate_valid( def generate_invalid( - schema: Optional[SchemaNode], rng: random.Random, depth: int = 0 + schema: SchemaNode | None, rng: random.Random, depth: int = 0 ) -> list[tuple[str, Any]]: """Generate values violating exactly one constraint each. @@ -124,13 +122,17 @@ def generate_invalid( base = _random_string(rng, 5) if schema.min_length is not None: violations.append( - (f"length < minLength({schema.min_length})", - _random_string(rng, max(schema.min_length - 1, 0))) + ( + f"length < minLength({schema.min_length})", + _random_string(rng, max(schema.min_length - 1, 0)), + ) ) if schema.max_length is not None: violations.append( - (f"length > maxLength({schema.max_length})", - _random_string(rng, schema.max_length + 5)) + ( + f"length > maxLength({schema.max_length})", + _random_string(rng, schema.max_length + 5), + ) ) if schema.pattern is not None: violations.append((f"violates pattern {schema.pattern!r}", base + "!!!")) @@ -144,13 +146,11 @@ def generate_invalid( violations.append((f"value > maximum({hi})", hi + 1)) if schema.exclusive_minimum is not None: violations.append( - (f"value <= exclusiveMinimum({schema.exclusive_minimum})", - schema.exclusive_minimum) + (f"value <= exclusiveMinimum({schema.exclusive_minimum})", schema.exclusive_minimum) ) if schema.exclusive_maximum is not None: violations.append( - (f"value >= exclusiveMaximum({schema.exclusive_maximum})", - schema.exclusive_maximum) + (f"value >= exclusiveMaximum({schema.exclusive_maximum})", schema.exclusive_maximum) ) if schema.multiple_of: m = schema.multiple_of @@ -159,8 +159,7 @@ def generate_invalid( if stype == "array": if schema.min_items is not None: violations.append( - (f"{max(schema.min_items - 1, 0)} items < minItems({schema.min_items})", - []) + (f"{max(schema.min_items - 1, 0)} items < minItems({schema.min_items})", []) ) if schema.items is not None: bad_items = generate_invalid(schema.items, rng, depth + 1) @@ -178,8 +177,7 @@ def generate_invalid( for name, sub in list(schema.properties.items())[:4]: for desc, val in generate_invalid(sub, rng, depth + 1)[:2]: full = { - n: generate_valid(s2, rng, depth + 1) - for n, s2 in schema.properties.items() + n: generate_valid(s2, rng, depth + 1) for n, s2 in schema.properties.items() } full[name] = val violations.append((f"field '{name}': {desc}", full)) @@ -227,15 +225,17 @@ def operation_cases(op: Operation, seed: int) -> list[dict[str, Any]]: body_schema = op.request_body.content[body_media] # positive case - cases.append({ - "kind": "positive", - "description": f"valid request to {op.key}", - "path_params": path_params, - "query": query_valid, - "headers": header_valid, - "body": generate_valid(body_schema, rng) if body_schema else None, - "media": body_media, - }) + cases.append( + { + "kind": "positive", + "description": f"valid request to {op.key}", + "path_params": path_params, + "query": query_valid, + "headers": header_valid, + "body": generate_valid(body_schema, rng) if body_schema else None, + "media": body_media, + } + ) # negative cases from parameters for p in op.parameters: @@ -244,39 +244,45 @@ def operation_cases(op: Operation, seed: int) -> list[dict[str, Any]]: q = dict(query_valid) if p.location.value == "query": q[p.name] = val - cases.append({ - "kind": "negative", - "description": f"parameter '{p.name}' invalid: {desc}", - "path_params": path_params, - "query": q, - "headers": header_valid, - "body": None, - "media": None, - }) + cases.append( + { + "kind": "negative", + "description": f"parameter '{p.name}' invalid: {desc}", + "path_params": path_params, + "query": q, + "headers": header_valid, + "body": None, + "media": None, + } + ) elif p.location.value == "path": pp = dict(path_params) pp[p.name] = val - cases.append({ - "kind": "negative", - "description": f"path parameter '{p.name}' invalid: {desc}", - "path_params": pp, - "query": query_valid, - "headers": header_valid, - "body": None, - "media": None, - }) + cases.append( + { + "kind": "negative", + "description": f"path parameter '{p.name}' invalid: {desc}", + "path_params": pp, + "query": query_valid, + "headers": header_valid, + "body": None, + "media": None, + } + ) # negative cases from body if body_schema is not None: for desc, val in generate_invalid(body_schema, rng): - cases.append({ - "kind": "negative", - "description": f"request body invalid: {desc}", - "path_params": path_params, - "query": query_valid, - "headers": header_valid, - "body": val, - "media": body_media, - }) - - return cases \ No newline at end of file + cases.append( + { + "kind": "negative", + "description": f"request body invalid: {desc}", + "path_params": path_params, + "query": query_valid, + "headers": header_valid, + "body": val, + "media": body_media, + } + ) + + return cases diff --git a/apiverity/fuzz/minimize.py b/apiverity/fuzz/minimize.py index 10bad22..509585a 100644 --- a/apiverity/fuzz/minimize.py +++ b/apiverity/fuzz/minimize.py @@ -7,8 +7,7 @@ from __future__ import annotations -import json -from typing import Any, Optional +from typing import Any import httpx @@ -131,4 +130,4 @@ def minimize_failures( budget -= 1 else: out.append(result) - return out \ No newline at end of file + return out diff --git a/apiverity/fuzz/models.py b/apiverity/fuzz/models.py index d97c17d..e525554 100644 --- a/apiverity/fuzz/models.py +++ b/apiverity/fuzz/models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, Field @@ -16,8 +16,8 @@ class TestCase(BaseModel): url_path: str query: dict[str, Any] = Field(default_factory=dict) headers: dict[str, str] = Field(default_factory=dict) - body: Optional[Any] = None - media: Optional[str] = None + body: Any | None = None + media: str | None = None expected: str # "2xx" for positive, "4xx" for negative @@ -27,8 +27,8 @@ class TestResult(BaseModel): kind: str description: str status: str # pass | fail | error - actual_status: Optional[int] = None + actual_status: int | None = None violations: list[str] = Field(default_factory=list) - reproduction: Optional[str] = None + reproduction: str | None = None minimized: bool = False - duration_ms: int = 0 \ No newline at end of file + duration_ms: int = 0 diff --git a/apiverity/fuzz/runner.py b/apiverity/fuzz/runner.py index 7c85b47..68b10ad 100644 --- a/apiverity/fuzz/runner.py +++ b/apiverity/fuzz/runner.py @@ -8,7 +8,6 @@ import json import time -from typing import Any, Optional import httpx @@ -62,9 +61,7 @@ def _declared_success(op: Operation) -> str: return "2xx" -def _check_response( - op: Operation, case: TestCase, response: httpx.Response -) -> list[str]: +def _check_response(op: Operation, case: TestCase, response: httpx.Response) -> list[str]: """Contract checks applied to every response.""" violations: list[str] = [] status = response.status_code @@ -81,9 +78,7 @@ def _check_response( return violations else: if 200 <= status < 300: - violations.append( - f"invalid input accepted with {status} (expected 4xx)" - ) + violations.append(f"invalid input accepted with {status} (expected 4xx)") return violations if 400 <= status < 500: return violations # correctly rejected @@ -120,9 +115,7 @@ def _check_response( violations.append("declared JSON response body is not valid JSON") body = None if body is not None: - violations.extend( - f"response body: {v}" for v in validate_value(schema, body) - ) + violations.extend(f"response body: {v}" for v in validate_value(schema, body)) # declared response headers present? for header in declared.headers: @@ -138,7 +131,7 @@ def run_cases( cases: list[TestCase], *, timeout: float = 10.0, - max_cases: Optional[int] = None, + max_cases: int | None = None, ) -> list[TestResult]: """Run cases sequentially against ``base_url``.""" ops = {op.key: op for op in service.operations} @@ -159,7 +152,8 @@ def run_cases( ) duration_ms = int((time.monotonic() - started) * 1000) violations = ( - _check_response(op, case, response) if op is not None + _check_response(op, case, response) + if op is not None else ["operation not found in contract"] ) results.append( @@ -189,4 +183,4 @@ def run_cases( duration_ms=duration_ms, ) ) - return results \ No newline at end of file + return results diff --git a/apiverity/mock/__init__.py b/apiverity/mock/__init__.py index 63bd9a3..1233e33 100644 --- a/apiverity/mock/__init__.py +++ b/apiverity/mock/__init__.py @@ -5,6 +5,6 @@ rate-limit simulation. """ -from apiverity.mock.server import MockServer, serve +from apiverity.mock.server import FaultConfig, MockServer, serve -__all__ = ["MockServer", "serve"] \ No newline at end of file +__all__ = ["FaultConfig", "MockServer", "serve"] diff --git a/apiverity/mock/server.py b/apiverity/mock/server.py index 6cbd58d..9602d1f 100644 --- a/apiverity/mock/server.py +++ b/apiverity/mock/server.py @@ -8,7 +8,7 @@ import time from dataclasses import dataclass, field from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Any, Optional +from typing import Any from apiverity.core.model import Operation, Service from apiverity.fuzz.generate import generate_valid @@ -19,9 +19,9 @@ class FaultConfig: """Development fault modes — never enabled by default.""" latency_ms: int = 0 - force_status: Optional[int] = None + force_status: int | None = None malformed_json: bool = False - rate_limit_after: Optional[int] = None # return 429 after N requests + rate_limit_after: int | None = None # return 429 after N requests seed: int = 0 @@ -41,7 +41,7 @@ def __init__( *, host: str = "127.0.0.1", port: int = 0, - faults: Optional[FaultConfig] = None, + faults: FaultConfig | None = None, ) -> None: self.service = service self.host = host @@ -49,8 +49,8 @@ def __init__( self.faults = faults or FaultConfig() self.state = _State() self._rng = random.Random(self.faults.seed) - self._httpd: Optional[ThreadingHTTPServer] = None - self._thread: Optional[threading.Thread] = None + self._httpd: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None ops = {op.key: op for op in service.operations} outer = self @@ -93,8 +93,7 @@ def _handle(self) -> None: return status_code = outer.faults.force_status or self._pick_status(op) - body = self._build_body(op, status_code, method, path, - request_body=request_body) + body = self._build_body(op, status_code, method, path, request_body=request_body) if outer.faults.malformed_json: payload = b'{"truncated...' @@ -102,7 +101,7 @@ def _handle(self) -> None: payload = json.dumps(body).encode("utf-8") self._respond(status_code, payload, raw=True) - def _match_template(self, method: str, path: str) -> Optional[Operation]: + def _match_template(self, method: str, path: str) -> Operation | None: parts = [p for p in path.split("/") if p] for candidate in outer.service.operations: if candidate.method != method or not candidate.path: @@ -111,8 +110,8 @@ def _match_template(self, method: str, path: str) -> Optional[Operation]: if len(cparts) != len(parts): continue ok = True - for cp, pp in zip(cparts, parts): - if not (cp.startswith("{") and cp.endswith("}") or cp == pp): + for cp, pp in zip(cparts, parts, strict=True): + if not ((cp.startswith("{") and cp.endswith("}")) or cp == pp): ok = False break if cp.startswith("{"): @@ -130,8 +129,13 @@ def _pick_status(self, op: Operation) -> int: return 200 def _build_body( - self, op: Operation, status: int, method: str, path: str, - *, request_body: Any = None, + self, + op: Operation, + status: int, + method: str, + path: str, + *, + request_body: Any = None, ) -> Any: # prefer declared examples for ex in op.examples: @@ -196,7 +200,7 @@ def stop(self) -> None: self._thread.join(timeout=2.0) self._thread = None - def __enter__(self) -> "MockServer": + def __enter__(self) -> MockServer: self.start() return self @@ -208,7 +212,7 @@ def serve( service: Service, host: str = "127.0.0.1", port: int = 8090, - faults: Optional[FaultConfig] = None, + faults: FaultConfig | None = None, ) -> None: """Run the mock server in the foreground until interrupted.""" server = MockServer(service, host=host, port=port, faults=faults) @@ -220,4 +224,4 @@ def serve( except KeyboardInterrupt: pass finally: - server.stop() \ No newline at end of file + server.stop() diff --git a/apiverity/performance/engine.py b/apiverity/performance/engine.py index b99c07e..2d7cf13 100644 --- a/apiverity/performance/engine.py +++ b/apiverity/performance/engine.py @@ -4,11 +4,16 @@ against measured samples; baselines enable regression gates with stable CI exit codes. """ + from __future__ import annotations -import re, statistics, time -from typing import Any, Optional + +import re +import time +from typing import Any + import httpx from pydantic import BaseModel, Field + from apiverity.core.model import Service from apiverity.fuzz.generate import fill_path, generate_valid @@ -28,7 +33,9 @@ def parse_policy(text: str) -> Policy: m = _POLICY_RE.match(text.strip()) if not m: raise ValueError(f"invalid policy '{text}'; expected e.g. 'GET /users p95 <= 250ms'") - return Policy(operation_key=f"{m['method']} {m['path']}", metric=m["metric"], value=float(m["value"])) + return Policy( + operation_key=f"{m['method']} {m['path']}", metric=m["metric"], value=float(m["value"]) + ) class OperationStats(BaseModel): @@ -84,8 +91,12 @@ def measure( t0 = time.monotonic() for _ in range(iterations): try: - resp = client.request(op.method, path, params=query or None, - json=body if body is not None else None) + resp = client.request( + op.method, + path, + params=query or None, + json=body if body is not None else None, + ) if resp.status_code >= 500 or resp.status_code == 429: errors += 1 except httpx.TimeoutException: @@ -118,23 +129,30 @@ def evaluate_policies(report: PerformanceReport, policies: list[str]) -> list[st for policy in parsed: stats = by_key.get(policy.operation_key) if stats is None: - violations.append(f"{policy.operation_key}: no measurements for policy " - f"'{policy.metric} <= {policy.value}'") + violations.append( + f"{policy.operation_key}: no measurements for policy " + f"'{policy.metric} <= {policy.value}'" + ) continue - actual_map = {"p50": stats.p50_ms, "p90": stats.p90_ms, "p95": stats.p95_ms, - "p99": stats.p99_ms, - "error_rate": (100.0 * (stats.errors + stats.timeouts) / max(stats.requests, 1)), - "throughput": stats.throughput_rps} + actual_map = { + "p50": stats.p50_ms, + "p90": stats.p90_ms, + "p95": stats.p95_ms, + "p99": stats.p99_ms, + "error_rate": (100.0 * (stats.errors + stats.timeouts) / max(stats.requests, 1)), + "throughput": stats.throughput_rps, + } actual = actual_map[policy.metric] if actual > policy.value: violations.append( - f"{policy.operation_key}: {policy.metric}={actual} exceeds budget " - f"{policy.value}") + f"{policy.operation_key}: {policy.metric}={actual} exceeds budget {policy.value}" + ) return violations -def compare_baseline(current: PerformanceReport, baseline: dict[str, Any], - tolerance_pct: float = 20.0) -> list[str]: +def compare_baseline( + current: PerformanceReport, baseline: dict[str, Any], tolerance_pct: float = 20.0 +) -> list[str]: """Flag regressions vs a stored baseline (per-operation p95).""" regressions = [] base_ops = {o["operation_key"]: o for o in baseline.get("operations", [])} @@ -146,8 +164,9 @@ def compare_baseline(current: PerformanceReport, baseline: dict[str, Any], if op.p95_ms > limit and prev.get("p95_ms", 0) > 0: regressions.append( f"{op.operation_key}: p95 regressed {prev['p95_ms']}ms -> {op.p95_ms}ms " - f"(tolerance {tolerance_pct}%)") + f"(tolerance {tolerance_pct}%)" + ) prev_err = prev.get("errors", 0) if prev_err == 0 and op.errors > 0: regressions.append(f"{op.operation_key}: new errors appeared ({op.errors})") - return regressions \ No newline at end of file + return regressions diff --git a/apiverity/plugins/__init__.py b/apiverity/plugins/__init__.py index 9289fb1..adebda8 100644 --- a/apiverity/plugins/__init__.py +++ b/apiverity/plugins/__init__.py @@ -15,4 +15,4 @@ from apiverity import PLUGIN_API_VERSION -__all__ = ["PLUGIN_API_VERSION"] \ No newline at end of file +__all__ = ["PLUGIN_API_VERSION"] diff --git a/apiverity/plugins/api.py b/apiverity/plugins/api.py index 0fdab11..125051e 100644 --- a/apiverity/plugins/api.py +++ b/apiverity/plugins/api.py @@ -47,4 +47,4 @@ class ExporterPlugin(ABC): @abstractmethod def export(self, artifact: Any) -> str: - """Render the artifact to a string in the target format.""" \ No newline at end of file + """Render the artifact to a string in the target format.""" diff --git a/apiverity/plugins/builtins.py b/apiverity/plugins/builtins.py index bf40201..512a6d4 100644 --- a/apiverity/plugins/builtins.py +++ b/apiverity/plugins/builtins.py @@ -3,6 +3,7 @@ Each attribute referenced from pyproject entry points is a zero-arg factory so ``PluginRegistry.instances()`` works uniformly. """ + from __future__ import annotations from typing import Any @@ -44,4 +45,4 @@ def make_client(**kwargs: Any) -> httpx.Client: kwargs.setdefault("follow_redirects", False) return httpx.Client(**kwargs) - return make_client \ No newline at end of file + return make_client diff --git a/apiverity/plugins/registry.py b/apiverity/plugins/registry.py index 6f0591d..cbfc828 100644 --- a/apiverity/plugins/registry.py +++ b/apiverity/plugins/registry.py @@ -4,8 +4,11 @@ exporters / transports. Plugin API contract version is ``1``; see docs/plugins.md. """ + from __future__ import annotations +from typing import Any + PLUGIN_API_VERSION = 1 ENTRY_POINT_GROUPS = [ @@ -23,10 +26,11 @@ def list_entry_points() -> dict[str, list[dict[str, str]]]: out: dict[str, list[dict[str, str]]] = {} for group in ENTRY_POINT_GROUPS: + eps: list[Any] try: - eps = entry_points(group=group) + eps = list(entry_points(group=group)) except TypeError: # pragma: no cover - older Python fallback - eps = entry_points().get(group, []) + eps = list(entry_points().get(group, [])) # type: ignore[attr-defined] out[group] = [{"name": ep.name, "value": ep.value} for ep in eps] return out @@ -54,10 +58,11 @@ def load_group(group: str) -> list[tuple[str, object]]: if group not in ENTRY_POINT_GROUPS: raise ValueError(f"unknown entry point group '{group}'") loaded = [] + eps: list[Any] try: - eps = entry_points(group=group) + eps = list(entry_points(group=group)) except TypeError: # pragma: no cover - eps = entry_points().get(group, []) + eps = list(entry_points().get(group, [])) # type: ignore[attr-defined] for ep in eps: loaded.append((ep.name, ep.load())) - return loaded \ No newline at end of file + return loaded diff --git a/apiverity/reports/renderers.py b/apiverity/reports/renderers.py index 87dc6ef..f512828 100644 --- a/apiverity/reports/renderers.py +++ b/apiverity/reports/renderers.py @@ -2,10 +2,12 @@ Each renderer takes a result payload dict and returns a string. """ + from __future__ import annotations import json -from typing import Any, Callable +from collections.abc import Callable +from typing import Any NL = chr(10) @@ -22,7 +24,8 @@ def terminal(data: dict[str, Any]) -> str: lines.append( 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', ''))}") + f"{d.get('message', d.get('description', ''))}" + ) elif not isinstance(value, (dict, list)): lines.append(f"{key}: {value}") return NL.join(lines) @@ -37,8 +40,9 @@ def markdown(data: dict[str, Any]) -> str: if findings: lines += ["", "| Rule | Severity | Message |", "|---|---|---|"] for f in findings: - lines.append(f"| `{f.get('rule_id', '')}` | {f.get('severity', '')} " - f"| {f.get('message', '')} |") + lines.append( + f"| `{f.get('rule_id', '')}` | {f.get('severity', '')} | {f.get('message', '')} |" + ) return NL.join(lines) @@ -46,9 +50,13 @@ def junit(data: dict[str, Any]) -> str: failures = data.get("failed", data.get("errors", 0)) total = data.get("total", 0) return ( - '' + NL + - f'' + - NL + "" + NL) + '' + + NL + + f'' + + NL + + "" + + NL + ) def html(data: dict[str, Any]) -> str: @@ -59,7 +67,8 @@ def html(data: dict[str, Any]) -> str: rows += ( f"{f.get('rule_id', '')}" f"{sev}" - f"{f.get('message', '')}") + f"{f.get('message', '')}" + ) return ( "" "apiverity report" @@ -69,21 +78,38 @@ def html(data: dict[str, Any]) -> str: "

apiverity report

" f"

{data.get('command', '')} — {data.get('spec', data.get('base_url', ''))}

" f"{rows}" - "
RuleSeverityMessage
") + "" + ) def sarif(data: dict[str, Any]) -> str: - return json.dumps({ - "$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "version": "2.1.0", - "runs": [{"tool": {"driver": {"name": "apiverity", - "informationUri": "https://github.com/webdevsamran/api-verity-lab"}}, - "results": [ - {"ruleId": f.get("rule_id", "APIVERITY"), - "level": {"ERROR": "error", "WARN": "warning"}.get( - str(f.get("severity")), "note"), - "message": {"text": f.get("message", "")}} - for f in data.get("findings", [])]}]}, indent=2) + return json.dumps( + { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "apiverity", + "informationUri": "https://github.com/webdevsamran/api-verity-lab", + } + }, + "results": [ + { + "ruleId": f.get("rule_id", "APIVERITY"), + "level": {"ERROR": "error", "WARN": "warning"}.get( + str(f.get("severity")), "note" + ), + "message": {"text": f.get("message", "")}, + } + for f in data.get("findings", []) + ], + } + ], + }, + indent=2, + ) RENDERERS: dict[str, Callable[[dict[str, Any]], str]] = { @@ -92,4 +118,4 @@ def sarif(data: dict[str, Any]) -> str: "junit": junit, "html": html, "sarif": sarif, -} \ No newline at end of file +} diff --git a/apiverity/rules/__init__.py b/apiverity/rules/__init__.py index 4a2cc2c..187f388 100644 --- a/apiverity/rules/__init__.py +++ b/apiverity/rules/__init__.py @@ -1 +1 @@ -"""Rule engines: breaking-change analysis, semver policy, security checks.""" \ No newline at end of file +"""Rule engines: breaking-change analysis, semver policy, security checks.""" diff --git a/apiverity/rules/breaking.py b/apiverity/rules/breaking.py index 17afd40..0b4f45b 100644 --- a/apiverity/rules/breaking.py +++ b/apiverity/rules/breaking.py @@ -14,7 +14,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional from apiverity.core.model import Change, ChangeKind, Finding, Severity @@ -45,102 +44,159 @@ class RuleSpec: #: The documented rule catalog (also rendered by ``apiverity rules``). -CATALOG: dict[str, RuleSpec] = {spec.rule_id: spec for spec in [ - RuleSpec("BRK-OP-REMOVED", Severity.ERROR, - "An operation was removed; existing callers will fail."), - RuleSpec("BRK-RPC-REMOVED", Severity.ERROR, - "A gRPC RPC was removed; existing callers will fail."), - RuleSpec("BRK-OP-ADDED", Severity.INFO, - "A new operation was added (additive, non-breaking)."), - RuleSpec("BRK-RPC-ADDED", Severity.INFO, - "A new gRPC RPC was added (additive, non-breaking)."), - RuleSpec("BRK-PARAM-REMOVED", Severity.ERROR, - "A request parameter was removed."), - RuleSpec("BRK-PARAM-ADDED-REQUIRED", Severity.ERROR, - "A new required request parameter was added."), - RuleSpec("BRK-PARAM-ADDED-OPTIONAL", Severity.INFO, - "A new optional request parameter was added."), - RuleSpec("BRK-PARAM-REQUIRED", Severity.ERROR, - "An optional request parameter became required."), - RuleSpec("BRK-PARAM-OPTIONALIZED", Severity.INFO, - "A required request parameter became optional."), - RuleSpec("BRK-PARAM-TYPE-CHANGED", Severity.ERROR, - "A request parameter's type/format changed."), - RuleSpec("BRK-RESP-TYPE-CHANGED", Severity.WARN, - "A response field's type changed; consumers may misparse values."), - RuleSpec("BRK-CONSTRAINT-TIGHTENED", Severity.ERROR, - "A request constraint was tightened; previously valid inputs fail."), - RuleSpec("BRK-CONSTRAINT-LOOSENED", Severity.INFO, - "A request constraint was loosened (previously invalid inputs pass)."), - RuleSpec("BRK-RESP-CONSTRAINT-TIGHTENED", Severity.WARN, - "A response constraint was tightened; returned values may fall " - "outside what clients expect."), - RuleSpec("BRK-ENUM-NARROWED-REQUEST", Severity.ERROR, - "Request enum values were removed; clients sending old values fail."), - RuleSpec("BRK-ENUM-NARROWED-RESPONSE", Severity.WARN, - "Response enum values were removed; clients may encounter " - "undeclared values at runtime."), - RuleSpec("BRK-ENUM-WIDENED", Severity.INFO, - "Enum values were added (additive)."), - RuleSpec("BRK-REQ-FIELD-REMOVED", Severity.ERROR, - "A request body field was removed."), - RuleSpec("BRK-REQ-FIELD-ADDED-REQUIRED", Severity.ERROR, - "A required field was added to a request body."), - RuleSpec("BRK-REQ-FIELD-ADDED-OPTIONAL", Severity.INFO, - "An optional field was added to a request body."), - RuleSpec("BRK-REQ-FIELD-BECAME-REQUIRED", Severity.ERROR, - "A request body field became required."), - RuleSpec("BRK-RESP-FIELD-REMOVED", Severity.ERROR, - "A response body field was removed; readers of it break."), - RuleSpec("BRK-RESP-FIELD-ADDED", Severity.INFO, - "A response body field was added (consumers ignore unknown fields)."), - RuleSpec("BRK-REQ-BODY-REMOVED", Severity.ERROR, - "The request body was removed."), - RuleSpec("BRK-REQ-BODY-ADDED-REQUIRED", Severity.ERROR, - "A required request body was added."), - RuleSpec("BRK-REQ-BODY-ADDED-OPTIONAL", Severity.INFO, - "An optional request body was added."), - RuleSpec("BRK-REQ-BODY-REQUIRED", Severity.ERROR, - "The request body became required."), - RuleSpec("BRK-RESP-STATUS-REMOVED", Severity.ERROR, - "A declared response status was removed."), - RuleSpec("BRK-RESP-STATUS-ADDED", Severity.INFO, - "A new response status was declared."), - RuleSpec("BRK-HEADER-REMOVED", Severity.WARN, - "A declared response header was removed."), - RuleSpec("BRK-HEADER-ADDED", Severity.INFO, - "A new response header was declared."), - RuleSpec("BRK-SECURITY-CHANGED", Severity.ERROR, - "Security requirements changed; unprepared clients fail auth."), - RuleSpec("BRK-DEPRECATION-ADDED", Severity.WARN, - "The operation is now deprecated; plan migration."), - RuleSpec("BRK-DEPRECATION-REMOVED", Severity.INFO, - "The deprecation marker was removed."), - RuleSpec("BRK-MEDIA-TYPE-CHANGED", Severity.ERROR, - "A request/response media type was added or removed."), -]} - - -def _constraint_change_is_tightening(attr: str, old: object, new: object) -> Optional[bool]: +CATALOG: dict[str, RuleSpec] = { + spec.rule_id: spec + for spec in [ + RuleSpec( + "BRK-OP-REMOVED", + Severity.ERROR, + "An operation was removed; existing callers will fail.", + ), + RuleSpec( + "BRK-RPC-REMOVED", Severity.ERROR, "A gRPC RPC was removed; existing callers will fail." + ), + RuleSpec( + "BRK-OP-ADDED", Severity.INFO, "A new operation was added (additive, non-breaking)." + ), + RuleSpec( + "BRK-RPC-ADDED", Severity.INFO, "A new gRPC RPC was added (additive, non-breaking)." + ), + RuleSpec("BRK-PARAM-REMOVED", Severity.ERROR, "A request parameter was removed."), + RuleSpec( + "BRK-PARAM-ADDED-REQUIRED", + Severity.ERROR, + "A new required request parameter was added.", + ), + RuleSpec( + "BRK-PARAM-ADDED-OPTIONAL", Severity.INFO, "A new optional request parameter was added." + ), + RuleSpec( + "BRK-PARAM-REQUIRED", Severity.ERROR, "An optional request parameter became required." + ), + RuleSpec( + "BRK-PARAM-OPTIONALIZED", Severity.INFO, "A required request parameter became optional." + ), + RuleSpec( + "BRK-PARAM-TYPE-CHANGED", Severity.ERROR, "A request parameter's type/format changed." + ), + RuleSpec( + "BRK-RESP-TYPE-CHANGED", + Severity.WARN, + "A response field's type changed; consumers may misparse values.", + ), + RuleSpec( + "BRK-CONSTRAINT-TIGHTENED", + Severity.ERROR, + "A request constraint was tightened; previously valid inputs fail.", + ), + RuleSpec( + "BRK-CONSTRAINT-LOOSENED", + Severity.INFO, + "A request constraint was loosened (previously invalid inputs pass).", + ), + RuleSpec( + "BRK-RESP-CONSTRAINT-TIGHTENED", + Severity.WARN, + "A response constraint was tightened; returned values may fall " + "outside what clients expect.", + ), + RuleSpec( + "BRK-ENUM-NARROWED-REQUEST", + Severity.ERROR, + "Request enum values were removed; clients sending old values fail.", + ), + RuleSpec( + "BRK-ENUM-NARROWED-RESPONSE", + Severity.WARN, + "Response enum values were removed; clients may encounter " + "undeclared values at runtime.", + ), + RuleSpec("BRK-ENUM-WIDENED", Severity.INFO, "Enum values were added (additive)."), + RuleSpec("BRK-REQ-FIELD-REMOVED", Severity.ERROR, "A request body field was removed."), + RuleSpec( + "BRK-REQ-FIELD-ADDED-REQUIRED", + Severity.ERROR, + "A required field was added to a request body.", + ), + RuleSpec( + "BRK-REQ-FIELD-ADDED-OPTIONAL", + Severity.INFO, + "An optional field was added to a request body.", + ), + RuleSpec( + "BRK-REQ-FIELD-BECAME-REQUIRED", Severity.ERROR, "A request body field became required." + ), + RuleSpec( + "BRK-RESP-FIELD-REMOVED", + Severity.ERROR, + "A response body field was removed; readers of it break.", + ), + RuleSpec( + "BRK-RESP-FIELD-ADDED", + Severity.INFO, + "A response body field was added (consumers ignore unknown fields).", + ), + RuleSpec("BRK-REQ-BODY-REMOVED", Severity.ERROR, "The request body was removed."), + RuleSpec( + "BRK-REQ-BODY-ADDED-REQUIRED", Severity.ERROR, "A required request body was added." + ), + RuleSpec( + "BRK-REQ-BODY-ADDED-OPTIONAL", Severity.INFO, "An optional request body was added." + ), + RuleSpec("BRK-REQ-BODY-REQUIRED", Severity.ERROR, "The request body became required."), + RuleSpec( + "BRK-RESP-STATUS-REMOVED", Severity.ERROR, "A declared response status was removed." + ), + RuleSpec("BRK-RESP-STATUS-ADDED", Severity.INFO, "A new response status was declared."), + RuleSpec("BRK-HEADER-REMOVED", Severity.WARN, "A declared response header was removed."), + RuleSpec("BRK-HEADER-ADDED", Severity.INFO, "A new response header was declared."), + RuleSpec( + "BRK-SECURITY-CHANGED", + Severity.ERROR, + "Security requirements changed; unprepared clients fail auth.", + ), + RuleSpec( + "BRK-DEPRECATION-ADDED", + Severity.WARN, + "The operation is now deprecated; plan migration.", + ), + RuleSpec("BRK-DEPRECATION-REMOVED", Severity.INFO, "The deprecation marker was removed."), + RuleSpec( + "BRK-MEDIA-TYPE-CHANGED", + Severity.ERROR, + "A request/response media type was added or removed.", + ), + ] +} + + +def _constraint_change_is_tightening(attr: str, old: object, new: object) -> bool | None: """Return True (tightened), False (loosened) or None (not comparable).""" if attr == "pattern": return None # pattern changes are judged separately as WARN - if attr in _TIGHTEN_ON_INCREASE: - if isinstance(old, (int, float)) and isinstance(new, (int, float)): - return new > old - elif attr in _TIGHTEN_ON_DECREASE: - if isinstance(old, (int, float)) and isinstance(new, (int, float)): - return new < old + tightened_up = ( + attr in _TIGHTEN_ON_INCREASE + and isinstance(old, (int, float)) + and isinstance(new, (int, float)) + and new > old + ) + tightened_down = ( + attr in _TIGHTEN_ON_DECREASE + and isinstance(old, (int, float)) + and isinstance(new, (int, float)) + and new < old + ) + if tightened_up or tightened_down: + return True return None class BreakingEngine: """Evaluates a change set against the breaking-rule catalog.""" - def __init__(self, severity_overrides: Optional[dict[str, str]] = None) -> None: + def __init__(self, severity_overrides: dict[str, str] | None = None) -> None: self.overrides = { - rule_id: Severity(value) - for rule_id, value in (severity_overrides or {}).items() + rule_id: Severity(value) for rule_id, value in (severity_overrides or {}).items() } def severity_for(self, rule_id: str) -> Severity: @@ -152,8 +208,9 @@ def evaluate(self, changes: list[Change]) -> list[Finding]: findings.extend(self._evaluate_change(change)) return findings - def _finding(self, rule_id: str, change: Change, message: str, - hint: Optional[str] = None) -> Finding: + def _finding( + self, rule_id: str, change: Change, message: str, hint: str | None = None + ) -> Finding: return Finding( rule_id=rule_id, severity=self.severity_for(rule_id), @@ -167,7 +224,6 @@ def _finding(self, rule_id: str, change: Change, message: str, def _evaluate_change(self, change: Change) -> list[Finding]: kind, direction = change.kind, change.direction - key = change.operation_key if kind in (ChangeKind.OPERATION_REMOVED,): return [self._finding("BRK-OP-REMOVED", change, change.description)] @@ -188,24 +244,17 @@ def _evaluate_change(self, change: Change) -> list[Finding]: if kind == ChangeKind.PARAMETER_REQUIREDNESS: became_required = change.new_value is True - rule = ( - "BRK-PARAM-REQUIRED" if became_required else "BRK-PARAM-OPTIONALIZED" - ) + rule = "BRK-PARAM-REQUIRED" if became_required else "BRK-PARAM-OPTIONALIZED" return [self._finding(rule, change, change.description)] if kind == ChangeKind.PARAMETER_TYPE_CHANGED: - rule = ( - "BRK-PARAM-TYPE-CHANGED" - if direction == "request" - else "BRK-RESP-TYPE-CHANGED" - ) + rule = "BRK-PARAM-TYPE-CHANGED" if direction == "request" else "BRK-RESP-TYPE-CHANGED" return [self._finding(rule, change, change.description)] if kind == ChangeKind.PARAMETER_CONSTRAINT_CHANGED: return self._evaluate_constraint(change) if kind == ChangeKind.ENUM_CHANGED: - narrowed = "removed [" in change.description and "removed []" not in change.description # decide by comparing values directly when available old_enum = change.old_value if isinstance(change.old_value, list) else [] new_enum = change.new_value if isinstance(change.new_value, list) else [] @@ -227,10 +276,7 @@ def _evaluate_change(self, change: Change) -> list[Finding]: return [self._finding("BRK-REQ-BODY-REQUIRED", change, desc)] if desc.endswith("added"): required = "and required" in desc - rule = ( - "BRK-REQ-BODY-ADDED-REQUIRED" if required - else "BRK-REQ-BODY-ADDED-OPTIONAL" - ) + rule = "BRK-REQ-BODY-ADDED-REQUIRED" if required else "BRK-REQ-BODY-ADDED-OPTIONAL" return [self._finding(rule, change, desc)] return [] @@ -273,12 +319,15 @@ def _evaluate_constraint(self, change: Change) -> list[Finding]: return [self._finding(rule, change, desc)] # pattern change if "'pattern' changed" in desc: - return [self._finding( - "BRK-CONSTRAINT-TIGHTENED" - if change.direction == "request" - else "BRK-RESP-CONSTRAINT-TIGHTENED", - change, desc, - )] + return [ + self._finding( + "BRK-CONSTRAINT-TIGHTENED" + if change.direction == "request" + else "BRK-RESP-CONSTRAINT-TIGHTENED", + change, + desc, + ) + ] # numeric/item constraints attr = desc.split("constraint '")[1].split("'")[0] if "constraint '" in desc else "" tightening = _constraint_change_is_tightening(attr, change.old_value, change.new_value) @@ -292,6 +341,6 @@ def _evaluate_constraint(self, change: Change) -> list[Finding]: def evaluate_breaking( - changes: list[Change], severity_overrides: Optional[dict[str, str]] = None + changes: list[Change], severity_overrides: dict[str, str] | None = None ) -> list[Finding]: - return BreakingEngine(severity_overrides).evaluate(changes) \ No newline at end of file + return BreakingEngine(severity_overrides).evaluate(changes) diff --git a/apiverity/rules/changelog.py b/apiverity/rules/changelog.py index c659be3..f3d6d72 100644 --- a/apiverity/rules/changelog.py +++ b/apiverity/rules/changelog.py @@ -24,9 +24,7 @@ def _grouped( ) -> dict[str, dict[str, list[tuple[str, str]]]]: """service -> operation -> [(severity_label, text)]""" severity_by_change = {f.change_id: f.severity for f in findings if f.change_id} - grouped: dict[str, dict[str, list[tuple[str, str]]]] = defaultdict( - lambda: defaultdict(list) - ) + grouped: dict[str, dict[str, list[tuple[str, str]]]] = defaultdict(lambda: defaultdict(list)) for change in changes: sev = severity_by_change.get(change.id, Severity.INFO) grouped["API"][change.operation_key].append( @@ -56,14 +54,12 @@ def render_markdown( "", ] counts: dict[str, int] = defaultdict(int) - for entries in grouped.values(): - for items in entries.values(): + for op_entries in grouped.values(): + for items in op_entries.values(): for badge, _ in items: counts[badge] += 1 if counts: - lines.append( - " | ".join(f"{badge} ×{n}" for badge, n in sorted(counts.items())) - ) + lines.append(" | ".join(f"{badge} x{n}" for badge, n in sorted(counts.items()))) lines.append("") for service, operations in sorted(grouped.items()): lines.append(f"## {service}") @@ -103,14 +99,8 @@ def render_html( for op in sorted(operations): parts.append(f"

{html.escape(op)}

") parts.append("") return "".join(parts) @@ -126,4 +116,4 @@ def generate_changelog( ) -> str: if fmt == "html": return render_html(title, old_version, new_version, changes, findings) - return render_markdown(title, old_version, new_version, changes, findings) \ No newline at end of file + return render_markdown(title, old_version, new_version, changes, findings) diff --git a/apiverity/rules/semver.py b/apiverity/rules/semver.py index fbaebc4..a1809d9 100644 --- a/apiverity/rules/semver.py +++ b/apiverity/rules/semver.py @@ -11,14 +11,12 @@ from __future__ import annotations -from typing import Optional - from packaging.version import InvalidVersion, Version from apiverity.core.model import Change, Finding, Severity -def _parse(version: str) -> Optional[Version]: +def _parse(version: str) -> Version | None: try: return Version(version) except InvalidVersion: @@ -112,4 +110,4 @@ def evaluate(self, findings: list[Finding], changes: list[Change]) -> list[Findi ) ) - return out \ No newline at end of file + return out diff --git a/apiverity/runtime/drift.py b/apiverity/runtime/drift.py index 4bc23ce..14790b4 100644 --- a/apiverity/runtime/drift.py +++ b/apiverity/runtime/drift.py @@ -1,9 +1,12 @@ """Runtime drift detection: declared contract vs actual responses.""" + from __future__ import annotations + import time -from typing import Any, Optional + import httpx from pydantic import BaseModel, Field + from apiverity.core.model import Service from apiverity.core.validation import validate_value from apiverity.fuzz.generate import fill_path, generate_valid @@ -12,7 +15,7 @@ class DriftFinding(BaseModel): operation_key: str rule_id: str # DRIFT-STATUS | DRIFT-CONTENT-TYPE | DRIFT-SCHEMA | - # DRIFT-MISSING-FIELD | DRIFT-UNDECLARED-FIELD | DRIFT-HEADER + # DRIFT-MISSING-FIELD | DRIFT-UNDECLARED-FIELD | DRIFT-HEADER severity: str = "WARN" message: str @@ -37,49 +40,78 @@ def detect_drift( for op in service.operations: if not op.method or not op.path: continue - params = {p.name: generate_valid(p.schema_node, __import__("random").Random(0)) - for p in op.parameters} - path = fill_path(op.path, {k: v for k, v in params.items()}) + params = { + p.name: generate_valid(p.schema_node, __import__("random").Random(0)) + for p in op.parameters + } + path = fill_path(op.path, dict(params)) query = {p.name: params[p.name] for p in op.parameters if p.location.value == "query"} try: resp = client.request(op.method, path, params=query or None) except httpx.HTTPError as exc: - report.findings.append(DriftFinding( - operation_key=op.key, rule_id="DRIFT-UNREACHABLE", severity="ERROR", - message=f"request failed: {exc}")) + report.findings.append( + DriftFinding( + operation_key=op.key, + rule_id="DRIFT-UNREACHABLE", + severity="ERROR", + message=f"request failed: {exc}", + ) + ) continue report.operations_checked += 1 declared = next((r for r in op.responses if r.status == str(resp.status_code)), None) if declared is None: - report.findings.append(DriftFinding( - operation_key=op.key, rule_id="DRIFT-STATUS", - message=f"returned status {resp.status_code} which is not declared " - f"(declared: {[r.status for r in op.responses]})")) + report.findings.append( + DriftFinding( + operation_key=op.key, + rule_id="DRIFT-STATUS", + message=f"returned status {resp.status_code} which is not declared " + f"(declared: {[r.status for r in op.responses]})", + ) + ) continue ctype = resp.headers.get("content-type", "").split(";")[0] if declared.content and ctype and ctype not in declared.content: - report.findings.append(DriftFinding( - operation_key=op.key, rule_id="DRIFT-CONTENT-TYPE", - message=f"content type '{ctype}' not declared")) + report.findings.append( + DriftFinding( + operation_key=op.key, + rule_id="DRIFT-CONTENT-TYPE", + message=f"content type '{ctype}' not declared", + ) + ) schema = declared.content.get(ctype) if declared.content else None if schema is not None and "json" in ctype: try: body = resp.json() except ValueError: - report.findings.append(DriftFinding( - operation_key=op.key, rule_id="DRIFT-SCHEMA", - message="body is not valid JSON")) + report.findings.append( + DriftFinding( + operation_key=op.key, + rule_id="DRIFT-SCHEMA", + message="body is not valid JSON", + ) + ) body = None if body is not None: for v in validate_value(schema, body, forbid_undeclared_fields=True): - rule = "DRIFT-UNDECLARED-FIELD" if "undeclared field" in v else ( - "DRIFT-MISSING-FIELD" if "missing required" in v else "DRIFT-SCHEMA") - report.findings.append(DriftFinding( - operation_key=op.key, rule_id=rule, message=v)) + rule = ( + "DRIFT-UNDECLARED-FIELD" + if "undeclared field" in v + else ( + "DRIFT-MISSING-FIELD" if "missing required" in v else "DRIFT-SCHEMA" + ) + ) + report.findings.append( + DriftFinding(operation_key=op.key, rule_id=rule, message=v) + ) for header in declared.headers: if header.lower() not in {h.lower() for h in resp.headers}: - report.findings.append(DriftFinding( - operation_key=op.key, rule_id="DRIFT-HEADER", - message=f"declared response header '{header}' missing")) + report.findings.append( + DriftFinding( + operation_key=op.key, + rule_id="DRIFT-HEADER", + message=f"declared response header '{header}' missing", + ) + ) report.duration_ms = int((time.monotonic() - started) * 1000) - return report \ No newline at end of file + return report diff --git a/apiverity/sdk.py b/apiverity/sdk.py index 0cb5750..8b80e32 100644 --- a/apiverity/sdk.py +++ b/apiverity/sdk.py @@ -3,10 +3,11 @@ Stable public exports; the plugin API contract version is ``apiverity.plugins.registry.PLUGIN_API_VERSION``. """ + from __future__ import annotations -from apiverity.core.artifact import ArtifactMeta # noqa: F401 -from apiverity.core.model import ( # noqa: F401 +from apiverity.core.artifact import ArtifactMeta +from apiverity.core.model import ( Contract, Finding, Operation, @@ -15,35 +16,55 @@ Severity, SourceLocation, ) -from apiverity.diff.engine import Change, diff_services # noqa: F401 -from apiverity.fuzz.models import TestCase, TestResult # noqa: F401 -from apiverity.performance.engine import ( # noqa: F401 +from apiverity.diff.engine import Change, diff_services +from apiverity.fuzz.models import TestCase, TestResult +from apiverity.performance.engine import ( OperationStats, PerformanceReport, Policy, ) - -# Spec-canonical names (§24): a PerformanceBudget is a parsed policy; -# RunReport is the versioned artifact envelope. -PerformanceBudget = Policy # noqa: F401 -RunReport = ArtifactMeta # noqa: F401 -from apiverity.plugins.registry import PLUGIN_API_VERSION # noqa: F401 -from apiverity.rules.breaking import CATALOG, RuleSpec, evaluate_breaking # noqa: F401 -from apiverity.rules.semver import SemverPolicy # noqa: F401 -from apiverity.runtime.drift import DriftFinding, DriftReport # noqa: F401 -from apiverity.stateful.models import ( # noqa: F401 +from apiverity.plugins.registry import PLUGIN_API_VERSION +from apiverity.rules.breaking import CATALOG, RuleSpec, evaluate_breaking +from apiverity.rules.semver import SemverPolicy +from apiverity.runtime.drift import DriftFinding, DriftReport +from apiverity.stateful.models import ( StepResult, Workflow, WorkflowResult, WorkflowStep, ) +# Spec-canonical names (§24): a PerformanceBudget is a parsed policy; +# RunReport is the versioned artifact envelope. +PerformanceBudget = Policy +RunReport = ArtifactMeta + __all__ = [ - "Contract", "Service", "Operation", "SchemaNode", "SourceLocation", - "Severity", "Finding", "Change", "RuleSpec", "CATALOG", - "evaluate_breaking", "SemverPolicy", "diff_services", - "TestCase", "TestResult", "Workflow", "WorkflowStep", "WorkflowResult", - "StepResult", "DriftFinding", "DriftReport", "Policy", "OperationStats", - "PerformanceReport", "PerformanceBudget", "RunReport", + "CATALOG", "PLUGIN_API_VERSION", + "Change", + "Contract", + "DriftFinding", + "DriftReport", + "Finding", + "Operation", + "OperationStats", + "PerformanceBudget", + "PerformanceReport", + "Policy", + "RuleSpec", + "RunReport", + "SchemaNode", + "SemverPolicy", + "Service", + "Severity", + "SourceLocation", + "StepResult", + "TestCase", + "TestResult", + "Workflow", + "WorkflowResult", + "WorkflowStep", + "diff_services", + "evaluate_breaking", ] diff --git a/apiverity/security/__init__.py b/apiverity/security/__init__.py index d05f576..7b8aa34 100644 --- a/apiverity/security/__init__.py +++ b/apiverity/security/__init__.py @@ -7,4 +7,4 @@ from apiverity.security.checks import run_security_checks -__all__ = ["run_security_checks"] \ No newline at end of file +__all__ = ["run_security_checks"] diff --git a/apiverity/security/checks.py b/apiverity/security/checks.py index 0e1ffd5..5bb8aa0 100644 --- a/apiverity/security/checks.py +++ b/apiverity/security/checks.py @@ -2,9 +2,14 @@ from __future__ import annotations -from typing import Optional - -from apiverity.core.model import Finding, Operation, Protocol, SecurityRequirement, Service, Severity +from apiverity.core.model import ( + Finding, + Operation, + Protocol, + SecurityRequirement, + Service, + Severity, +) _MUTATING = {"POST", "PUT", "PATCH", "DELETE"} _SENSITIVE_RESPONSE_HEADERS = { @@ -15,7 +20,7 @@ } -def _effective_security(op: Operation, service: Service) -> Optional[list[SecurityRequirement]]: +def _effective_security(op: Operation, service: Service) -> list[SecurityRequirement] | None: """Operation-level security; None means inherit global; [] means anonymous.""" if op.security is not None: return op.security @@ -31,8 +36,12 @@ def run_security_checks( findings: list[Finding] = [] for url in service.servers: - if require_https and url.url.startswith("http://") and "localhost" not in url.url \ - and "127.0.0.1" not in url.url: + if ( + require_https + and url.url.startswith("http://") + and "localhost" not in url.url + and "127.0.0.1" not in url.url + ): findings.append( Finding( rule_id="SEC-HTTPS-POLICY", @@ -91,8 +100,7 @@ def run_security_checks( Finding( rule_id="SEC-UNAUTH-WRITE", severity=Severity.ERROR, - message=f"mutating operation '{op.key}' has no authentication " - "declaration", + message=f"mutating operation '{op.key}' has no authentication declaration", operation_key=op.key, location=op.source_location, ) @@ -183,4 +191,4 @@ def walk(schema_node: object, op_key: str) -> None: for s in r.content.values(): walk(s, op.key) - return findings \ No newline at end of file + return findings diff --git a/apiverity/specs/__init__.py b/apiverity/specs/__init__.py index b5814d1..93cd3f6 100644 --- a/apiverity/specs/__init__.py +++ b/apiverity/specs/__init__.py @@ -57,4 +57,4 @@ def parse_document(raw: bytes) -> dict[str, Any]: doc = yaml.safe_load(text) if not isinstance(doc, dict): raise ValueError("spec document must be a mapping at the top level") - return doc \ No newline at end of file + return doc diff --git a/apiverity/specs/graphql/__init__.py b/apiverity/specs/graphql/__init__.py index 74397bd..b402cfa 100644 --- a/apiverity/specs/graphql/__init__.py +++ b/apiverity/specs/graphql/__init__.py @@ -19,18 +19,25 @@ Protocol, SchemaNode, Service, + Severity, SourceLocation, ) from apiverity.specs import SpecPlugin try: - from graphql import build_schema, parse, visit # type: ignore[import-untyped] + from graphql import build_schema, parse # type: ignore[import-not-found] _HAS_GRAPHQL = True except ImportError: # pragma: no cover - optional extra _HAS_GRAPHQL = False +def _token_line(node: Any) -> int: + loc = getattr(node, "loc", None) + token = getattr(loc, "start_token", None) if loc is not None else None + return int(token.line) if token is not None else 0 + + def _type_to_schema(type_node: Any) -> SchemaNode: """Convert a GraphQL type node into a SchemaNode.""" name = str(type_node) @@ -74,25 +81,25 @@ def load(self, source: str) -> tuple[Service, list[Finding]]: findings: list[Finding] = [] if not _HAS_GRAPHQL: raise NotImplementedError( - "GraphQL support requires the 'graphql' extra: " - "pip install api-verity-lab[graphql]" + "GraphQL support requires the 'graphql' extra: pip install api-verity-lab[graphql]" ) from apiverity.specs import read_source _, raw = read_source(source) sdl = raw.decode("utf-8-sig") from pathlib import Path as _Path + label = _Path(source).name try: doc = parse(sdl) - except Exception as exc: # noqa: BLE001 - surfaced as finding + except Exception as exc: return ( Service(title="Invalid GraphQL schema", version="0", protocol=Protocol.GRAPHQL), [ Finding( rule_id="SPEC-SDL-INVALID", - severity="ERROR", + severity=Severity.ERROR, message=f"failed to parse GraphQL SDL: {exc}", location=SourceLocation(file=label), ) @@ -109,11 +116,11 @@ def load(self, source: str) -> tuple[Service, list[Finding]]: # Walk object type definitions for root operation types. try: schema_obj = build_schema(sdl) - except Exception as exc: # noqa: BLE001 + except Exception as exc: findings.append( Finding( rule_id="SPEC-SDL-BUILD", - severity="WARN", + severity=Severity.WARN, message=f"SDL parsed but schema build failed: {exc}", location=SourceLocation(file=label), ) @@ -145,10 +152,10 @@ def load(self, source: str) -> tuple[Service, list[Finding]]: name=arg.name.value, location=ParameterLocation.QUERY, required=str(arg.type).endswith("!"), - schema=_type_to_schema(arg.type), + schema_node=_type_to_schema(arg.type), source_location=SourceLocation( - file=label, line=getattr(arg.loc, "start_token", None).line - if getattr(arg, "loc", None) else 0 + file=label, + line=_token_line(arg), ), ) ) @@ -166,10 +173,10 @@ def load(self, source: str) -> tuple[Service, list[Finding]]: responses=[], source_location=SourceLocation( file=label, - line=field.loc.start_token.line if field.loc else 0, + line=_token_line(field), ), ) ) service.operations.sort(key=lambda o: o.key) - return service, findings \ No newline at end of file + return service, findings diff --git a/apiverity/specs/grpc/__init__.py b/apiverity/specs/grpc/__init__.py index 5c145bc..30e582d 100644 --- a/apiverity/specs/grpc/__init__.py +++ b/apiverity/specs/grpc/__init__.py @@ -26,6 +26,7 @@ Response, SchemaNode, Service, + Severity, SourceLocation, ) from apiverity.specs import SpecPlugin @@ -56,7 +57,9 @@ _RE_RPC = re.compile( r"rpc\s+(\w+)\s*\(\s*(stream\s+)?([\w.]+)\s*\)\s*returns\s*\(\s*(stream\s+)?([\w.]+)\s*\)" ) -_RE_FIELD = re.compile(r"^(?:(repeated|optional|required|map\s*<[^>]+>)\s+)?([\w.]+)\s+(\w+)\s*=\s*(\d+)") +_RE_FIELD = re.compile( + r"^(?:(repeated|optional|required|map\s*<[^>]+>)\s+)?([\w.]+)\s+(\w+)\s*=\s*(\d+)" +) _RE_ENUM_VALUE = re.compile(r"(\w+)\s*=\s*(-?\d+)") @@ -91,7 +94,7 @@ def _message_to_schema(name: str, body: str, line_of: dict[int, int], base_line: findings.append( Finding( rule_id="PROTO-FIELD-NUMBER-REUSE", - severity="ERROR", + severity=Severity.ERROR, message=f"message '{name}' reuses field number {number} " f"(used by both '{seen_numbers[number]}' and '{fname}') — " "this corrupts wire data", @@ -169,9 +172,8 @@ def load(self, source: str) -> tuple[Service, list[Finding]]: findings.append( Finding( rule_id="PROTO-RPC-DUPLICATE", - severity="ERROR", - message=f"service '{svc_name}' declares duplicate RPC " - f"'{rpc_name}'", + severity=Severity.ERROR, + message=f"service '{svc_name}' declares duplicate RPC '{rpc_name}'", location=SourceLocation(file=label, line=svc_base_line + offset), ) ) @@ -201,11 +203,11 @@ def load(self, source: str) -> tuple[Service, list[Finding]]: findings.append( Finding( rule_id="PROTO-PARSE-EMPTY", - severity="ERROR", + severity=Severity.ERROR, message=f"no services or messages found in '{label}'", location=SourceLocation(file=label), ) ) service.operations.sort(key=lambda o: o.key) - return service, findings \ No newline at end of file + return service, findings diff --git a/apiverity/specs/loader.py b/apiverity/specs/loader.py index a345ca3..07bcc4a 100644 --- a/apiverity/specs/loader.py +++ b/apiverity/specs/loader.py @@ -7,8 +7,6 @@ from __future__ import annotations -from typing import Optional - from apiverity.core.model import Finding, Service from apiverity.plugins.registry import PluginRegistry from apiverity.specs import SpecPlugin, read_source @@ -23,13 +21,13 @@ def _builtin_plugins() -> list[SpecPlugin]: def detect_and_load( - source: str, registry: Optional[PluginRegistry] = None + source: str, registry: PluginRegistry | None = None ) -> tuple[Service, list[Finding], SpecPlugin]: """Load a contract from any supported format.""" _, raw = read_source(source) plugins = list(_builtin_plugins()) if registry is not None: - plugins.extend(registry.spec_plugins()) + plugins.extend(p for p in registry.instances() if isinstance(p, SpecPlugin)) for plugin in plugins: try: @@ -38,4 +36,4 @@ def detect_and_load( return service, findings, plugin except NotImplementedError: continue - raise ValueError(f"no spec plugin could handle '{source}'") \ No newline at end of file + raise ValueError(f"no spec plugin could handle '{source}'") diff --git a/apiverity/specs/openapi/__init__.py b/apiverity/specs/openapi/__init__.py index c3492a4..4d0cbbe 100644 --- a/apiverity/specs/openapi/__init__.py +++ b/apiverity/specs/openapi/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from apiverity.core.model import Protocol, Service, Finding +from apiverity.core.model import Finding, Protocol, Service from apiverity.specs import SpecPlugin from apiverity.specs.openapi.parser import load_openapi @@ -25,4 +25,4 @@ def detect(self, source: str, raw: bytes | None = None) -> bool: return '"openapi"' in text or "openapi:" in text def load(self, source: str) -> tuple[Service, list[Finding]]: - return load_openapi(source) \ No newline at end of file + return load_openapi(source) diff --git a/apiverity/specs/openapi/parser.py b/apiverity/specs/openapi/parser.py index 65cb2ba..5a186af 100644 --- a/apiverity/specs/openapi/parser.py +++ b/apiverity/specs/openapi/parser.py @@ -10,7 +10,7 @@ from __future__ import annotations import json -from typing import Any +from typing import Any, ClassVar import yaml @@ -29,6 +29,7 @@ SecurityScheme, Server, Service, + Severity, SourceLocation, ) from apiverity.specs import parse_document, read_source @@ -41,7 +42,7 @@ class _LineTrackingLoader(yaml.SafeLoader): """SafeLoader that records (line, column) per constructed mapping/list.""" - line_index: dict[int, tuple[int, int]] = {} + line_index: ClassVar[dict[int, tuple[int, int]]] = {} def construct_yaml_map(self, node: yaml.MappingNode) -> Any: data: dict[Any, Any] = {} @@ -66,12 +67,8 @@ def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[ # PyYAML registers constructor functions against the *base* class at import # time, so overrides must be re-registered to take effect. -_LineTrackingLoader.add_constructor( - "tag:yaml.org,2002:map", _LineTrackingLoader.construct_yaml_map -) -_LineTrackingLoader.add_constructor( - "tag:yaml.org,2002:seq", _LineTrackingLoader.construct_yaml_seq -) +_LineTrackingLoader.add_constructor("tag:yaml.org,2002:map", _LineTrackingLoader.construct_yaml_map) +_LineTrackingLoader.add_constructor("tag:yaml.org,2002:seq", _LineTrackingLoader.construct_yaml_seq) def load_yaml_with_lines(text: str) -> tuple[dict[str, Any], dict[int, tuple[int, int]]]: @@ -118,7 +115,7 @@ def resolve_ref(self, root: dict[str, Any], ref: str, pointer: str) -> Any: self.findings.append( Finding( rule_id="SPEC-REF-EXTERNAL", - severity="WARN", + severity=Severity.WARN, message=f"external reference '{ref}' cannot be resolved by the " "built-in loader; bundle external docs or inline the schema", location=self._loc(pointer), @@ -133,9 +130,8 @@ def resolve_ref(self, root: dict[str, Any], ref: str, pointer: str) -> Any: self.findings.append( Finding( rule_id="SPEC-REF-UNRESOLVED", - severity="ERROR", - message=f"unresolved reference '{ref}' " - f"(missing segment '{part}')", + severity=Severity.ERROR, + message=f"unresolved reference '{ref}' (missing segment '{part}')", location=self._loc(pointer), ) ) @@ -148,7 +144,7 @@ def resolve_ref(self, root: dict[str, Any], ref: str, pointer: str) -> Any: self.findings.append( Finding( rule_id="SPEC-REF-UNRESOLVED", - severity="ERROR", + severity=Severity.ERROR, message=f"unresolved reference '{ref}'", location=self._loc(pointer), ) @@ -158,7 +154,7 @@ def resolve_ref(self, root: dict[str, Any], ref: str, pointer: str) -> Any: self.findings.append( Finding( rule_id="SPEC-REF-UNRESOLVED", - severity="ERROR", + severity=Severity.ERROR, message=f"unresolved reference '{ref}': traversal dead-end", location=self._loc(pointer), ) @@ -184,7 +180,7 @@ def deref( self.findings.append( Finding( rule_id="SPEC-REF-CYCLE", - severity="ERROR", + severity=Severity.ERROR, message=f"circular reference detected at '{ref}'", location=self._loc(pointer, node), ) @@ -200,7 +196,7 @@ def deref( self.findings.append( Finding( rule_id="SPEC-REF-DEEP", - severity="ERROR", + severity=Severity.ERROR, message=f"reference chain too deep at '{ref}'", location=self._loc(pointer), ) @@ -218,7 +214,7 @@ def to_schema(self, root: dict[str, Any], node: Any, pointer: str) -> SchemaNode self.findings.append( Finding( rule_id="SPEC-SCHEMA-INVALID", - severity="ERROR", + severity=Severity.ERROR, message=f"schema at '{pointer}' is not an object", location=self._loc(pointer, node), ) @@ -273,7 +269,9 @@ def to_schema(self, root: dict[str, Any], node: Any, pointer: str) -> SchemaNode props = node.get("properties") if isinstance(props, dict): for name, sub in props.items(): - converted = self.to_schema(root, sub, f"{pointer}/properties/{self._escape_pointer(name)}") + converted = self.to_schema( + root, sub, f"{pointer}/properties/{self._escape_pointer(name)}" + ) if converted is not None: out.properties[name] = converted required = node.get("required") @@ -284,7 +282,9 @@ def to_schema(self, root: dict[str, Any], node: Any, pointer: str) -> SchemaNode if isinstance(addl, bool): out.additional_properties = addl elif isinstance(addl, dict): - out.additional_properties = self.to_schema(root, addl, f"{pointer}/additionalProperties") + out.additional_properties = self.to_schema( + root, addl, f"{pointer}/additionalProperties" + ) if isinstance(node.get("items"), (dict, bool)): out.items = self.to_schema(root, node["items"], f"{pointer}/items") @@ -292,7 +292,7 @@ def to_schema(self, root: dict[str, Any], node: Any, pointer: str) -> SchemaNode for key, attr in (("oneOf", "one_of"), ("anyOf", "any_of"), ("allOf", "all_of")): variants = node.get(key) if isinstance(variants, list): - converted = [ + variant_schemas = [ s for s in ( self.to_schema(root, v, f"{pointer}/{key}/{i}") @@ -300,7 +300,7 @@ def to_schema(self, root: dict[str, Any], node: Any, pointer: str) -> SchemaNode ) if s is not None ] - setattr(out, attr, converted) + setattr(out, attr, variant_schemas) return out @@ -315,7 +315,7 @@ def _to_parameter(self, root: dict[str, Any], node: Any, pointer: str) -> Parame self.findings.append( Finding( rule_id="SPEC-PARAM-LOCATION", - severity="ERROR", + severity=Severity.ERROR, message=f"parameter '{node.get('name', '?')}' has invalid 'in' value " f"'{loc_raw}'", location=self._loc(pointer, node), @@ -352,7 +352,9 @@ def _to_request_body(self, root: dict[str, Any], node: Any, pointer: str) -> Req for media, media_obj in raw_content.items(): if isinstance(media_obj, dict) and isinstance(media_obj.get("schema"), dict): converted = self.to_schema( - root, media_obj["schema"], f"{pointer}/content/{self._escape_pointer(str(media))}/schema" + root, + media_obj["schema"], + f"{pointer}/content/{self._escape_pointer(str(media))}/schema", ) if converted is not None: content[str(media)] = converted @@ -363,7 +365,9 @@ def _to_request_body(self, root: dict[str, Any], node: Any, pointer: str) -> Req source_location=self._loc(pointer, node), ) - def _to_response(self, root: dict[str, Any], status: str, node: Any, pointer: str) -> Response | None: + def _to_response( + self, root: dict[str, Any], status: str, node: Any, pointer: str + ) -> Response | None: node = self.deref(root, node, pointer) if not isinstance(node, dict): return None @@ -374,7 +378,9 @@ def _to_response(self, root: dict[str, Any], status: str, node: Any, pointer: st if isinstance(hobj, dict): hobj = self.deref(root, hobj, f"{pointer}/headers/{hname}") if isinstance(hobj, dict) and isinstance(hobj.get("schema"), dict): - converted = self.to_schema(root, hobj["schema"], f"{pointer}/headers/{hname}/schema") + converted = self.to_schema( + root, hobj["schema"], f"{pointer}/headers/{hname}/schema" + ) if converted is not None: headers[str(hname)] = converted content: dict[str, SchemaNode] = {} @@ -383,7 +389,9 @@ def _to_response(self, root: dict[str, Any], status: str, node: Any, pointer: st for media, media_obj in raw_content.items(): if isinstance(media_obj, dict) and isinstance(media_obj.get("schema"), dict): converted = self.to_schema( - root, media_obj["schema"], f"{pointer}/content/{self._escape_pointer(str(media))}/schema" + root, + media_obj["schema"], + f"{pointer}/content/{self._escape_pointer(str(media))}/schema", ) if converted is not None: content[str(media)] = converted @@ -412,10 +420,12 @@ def _to_examples(self, root: dict[str, Any], node: Any, pointer: str) -> list[Ex ) return examples - def _to_security_requirements(self, root: dict[str, Any], node: Any, pointer: str) -> list[SecurityRequirement]: + def _to_security_requirements( + self, root: dict[str, Any], node: Any, pointer: str + ) -> list[SecurityRequirement]: reqs: list[SecurityRequirement] = [] if isinstance(node, list): - for i, entry in enumerate(node): + for entry in node: if isinstance(entry, dict): for scheme_name, scopes in entry.items(): reqs.append( @@ -452,7 +462,7 @@ def parse(self, source: str) -> tuple[Service, list[Finding]]: self.findings.append( Finding( rule_id="SPEC-VERSION-UNSUPPORTED", - severity="ERROR", + severity=Severity.ERROR, message=f"unsupported OpenAPI version '{openapi_version or '(missing)'}'; " "expected 3.0.x or 3.1.x", location=self._loc(""), @@ -471,7 +481,7 @@ def parse(self, source: str) -> tuple[Service, list[Finding]]: servers = doc.get("servers") if isinstance(servers, list): - for i, srv in enumerate(servers): + for srv in servers: if isinstance(srv, dict) and "url" in srv: service.servers.append( Server(url=str(srv["url"]), description=srv.get("description")) @@ -488,16 +498,16 @@ def parse(self, source: str) -> tuple[Service, list[Finding]]: service.security_schemes[str(name)] = SecurityScheme( name=str(name), type=str(sch.get("type", "")), - location=ParameterLocation(loc_raw) if loc_raw in _VALID_PARAM_LOCATIONS else None, + location=ParameterLocation(loc_raw) + if loc_raw in _VALID_PARAM_LOCATIONS + else None, scheme=sch.get("scheme"), bearer_format=sch.get("bearerFormat"), deprecated=bool(sch.get("deprecated", False)), source_location=self._loc(f"/components/securitySchemes/{name}", sch), ) - global_security = self._to_security_requirements( - doc, doc.get("security"), "/security" - ) + global_security = self._to_security_requirements(doc, doc.get("security"), "/security") service.global_security = global_security paths = doc.get("paths") or {} @@ -531,7 +541,7 @@ def parse(self, source: str) -> tuple[Service, list[Finding]]: self.findings.append( Finding( rule_id="SPEC-OP-DUPLICATE", - severity="ERROR", + severity=Severity.ERROR, message=f"duplicate/conflicting operation '{key}'", location=self._loc(op_pointer, op_node), ) @@ -560,18 +570,18 @@ def parse(self, source: str) -> tuple[Service, list[Finding]]: self.findings.append( Finding( rule_id="SPEC-RESPONSE-MISSING", - severity="WARN", + severity=Severity.WARN, message=f"operation '{key}' declares no responses", location=self._loc(op_pointer, op_node), ) ) if isinstance(raw_responses, dict): for status, resp in raw_responses.items(): - conv = self._to_response( + resp_conv = self._to_response( doc, str(status), resp, f"{op_pointer}/responses/{status}" ) - if conv is not None: - responses.append(conv) + if resp_conv is not None: + responses.append(resp_conv) op_id = op_node.get("operationId") if op_id is not None: @@ -580,7 +590,7 @@ def parse(self, source: str) -> tuple[Service, list[Finding]]: self.findings.append( Finding( rule_id="SPEC-OPID-DUPLICATE", - severity="ERROR", + severity=Severity.ERROR, message=f"duplicate operationId '{op_id}' " f"(also used by {seen_operation_ids[op_id]})", location=self._loc(op_pointer, op_node), @@ -622,4 +632,4 @@ def load_openapi(source: str) -> tuple[Service, list[Finding]]: from pathlib import Path as _Path label = source if source.startswith("http") else _Path(source).name - return OpenApiParser(label).parse(source) \ No newline at end of file + return OpenApiParser(label).parse(source) diff --git a/apiverity/stateful/__init__.py b/apiverity/stateful/__init__.py index ce1c708..f8ac810 100644 --- a/apiverity/stateful/__init__.py +++ b/apiverity/stateful/__init__.py @@ -8,17 +8,17 @@ from apiverity.stateful.engine import WorkflowEngine, load_workflow_manifest from apiverity.stateful.models import ( + StepResult, Workflow, - WorkflowStep, WorkflowResult, - StepResult, + WorkflowStep, ) __all__ = [ - "WorkflowEngine", - "load_workflow_manifest", + "StepResult", "Workflow", - "WorkflowStep", + "WorkflowEngine", "WorkflowResult", - "StepResult", -] \ No newline at end of file + "WorkflowStep", + "load_workflow_manifest", +] diff --git a/apiverity/stateful/engine.py b/apiverity/stateful/engine.py index 5754104..7a6ee93 100644 --- a/apiverity/stateful/engine.py +++ b/apiverity/stateful/engine.py @@ -9,7 +9,6 @@ from __future__ import annotations -import json import re import time from pathlib import Path @@ -22,6 +21,7 @@ from apiverity.stateful.models import ( StepResult, Workflow, + WorkflowRequest, WorkflowResult, WorkflowStep, ) @@ -33,10 +33,9 @@ def load_workflow_manifest(path: str) -> Workflow: raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) if not isinstance(raw, dict): raise ValueError("workflow manifest must be a mapping") - steps = [] + steps: list[WorkflowStep] = [] for s in raw.get("steps") or []: req = s.get("request") or {} - assert_status = req.get("assert", {}).get("status") if isinstance(req.get("assert"), dict) else None # assertions may live on the step or inside request.assert step_assert = s.get("assert") or (req.get("assert") or {}) status_list = step_assert.get("status") if isinstance(step_assert, dict) else None @@ -50,32 +49,32 @@ def load_workflow_manifest(path: str) -> Workflow: steps.append( WorkflowStep( name=str(s.get("name", f"step-{len(steps)}")), - request={ - "method": str(req.get("method", "GET")).upper(), - "path": str(req.get("path", "/")), - "body": req.get("body"), - "headers": req.get("headers") or {}, - "query": req.get("query") or {}, - }, + request=WorkflowRequest( + method=str(req.get("method", "GET")).upper(), + path=str(req.get("path", "/")), + body=req.get("body"), + headers={str(k): str(v) for k, v in (req.get("headers") or {}).items()}, + query=req.get("query") or {}, + ), extract=s.get("extract") or {}, assert_status=status_list, assert_jsonpath=jsonpath_asserts, timeout_seconds=float(s.get("timeout", 30.0)), ) ) - cleanup = [] + cleanup: list[WorkflowStep] = [] for s in raw.get("cleanup") or []: req = s.get("request") or {} cleanup.append( WorkflowStep( name=str(s.get("name", f"cleanup-{len(cleanup)}")), - request={ - "method": str(req.get("method", "GET")).upper(), - "path": str(req.get("path", "/")), - "body": req.get("body"), - "headers": req.get("headers") or {}, - "query": req.get("query") or {}, - }, + request=WorkflowRequest( + method=str(req.get("method", "GET")).upper(), + path=str(req.get("path", "/")), + body=req.get("body"), + headers={str(k): str(v) for k, v in (req.get("headers") or {}).items()}, + query=req.get("query") or {}, + ), timeout_seconds=float(s.get("timeout", 30.0)), ) ) @@ -84,8 +83,10 @@ def load_workflow_manifest(path: str) -> Workflow: description=raw.get("description"), base_url=raw.get("base_url"), allowed_hosts=[str(h) for h in raw.get("allowed_hosts") or []], - allowed_methods=[str(m).upper() for m in raw.get("allowed_methods") - or ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]], + allowed_methods=[ + str(m).upper() + for m in raw.get("allowed_methods") or ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] + ], steps=steps, cleanup=cleanup, ) @@ -142,9 +143,12 @@ def _check_host(self, url: str) -> None: # Allowlist entries may omit the port (e.g. "http://127.0.0.1"). origin = f"{parsed.scheme}://{parsed.hostname}" allowed = {h.rstrip("/") for h in self.workflow.allowed_hosts} - allowed_origins = {a.split("://")[0] + "://" + - urlparse(a if "://" in a else "http://" + a).hostname - for a in allowed} + allowed_origins = { + a.split("://")[0] + + "://" + + (urlparse(a if "://" in a else "http://" + a).hostname or "") + for a in allowed + } if allowed and host not in allowed and origin not in allowed_origins: raise ValueError( f"host '{host}' is not in the workflow allowlist " @@ -212,9 +216,7 @@ def _run_step( if expected_statuses is None: expected_statuses = [200, 201, 202, 204] if response.status_code not in expected_statuses: - violations.append( - f"status {response.status_code} not in expected {expected_statuses}" - ) + violations.append(f"status {response.status_code} not in expected {expected_statuses}") extracted: dict[str, Any] = {} try: @@ -237,8 +239,7 @@ def _run_step( violations.append(f"assertion path '{expression}' not found") elif actual != expected: violations.append( - f"assertion failed at '{expression}': " - f"expected {expected!r}, got {actual!r}" + f"assertion failed at '{expression}': expected {expected!r}, got {actual!r}" ) status = "fail" if violations else "pass" @@ -257,4 +258,4 @@ def _run_step( def run_workflow_manifest(path: str, base_url: str) -> WorkflowResult: wf = load_workflow_manifest(path) - return WorkflowEngine(wf, base_url).run() \ No newline at end of file + return WorkflowEngine(wf, base_url).run() diff --git a/apiverity/stateful/models.py b/apiverity/stateful/models.py index c5050e3..a07a930 100644 --- a/apiverity/stateful/models.py +++ b/apiverity/stateful/models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, Field @@ -10,7 +10,7 @@ class WorkflowRequest(BaseModel): method: str = "GET" path: str - body: Optional[Any] = None + body: Any | None = None headers: dict[str, str] = Field(default_factory=dict) query: dict[str, Any] = Field(default_factory=dict) @@ -19,15 +19,15 @@ class WorkflowStep(BaseModel): name: str request: WorkflowRequest extract: dict[str, str] = Field(default_factory=dict) # var -> jsonpath-ish - assert_status: Optional[list[int]] = None + assert_status: list[int] | None = None assert_jsonpath: dict[str, Any] = Field(default_factory=dict) timeout_seconds: float = 30.0 class Workflow(BaseModel): name: str - description: Optional[str] = None - base_url: Optional[str] = None + description: str | None = None + base_url: str | None = None allowed_hosts: list[str] = Field(default_factory=list) allowed_methods: list[str] = Field( default_factory=lambda: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"] @@ -39,7 +39,7 @@ class Workflow(BaseModel): class StepResult(BaseModel): step: str status: str # pass | fail | error | skipped - actual_status: Optional[int] = None + actual_status: int | None = None violations: list[str] = Field(default_factory=list) extracted: dict[str, Any] = Field(default_factory=dict) duration_ms: int = 0 @@ -50,4 +50,4 @@ class WorkflowResult(BaseModel): status: str # pass | fail | error steps: list[StepResult] = Field(default_factory=list) cleanup_steps: list[StepResult] = Field(default_factory=list) - variables: dict[str, Any] = Field(default_factory=dict) \ No newline at end of file + variables: dict[str, Any] = Field(default_factory=dict) diff --git a/apiverity/traffic/auth.py b/apiverity/traffic/auth.py index eb357d4..f20dc9e 100644 --- a/apiverity/traffic/auth.py +++ b/apiverity/traffic/auth.py @@ -4,18 +4,19 @@ request time and are NEVER persisted in result bundles — only the *reference* (env var name / file path) is stored. """ + from __future__ import annotations import base64 import os -from enum import Enum +from enum import StrEnum from pathlib import Path from typing import Any from pydantic import BaseModel, Field -class AuthKind(str, Enum): +class AuthKind(StrEnum): bearer = "bearer" api_key = "api_key" basic = "basic" @@ -57,13 +58,13 @@ def redacted_summary(self) -> dict[str, Any]: def resolve_headers(profile: AuthProfile) -> dict[str, str]: """Resolve a profile into request headers (secrets stay in memory).""" + def env(name: str | None) -> str: if not name: raise ValueError(f"profile '{profile.name}': missing environment reference") value = os.environ.get(name) if not value: - raise ValueError( - f"profile '{profile.name}': environment variable '{name}' is not set") + raise ValueError(f"profile '{profile.name}': environment variable '{name}' is not set") return value if profile.kind in (AuthKind.bearer, AuthKind.oauth_token): @@ -97,7 +98,7 @@ class AuthProfileSet(BaseModel): profiles: list[AuthProfile] = Field(default_factory=list) @classmethod - def load(cls, path: str) -> "AuthProfileSet": + def load(cls, path: str) -> AuthProfileSet: import yaml raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} @@ -107,4 +108,4 @@ def get(self, name: str) -> AuthProfile: for p in self.profiles: if p.name == name: return p - raise KeyError(f"auth profile '{name}' not found") \ No newline at end of file + raise KeyError(f"auth profile '{name}' not found") diff --git a/apiverity/traffic/redact.py b/apiverity/traffic/redact.py index 6f1682a..ebe8376 100644 --- a/apiverity/traffic/redact.py +++ b/apiverity/traffic/redact.py @@ -4,17 +4,31 @@ configurable sensitive fields. Redaction is always applied before any corpus is stored in a bundle; secret values never persist. """ + from __future__ import annotations -import json, re + +import json +import re from pathlib import Path from typing import Any + from pydantic import BaseModel, Field -DEFAULT_SENSITIVE_HEADERS = {"authorization", "cookie", "set-cookie", "proxy-authorization", - "x-api-key", "x-auth-token", "x-csrf-token"} +DEFAULT_SENSITIVE_HEADERS = { + "authorization", + "cookie", + "set-cookie", + "proxy-authorization", + "x-api-key", + "x-auth-token", + "x-csrf-token", +} DEFAULT_SENSITIVE_QUERY = {"api_key", "apikey", "token", "access_token", "secret", "password"} -DEFAULT_PATTERNS = [r"(?i)bearer\s+[a-z0-9._\-]+", r"(?i)sk-[a-z0-9]{16,}", - r"(?i)(?:api[_-]?key|token|secret)\s*[=:]\s*\S+"] +DEFAULT_PATTERNS = [ + r"(?i)bearer\s+[a-z0-9._\-]+", + r"(?i)sk-[a-z0-9]{16,}", + r"(?i)(?:api[_-]?key|token|secret)\s*[=:]\s*\S+", +] class RedactionConfig(BaseModel): @@ -42,8 +56,10 @@ def redact_headers(headers: dict[str, str], cfg: RedactionConfig) -> dict[str, s def redact_query(params: dict[str, Any], cfg: RedactionConfig) -> dict[str, Any]: - return {k: (cfg.replacement if k.lower() in cfg.sensitive_query_fields else v) - for k, v in params.items()} + return { + k: (cfg.replacement if k.lower() in cfg.sensitive_query_fields else v) + for k, v in params.items() + } def redact_json(value: Any, cfg: RedactionConfig, *, is_body: bool = True) -> Any: @@ -76,14 +92,20 @@ def import_har(path: str, cfg: RedactionConfig | None = None) -> list[dict[str, resp = entry.get("response", {}) qdict = {q["name"]: q.get("value") for q in req.get("queryString", [])} post = req.get("postData", {}).get("text") - entries.append({ - "method": req.get("method"), - "url": req.get("url"), - "request_headers": redact_headers({h["name"]: h.get("value") for h in req.get("headers", [])}, cfg), - "query": redact_query(qdict, cfg), - "request_body": redact_json(json.loads(post), cfg) if post else None, - "status": resp.get("status"), - "response_headers": redact_headers({h["name"]: h.get("value") for h in resp.get("headers", [])}, cfg), - "response_body": None, # bodies are not persisted by default - }) - return entries \ No newline at end of file + entries.append( + { + "method": req.get("method"), + "url": req.get("url"), + "request_headers": redact_headers( + {h["name"]: h.get("value") for h in req.get("headers", [])}, cfg + ), + "query": redact_query(qdict, cfg), + "request_body": redact_json(json.loads(post), cfg) if post else None, + "status": resp.get("status"), + "response_headers": redact_headers( + {h["name"]: h.get("value") for h in resp.get("headers", [])}, cfg + ), + "response_body": None, # bodies are not persisted by default + } + ) + return entries diff --git a/apiverity/traffic/replay.py b/apiverity/traffic/replay.py index 4fa3dee..df9206d 100644 --- a/apiverity/traffic/replay.py +++ b/apiverity/traffic/replay.py @@ -4,9 +4,12 @@ concurrency and rate controls, and refuses targets marked production unless explicitly opted in via ``allow_production=True``. """ + from __future__ import annotations + import time from typing import Any + import httpx from pydantic import BaseModel, Field @@ -46,7 +49,8 @@ def replay_corpus( if any(e.production for e in entries) and not allow_production: raise ValueError( "corpus contains entries marked production; pass explicit opt-in " - "(--i-know-this-is-production) to replay them") + "(--i-know-this-is-production) to replay them" + ) report = ReplayReport(target=base_url, dry_run=dry_run) delay = 1.0 / max(rate_per_second, 0.01) @@ -54,9 +58,13 @@ def replay_corpus( with httpx.Client(base_url=base_url, timeout=timeout) as client: for entry in entries: try: - resp = client.request(entry.method, entry.path, params=entry.query or None, - headers=entry.headers or None, - json=entry.body if entry.body is not None else None) + resp = client.request( + entry.method, + entry.path, + params=entry.query or None, + headers=entry.headers or None, + json=entry.body if entry.body is not None else None, + ) key = f"{resp.status_code // 100}xx" report.statuses[key] = report.statuses.get(key, 0) + 1 report.sent += 1 @@ -65,4 +73,4 @@ def replay_corpus( time.sleep(delay) else: report.skipped = len(entries) - return report \ No newline at end of file + return report diff --git a/pyproject.toml b/pyproject.toml index 67f11e6..05363c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM", "C4", "RUF"] ignore = ["E501"] [tool.mypy] -python_version = "3.11" +python_version = "3.12" strict = true warn_unreachable = true show_error_codes = true diff --git a/scripts/_fix2.py b/scripts/_fix2.py deleted file mode 100644 index df109a6..0000000 --- a/scripts/_fix2.py +++ /dev/null @@ -1,36 +0,0 @@ -"""One-off repairs: parser.py label line + Parameter.schema rename.""" -import pathlib - -NL = chr(10) - -# --- fix 1: openapi parser label line --- -p = pathlib.Path("apiverity/specs/openapi/parser.py") -t = p.read_text(encoding="utf-8") -old = ( - ' label = source if source.startswith("http") else ' - 'source.replace(chr(92), chr(47))' + '").split("/")[-1]' -) -assert old in t, "parser old not found" -new = ( - " from pathlib import Path as _Path" + NL - + NL - + ' label = source if source.startswith("http") else _Path(source).name' -) -t = t.replace(old, new) -p.write_text(t, encoding="utf-8") -print("parser fixed") - -# --- fix 2: rename Parameter.schema -> schema_node --- -m = pathlib.Path("apiverity/core/model.py") -mt = m.read_text(encoding="utf-8") -old2 = " schema: Optional[SchemaNode] = None" -assert old2 in mt, "model old not found" -new2 = " schema_node: Optional[SchemaNode] = None" -mt = mt.replace(old2, new2) -m.write_text(mt, encoding="utf-8") - -pp = pathlib.Path("apiverity/specs/openapi/parser.py") -pt = pp.read_text(encoding="utf-8") -pt = pt.replace(" schema=schema,", " schema_node=schema,") -pp.write_text(pt, encoding="utf-8") -print("model fixed") \ No newline at end of file diff --git a/scripts/_fix4.py b/scripts/_fix4.py deleted file mode 100644 index f0136e0..0000000 --- a/scripts/_fix4.py +++ /dev/null @@ -1,14 +0,0 @@ -import pathlib -NL = chr(10) -p = pathlib.Path("apiverity/cli/main.py") -t = p.read_text(encoding="utf-8") -t = t.replace(' print("' + NL + '".join(lines))', ' print(NL.join(lines))') -t = t.replace(' (out / "SHA256SUMS").write_text(' + NL + - ' "' + NL + '".join(f"{v} {k}" for k, v in checksums.items()) + "' + NL + - '", encoding="utf-8")', - ' (out / "SHA256SUMS").write_text(' + NL + - ' NL.join(f"{v} {k}" for k, v in checksums.items()) + NL, encoding="utf-8")') -if "NL = chr(10)" not in t: - t = t.replace("EXIT_OK = 0", "NL = chr(10)" + NL + NL + "EXIT_OK = 0") -p.write_text(t, encoding="utf-8") -print("fixed:", '"' + NL + '"' not in t) \ No newline at end of file diff --git a/scripts/e2e.py b/scripts/e2e.py index a2fd0b5..a668fd2 100644 --- a/scripts/e2e.py +++ b/scripts/e2e.py @@ -1,7 +1,7 @@ """End-to-end validation against bundled fixtures (mock hosted in-process).""" + from __future__ import annotations -import json import sys from pathlib import Path @@ -22,27 +22,43 @@ def main() -> None: failures = [] # 1. validate all fixtures - for spec in ["crud/openapi.yaml", "versioned/v1.yaml", "versioned/v2.yaml", - "drift/openapi.yaml"]: + for spec in [ + "crud/openapi.yaml", + "versioned/v1.yaml", + "versioned/v2.yaml", + "drift/openapi.yaml", + ]: code = run(["validate", str(FIX / "apis" / spec)]) if code not in (0,): failures.append(f"validate {spec} -> {code}") # 2. diff + breaking + semver - code = run(["diff", str(FIX / "apis/versioned/v1.yaml"), - str(FIX / "apis/versioned/v2.yaml")]) + code = run(["diff", str(FIX / "apis/versioned/v1.yaml"), str(FIX / "apis/versioned/v2.yaml")]) if code != 0: failures.append(f"diff -> {code}") - code = run(["breaking", str(FIX / "apis/versioned/v1.yaml"), - str(FIX / "apis/versioned/v2.yaml"), "--check-semver"]) + code = run( + [ + "breaking", + str(FIX / "apis/versioned/v1.yaml"), + str(FIX / "apis/versioned/v2.yaml"), + "--check-semver", + ] + ) if code != 1: # breaking changes expected failures.append(f"breaking -> {code} (expected 1)") # 3. changelog out = Path("build/changelog.md") out.parent.mkdir(exist_ok=True) - code = run(["changelog", str(FIX / "apis/versioned/v1.yaml"), - str(FIX / "apis/versioned/v2.yaml"), "--output", str(out)]) + code = run( + [ + "changelog", + str(FIX / "apis/versioned/v1.yaml"), + str(FIX / "apis/versioned/v2.yaml"), + "--output", + str(out), + ] + ) if code != 0 or not out.exists(): failures.append("changelog failed") @@ -58,8 +74,7 @@ def main() -> None: failures.append(f"test -> {code}") # workflow lifecycle - code = run(["workflow", str(FIX / "workflows/crud-lifecycle.yaml"), - "--base-url", base]) + code = run(["workflow", str(FIX / "workflows/crud-lifecycle.yaml"), "--base-url", base]) if code != 0: failures.append(f"workflow -> {code}") @@ -75,17 +90,39 @@ def main() -> None: # performance baseline + regression gate baseline_path = Path("build/perf-baseline.json") - code = run(["baseline", str(FIX / "apis/crud/openapi.yaml"), - "--base-url", base, "-o", str(baseline_path), "--iterations", "30"]) + code = run( + [ + "baseline", + str(FIX / "apis/crud/openapi.yaml"), + "--base-url", + base, + "-o", + str(baseline_path), + "--iterations", + "30", + ] + ) if code != 0: failures.append(f"baseline -> {code}") # Tolerance is deliberately generous: localhost timings are noisy and # the strict comparison logic is unit-tested in the pytest suite. # Here we verify the command wiring and exit codes end-to-end. - code = run(["regression", str(FIX / "apis/crud/openapi.yaml"), "--base-url", base, - "--baseline", str(baseline_path), "--iterations", "30", - "--tolerance", "400", - "--policy", "GET /users p95 <= 5000ms"]) + code = run( + [ + "regression", + str(FIX / "apis/crud/openapi.yaml"), + "--base-url", + base, + "--baseline", + str(baseline_path), + "--iterations", + "30", + "--tolerance", + "400", + "--policy", + "GET /users p95 <= 5000ms", + ] + ) if code != 0: failures.append(f"regression -> {code}") @@ -93,8 +130,9 @@ def main() -> None: from apiverity.traffic.redact import RedactionConfig, redact_headers, redact_json cfg = RedactionConfig() - hdrs = redact_headers({"Authorization": "Bearer sk-abcdefghijklmnop1234", - "X-Custom": "ok"}, cfg) + hdrs = redact_headers( + {"Authorization": "Bearer sk-abcdefghijklmnop1234", "X-Custom": "ok"}, cfg + ) assert hdrs["Authorization"] == "[REDACTED]", hdrs body = redact_json({"password": "hunter2", "note": "token=abc123"}, cfg) assert body["password"] == "[REDACTED]" and "[REDACTED]" in body["note"], body @@ -110,4 +148,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/generate-demo-data.py b/scripts/generate-demo-data.py index cf34142..4f03098 100644 --- a/scripts/generate-demo-data.py +++ b/scripts/generate-demo-data.py @@ -1,4 +1,5 @@ """Generate real frontend demo data by running bundled fixtures through apiverity.""" + from __future__ import annotations import json @@ -14,8 +15,8 @@ def cli_json(argv): - import io import contextlib + import io buf = io.StringIO() with contextlib.redirect_stdout(buf): @@ -38,9 +39,9 @@ def main() -> None: service, _, _ = detect_and_load(str(crud)) drift_service, _, _ = detect_and_load(str(FIX / "apis/drift/openapi.yaml")) + from apiverity.coverage import measure_coverage from apiverity.fuzz.runner import build_cases, run_cases from apiverity.runtime.drift import detect_drift - from apiverity.coverage import measure_coverage with MockServer(service, port=8095) as mock: base = mock.base_url @@ -57,10 +58,11 @@ def main() -> None: for r in results: if r.actual_status: statuses.setdefault(r.operation_key, set()).add(r.actual_status) - coverage = measure_coverage(service, exercised_operations=exercised, - statuses_by_operation=statuses) + coverage = measure_coverage( + service, exercised_operations=exercised, statuses_by_operation=statuses + ) - from apiverity.stateful.engine import load_workflow_manifest, WorkflowEngine + from apiverity.stateful.engine import WorkflowEngine, load_workflow_manifest wf = load_workflow_manifest(str(FIX / "workflows/crud-lifecycle.yaml")) with MockServer(service, port=8096) as mock: @@ -92,22 +94,35 @@ def main() -> None: "generated_from": "fixtures/apis (crud, versioned v1->v2, drift)", "label": "EXAMPLE RUN — generated locally from bundled fixture APIs", }, - "diff": {"old_version": old.version, "new_version": new.version, - "changes": [c.model_dump() for c in changes]}, + "diff": { + "old_version": old.version, + "new_version": new.version, + "changes": [c.model_dump() for c in changes], + }, "breaking": {"findings": [f.model_dump() for f in findings]}, - "test": {"total": len(results), - "passed": sum(1 for r in results if r.status == "pass"), - "failed": sum(1 for r in results if r.status != "pass"), - "results": [r.model_dump() for r in results]}, + "test": { + "total": len(results), + "passed": sum(1 for r in results if r.status == "pass"), + "failed": sum(1 for r in results if r.status != "pass"), + "results": [r.model_dump() for r in results], + }, "drift": {"findings": [f.model_dump() for f in drift.findings]}, "performance": {"operations": json.loads(perf.model_dump_json())["operations"]}, - "coverage": {"overall_percent": coverage.overall_percent(), - "operations": json.loads(coverage.model_dump_json())["operations"]}, + "coverage": { + "overall_percent": coverage.overall_percent(), + "operations": json.loads(coverage.model_dump_json())["operations"], + }, "rules": {"count": len(rules_catalog), "catalog": rules_catalog}, - "workflow": {"name": wf.name, "description": wf.description, - "result": json.loads(wf_result.model_dump_json())}, - "contract": {"title": service.title, "version": service.version, - "operations": contract_tree}, + "workflow": { + "name": wf.name, + "description": wf.description, + "result": json.loads(wf_result.model_dump_json()), + }, + "contract": { + "title": service.title, + "version": service.version, + "operations": contract_tree, + }, } OUT.parent.mkdir(parents=True, exist_ok=True) OUT.write_text(json.dumps(payload, indent=2), encoding="utf-8") @@ -115,4 +130,4 @@ def main() -> None: if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/_dbg3.py b/tests/_dbg3.py deleted file mode 100644 index 608810f..0000000 --- a/tests/_dbg3.py +++ /dev/null @@ -1,9 +0,0 @@ -import tests._smoke3 as s - -old = s.load(s.V1) -new = s.load(s.V2) -changes = s.diff_services(old, new) -for c in changes: - if c.kind.value == "enum_changed": - print(c.id, "|", c.direction, "|", c.operation_key, "|", c.description) -print("total:", len(changes)) \ No newline at end of file diff --git a/tests/_dbg4.py b/tests/_dbg4.py deleted file mode 100644 index ae86c31..0000000 --- a/tests/_dbg4.py +++ /dev/null @@ -1,12 +0,0 @@ -import tests._smoke3 as s -from apiverity.rules.breaking import evaluate_breaking - -old = s.load(s.V1) -new = s.load(s.V2) -changes = s.diff_services(old, new) -enum_changes = [c for c in changes if c.kind.value == "enum_changed"] -for c in enum_changes: - print("dir:", repr(c.direction), "old:", c.old_value, "new:", c.new_value) -findings = evaluate_breaking(enum_changes) -for f in findings: - print(f.rule_id, "|", f.message[:80]) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 082a6b0..d7d2682 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Shared fixtures for the apiverity test suite.""" + from __future__ import annotations import sys @@ -27,4 +28,4 @@ def v1_service(): @pytest.fixture(scope="session") def v2_service(): service, _, _ = detect_and_load(str(ROOT / "fixtures/apis/versioned/v2.yaml")) - return service \ No newline at end of file + return service diff --git a/tests/test_core_pipeline.py b/tests/test_core_pipeline.py index 5512cad..d036f25 100644 --- a/tests/test_core_pipeline.py +++ b/tests/test_core_pipeline.py @@ -1,7 +1,9 @@ """Core pipeline tests: parsing, diff, breaking, semver, changelog, security.""" + from __future__ import annotations -from hypothesis import given, strategies as st +from hypothesis import given +from hypothesis import strategies as st from apiverity.core.model import SchemaNode from apiverity.diff.engine import diff_services @@ -119,6 +121,7 @@ def test_deterministic_case_generation(crud_service): def test_mock_server_stateful_crud(crud_service): import httpx + from apiverity.mock import MockServer with MockServer(crud_service, port=0) as mock: @@ -130,7 +133,7 @@ def test_mock_server_stateful_crud(crud_service): def test_workflow_allowlist_refuses_unknown_host(): - from apiverity.stateful.engine import load_workflow_manifest, WorkflowEngine + from apiverity.stateful.engine import WorkflowEngine, load_workflow_manifest wf = load_workflow_manifest("fixtures/workflows/crud-lifecycle.yaml") try: @@ -152,12 +155,18 @@ def test_redaction_removes_secrets(): def test_performance_policy_parsing_and_evaluation(): - from apiverity.performance.engine import OperationStats, PerformanceReport, evaluate_policies, parse_policy + from apiverity.performance.engine import ( + OperationStats, + PerformanceReport, + evaluate_policies, + parse_policy, + ) p = parse_policy("GET /users p95 <= 250ms") assert p.operation_key == "GET /users" and p.metric == "p95" and p.value == 250.0 - report = PerformanceReport(operations=[ - OperationStats(operation_key="GET /users", requests=10, p95_ms=300)]) + report = PerformanceReport( + operations=[OperationStats(operation_key="GET /users", requests=10, p95_ms=300)] + ) violations = evaluate_policies(report, ["GET /users p95 <= 250ms"]) assert len(violations) == 1 @@ -167,6 +176,7 @@ def test_cli_json_smoke(): rc = main(["rules", "--json"]) assert rc == 0 - rc = main(["diff", "fixtures/apis/versioned/v1.yaml", - "fixtures/apis/versioned/v2.yaml", "--json"]) - assert rc == 0 \ No newline at end of file + rc = main( + ["diff", "fixtures/apis/versioned/v1.yaml", "fixtures/apis/versioned/v2.yaml", "--json"] + ) + assert rc == 0 diff --git a/tests/test_engines_integration.py b/tests/test_engines_integration.py new file mode 100644 index 0000000..f4eed8f --- /dev/null +++ b/tests/test_engines_integration.py @@ -0,0 +1,119 @@ +"""Integration tests exercising the coverage, fuzz-runner, minimization, +workflow, performance and security engines against the in-process mock.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from apiverity.coverage import measure_coverage +from apiverity.fuzz.minimize import minimize_failures +from apiverity.fuzz.runner import build_cases, run_cases +from apiverity.mock import MockServer +from apiverity.performance.engine import compare_baseline, evaluate_policies, measure +from apiverity.security import run_security_checks +from apiverity.specs.loader import detect_and_load +from apiverity.stateful.engine import WorkflowEngine, load_workflow_manifest + +FIX = Path("fixtures") + + +@pytest.fixture(scope="module") +def crud_base() -> str: + service, _, _ = detect_and_load(str(FIX / "apis/crud/openapi.yaml")) + with MockServer(service, port=8097) as mock: + yield mock.base_url + + +@pytest.fixture(scope="module") +def crud_service(): + service, _, _ = detect_and_load(str(FIX / "apis/crud/openapi.yaml")) + return service + + +def test_coverage_measurement(crud_service: object) -> None: + exercised = {op.key for op in crud_service.operations} # type: ignore[attr-defined] + statuses = { + op.key: [200] + for op in crud_service.operations # type: ignore[attr-defined] + } + report = measure_coverage( + crud_service, exercised_operations=exercised, statuses_by_operation=statuses + ) + assert report.overall_percent() == 100.0 + empty = measure_coverage(crud_service) + assert empty.overall_percent() < 100.0 + + +def test_fuzz_runner_against_mock(crud_service: object, crud_base: str) -> None: + cases = build_cases(crud_service, seed=7) + assert cases, "cases must be generated" + # determinism + again = build_cases(crud_service, seed=7) + assert [c.id for c in cases] == [c.id for c in again] + results = run_cases(crud_service, crud_base, cases) + assert len(results) == len(cases) + assert all(r.status in ("pass", "fail") for r in results) + + +def test_minimize_reduces_or_preserves(crud_service: object, crud_base: str) -> None: + cases = build_cases(crud_service, seed=7) + results = run_cases(crud_service, crud_base, cases) + minimized = minimize_failures(crud_service, crud_base, results, cases) + assert len(minimized) <= len(results) + + +def test_workflow_engine_lifecycle(crud_base: str) -> None: + wf = load_workflow_manifest(str(FIX / "workflows/crud-lifecycle.yaml")) + result = WorkflowEngine(wf, crud_base).run() + assert result.status == "pass", [s.violations for s in result.steps] + assert len(result.steps) >= 3 + assert result.variables.get("user_id") + + +def test_workflow_allowlist_refusal() -> None: + wf = load_workflow_manifest(str(FIX / "workflows/crud-lifecycle.yaml")) + from apiverity.stateful.engine import WorkflowEngine as WE + + with pytest.raises(ValueError, match="allowlist"): + WE(wf, "http://evil.example.com") + + +def test_performance_measure_and_policies(crud_service: object, crud_base: str) -> None: + report = measure(crud_service, crud_base, iterations=4) + assert report.operations and all(o.requests == 4 for o in report.operations) + violations = evaluate_policies(report, ["GET /users p95 <= 60000ms"]) + assert violations == [] + baseline = {"operations": [o.model_dump() for o in report.operations]} + again = evaluate_policies(report, []) + assert again == [] + regressed = compare_baseline(report, baseline, tolerance_pct=400) + assert isinstance(regressed, list) + + +def test_security_checks_on_fixtures() -> None: + service, _, _ = detect_and_load(str(FIX / "apis/crud/openapi.yaml")) + findings = run_security_checks(service) + assert all(f.rule_id.startswith("SEC-") for f in findings) + + +def test_core_hash_helper() -> None: + from apiverity.core.hash import canonical_json, sha256_hex + + payload = {"b": 1, "a": [2, 3]} + assert sha256_hex(payload) == sha256_hex({"a": [2, 3], "b": 1}) + assert json_loads(canonical_json(payload)) == payload + + +def json_loads(text: str) -> object: + import json + + return json.loads(text) + + +def test_sdk_exports_importable() -> None: + import apiverity.sdk as sdk + + for name in sdk.__all__: + assert hasattr(sdk, name), name diff --git a/tests/test_platform_modules.py b/tests/test_platform_modules.py new file mode 100644 index 0000000..00dc2c7 --- /dev/null +++ b/tests/test_platform_modules.py @@ -0,0 +1,165 @@ +"""Tests for reports renderers, artifact envelope, auth profiles, +plugin builtins and the export bundle.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from apiverity.core.artifact import ArtifactMeta, contract_hash, enrich +from apiverity.plugins.builtins import ( + core_rules, + httpx_transport, + report_exporters, + schema_case_generator, + security_checks, +) +from apiverity.reports.renderers import RENDERERS +from apiverity.traffic.auth import ( + AuthKind, + AuthProfile, + AuthProfileSet, + resolve_headers, + resolve_verify, +) + +SAMPLE = { + "tool": "apiverity", + "command": "breaking", + "findings": [ + {"rule_id": "BRK-OP-REMOVED", "severity": "ERROR", "message": "op removed"}, + {"rule_id": "BRK-DEPRECATION-ADDED", "severity": "WARN", "message": "deprecated"}, + ], +} + + +def test_renderers_all_formats() -> None: + for fmt, render in RENDERERS.items(): + out = render(SAMPLE) + assert isinstance(out, str) and len(out) > 20, fmt + assert "BRK-OP-REMOVED" in RENDERERS["markdown"](SAMPLE) + assert "" in RENDERERS["html"](SAMPLE).lower() + sarif = json.loads(RENDERERS["sarif"](SAMPLE)) + assert sarif["version"] == "2.1.0" + assert sarif["runs"][0]["results"][0]["ruleId"] == "BRK-OP-REMOVED" + junit = RENDERERS["junit"]({"total": 3, "failed": 1}) + assert 'tests="3"' in junit and 'failures="1"' in junit + + +def test_terminal_renderer_skips_nested() -> None: + out = RENDERERS["terminal"](SAMPLE) + assert "breaking:" in out + assert "[ERROR] BRK-OP-REMOVED op removed" in out + + +def test_contract_hash_stable_and_missing() -> None: + p = Path("fixtures/apis/crud/openapi.yaml") + h1 = contract_hash(str(p)) + h2 = contract_hash(str(p)) + assert h1 == h2 and len(h1) == 64 + assert contract_hash("does/not/exist.yaml") == "0" * 64 + assert contract_hash(None) == "0" * 64 + + +def test_enrich_adds_metadata() -> None: + payload = enrich({"tool": "apiverity", "command": "validate"}, spec_path=None) + assert payload["result_schema_version"] == 1 + assert payload["tool_version"] + assert payload["redaction"]["applied"] is True + meta = ArtifactMeta() + assert meta.contract_hash == "0" * 64 + + +def test_auth_bearer_and_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("T_TOKEN", "tok-123") + monkeypatch.setenv("T_KEY", "key-456") + bearer = AuthProfile(name="b", kind=AuthKind.bearer, token_env="T_TOKEN") + assert resolve_headers(bearer) == {"Authorization": "Bearer tok-123"} + api_key = AuthProfile(name="k", kind=AuthKind.api_key, key_env="T_KEY") + headers = resolve_headers(api_key) + assert headers == {"X-Api-Key": "key-456"} + # references only — no secret values in the summary + summary = str(bearer.redacted_summary()) + assert "tok-123" not in summary + + +def test_auth_basic_and_missing_env(monkeypatch: pytest.MonkeyPatch) -> None: + import base64 + + monkeypatch.setenv("U", "alice") + monkeypatch.setenv("P", "s3cret") + basic = AuthProfile(name="x", kind=AuthKind.basic, username_env="U", password_env="P") + expected = base64.b64encode(b"alice:s3cret").decode() + assert resolve_headers(basic) == {"Authorization": f"Basic {expected}"} + with pytest.raises(ValueError, match="not set"): + resolve_headers(AuthProfile(name="y", kind=AuthKind.bearer, token_env="MISSING_ENV_X")) + + +def test_auth_mtls(tmp_path: Path) -> None: + cert = tmp_path / "c.pem" + cert.write_text("cert") + prof = AuthProfile(name="m", kind=AuthKind.mtls, cert_file=str(cert), key_file=str(cert)) + assert resolve_headers(prof) == {} + assert resolve_verify(prof) == (str(cert), str(cert)) + with pytest.raises(ValueError, match="not found"): + resolve_headers( + AuthProfile(name="m2", kind=AuthKind.mtls, cert_file="nope.pem", key_file="nope.pem") + ) + + +def test_auth_profile_set(tmp_path: Path) -> None: + manifest = tmp_path / "auth.yaml" + manifest.write_text("profiles:\n - name: ci\n kind: bearer\n token_env: CI_T\n") + ps = AuthProfileSet.load(str(manifest)) + assert ps.get("ci").token_env == "CI_T" + with pytest.raises(KeyError): + ps.get("nope") + + +def test_plugin_builtins_load() -> None: + rules = core_rules() + assert "BRK-OP-REMOVED" in rules + assert callable(security_checks()) + assert callable(schema_case_generator()) + exporters = report_exporters() + assert "sarif" in exporters and "html" in exporters + make_client = httpx_transport() + client = make_client(timeout=1.0) + try: + assert client.timeout.read == 1.0 + finally: + client.close() + + +def test_export_bundle_contents(tmp_path: Path) -> None: + from apiverity.cli.main import main as cli_main + + out = tmp_path / "bundle.apiverity" + code = cli_main( + [ + "export", + "--data", + json.dumps( + { + **SAMPLE, + "results": [ + {"case_id": "c1", "status": "pass"}, + {"case_id": "c2", "status": "fail", "violations": ["v"]}, + ], + } + ), + "-o", + str(out), + "--spec", + "fixtures/apis/crud/openapi.yaml", + ] + ) + assert code == 0 + names = {p.name for p in out.iterdir()} + assert {"result.json", "contract-snapshot", "SHA256SUMS"} <= names + result = json.loads((out / "result.json").read_text(encoding="utf-8")) + assert result["contract_hash"] == contract_hash("fixtures/apis/crud/openapi.yaml") + failing = json.loads((out / "failing-cases.json").read_text(encoding="utf-8")) + assert [f["case_id"] for f in failing] == ["c2"] diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index b79a6b5..3c1c773 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' import App from './App' // Provide demo data so the app renders its real content path.