diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c25ae12 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Version control +.git/ +.gitignore +.github/ + +# Python build/test artifacts +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.hypothesis/ +.coverage +htmlcov/ + +# Frontend (served separately; not needed inside the server image) +web/node_modules/ +web/dist/ +web/coverage/ + +# Docs, fixtures and demo data are not required by the server image +docs/ +fixtures/ +data/ +examples/ +schemas/ +scripts/ +tests/ + +# Local artifacts +*.apiverity/ +.apiverity-bundles/ +demo-data/ +.env* +Dockerfile +.dockerignore diff --git a/.github/workflows/api-verity.yml b/.github/workflows/api-verity.yml index d006eec..1097e61 100644 --- a/.github/workflows/api-verity.yml +++ b/.github/workflows/api-verity.yml @@ -40,10 +40,15 @@ jobs: id: specs run: | git fetch origin "${{ github.base_ref }}" --depth=1 + # Allowlist, not denylist: only files under known contract directories are + # treated as API specs. A denylist leaks -- every new root-level YAML + # (.pre-commit-config.yaml, and any future tool config) would otherwise be + # handed to `apiverity validate` and fail the gate. SPEC_DIRS is the one + # place to extend when contracts move or a new location is added. + SPEC_DIRS='^(fixtures/apis|openapi|specs|contracts)/' SPECS=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" \ - | grep -E '\.(yaml|yml|json)$' \ - | grep -Eiv 'package|lock|\.github' \ - | grep -Ev '^(data|web|schemas|docs|build|dist)/' || true) + | grep -E '[.](yaml|yml|json)$' \ + | grep -E "$SPEC_DIRS" || true) echo "specs<> "$GITHUB_OUTPUT" echo "$SPECS" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6354615..f7fde7c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Type check run: mypy apiverity - name: Tests with coverage - run: pytest tests/ -q --cov=apiverity --cov-report=term-missing --cov-fail-under=60 + run: pytest tests/ -q --cov=apiverity --cov-report=term-missing --cov-fail-under=72 - name: Build distribution run: python -m build - name: Dependency scan (pip-audit) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..84a7454 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + name: Build sdist & wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.6.0 + with: + python-version: "3.12" + - name: Install build tool + run: python -m pip install --upgrade pip && pip install build + - name: Build distributions + run: python -m build + - name: Check metadata consistency + run: | + pip install twine + twine check dist/* + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: distributions + path: dist/ + if-no-files-found: error + + pypi-publish: + name: Publish to PyPI (trusted publishing) + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/api-verity-lab + permissions: + id-token: write # OIDC trusted publishing — no API token stored + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Publish + uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 + + github-release: + name: GitHub release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Create release with artifacts + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + gh release create "$TAG" dist/* \ + --title "api-verity-lab $TAG" \ + --generate-notes + + docker-image: + name: Build & push server image to GHCR + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Log in to GHCR + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + - name: Extract image metadata + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: ghcr.io/${{ github.repository }}-server + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} + - name: Build and push + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3f7e62..3e98aec 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: - id: detect-private-key - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.5 + rev: v0.16.4 hooks: - id: ruff args: [--fix] diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 04678b8..ba31a3b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -41,7 +41,13 @@ apiverity/ ├── reports/ terminal/json/yaml/markdown/junit/sarif/html reporters ├── exporters/ .apiverity bundle writer with checksums ├── plugins/ Plugin loader + versioned plugin API protocols -└── cli/ Click-based CLI: 18 commands, JSON output, exit codes +├── security/ Defensive security checks + rule packs +├── server/ Self-hosted Flask monolith: api.py (route factory), +│ store.py (SQLite persistence), schema.py (DDL + helpers), +│ decision.py (can-i-deploy), auth/jobs/webhooks +└── cli/ argparse-based CLI: parser in main.py, implementations in + commands/ grouped by lane (governance, testing, runtime, + artifacts, platform) with shared plumbing in commands/common ``` ## Data flow diff --git a/CHANGELOG.md b/CHANGELOG.md index c88590e..16d0949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes. Format based on Keep a Changelog; versions are semver. ## [Unreleased] ### Fixed +- **Console script entry point** pointed at a nonexistent symbol + (`apiverity.cli.main:cli`); installing the package produced an `apiverity` + command that crashed with ImportError. Now `apiverity.cli.main:main`. - GraphQL spec plugin silently loaded **zero operations** from valid SDL: graphql-core node kinds are snake_case (`object_type_definition`) while the loader compared camelCase strings. Root-type fields now normalize correctly @@ -16,9 +19,38 @@ All notable changes. Format based on Keep a Changelog; versions are semver. - CI dependency audit no longer hides failures behind `|| true`. - Lint/format drift under ruff 0.16 normalized; pytest-asyncio loop-scope configured explicitly. +- ARCHITECTURE.md incorrectly described the CLI as Click-based; it is + argparse-based (doc drift). + +### Changed +- Frontend restructured from a single-file app into `components/`, `hooks/` + and domain-grouped `pages/` modules (`overview`, `contract`, `testing`, + `runtime`, `team`) with a central page registry — same behavior, now + maintainable and code-split-ready. +- CLI split into `apiverity/cli/commands/` grouped by product lane + (`common`, `governance`, `testing`, `runtime`, `artifacts`, `platform`); + `apiverity.cli.main` remains the stable entry point and re-exports all + command functions. +- Server store schema extracted into `apiverity/server/schema.py` (DDL, + timestamp/token helpers) and can-i-deploy / auth-fallback logic into + `apiverity/server/decision.py`; `Store` and `create_app` keep their public + signatures and `apiverity.server.api` re-exports the moved helpers. +- Tests organized into `tests/unit/` (pure logic) and `tests/integration/` + (mock server + self-hosted API over live HTTP); CI coverage floor raised + from 60% to 72% (current measured coverage: 76%). +- pre-commit ruff hook bumped to v0.16.4 to match the ruff version used for + formatting in CI; removed dead `_start_mock` helper and stray one-off + maintenance script. ### Added +- Release engineering: tag-triggered GitHub Actions release workflow with + PyPI trusted publishing (OIDC, no stored tokens), signed-off GitHub + Releases with distribution artifacts, and a GHCR container image for the + self-hosted server; repo ships a hardened non-root `Dockerfile` + (healthcheck on `/healthz`, volume-backed SQLite storage) plus + `.dockerignore`. - Protocol-aware compatibility analysis: GraphQL breaking rules plus a + distinct *dangerous-change* category (field additions, return-type relaxation); gRPC/protobuf wire-compatibility rules (RPC removal, message type swaps, scalar wire-type changes, integer-width changes, enum-value diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee70d30 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Self-hosted API Verity Lab server. +# +# Build: docker build -t apiverity-server . +# Run: docker run -p 8090:8090 -v verity-data:/data apiverity-server +# +# The server stores everything in a single SQLite file; mount a volume at +# /data for persistence. Configuration via environment variables: +# VERITY_DB - SQLite database path inside the container (default /data/verity.db) +# VERITY_PORT - listen port (default 8090) + +FROM python:3.12-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY pyproject.toml README.md LICENSE NOTICE ./ +COPY apiverity ./apiverity + +RUN pip install . + +RUN useradd --system --create-home --uid 10001 verity \ + && mkdir -p /data && chown verity:verity /data +USER verity + +ENV VERITY_DB=/data/verity.db \ + VERITY_PORT=8090 + +EXPOSE 8090 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ.get('VERITY_PORT','8090') + '/healthz', timeout=2)" + +CMD ["python", "-c", "import os; from apiverity.server import Store, create_app; app = create_app(Store(os.environ.get('VERITY_DB', '/data/verity.db'))); app.run(host='0.0.0.0', port=int(os.environ.get('VERITY_PORT', '8090')))"] diff --git a/apiverity/cli/commands/__init__.py b/apiverity/cli/commands/__init__.py new file mode 100644 index 0000000..9cdfa36 --- /dev/null +++ b/apiverity/cli/commands/__init__.py @@ -0,0 +1 @@ +"""Versioned CLI command implementations, grouped by product lane.""" diff --git a/apiverity/cli/commands/artifacts.py b/apiverity/cli/commands/artifacts.py new file mode 100644 index 0000000..ca3a791 --- /dev/null +++ b/apiverity/cli/commands/artifacts.py @@ -0,0 +1,161 @@ +"""Artifact commands: report rendering, bundle export, local serving.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from apiverity.cli.commands.common import EXIT_OK, EXIT_USAGE, NL, _emit + + +def cmd_report(args: argparse.Namespace) -> int: + """Render a bundle's result.json in the requested format.""" + result_path = Path(args.bundle) / "result.json" + if not result_path.exists(): + print(f"error: no result.json in {args.bundle}", file=sys.stderr) + return EXIT_USAGE + data = json.loads(result_path.read_text(encoding="utf-8")) + fmt = args.format + if fmt == "json": + print(json.dumps(data, indent=2)) + elif fmt == "markdown": + lines = [f"# apiverity report — {data.get('command', '?')}"] + for k, v in data.items(): + if k not in ("results", "findings"): + lines.append(f"- **{k}**: {v}") + print(NL.join(lines)) + elif fmt == "junit": + failures = data.get("failed", data.get("errors", 0)) + total = data.get("total", 0) + print('') + print(f'') + print("") + elif fmt == "yaml": + import yaml + + print(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) + elif fmt == "html": + rows = "" + for f in data.get("findings", []): + sev = str(f.get("severity", "INFO")) + color = {"ERROR": "#e5484d", "WARN": "#f5a623"}.get(sev, "#3b82f6") + rows += ( + f"{f.get('rule_id', '')}" + f"{sev}" + f"{f.get('message', '')}" + ) + print( + "" + "apiverity report" + "

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", []) + ], + } + ], + } + print(json.dumps(sarif, indent=2)) + else: + print(f"error: unknown format '{fmt}'", file=sys.stderr) + return EXIT_USAGE + return EXIT_OK + + +def cmd_export(args: argparse.Namespace) -> int: + """Write a .apiverity bundle: result.json, contract snapshot+hash, + config, sanitized failing cases, workflow manifests, performance + summary and SHA256 checksums.""" + import hashlib + + 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} + ) + + if args.spec: + spec_bytes = Path(args.spec).read_bytes() + (out / "contract-snapshot").write_bytes(spec_bytes) + payload["contract_hash"] = hashlib.sha256(spec_bytes).hexdigest() + payload["contract_snapshot"] = "contract-snapshot" + if args.config: + (out / "config.yaml").write_text( + 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" + ) + if args.perf: + (out / "performance-summary.json").write_text( + 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") + + (out / "result.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + checksums = {} + for f in sorted(out.iterdir()): + 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, + ) + return EXIT_OK + + +def cmd_serve(args: argparse.Namespace) -> int: + """Serve a result bundle (or web/dist) on localhost.""" + import functools + from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + + root = Path(args.directory) + handler = functools.partial(SimpleHTTPRequestHandler, directory=str(root)) + httpd = ThreadingHTTPServer(("127.0.0.1", args.port), handler) + print(f"serving {root} at http://127.0.0.1:{args.port} (Ctrl+C to stop)") + try: + httpd.serve_forever() + except KeyboardInterrupt: + pass + finally: + httpd.server_close() + return EXIT_OK diff --git a/apiverity/cli/commands/common.py b/apiverity/cli/commands/common.py new file mode 100644 index 0000000..c06818c --- /dev/null +++ b/apiverity/cli/commands/common.py @@ -0,0 +1,91 @@ +"""Shared CLI plumbing: stable exit codes, spec loading, result emission.""" + +from __future__ import annotations + +import argparse +import json +import sys +from typing import TYPE_CHECKING, Any + +NL = chr(10) + +EXIT_OK = 0 +EXIT_FINDINGS = 1 +EXIT_USAGE = 2 +EXIT_UNREACHABLE = 3 +EXIT_INTERNAL = 4 + +if TYPE_CHECKING: + from apiverity.core.model import Finding, Service + from apiverity.specs import SpecPlugin + +_LAST_SPEC: str | None = None +_LAST_TARGET: str | None = None +_LAST_SEED: int | None = None + + +def set_last_target(target: str | None) -> None: + """Record the most recent base URL for artifact enrichment.""" + global _LAST_TARGET + _LAST_TARGET = target + + +def set_last_seed(seed: int | None) -> None: + """Record the most recent generation seed for artifact enrichment.""" + global _LAST_SEED + _LAST_SEED = seed + + +def _load(path: str) -> tuple[Service, list[Finding], SpecPlugin]: + from apiverity.specs import UnrecognizedSpecError + from apiverity.specs.loader import detect_and_load + + global _LAST_SPEC + _LAST_SPEC = path + try: + return detect_and_load(path) + except FileNotFoundError: + print(f"error: file not found: {path}", file=sys.stderr) + sys.exit(EXIT_USAGE) + except UnrecognizedSpecError as exc: + # Not a contract at all -- distinct from a contract that fails to parse. + # Say so explicitly so the caller can tell "skip this file" from + # "this contract is broken"; a CI gate scanning a mixed directory + # depends on that distinction. + print(f"error: not an API contract: {exc}", file=sys.stderr) + print( + "hint: pass an OpenAPI, Swagger 2.0, GraphQL, gRPC or AsyncAPI " + "document, or point the gate at your contract directory.", + file=sys.stderr, + ) + sys.exit(EXIT_USAGE) + except Exception as exc: + print(f"error: failed to load spec: {exc}", file=sys.stderr) + sys.exit(EXIT_USAGE) + + +def _pair(args: argparse.Namespace) -> tuple[Service, Service]: + old_service, _, _ = _load(args.old) + new_service, _, _ = _load(args.new) + return old_service, new_service + + +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) + if as_json: + print(json.dumps(data, indent=2, default=str)) + else: + for key, value in data.items(): + if isinstance(value, list) and value and hasattr(value[0], "model_dump"): + 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', ''))}" + ) + else: + print(f"{key}: {value}") diff --git a/apiverity/cli/commands/governance.py b/apiverity/cli/commands/governance.py new file mode 100644 index 0000000..ef66d72 --- /dev/null +++ b/apiverity/cli/commands/governance.py @@ -0,0 +1,118 @@ +"""Governance lane commands: validate, diff, breaking, changelog.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from apiverity.cli.commands.common import ( + EXIT_FINDINGS, + EXIT_OK, + _emit, + _load, + _pair, +) + + +def cmd_validate(args: argparse.Namespace) -> int: + service, findings, plugin = _load(args.spec) + from apiverity.security import run_security_checks + + sec = run_security_checks(service) + 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, + } + _emit(data, args.json) + return EXIT_FINDINGS if errors else EXIT_OK + + +def cmd_diff(args: argparse.Namespace) -> int: + from apiverity.diff.engine import diff_services + + 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, + ) + return EXIT_OK + + +def cmd_breaking(args: argparse.Namespace) -> int: + from apiverity.diff.engine import diff_services + from apiverity.rules.breaking import evaluate_breaking + from apiverity.rules.semver import SemverPolicy + + old, new = _pair(args) + changes = diff_services(old, new) + overrides = {} + if args.severity_override: + for item in args.severity_override: + rule_id, _, sev = item.partition("=") + overrides[rule_id] = sev.upper() + findings = evaluate_breaking(changes, overrides or None) + # whole-contract HTTP compatibility + protocol-specific (GraphQL/gRPC) rules + from apiverity.diff.compat import analyze_compat + from apiverity.diff.protocol_compat import analyze_protocol_compat + + findings = findings + analyze_compat(old, new) + analyze_protocol_compat(old, new) + 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, + ) + 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, + ) + return EXIT_FINDINGS if errors else EXIT_OK + + +def cmd_changelog(args: argparse.Namespace) -> int: + from apiverity.diff.engine import diff_services + from apiverity.rules.breaking import evaluate_breaking + from apiverity.rules.changelog import generate_changelog + + 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", + ) + if args.output: + Path(args.output).write_text(text, encoding="utf-8") + else: + print(text) + return EXIT_OK diff --git a/apiverity/cli/commands/platform.py b/apiverity/cli/commands/platform.py new file mode 100644 index 0000000..81f4e14 --- /dev/null +++ b/apiverity/cli/commands/platform.py @@ -0,0 +1,122 @@ +"""Platform commands: server-db administration, plugins, rules, self-test.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from apiverity.cli.commands.common import ( + EXIT_INTERNAL, + EXIT_OK, + EXIT_USAGE, + _emit, + _load, +) + + +def cmd_server_db(args: argparse.Namespace) -> int: + """Administer a self-hosted server SQLite database. + + Actions: backup (consistent snapshot), restore (from a snapshot), + export (org JSON snapshot without token hashes), import (snapshot as a + new org). + """ + from apiverity.server.store import Store + + action = args.action + if action == "backup": + store = Store(args.db) + out = store.backup_to(args.output) + _emit( + {"tool": "apiverity", "command": "server-db", "action": "backup", "output": str(out)}, + args.json, + ) + return EXIT_OK + if action == "restore": + store = Store.restore_from(args.db, target=args.output) + orgs = store.conn.execute("SELECT COUNT(*) FROM orgs").fetchone()[0] + store.close() + _emit( + { + "tool": "apiverity", + "command": "server-db", + "action": "restore", + "target": args.output, + "orgs_restored": int(orgs), + }, + args.json, + ) + return EXIT_OK + if action == "export": + store = Store(args.db) + snap = store.export_org(int(args.org_id)) + Path(args.output).write_text(json.dumps(snap, indent=2), encoding="utf-8") + _emit( + { + "tool": "apiverity", + "command": "server-db", + "action": "export", + "org_id": int(args.org_id), + "output": args.output, + }, + args.json, + ) + return EXIT_OK + if action == "import": + store = Store(args.db) + snap = json.loads(Path(args.input).read_text(encoding="utf-8")) + new_org = store.import_org(snap) + _emit( + { + "tool": "apiverity", + "command": "server-db", + "action": "import", + "new_org_id": new_org, + }, + args.json, + ) + return EXIT_OK + print(f"error: unknown action '{action}'", file=sys.stderr) + return EXIT_USAGE + + +def cmd_plugins(args: argparse.Namespace) -> int: + from apiverity.plugins.registry import list_entry_points + + groups = list_entry_points() + _emit({"tool": "apiverity", "command": "plugins", "groups": groups}, args.json) + return EXIT_OK + + +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) + return EXIT_OK + + +def cmd_self_test(args: argparse.Namespace) -> int: + """Run built-in sanity checks against bundled fixtures.""" + fixture = Path(__file__).parents[3] / "fixtures" / "apis" / "crud" / "openapi.yaml" + if not fixture.exists(): + print("self-test: fixtures missing", file=sys.stderr) + 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, + ) + return EXIT_OK if ok else EXIT_INTERNAL diff --git a/apiverity/cli/commands/runtime.py b/apiverity/cli/commands/runtime.py new file mode 100644 index 0000000..0d0cbd6 --- /dev/null +++ b/apiverity/cli/commands/runtime.py @@ -0,0 +1,108 @@ +"""Runtime lane commands: drift, replay, baseline, regression.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from apiverity.cli.commands.common import ( + EXIT_FINDINGS, + EXIT_OK, + EXIT_UNREACHABLE, + EXIT_USAGE, + _emit, + _load, + set_last_target, +) + + +def cmd_drift(args: argparse.Namespace) -> int: + from apiverity.runtime.drift import detect_drift + + service, _, _ = _load(args.spec) + set_last_target(args.base_url) + try: + report = detect_drift(service, args.base_url, timeout=args.timeout) + 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) + return EXIT_FINDINGS if report.findings else EXIT_OK + + +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 + + 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"], + ) + ) + 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, + ) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return EXIT_USAGE + _emit({"tool": "apiverity", "command": "replay", "report": report}, args.json) + return EXIT_OK + + +def cmd_baseline(args: argparse.Namespace) -> int: + from apiverity.performance.engine import measure + + service, _, _ = _load(args.spec) + try: + report = measure(service, args.base_url, iterations=args.iterations) + except Exception as exc: + print(f"error: target unreachable: {exc}", file=sys.stderr) + return EXIT_UNREACHABLE + set_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, + ) + return EXIT_OK + + +def cmd_regression(args: argparse.Namespace) -> int: + from apiverity.performance.engine import compare_baseline, evaluate_policies, measure + + service, _, _ = _load(args.spec) + try: + report = measure(service, args.base_url, iterations=args.iterations) + except Exception as exc: + print(f"error: target unreachable: {exc}", file=sys.stderr) + return EXIT_UNREACHABLE + violations = evaluate_policies(report, args.policy or []) + if args.baseline: + 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, + ) + return EXIT_FINDINGS if violations else EXIT_OK diff --git a/apiverity/cli/commands/testing.py b/apiverity/cli/commands/testing.py new file mode 100644 index 0000000..a1a5bcd --- /dev/null +++ b/apiverity/cli/commands/testing.py @@ -0,0 +1,105 @@ +"""Testing lane commands: test, workflow, mock, coverage.""" + +from __future__ import annotations + +import argparse +import sys + +from apiverity.cli.commands.common import ( + EXIT_FINDINGS, + EXIT_OK, + EXIT_UNREACHABLE, + EXIT_USAGE, + _emit, + _load, + set_last_seed, + set_last_target, +) + + +def cmd_test(args: argparse.Namespace) -> int: + from apiverity.fuzz.minimize import minimize_failures + from apiverity.fuzz.runner import build_cases, run_cases + + service, _, _ = _load(args.spec) + set_last_target(args.base_url) + set_last_seed(args.seed) + cases = build_cases(service, seed=args.seed) + try: + results = run_cases(service, args.base_url, cases, timeout=args.timeout) + 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, + ) + return EXIT_FINDINGS if failures else EXIT_OK + + +def cmd_workflow(args: argparse.Namespace) -> int: + 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) + 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: + _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 + + +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, + ) + host = "127.0.0.1" # always localhost by default + serve(service, host=host, port=args.port, faults=faults) + return EXIT_OK + + +def cmd_coverage(args: argparse.Namespace) -> int: + from apiverity.coverage import measure_coverage + + 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, + ) + return EXIT_OK diff --git a/apiverity/cli/main.py b/apiverity/cli/main.py index 771e9ef..0bddff1 100644 --- a/apiverity/cli/main.py +++ b/apiverity/cli/main.py @@ -3,626 +3,67 @@ All commands support ``--json`` and stable exit codes: 0 ok · 1 findings at/above threshold · 2 usage error · 3 target unreachable · 4 internal error. + +Command implementations live in ``apiverity.cli.commands``, grouped by +product lane; this module owns argument parsing and process exit codes. """ from __future__ import annotations import argparse -import json import sys -from pathlib import Path -from typing import TYPE_CHECKING, Any - -NL = chr(10) - -EXIT_OK = 0 -EXIT_FINDINGS = 1 -EXIT_USAGE = 2 -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) -> tuple[Service, list[Finding], SpecPlugin]: - from apiverity.specs.loader import detect_and_load - - global _LAST_SPEC - _LAST_SPEC = path - try: - return detect_and_load(path) - except FileNotFoundError: - print(f"error: file not found: {path}", file=sys.stderr) - sys.exit(EXIT_USAGE) - except Exception as exc: - print(f"error: failed to load spec: {exc}", file=sys.stderr) - sys.exit(EXIT_USAGE) - - -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) - if as_json: - print(json.dumps(data, indent=2, default=str)) - else: - for key, value in data.items(): - if isinstance(value, list) and value and hasattr(value[0], "model_dump"): - 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', ''))}" - ) - else: - print(f"{key}: {value}") - - -def cmd_validate(args: argparse.Namespace) -> int: - service, findings, plugin = _load(args.spec) - from apiverity.security import run_security_checks - - sec = run_security_checks(service) - 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, - } - _emit(data, args.json) - return EXIT_FINDINGS if errors else EXIT_OK - - -def _pair(args: argparse.Namespace) -> tuple[Service, Service]: - old_service, _, _ = _load(args.old) - new_service, _, _ = _load(args.new) - return old_service, new_service - - -def cmd_diff(args: argparse.Namespace) -> int: - from apiverity.diff.engine import diff_services - - 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, - ) - return EXIT_OK - - -def cmd_breaking(args: argparse.Namespace) -> int: - from apiverity.diff.engine import diff_services - from apiverity.rules.breaking import evaluate_breaking - from apiverity.rules.semver import SemverPolicy - - old, new = _pair(args) - changes = diff_services(old, new) - overrides = {} - if args.severity_override: - for item in args.severity_override: - rule_id, _, sev = item.partition("=") - overrides[rule_id] = sev.upper() - findings = evaluate_breaking(changes, overrides or None) - # whole-contract HTTP compatibility + protocol-specific (GraphQL/gRPC) rules - from apiverity.diff.compat import analyze_compat - from apiverity.diff.protocol_compat import analyze_protocol_compat - - findings = findings + analyze_compat(old, new) + analyze_protocol_compat(old, new) - 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, - ) - 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, - ) - return EXIT_FINDINGS if errors else EXIT_OK - - -def cmd_changelog(args: argparse.Namespace) -> int: - from apiverity.diff.engine import diff_services - from apiverity.rules.breaking import evaluate_breaking - from apiverity.rules.changelog import generate_changelog - - 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", - ) - if args.output: - Path(args.output).write_text(text, encoding="utf-8") - else: - print(text) - return EXIT_OK - - -def _start_mock(spec_path: str, port: int) -> MockServer: - from apiverity.mock import MockServer - - service, _, _ = _load(spec_path) - server = MockServer(service, port=port) - server.start() - return server - - -def cmd_test(args: argparse.Namespace) -> int: - from apiverity.fuzz.minimize import minimize_failures - from apiverity.fuzz.runner import build_cases, run_cases - - global _LAST_TARGET, _LAST_SEED - service, _, _ = _load(args.spec) - _LAST_TARGET = args.base_url - _LAST_SEED = args.seed - cases = build_cases(service, seed=args.seed) - try: - results = run_cases(service, args.base_url, cases, timeout=args.timeout) - 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, - ) - return EXIT_FINDINGS if failures else EXIT_OK - - -def cmd_workflow(args: argparse.Namespace) -> int: - 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) - 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: - _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 - - -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, - ) - host = "127.0.0.1" # always localhost by default - serve(service, host=host, port=args.port, faults=faults) - return EXIT_OK - - -def cmd_coverage(args: argparse.Namespace) -> int: - from apiverity.coverage import measure_coverage - - 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, - ) - return EXIT_OK - - -def cmd_drift(args: argparse.Namespace) -> int: - from apiverity.runtime.drift import detect_drift - - global _LAST_TARGET - service, _, _ = _load(args.spec) - _LAST_TARGET = args.base_url - try: - report = detect_drift(service, args.base_url, timeout=args.timeout) - 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) - return EXIT_FINDINGS if report.findings else EXIT_OK - - -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 - - 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"], - ) - ) - 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, - ) - except ValueError as exc: - print(f"error: {exc}", file=sys.stderr) - return EXIT_USAGE - _emit({"tool": "apiverity", "command": "replay", "report": report}, args.json) - return EXIT_OK - - -def cmd_baseline(args: argparse.Namespace) -> int: - from apiverity.performance.engine import measure - - service, _, _ = _load(args.spec) - try: - report = measure(service, args.base_url, iterations=args.iterations) - 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, - ) - return EXIT_OK - - -def cmd_regression(args: argparse.Namespace) -> int: - from apiverity.performance.engine import compare_baseline, evaluate_policies, measure - - service, _, _ = _load(args.spec) - try: - report = measure(service, args.base_url, iterations=args.iterations) - except Exception as exc: - print(f"error: target unreachable: {exc}", file=sys.stderr) - return EXIT_UNREACHABLE - violations = evaluate_policies(report, args.policy or []) - if args.baseline: - 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, - ) - return EXIT_FINDINGS if violations else EXIT_OK - - -def cmd_report(args: argparse.Namespace) -> int: - """Render a bundle's result.json in the requested format.""" - result_path = Path(args.bundle) / "result.json" - if not result_path.exists(): - print(f"error: no result.json in {args.bundle}", file=sys.stderr) - return EXIT_USAGE - data = json.loads(result_path.read_text(encoding="utf-8")) - fmt = args.format - if fmt == "json": - print(json.dumps(data, indent=2)) - elif fmt == "markdown": - lines = [f"# apiverity report — {data.get('command', '?')}"] - for k, v in data.items(): - if k not in ("results", "findings"): - lines.append(f"- **{k}**: {v}") - print(NL.join(lines)) - elif fmt == "junit": - failures = data.get("failed", data.get("errors", 0)) - total = data.get("total", 0) - print('') - print(f'') - print("") - elif fmt == "yaml": - import yaml - - print(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) - elif fmt == "html": - rows = "" - for f in data.get("findings", []): - sev = str(f.get("severity", "INFO")) - color = {"ERROR": "#e5484d", "WARN": "#f5a623"}.get(sev, "#3b82f6") - rows += ( - f"{f.get('rule_id', '')}" - f"{sev}" - f"{f.get('message', '')}" - ) - print( - "" - "apiverity report" - "

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", []) - ], - } - ], - } - print(json.dumps(sarif, indent=2)) - else: - print(f"error: unknown format '{fmt}'", file=sys.stderr) - return EXIT_USAGE - return EXIT_OK - - -def cmd_export(args: argparse.Namespace) -> int: - """Write a .apiverity bundle: result.json, contract snapshot+hash, - config, sanitized failing cases, workflow manifests, performance - summary and SHA256 checksums.""" - import hashlib - - 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} - ) - - if args.spec: - spec_bytes = Path(args.spec).read_bytes() - (out / "contract-snapshot").write_bytes(spec_bytes) - payload["contract_hash"] = hashlib.sha256(spec_bytes).hexdigest() - payload["contract_snapshot"] = "contract-snapshot" - if args.config: - (out / "config.yaml").write_text( - 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" - ) - if args.perf: - (out / "performance-summary.json").write_text( - 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") - - (out / "result.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") - checksums = {} - for f in sorted(out.iterdir()): - 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, - ) - return EXIT_OK - - -def cmd_serve(args: argparse.Namespace) -> int: - """Serve a result bundle (or web/dist) on localhost.""" - import functools - from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer - - root = Path(args.directory) - handler = functools.partial(SimpleHTTPRequestHandler, directory=str(root)) - httpd = ThreadingHTTPServer(("127.0.0.1", args.port), handler) - print(f"serving {root} at http://127.0.0.1:{args.port} (Ctrl+C to stop)") - try: - httpd.serve_forever() - except KeyboardInterrupt: - pass - finally: - httpd.server_close() - return EXIT_OK - - -def cmd_server_db(args: argparse.Namespace) -> int: - """Administer a self-hosted server SQLite database. - - Actions: backup (consistent snapshot), restore (from a snapshot), - export (org JSON snapshot without token hashes), import (snapshot as a - new org). - """ - from apiverity.server.store import Store - - action = args.action - if action == "backup": - store = Store(args.db) - out = store.backup_to(args.output) - _emit( - {"tool": "apiverity", "command": "server-db", "action": "backup", "output": str(out)}, - args.json, - ) - return EXIT_OK - if action == "restore": - store = Store.restore_from(args.db, target=args.output) - orgs = store.conn.execute("SELECT COUNT(*) FROM orgs").fetchone()[0] - store.close() - _emit( - { - "tool": "apiverity", - "command": "server-db", - "action": "restore", - "target": args.output, - "orgs_restored": int(orgs), - }, - args.json, - ) - return EXIT_OK - if action == "export": - store = Store(args.db) - snap = store.export_org(int(args.org_id)) - Path(args.output).write_text(json.dumps(snap, indent=2), encoding="utf-8") - _emit( - { - "tool": "apiverity", - "command": "server-db", - "action": "export", - "org_id": int(args.org_id), - "output": args.output, - }, - args.json, - ) - return EXIT_OK - if action == "import": - store = Store(args.db) - snap = json.loads(Path(args.input).read_text(encoding="utf-8")) - new_org = store.import_org(snap) - _emit( - { - "tool": "apiverity", - "command": "server-db", - "action": "import", - "new_org_id": new_org, - }, - args.json, - ) - return EXIT_OK - print(f"error: unknown action '{action}'", file=sys.stderr) - return EXIT_USAGE - - -def cmd_plugins(args: argparse.Namespace) -> int: - from apiverity.plugins.registry import list_entry_points - - groups = list_entry_points() - _emit({"tool": "apiverity", "command": "plugins", "groups": groups}, args.json) - return EXIT_OK - - -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) - return EXIT_OK - - -def cmd_self_test(args: argparse.Namespace) -> int: - """Run built-in sanity checks against bundled fixtures.""" - fixture = Path(__file__).parents[2] / "fixtures" / "apis" / "crud" / "openapi.yaml" - if not fixture.exists(): - print("self-test: fixtures missing", file=sys.stderr) - 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, - ) - return EXIT_OK if ok else EXIT_INTERNAL +from typing import Any + +from apiverity.cli.commands.artifacts import cmd_export, cmd_report, cmd_serve +from apiverity.cli.commands.common import EXIT_INTERNAL, EXIT_OK +from apiverity.cli.commands.governance import ( + cmd_breaking, + cmd_changelog, + cmd_diff, + cmd_validate, +) +from apiverity.cli.commands.platform import ( + cmd_plugins, + cmd_rules, + cmd_self_test, + cmd_server_db, +) +from apiverity.cli.commands.runtime import ( + cmd_baseline, + cmd_drift, + cmd_regression, + cmd_replay, +) +from apiverity.cli.commands.testing import ( + cmd_coverage, + cmd_mock, + cmd_test, + cmd_workflow, +) + +__all__ = [ + "build_parser", + "cmd_baseline", + "cmd_breaking", + "cmd_changelog", + "cmd_coverage", + "cmd_diff", + "cmd_drift", + "cmd_export", + "cmd_mock", + "cmd_plugins", + "cmd_regression", + "cmd_replay", + "cmd_report", + "cmd_rules", + "cmd_self_test", + "cmd_serve", + "cmd_server_db", + "cmd_test", + "cmd_validate", + "cmd_workflow", + "main", +] def build_parser() -> argparse.ArgumentParser: diff --git a/apiverity/server/api.py b/apiverity/server/api.py index 1243508..76b2aaa 100644 --- a/apiverity/server/api.py +++ b/apiverity/server/api.py @@ -14,8 +14,11 @@ from flask import Flask, Response, g, jsonify, request from apiverity.server.auth import Identity, IdentityProvider, LocalTokenProvider, authorize +from apiverity.server.decision import authenticate_safe, compute_can_i_deploy from apiverity.server.store import Store +__all__ = ["authenticate_safe", "compute_can_i_deploy", "create_app"] + _METRICS = { "requests_total": 0, "errors_total": 0, @@ -492,60 +495,3 @@ def list_webhooks() -> Any: return jsonify(store.list_webhooks(g.identity.org_id)) return app - - -def authenticate_safe(providers: list[IdentityProvider], token: str) -> Identity | None: - from apiverity.server.auth import authenticate - - if not token: - return None - try: - return authenticate(providers, token) - except Exception: - return None - - -def compute_can_i_deploy(store: Store, org_id: int, body: dict[str, Any]) -> dict[str, Any]: - """Pact-broker-style decision from verifications recorded in runs. - - Body: provider, provider_version, consumer, consumer_version(optional), - environment. A provider version is deployable to an environment when a - successful verification run exists for the latest consumer contract - published against it targeting that environment. - """ - provider = body["provider"] - version = body["provider_version"] - environment = body.get("environment", "") - contracts = store.list_contracts(org_id, title=provider) - target = next((c for c in contracts if c["version"] == version), None) - if target is None: - return {"deployable": False, "reason": f"{provider}@{version} has never been published"} - - verifications = [] - for run in _all_runs(store, org_id): - if ( - run.get("verification_for") == f"{provider}@{version}" - and run.get("environment") == environment - and run.get("status") == "passed" - ): - verifications.append(run) - if not verifications: - return { - "deployable": False, - "reason": f"no passed verification of {provider}@{version} against {environment!r}", - } - return { - "deployable": True, - "reason": f"{len(verifications)} passed verification(s) recorded", - "verified_by": sorted({v["requested_by"] for v in verifications}), - } - - -def _all_runs(store: Store, org_id: int) -> list[dict[str, Any]]: - rows = store.conn.execute("SELECT * FROM runs WHERE org_id = ?", (org_id,)).fetchall() - out = [] - for r in rows: - d = dict(r) - d["result"] = json.loads(d["result_json"]) if d.pop("result_json") else None - out.append(d) - return out diff --git a/apiverity/server/decision.py b/apiverity/server/decision.py new file mode 100644 index 0000000..72a29d8 --- /dev/null +++ b/apiverity/server/decision.py @@ -0,0 +1,70 @@ +"""Deploy-decision and auth fallback helpers for the self-hosted API. + +Extracted from ``api.py`` so can-i-deploy reasoning and authentication error +handling can be exercised without standing up a Flask app. +""" + +from __future__ import annotations + +import json +from typing import Any + +from apiverity.server.auth import Identity, IdentityProvider +from apiverity.server.store import Store + + +def authenticate_safe(providers: list[IdentityProvider], token: str) -> Identity | None: + from apiverity.server.auth import authenticate + + if not token: + return None + try: + return authenticate(providers, token) + except Exception: + return None + + +def compute_can_i_deploy(store: Store, org_id: int, body: dict[str, Any]) -> dict[str, Any]: + """Pact-broker-style decision from verifications recorded in runs. + + Body: provider, provider_version, consumer, consumer_version(optional), + environment. A provider version is deployable to an environment when a + successful verification run exists for the latest consumer contract + published against it targeting that environment. + """ + provider = body["provider"] + version = body["provider_version"] + environment = body.get("environment", "") + contracts = store.list_contracts(org_id, title=provider) + target = next((c for c in contracts if c["version"] == version), None) + if target is None: + return {"deployable": False, "reason": f"{provider}@{version} has never been published"} + + verifications = [] + for run in _all_runs(store, org_id): + if ( + run.get("verification_for") == f"{provider}@{version}" + and run.get("environment") == environment + and run.get("status") == "passed" + ): + verifications.append(run) + if not verifications: + return { + "deployable": False, + "reason": f"no passed verification of {provider}@{version} against {environment!r}", + } + return { + "deployable": True, + "reason": f"{len(verifications)} passed verification(s) recorded", + "verified_by": sorted({v["requested_by"] for v in verifications}), + } + + +def _all_runs(store: Store, org_id: int) -> list[dict[str, Any]]: + rows = store.conn.execute("SELECT * FROM runs WHERE org_id = ?", (org_id,)).fetchall() + out = [] + for r in rows: + d = dict(r) + d["result"] = json.loads(d["result_json"]) if d.pop("result_json") else None + out.append(d) + return out diff --git a/apiverity/server/schema.py b/apiverity/server/schema.py new file mode 100644 index 0000000..c9db4c7 --- /dev/null +++ b/apiverity/server/schema.py @@ -0,0 +1,141 @@ +"""Explicit SQLite schema and helpers for the self-hosted server store. + +Kept separate from :class:`apiverity.server.store.Store` so the DDL can be +inspected or migrated without instantiating a connection. No ORM dependency, +by design. All timestamps are UTC ISO-8601. +""" + +from __future__ import annotations + +import hashlib +from datetime import UTC, datetime + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS orgs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + subject TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL CHECK (role IN ('owner','admin','member','viewer')), + kind TEXT NOT NULL DEFAULT 'user' CHECK (kind IN ('user','service_account')), + token_hash TEXT, + UNIQUE (org_id, subject) +); +CREATE TABLE IF NOT EXISTS contracts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + title TEXT NOT NULL, + version TEXT NOT NULL, + protocol TEXT NOT NULL, + checksum TEXT NOT NULL, + spec_json TEXT NOT NULL, + published_by TEXT NOT NULL, + published_at TEXT NOT NULL, + superseded_by INTEGER, + UNIQUE (org_id, title, version) +); +CREATE TABLE IF NOT EXISTS findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contract_id INTEGER NOT NULL REFERENCES contracts(id), + rule_id TEXT NOT NULL, + severity TEXT NOT NULL, + message TEXT NOT NULL, + operation_key TEXT, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + requested_by TEXT NOT NULL, + result_json TEXT, + verification_for TEXT, + environment TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS environments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + name TEXT NOT NULL, + base_url TEXT NOT NULL, + safety_class TEXT NOT NULL DEFAULT 'dev', + owner TEXT, + allowed_modes TEXT NOT NULL DEFAULT 'read-only', + UNIQUE (org_id, name) +); +CREATE TABLE IF NOT EXISTS policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + name TEXT NOT NULL, + content TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (org_id, name) +); +CREATE TABLE IF NOT EXISTS approvals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + contract_title TEXT NOT NULL, + from_version TEXT NOT NULL, + to_version TEXT NOT NULL, + justification TEXT NOT NULL, + migration_guide TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')), + requested_by TEXT NOT NULL, + decided_by TEXT, + created_at TEXT NOT NULL, + decided_at TEXT +); +CREATE TABLE IF NOT EXISTS audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + ts TEXT NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + prev_hash TEXT NOT NULL DEFAULT '', + entry_hash TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + url TEXT NOT NULL, + secret_ref TEXT NOT NULL, + events TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS workers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + org_id INTEGER NOT NULL REFERENCES orgs(id), + name TEXT NOT NULL, + labels TEXT NOT NULL DEFAULT '[]', + capacity INTEGER NOT NULL DEFAULT 1, + last_seen TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + UNIQUE (org_id, name) +); +CREATE TABLE IF NOT EXISTS run_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL REFERENCES runs(id), + ts TEXT NOT NULL, + message TEXT NOT NULL, + pct INTEGER +); +CREATE INDEX IF NOT EXISTS idx_runs_org_status ON runs(org_id, status); +""" + + +def now_utc() -> str: + return datetime.now(UTC).isoformat() + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/apiverity/server/store.py b/apiverity/server/store.py index c3e1bd1..1a439df 100644 --- a/apiverity/server/store.py +++ b/apiverity/server/store.py @@ -15,135 +15,9 @@ from pathlib import Path from typing import Any -_SCHEMA = """ -CREATE TABLE IF NOT EXISTS orgs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - created_at TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - subject TEXT NOT NULL, - display_name TEXT NOT NULL DEFAULT '', - role TEXT NOT NULL CHECK (role IN ('owner','admin','member','viewer')), - kind TEXT NOT NULL DEFAULT 'user' CHECK (kind IN ('user','service_account')), - token_hash TEXT, - UNIQUE (org_id, subject) -); -CREATE TABLE IF NOT EXISTS contracts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - title TEXT NOT NULL, - version TEXT NOT NULL, - protocol TEXT NOT NULL, - checksum TEXT NOT NULL, - spec_json TEXT NOT NULL, - published_by TEXT NOT NULL, - published_at TEXT NOT NULL, - superseded_by INTEGER, - UNIQUE (org_id, title, version) -); -CREATE TABLE IF NOT EXISTS findings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contract_id INTEGER NOT NULL REFERENCES contracts(id), - rule_id TEXT NOT NULL, - severity TEXT NOT NULL, - message TEXT NOT NULL, - operation_key TEXT, - created_at TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - kind TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'queued', - requested_by TEXT NOT NULL, - result_json TEXT, - verification_for TEXT, - environment TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS environments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - name TEXT NOT NULL, - base_url TEXT NOT NULL, - safety_class TEXT NOT NULL DEFAULT 'dev', - owner TEXT, - allowed_modes TEXT NOT NULL DEFAULT 'read-only', - UNIQUE (org_id, name) -); -CREATE TABLE IF NOT EXISTS policies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - name TEXT NOT NULL, - content TEXT NOT NULL, - updated_at TEXT NOT NULL, - UNIQUE (org_id, name) -); -CREATE TABLE IF NOT EXISTS approvals ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - contract_title TEXT NOT NULL, - from_version TEXT NOT NULL, - to_version TEXT NOT NULL, - justification TEXT NOT NULL, - migration_guide TEXT, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')), - requested_by TEXT NOT NULL, - decided_by TEXT, - created_at TEXT NOT NULL, - decided_at TEXT -); -CREATE TABLE IF NOT EXISTS audit_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - ts TEXT NOT NULL, - actor TEXT NOT NULL, - action TEXT NOT NULL, - target TEXT NOT NULL, - payload_json TEXT NOT NULL DEFAULT '{}', - prev_hash TEXT NOT NULL DEFAULT '', - entry_hash TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS webhooks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - url TEXT NOT NULL, - secret_ref TEXT NOT NULL, - events TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS workers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - org_id INTEGER NOT NULL REFERENCES orgs(id), - name TEXT NOT NULL, - labels TEXT NOT NULL DEFAULT '[]', - capacity INTEGER NOT NULL DEFAULT 1, - last_seen TEXT NOT NULL, - active INTEGER NOT NULL DEFAULT 1, - UNIQUE (org_id, name) -); -CREATE TABLE IF NOT EXISTS run_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id INTEGER NOT NULL REFERENCES runs(id), - ts TEXT NOT NULL, - message TEXT NOT NULL, - pct INTEGER -); -CREATE INDEX IF NOT EXISTS idx_runs_org_status ON runs(org_id, status); -""" - - -def _now() -> str: - return datetime.now(UTC).isoformat() - - -def _hash_token(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() +from apiverity.server.schema import SCHEMA as _SCHEMA +from apiverity.server.schema import hash_token as _hash_token +from apiverity.server.schema import now_utc as _now class Store: diff --git a/apiverity/specs/__init__.py b/apiverity/specs/__init__.py index 93cd3f6..54bd423 100644 --- a/apiverity/specs/__init__.py +++ b/apiverity/specs/__init__.py @@ -9,6 +9,24 @@ from apiverity.core.model import Finding, Protocol, Service +class UnrecognizedSpecError(ValueError): + """Raised when a source is not an API contract in any known format. + + Deliberately distinct from a parse/validation failure. "This file is not + a contract" and "this contract is broken" are different conditions with + different correct responses: the first should usually be skipped (it is + routine for a repository to contain YAML that is not a spec), the second + is a real failure that should stop a pipeline. Callers that scan mixed + directories -- CI contract gates especially -- need to tell them apart. + """ + + def __init__(self, source: str, tried: list[str] | None = None) -> None: + self.source = source + self.tried = list(tried or []) + detail = f": tried {', '.join(self.tried)}" if self.tried else "" + super().__init__(f"{source!r} is not an API contract in any recognized format{detail}") + + class SpecPlugin(ABC): """Base class for spec adapters. diff --git a/apiverity/specs/loader.py b/apiverity/specs/loader.py index eed00c1..196fd82 100644 --- a/apiverity/specs/loader.py +++ b/apiverity/specs/loader.py @@ -9,7 +9,7 @@ from apiverity.core.model import Finding, Service from apiverity.plugins.registry import PluginRegistry -from apiverity.specs import SpecPlugin, read_source +from apiverity.specs import SpecPlugin, UnrecognizedSpecError, read_source def _builtin_plugins() -> list[SpecPlugin]: @@ -44,4 +44,9 @@ def detect_and_load( return service, findings, plugin except NotImplementedError: continue - raise ValueError(f"no spec plugin could handle '{source}'") + tried: list[str] = [] + for plugin in plugins: + name = plugin.protocol().value + if name not in tried: # OpenAPI and Swagger 2.0 share a protocol value + tried.append(name) + raise UnrecognizedSpecError(source, tried=tried) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 26202e6..3f9e238 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -15,6 +15,18 @@ app.run(port=8090) " ``` +### Docker + +```bash +docker build -t apiverity-server . +docker run -p 8090:8090 -v verity-data:/data apiverity-server +``` + +Configuration via environment variables: `VERITY_DB` (SQLite path inside the +container, default `/data/verity.db`) and `VERITY_PORT` (default `8090`). +Prebuilt images are published to `ghcr.io/webdevsamran/api-verity-lab-server` +on every tagged release (`docker pull ghcr.io/webdevsamran/api-verity-lab-server:latest`). + Endpoints: `/healthz`, `/readyz`, `/metrics`, and `/v1/*` for orgs, users, contracts, findings, runs, environments, policies, approvals, webhooks, can-i-deploy, workers and jobs. RBAC: `owner > admin > member > viewer`; diff --git a/pyproject.toml b/pyproject.toml index d3a1a87..e686364 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ dev = [ ] [project.scripts] -apiverity = "apiverity.cli.main:cli" +apiverity = "apiverity.cli.main:main" [project.entry-points."apiverity.specs"] openapi = "apiverity.specs.openapi:OpenApiSpecPlugin" @@ -91,6 +91,8 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM", "C4", "RUF"] ignore = ["E501"] [tool.mypy] +# Kept at 3.12 deliberately: some shipped stubs (httpx chain) use PEP 695 +# syntax that cannot be parsed when targeting 3.11. python_version = "3.12" strict = true warn_unreachable = true diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..30a816b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,12 @@ +# Test suite layout + +- `unit/` — fast, isolated tests of pure logic (models, rules engines, + generators, plugins, protocol compatibility). No network, no servers. +- `integration/` — tests that exercise real processes: the deterministic + mock server (`MockServer`) and the self-hosted Flask API (`create_app`) + over live HTTP. +- `conftest.py` — shared fixtures (loaded specs from `fixtures/`). + +Run everything with `pytest` from the repository root. The CI pipeline runs +the same suite with coverage enforced (`--cov-fail-under`, see +`.github/workflows/ci.yml`). diff --git a/tests/test_core_pipeline.py b/tests/integration/test_core_pipeline.py similarity index 100% rename from tests/test_core_pipeline.py rename to tests/integration/test_core_pipeline.py diff --git a/tests/test_engines_integration.py b/tests/integration/test_engines_integration.py similarity index 100% rename from tests/test_engines_integration.py rename to tests/integration/test_engines_integration.py diff --git a/tests/test_enterprise_ops.py b/tests/integration/test_enterprise_ops.py similarity index 100% rename from tests/test_enterprise_ops.py rename to tests/integration/test_enterprise_ops.py diff --git a/tests/test_security_packs_and_virtualization.py b/tests/integration/test_security_packs_and_virtualization.py similarity index 100% rename from tests/test_security_packs_and_virtualization.py rename to tests/integration/test_security_packs_and_virtualization.py diff --git a/tests/test_selfhosted_server.py b/tests/integration/test_selfhosted_server.py similarity index 100% rename from tests/test_selfhosted_server.py rename to tests/integration/test_selfhosted_server.py diff --git a/tests/test_generation_and_stateful_v2.py b/tests/unit/test_generation_and_stateful_v2.py similarity index 100% rename from tests/test_generation_and_stateful_v2.py rename to tests/unit/test_generation_and_stateful_v2.py diff --git a/tests/test_governance_engines.py b/tests/unit/test_governance_engines.py similarity index 100% rename from tests/test_governance_engines.py rename to tests/unit/test_governance_engines.py diff --git a/tests/test_model_v2_and_adapters.py b/tests/unit/test_model_v2_and_adapters.py similarity index 100% rename from tests/test_model_v2_and_adapters.py rename to tests/unit/test_model_v2_and_adapters.py diff --git a/tests/test_platform_modules.py b/tests/unit/test_platform_modules.py similarity index 100% rename from tests/test_platform_modules.py rename to tests/unit/test_platform_modules.py diff --git a/tests/test_plugin_v2.py b/tests/unit/test_plugin_v2.py similarity index 100% rename from tests/test_plugin_v2.py rename to tests/unit/test_plugin_v2.py diff --git a/tests/test_protocol_compat.py b/tests/unit/test_protocol_compat.py similarity index 100% rename from tests/test_protocol_compat.py rename to tests/unit/test_protocol_compat.py diff --git a/tests/test_safety_load_drift_trend.py b/tests/unit/test_safety_load_drift_trend.py similarity index 100% rename from tests/test_safety_load_drift_trend.py rename to tests/unit/test_safety_load_drift_trend.py diff --git a/tests/unit/test_spec_recognition.py b/tests/unit/test_spec_recognition.py new file mode 100644 index 0000000..d2088db --- /dev/null +++ b/tests/unit/test_spec_recognition.py @@ -0,0 +1,60 @@ +"""Unrecognized sources must be distinguishable from broken contracts. + +Regression guard for the contract gate: a repository routinely contains YAML +that is not an API spec (tool configs, CI files). Treating those as validation +failures made `api-verity.yml` block its own pull requests. The loader must +report "not a contract" as its own condition so callers can skip rather than +fail. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from apiverity.specs import UnrecognizedSpecError +from apiverity.specs.loader import detect_and_load + +_NOT_A_SPEC = """\ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.4 + hooks: + - id: ruff +""" + +_BROKEN_OPENAPI = """\ +openapi: "3.0.0" +info: {title: X, version: "1"} +paths: {"/a": {get: {responses: {"200": {description: ok +""" + + +def test_non_spec_yaml_raises_unrecognized(tmp_path: Path) -> None: + src = tmp_path / ".pre-commit-config.yaml" + src.write_text(_NOT_A_SPEC, encoding="utf-8") + + with pytest.raises(UnrecognizedSpecError) as excinfo: + detect_and_load(str(src)) + + assert excinfo.value.source == str(src) + assert "openapi" in excinfo.value.tried + # Each protocol is named once: OpenAPI and Swagger 2.0 share a value. + assert len(excinfo.value.tried) == len(set(excinfo.value.tried)) + + +def test_malformed_contract_is_not_reported_as_unrecognized(tmp_path: Path) -> None: + """A broken spec is a real failure, not a file to skip.""" + src = tmp_path / "openapi.yaml" + src.write_text(_BROKEN_OPENAPI, encoding="utf-8") + + with pytest.raises(Exception) as excinfo: + detect_and_load(str(src)) + + assert not isinstance(excinfo.value, UnrecognizedSpecError) + + +def test_unrecognized_is_a_valueerror() -> None: + """Back-compat: existing `except ValueError` handlers keep working.""" + assert issubclass(UnrecognizedSpecError, ValueError) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1318452..503d706 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,656 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { loadData, type DemoData } from './data' -import { navigate, setParam, useRoute } from './router' - -/* ---------- shared UI ---------- */ -const SEV_COLORS: Record = { ERROR: '#e5484d', WARN: '#f5a623', INFO: '#3b82f6' } -const METHOD_COLORS: Record = { - GET: '#3b82f6', POST: '#2ea043', PUT: '#f5a623', PATCH: '#a855f7', DELETE: '#e5484d', -} - -function Badge({ children, color }: { children: React.ReactNode; color?: string }) { - return {children} -} -function SevBadge({ sev }: { sev: string }) { - return {sev} -} -function StatusBadge({ ok }: { ok: boolean }) { - return {ok ? 'pass' : 'fail'} -} -function Empty({ msg }: { msg: string }) { - return
{msg}
-} -function DemoTag() { - return DEMO DATA -} -function PageHead({ title, sub }: { title: string; sub?: string }) { - return ( - <> -

{title} {sub && {sub}}

-

static demo artifacts generated from bundled fixtures — run the CLI for your own APIs.

- - ) -} -function BarChart({ rows }: { rows: { label: string; value: number; max: number }[] }) { - return ( -
- {rows.map((r) => ( -
- {r.label} -
-
-
- {r.value} -
- ))} -
- ) -} -/** Simple windowed list for large tables (virtualization). */ -function VirtualRows({ items, rowHeight = 36, render }: { - items: T[]; rowHeight?: number; render: (item: T, index: number) => React.ReactNode -}) { - const [scrollTop, setScrollTop] = useState(0) - const ref = useRef(null) - const viewport = 480 - if (items.length <= 40) return <>{items.map((it, i) => render(it, i))} - const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5) - const end = Math.min(items.length, start + Math.ceil(viewport / rowHeight) + 10) - return ( -
setScrollTop((e.target as HTMLDivElement).scrollTop)}> -
-
- {items.slice(start, end).map((it, i) => render(it, start + i))} -
-
-
- ) -} -function Filters({ options, active, onPick }: { options: string[]; active: string; onPick: (v: string) => void }) { - return ( -
- {options.map((o) => ( - - ))} -
- ) -} -function CopyCmd({ cmd }: { cmd: string }) { - const [copied, setCopied] = useState(false) - return ( - - ) -} -function useData(): { data: DemoData | null; error: string | null } { - const [data, setData] = useState(null) - const [error, setError] = useState(null) - useEffect(() => { - loadData().then(setData).catch((e) => setError(String(e))) - }, []) - return { data, error } -} - -/* ---------- overview pages ---------- */ -function HomePage({ data }: { data: DemoData | null }) { - if (!data) return - const cards: [string, string | number][] = [ - ['Changes detected', data.diff.changes.length], - ['Breaking findings', data.breaking.findings.filter((f) => f.severity === 'ERROR').length], - ['Test cases', `${data.test.passed}/${data.test.total} passed`], - ['Drift findings', data.drift.findings.length], - ['Contract coverage', `${data.coverage.overall_percent}%`], - ['Services in catalog', data.catalog?.services.length ?? 0], - ] - return ( - <> - -
{cards.map(([k, v]) => ( -
{v}
{k}
- ))}
-

Signature workflows

-
    -
  • apiverity diff / breaking — semantic source-aware comparison & explainable compatibility rules
  • -
  • apiverity test / workflow — deterministic schema-derived and stateful verification
  • -
  • apiverity drift / replay / regression — runtime truth vs declared contracts, safely
  • -
  • can-i-deploy — connect contract changes to registered consumers
  • -
- - - ) -} - -function CatalogPage({ data }: { data: DemoData | null }) { - if (!data?.catalog) return - return ( - <> - - - {data.catalog.services.map((s) => ( - - - - ))}
ServiceProductProtocolLifecycleOwnerVersionsEnvironments
{s.title}{s.product}{s.protocol}{s.lifecycle}{s.owner}{s.versions.join(', ')}{s.environments.join(', ')}
- - ) -} - -/* ---------- contract pages ---------- */ -function ExplorerPage({ data }: { data: DemoData | null }) { - const route = useRoute() - const selected = route.params.get('op') - if (!data) return - const op = data.contract.operations.find((o) => o.key === selected) - return ( - <> - -
-
- {data.contract.operations.map((o) => ( -
navigate('explorer', { op: o.key })} - onKeyDown={(e) => e.key === 'Enter' && navigate('explorer', { op: o.key })} - role="button" tabIndex={0} className={'op-row' + (selected === o.key ? ' selected' : '')}> - {o.method}{' '} - {o.path} {o.deprecated && deprecated} -
- ))} -
-
- {!op ? : ( - <> -

{op.method} {op.path}

-

{op.summary ?? 'No summary.'}

-

Parameters: {op.parameters.join(', ') || '—'}

-

Responses: {op.responses.join(', ')}

- - )} -
-
- - ) -} - -function HistoryPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - const versions = data.org.contracts.filter((c) => c.title === 'Catalog') - return ( - <> - - - {versions.map((c) => ( - - - - ))}
VersionProtocolChecksumPublished byWhen (UTC)
v{c.version}{c.protocol}{c.checksum.slice(0, 12)}…{c.published_by}{new Date(c.published_at).toISOString()}
- - ) -} - -function DiffPage({ data }: { data: DemoData | null }) { - if (!data) return - return ( - <> - - - {data.diff.changes.map((c) => ( - - ))}
IDKindDirectionDescription
{c.id}{c.kind}{c.direction}{c.description}
- - ) -} - -function BreakingPage({ data, route }: { data: DemoData | null; route: ReturnType }) { - const filter = route.params.get('sev') ?? 'ALL' - if (!data) return - const shown = data.breaking.findings.filter((f) => filter === 'ALL' || f.severity === filter) - return ( - <> - - setParam(route, 'sev', v === 'ALL' ? '' : v)} /> - {shown.length === 0 ? : ( - - {shown.map((f, i) => ( - - ))}
RuleSeverityMessage
{f.rule_id}{f.message}
- )} - - ) -} - -function SemverPage({ data }: { data: DemoData | null }) { - if (!data?.semver) return - const v = data.semver - return ( - <> - -

Required bump: {v.required_bump}

-

Policy compliant:

- {v.findings.length > 0 && ( - - {v.findings.map((f, i) => ( - - ))}
RuleSeverityMessage
{f.rule_id}{f.message}
- )} - - ) -} - -function RulesPage({ data }: { data: DemoData | null }) { - if (!data) return - return ( - <> - - - {data.rules.catalog.map((r) => ( - - ))}
RuleSeverityDescription
{r.rule_id}{r.description}
- - ) -} - -function ChangelogPage({ data }: { data: DemoData | null }) { - if (!data?.changelog) return - return ( - <> - -
{data.changelog.markdown}
- - ) -} - -/* ---------- testing pages ---------- */ -function TestRunsPage({ data }: { data: DemoData | null }) { - if (!data) return - return ( - <> - - - ( - - - - - - )} />
CaseOperationKindStatusHTTPViolations
{r.case_id}{r.operation_key}{r.kind}{r.actual_status ?? '—'}{r.violations.join('; ') || '—'}
- - ) -} - -function FuzzPage({ data }: { data: DemoData | null }) { - if (!data) return - const failures = data.test.results.filter((r) => r.status !== 'pass') - return ( - <> - - {failures.length === 0 ? : ( - - {failures.map((r) => ( - - - ))}
CaseOperationKindHTTPViolations
{r.case_id}{r.operation_key}{r.kind}{r.actual_status ?? '—'}{r.violations.join('; ')}
- )} - - ) -} - -function MinimizerPage({ data }: { data: DemoData | null }) { - if (!data?.minimizer) return - return ( - <> - - {data.minimizer.results.length === 0 ? : ( - - {data.minimizer.results.map((r) => ( - - - - ))}
CaseOperationStatusReproduction
{r.case_id}{r.operation_key}{r.reproduction ?? '—'}
- )} - - ) -} - -function WorkflowsPage({ data }: { data: DemoData | null }) { - if (!data) return - const wf = data.workflow.result - return ( - <> - -

{data.workflow.description}

-

Status:

-

Steps

- - {wf.steps.map((s) => ( - - - ))}
StepStatusHTTPmsNotes
{s.step}{s.actual_status ?? '—'}{s.duration_ms}{s.violations.join('; ') || '—'}
- {wf.cleanup_steps.length > 0 && (<> -

Cleanup

- - {wf.cleanup_steps.map((s) => ( - - ))}
StepStatusHTTP
{s.step}{s.status}{s.actual_status ?? '—'}
- )} - - ) -} - -function CoveragePage({ data }: { data: DemoData | null }) { - if (!data) return - return ( - <> - - ({ - label: o.operation_key, - value: o.statuses_seen.length, - max: Math.max(o.declared_statuses.length, 1), - }))} /> - - {data.coverage.operations.map((o) => ( - - - - ))}
OperationExercisedDeclared statusesSeen
{o.operation_key}{o.declared_statuses.join(', ')}{o.statuses_seen.join(', ') || '—'}
- - ) -} - -/* ---------- runtime pages ---------- */ -function DriftPage({ data }: { data: DemoData | null }) { - if (!data) return - return ( - <> - - {data.drift.findings.length === 0 ? : ( - - {data.drift.findings.map((d, i) => ( - - ))}
RuleOperationMessage
{d.rule_id}{d.operation_key}{d.message}
- )} - - ) -} - -function ReplayPage({ data }: { data: DemoData | null }) { - if (!data?.replay) return - const m = data.replay.manifest - const d = data.replay.dry_run - return ( - <> - - - - - - - - -
Target{m.target}
Safety class{m.safety_class}
Corpus{m.corpus} ({m.entries} GET entries)
Rate limit{m.rate_per_second} req/s max
Destructive methods {m.destructive_methods_allowed ? 'allowed' : 'blocked (allowlist required)'}
Dry-run result{d.sent} sent · {d.skipped} skipped (dry-run)
- - - ) -} - -function PerfPage({ data }: { data: DemoData | null }) { - if (!data) return - const ops = data.performance.operations - const max = Math.max(...ops.map((o) => o.p99_ms), 1) - return ( - <> - - [ - { label: `${o.operation_key} p50`, value: o.p50_ms, max }, - { label: `${o.operation_key} p95`, value: o.p95_ms, max }, - { label: `${o.operation_key} p99`, value: o.p99_ms, max }, - ])} /> - - {ops.map((o) => ( - - - - ))}
Operationp50 msp95 msp99 msErrorsreq/s
{o.operation_key}{o.p50_ms.toFixed(1)}{o.p95_ms.toFixed(1)}{o.p99_ms.toFixed(1)}{o.errors}{o.throughput_rps.toFixed(1)}
- - ) -} - -function MockPage({ data }: { data: DemoData | null }) { - if (!data) return - return ( - <> - - - - {data.contract.operations.map((o) => ( - - - ))}
OperationMethodPathDeterministic responses
{o.key}{o.method}{o.path}{o.responses.join(', ')}
- - ) -} - -/* ---------- team / enterprise pages ---------- */ -function OrgDashboard({ data }: { data: DemoData | null }) { - if (!data?.org) return - const org = data.org - const cards: [string, string | number][] = [ - ['Organization', org.org.name], - ['Members', org.users.filter((u) => u.kind === 'user').length], - ['Service accounts', org.users.filter((u) => u.kind === 'service_account').length], - ['Contracts published', org.contracts.length], - ['Environments', org.environments.length], - ['Audit chain valid', org.chain_valid ? 'yes' : 'TAMPERED'], - ] - return ( - <> - -
{cards.map(([k, v]) => ( -
{String(v)}
{k}
- ))}
- - ) -} - -function EnvironmentsPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - return ( - <> - - - {data.org.environments.map((e) => ( - - - - ))}
NameBase URLSafety classOwnerAllowed modes
{e.name}{e.base_url}{e.safety_class}{e.owner ?? '—'}{e.allowed_modes}
- - ) -} - -function ApprovalsPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - return ( - <> - - {data.org.approvals.length === 0 ? : ( - - {data.org.approvals.map((a) => ( - - - - - ))}
ContractTransitionJustificationStatusRequested byDecided by
{a.contract_title}v{a.from_version} → v{a.to_version}{a.justification}{a.status}{a.requested_by}{a.decided_by ?? '—'}
- )} - - ) -} - -function PoliciesPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - return ( - <> - - {data.org.policies.map((p) => ( -
-

{p.name}

-
{p.content}
-
- ))} - - ) -} - -function JobsPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - return ( - <> - - - {data.org.runs.map((r) => ( - - - - ))}
IDKindStatusRequested byVerifiesEnvironment
#{r.id}{r.kind}{r.status}{r.requested_by}{r.verification_for ?? '—'}{r.environment ?? '—'}
- - ) -} - -function AuditPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - return ( - <> - - - {data.org.audit_events.map((e) => ( - - - - ))}
#When (UTC)ActorActionTargetEntry hash
{e.id}{new Date(e.ts).toISOString()}{e.actor}{e.action}{e.target}{e.entry_hash.slice(0, 10)}…
- - ) -} - -function WebhooksPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - return ( - <> - - - {data.org.webhooks.map((w) => ( - - - ))}
URLSecret refEventsActive
{w.url}{w.secret_ref}{w.events.join(', ')}{w.active ? 'yes' : 'no'}
- - ) -} - -function UsersPage({ data }: { data: DemoData | null }) { - if (!data?.org) return - return ( - <> - - - {data.org.users.map((u) => ( - - - - ))}
SubjectNameRoleKind
{u.subject}{u.display_name}{u.role}{u.kind}
- - ) -} - -/* ---------- static pages ---------- */ -function DocsPage() { - return ( - <> -

Docs

-
    -
  • Getting started — README quickstart, install, first diff in under a minute
  • -
  • Rule catalog — docs/rule-catalog.md (direction-aware breaking rules + semver policy)
  • -
  • Spec support — PROTOCOL_SUPPORT.md (verified levels per protocol)
  • -
  • Safety model — SAFETY_MODEL.md (target authorization, replay/load protections)
  • -
  • Privacy & redaction — docs/privacy.md
  • -
  • CI integration — docs/ci.md (PR gate, JUnit/SARIF, perf budgets)
  • -
  • Workflow authoring — docs/workflow-authoring.md
  • -
  • Self-hosting — docs/self-hosting.md (server, RBAC, audit, webhooks)
  • -
  • Plugins — docs/plugins.md (plugin API v2, conformance kit, scaffolder)
  • -
  • Competitive analysis — docs/competitive-analysis.md
  • -
- - ) -} - -const BUILTIN_PLUGINS = [ - ['core-rules', 'rules', 'Direction-aware breaking-change rule pack'], - ['security-checks', 'checks', 'Defensive security rule pack (auth, CORS, secrets)'], - ['schema-case-generator', 'generators', 'Deterministic positive/negative case generation'], - ['report-exporters', 'exporters', 'JSON / JUnit / SARIF / Markdown report export'], - ['httpx-transport', 'transports', 'Default HTTP transport with safe defaults'], -] - -function PluginsPage() { - return ( - <> - - - {BUILTIN_PLUGINS.map(([name, cap, desc]) => ( - - ))}
PluginCapabilityDescription
{name}{cap}{desc}
- - - ) -} - -function ContributorsPage() { - return ( - <> -

Contributors

-
-
@webdevsamran
-
Creator · Founder · Lead Maintainer
-
-

See CONTRIBUTING.md to join — good first tasks are listed in ISSUES.md.

- - ) -} - -function AboutPage() { - return ( - <> -

About

-

- API Verity Lab is a local-first API reliability laboratory unifying contract - governance, breaking-change analysis, schema-driven/stateful testing, runtime - drift detection, safe traffic replay and performance regression for OpenAPI, - GraphQL and gRPC — one shared contract model instead of a bag of wrappers. -

-

Apache-2.0 · Created by @webdevsamran · No cloud component required.

- - ) -} - -/* ---------- shell ---------- */ -type PageProps = { data: DemoData | null; route: ReturnType } - -const NAV: { group: string; items: [string, string][] }[] = [ - { group: 'Overview', items: [['home', 'Home'], ['catalog', 'API Catalog'], ['docs', 'Docs'], ['plugins', 'Plugins'], ['contributors', 'Contributors'], ['about', 'About']] }, - { group: 'Contract', items: [['explorer', 'Explorer'], ['history', 'Version History'], ['diff', 'Diff Review'], ['breaking', 'Breaking Changes'], ['semver', 'SemVer Verdict'], ['changelog', 'Changelog'], ['rules', 'Rules']] }, - { group: 'Testing', items: [['tests', 'Test Runs'], ['fuzz', 'Fuzz Cases'], ['minimizer', 'Minimizer'], ['workflows', 'Workflows'], ['coverage', 'Coverage']] }, - { group: 'Runtime', items: [['drift', 'Drift'], ['replay', 'Replay'], ['perf', 'Performance'], ['mock', 'Mock']] }, - { group: 'Team', items: [['org', 'Org Dashboard'], ['environments', 'Environments'], ['approvals', 'Approvals'], ['policies', 'Policies'], ['jobs', 'Runs/Jobs'], ['audit', 'Audit Log'], ['webhooks', 'Webhooks'], ['users', 'Users']] }, -] - -const PAGES: Record React.ReactElement> = { - home: HomePage, catalog: CatalogPage, docs: () => , plugins: () => , - contributors: () => , about: () => , - explorer: ExplorerPage, history: HistoryPage, diff: DiffPage, breaking: BreakingPage, - semver: SemverPage, rules: RulesPage, changelog: ChangelogPage, - tests: TestRunsPage, fuzz: FuzzPage, minimizer: MinimizerPage, workflows: WorkflowsPage, - coverage: CoveragePage, drift: DriftPage, replay: ReplayPage, perf: PerfPage, mock: MockPage, - org: OrgDashboard, environments: EnvironmentsPage, approvals: ApprovalsPage, - policies: PoliciesPage, jobs: JobsPage, audit: AuditPage, webhooks: WebhooksPage, users: UsersPage, -} +/* App shell: theme cycling, sidebar navigation, hash-routed page rendering. */ +import { useEffect, useMemo, useState } from 'react' +import { useData } from './hooks/useData' +import { NAV, resolvePage } from './pages' +import { useRoute } from './router' type ThemeMode = 'dark' | 'light' | 'system' @@ -667,7 +19,7 @@ export default function App() { }, [theme]) useEffect(() => { setMenuOpen(false); window.scrollTo(0, 0) }, [route.page]) - const Page = PAGES[route.page] ?? HomePage + const Page = resolvePage(route.page) const content = useMemo( () => , [route.page, route.params.toString(), data], @@ -707,4 +59,4 @@ export default function App() {
) -} \ No newline at end of file +} diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx new file mode 100644 index 0000000..2c57b4d --- /dev/null +++ b/web/src/components/ui.tsx @@ -0,0 +1,84 @@ +/* Shared presentational building blocks used by every page. */ +import { useRef, useState, type ReactNode } from 'react' + +export const SEV_COLORS: Record = { ERROR: '#e5484d', WARN: '#f5a623', INFO: '#3b82f6' } +export const METHOD_COLORS: Record = { + GET: '#3b82f6', POST: '#2ea043', PUT: '#f5a623', PATCH: '#a855f7', DELETE: '#e5484d', +} + +export function Badge({ children, color }: { children: ReactNode; color?: string }) { + return {children} +} +export function SevBadge({ sev }: { sev: string }) { + return {sev} +} +export function StatusBadge({ ok }: { ok: boolean }) { + return {ok ? 'pass' : 'fail'} +} +export function Empty({ msg }: { msg: string }) { + return
{msg}
+} +export function DemoTag() { + return DEMO DATA +} +export function PageHead({ title, sub }: { title: string; sub?: string }) { + return ( + <> +

{title} {sub && {sub}}

+

static demo artifacts generated from bundled fixtures — run the CLI for your own APIs.

+ + ) +} +export function BarChart({ rows }: { rows: { label: string; value: number; max: number }[] }) { + return ( +
+ {rows.map((r) => ( +
+ {r.label} +
+
+
+ {r.value} +
+ ))} +
+ ) +} +/** Simple windowed list for large tables (virtualization). */ +export function VirtualRows({ items, rowHeight = 36, render }: { + items: T[]; rowHeight?: number; render: (item: T, index: number) => ReactNode +}) { + const [scrollTop, setScrollTop] = useState(0) + const ref = useRef(null) + const viewport = 480 + if (items.length <= 40) return <>{items.map((it, i) => render(it, i))} + const start = Math.max(0, Math.floor(scrollTop / rowHeight) - 5) + const end = Math.min(items.length, start + Math.ceil(viewport / rowHeight) + 10) + return ( +
setScrollTop((e.target as HTMLDivElement).scrollTop)}> +
+
+ {items.slice(start, end).map((it, i) => render(it, start + i))} +
+
+
+ ) +} +export function Filters({ options, active, onPick }: { options: string[]; active: string; onPick: (v: string) => void }) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ) +} +export function CopyCmd({ cmd }: { cmd: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} diff --git a/web/src/hooks/useData.ts b/web/src/hooks/useData.ts new file mode 100644 index 0000000..ce61e51 --- /dev/null +++ b/web/src/hooks/useData.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from 'react' +import { loadData, type DemoData } from '../data' + +/** Load the demo artifact bundle once per session. */ +export function useData(): { data: DemoData | null; error: string | null } { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + useEffect(() => { + loadData().then(setData).catch((e) => setError(String(e))) + }, []) + return { data, error } +} diff --git a/web/src/pages/contract.tsx b/web/src/pages/contract.tsx new file mode 100644 index 0000000..3c1e5ba --- /dev/null +++ b/web/src/pages/contract.tsx @@ -0,0 +1,128 @@ +/* Contract pages: explorer, version history, diff, breaking, semver, rules, changelog. */ +import { METHOD_COLORS, Badge, Empty, Filters, PageHead, SevBadge, StatusBadge } from '../components/ui' +import { navigate, setParam, useRoute } from '../router' +import type { PageProps } from './types' + +export function ExplorerPage({ data }: { data: PageProps['data'] }) { + const route = useRoute() + const selected = route.params.get('op') + if (!data) return + const op = data.contract.operations.find((o) => o.key === selected) + return ( + <> + +
+
+ {data.contract.operations.map((o) => ( +
navigate('explorer', { op: o.key })} + onKeyDown={(e) => e.key === 'Enter' && navigate('explorer', { op: o.key })} + role="button" tabIndex={0} className={'op-row' + (selected === o.key ? ' selected' : '')}> + {o.method}{' '} + {o.path} {o.deprecated && deprecated} +
+ ))} +
+
+ {!op ? : ( + <> +

{op.method} {op.path}

+

{op.summary ?? 'No summary.'}

+

Parameters: {op.parameters.join(', ') || '—'}

+

Responses: {op.responses.join(', ')}

+ + )} +
+
+ + ) +} + +function HistoryPageImpl({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + const versions = data.org.contracts.filter((c) => c.title === 'Catalog') + return ( + <> + + + {versions.map((c) => ( + + + + ))}
VersionProtocolChecksumPublished byWhen (UTC)
v{c.version}{c.protocol}{c.checksum.slice(0, 12)}…{c.published_by}{new Date(c.published_at).toISOString()}
+ + ) +} +export const HistoryPage = HistoryPageImpl + +export function DiffPage({ data }: { data: PageProps['data'] }) { + if (!data) return + return ( + <> + + + {data.diff.changes.map((c) => ( + + ))}
IDKindDirectionDescription
{c.id}{c.kind}{c.direction}{c.description}
+ + ) +} + +export function BreakingPage({ data, route }: PageProps) { + const filter = route.params.get('sev') ?? 'ALL' + if (!data) return + const shown = data.breaking.findings.filter((f) => filter === 'ALL' || f.severity === filter) + return ( + <> + + setParam(route, 'sev', v === 'ALL' ? '' : v)} /> + {shown.length === 0 ? : ( + + {shown.map((f, i) => ( + + ))}
RuleSeverityMessage
{f.rule_id}{f.message}
+ )} + + ) +} + +export function SemverPage({ data }: { data: PageProps['data'] }) { + if (!data?.semver) return + const v = data.semver + return ( + <> + +

Required bump: {v.required_bump}

+

Policy compliant:

+ {v.findings.length > 0 && ( + + {v.findings.map((f, i) => ( + + ))}
RuleSeverityMessage
{f.rule_id}{f.message}
+ )} + + ) +} + +export function RulesPage({ data }: { data: PageProps['data'] }) { + if (!data) return + return ( + <> + + + {data.rules.catalog.map((r) => ( + + ))}
RuleSeverityDescription
{r.rule_id}{r.description}
+ + ) +} + +export function ChangelogPage({ data }: { data: PageProps['data'] }) { + if (!data?.changelog) return + return ( + <> + +
{data.changelog.markdown}
+ + ) +} diff --git a/web/src/pages/index.tsx b/web/src/pages/index.tsx new file mode 100644 index 0000000..e7736da --- /dev/null +++ b/web/src/pages/index.tsx @@ -0,0 +1,43 @@ +/* Page registry and navigation structure for the app shell. */ +import type { ReactElement } from 'react' +import { + AboutPage, CatalogPage, ContributorsPage, DocsPage, HomePage, PluginsPage, +} from './overview' +import { + BreakingPage, ChangelogPage, DiffPage, ExplorerPage, HistoryPage, RulesPage, SemverPage, +} from './contract' +import { + CoveragePage, FuzzPage, MinimizerPage, TestRunsPage, WorkflowsPage, +} from './testing' +import { DriftPage, MockPage, PerfPage, ReplayPage } from './runtime' +import { + ApprovalsPage, AuditPage, EnvironmentsPage, JobsPage, OrgDashboard, + PoliciesPage, UsersPage, WebhooksPage, +} from './team' +import type { PageProps } from './types' + +export type { PageProps } + +export const NAV: { group: string; items: [string, string][] }[] = [ + { group: 'Overview', items: [['home', 'Home'], ['catalog', 'API Catalog'], ['docs', 'Docs'], ['plugins', 'Plugins'], ['contributors', 'Contributors'], ['about', 'About']] }, + { group: 'Contract', items: [['explorer', 'Explorer'], ['history', 'Version History'], ['diff', 'Diff Review'], ['breaking', 'Breaking Changes'], ['semver', 'SemVer Verdict'], ['changelog', 'Changelog'], ['rules', 'Rules']] }, + { group: 'Testing', items: [['tests', 'Test Runs'], ['fuzz', 'Fuzz Cases'], ['minimizer', 'Minimizer'], ['workflows', 'Workflows'], ['coverage', 'Coverage']] }, + { group: 'Runtime', items: [['drift', 'Drift'], ['replay', 'Replay'], ['perf', 'Performance'], ['mock', 'Mock']] }, + { group: 'Team', items: [['org', 'Org Dashboard'], ['environments', 'Environments'], ['approvals', 'Approvals'], ['policies', 'Policies'], ['jobs', 'Runs/Jobs'], ['audit', 'Audit Log'], ['webhooks', 'Webhooks'], ['users', 'Users']] }, +] + +const PAGES: Record ReactElement> = { + home: HomePage, catalog: CatalogPage, docs: () => , plugins: () => , + contributors: () => , about: () => , + explorer: ExplorerPage, history: HistoryPage, diff: DiffPage, breaking: BreakingPage, + semver: SemverPage, rules: RulesPage, changelog: ChangelogPage, + tests: TestRunsPage, fuzz: FuzzPage, minimizer: MinimizerPage, workflows: WorkflowsPage, + coverage: CoveragePage, drift: DriftPage, replay: ReplayPage, perf: PerfPage, mock: MockPage, + org: OrgDashboard, environments: EnvironmentsPage, approvals: ApprovalsPage, + policies: PoliciesPage, jobs: JobsPage, audit: AuditPage, webhooks: WebhooksPage, users: UsersPage, +} + +/** Resolve a route name to its page component; unknown routes fall back to home. */ +export function resolvePage(name: string): (p: PageProps) => ReactElement { + return PAGES[name] ?? HomePage +} diff --git a/web/src/pages/overview.tsx b/web/src/pages/overview.tsx new file mode 100644 index 0000000..5684ba3 --- /dev/null +++ b/web/src/pages/overview.tsx @@ -0,0 +1,115 @@ +/* Overview pages: home, catalog, docs, plugins, contributors, about. */ +import { Badge, CopyCmd, Empty, PageHead } from '../components/ui' +import type { PageProps } from './types' + +export function HomePage({ data }: { data: PageProps['data'] }) { + if (!data) return + const cards: [string, string | number][] = [ + ['Changes detected', data.diff.changes.length], + ['Breaking findings', data.breaking.findings.filter((f) => f.severity === 'ERROR').length], + ['Test cases', `${data.test.passed}/${data.test.total} passed`], + ['Drift findings', data.drift.findings.length], + ['Contract coverage', `${data.coverage.overall_percent}%`], + ['Services in catalog', data.catalog?.services.length ?? 0], + ] + return ( + <> + +
{cards.map(([k, v]) => ( +
{v}
{k}
+ ))}
+

Signature workflows

+
    +
  • apiverity diff / breaking — semantic source-aware comparison & explainable compatibility rules
  • +
  • apiverity test / workflow — deterministic schema-derived and stateful verification
  • +
  • apiverity drift / replay / regression — runtime truth vs declared contracts, safely
  • +
  • can-i-deploy — connect contract changes to registered consumers
  • +
+ + + ) +} + +export function CatalogPage({ data }: { data: PageProps['data'] }) { + if (!data?.catalog) return + return ( + <> + + + {data.catalog.services.map((s) => ( + + + + ))}
ServiceProductProtocolLifecycleOwnerVersionsEnvironments
{s.title}{s.product}{s.protocol}{s.lifecycle}{s.owner}{s.versions.join(', ')}{s.environments.join(', ')}
+ + ) +} + +export function DocsPage() { + return ( + <> +

Docs

+
    +
  • Getting started — README quickstart, install, first diff in under a minute
  • +
  • Rule catalog — docs/rule-catalog.md (direction-aware breaking rules + semver policy)
  • +
  • Spec support — PROTOCOL_SUPPORT.md (verified levels per protocol)
  • +
  • Safety model — SAFETY_MODEL.md (target authorization, replay/load protections)
  • +
  • Privacy & redaction — docs/privacy.md
  • +
  • CI integration — docs/ci.md (PR gate, JUnit/SARIF, perf budgets)
  • +
  • Workflow authoring — docs/workflow-authoring.md
  • +
  • Self-hosting — docs/self-hosting.md (server, RBAC, audit, webhooks)
  • +
  • Plugins — docs/plugins.md (plugin API v2, conformance kit, scaffolder)
  • +
  • Competitive analysis — docs/competitive-analysis.md
  • +
+ + ) +} + +const BUILTIN_PLUGINS = [ + ['core-rules', 'rules', 'Direction-aware breaking-change rule pack'], + ['security-checks', 'checks', 'Defensive security rule pack (auth, CORS, secrets)'], + ['schema-case-generator', 'generators', 'Deterministic positive/negative case generation'], + ['report-exporters', 'exporters', 'JSON / JUnit / SARIF / Markdown report export'], + ['httpx-transport', 'transports', 'Default HTTP transport with safe defaults'], +] + +export function PluginsPage() { + return ( + <> + + + {BUILTIN_PLUGINS.map(([name, cap, desc]) => ( + + ))}
PluginCapabilityDescription
{name}{cap}{desc}
+ + + ) +} + +export function ContributorsPage() { + return ( + <> +

Contributors

+
+
@webdevsamran
+
Creator · Founder · Lead Maintainer
+
+

See CONTRIBUTING.md to join — good first tasks are listed in ISSUES.md.

+ + ) +} + +export function AboutPage() { + return ( + <> +

About

+

+ API Verity Lab is a local-first API reliability laboratory unifying contract + governance, breaking-change analysis, schema-driven/stateful testing, runtime + drift detection, safe traffic replay and performance regression for OpenAPI, + GraphQL and gRPC — one shared contract model instead of a bag of wrappers. +

+

Apache-2.0 · Created by @webdevsamran · No cloud component required.

+ + ) +} diff --git a/web/src/pages/runtime.tsx b/web/src/pages/runtime.tsx new file mode 100644 index 0000000..2367db7 --- /dev/null +++ b/web/src/pages/runtime.tsx @@ -0,0 +1,75 @@ +/* Runtime pages: drift, replay, performance, mock. */ +import { METHOD_COLORS, BarChart, Badge, CopyCmd, Empty, PageHead, StatusBadge } from '../components/ui' +import type { PageProps } from './types' + +export function DriftPage({ data }: { data: PageProps['data'] }) { + if (!data) return + return ( + <> + + {data.drift.findings.length === 0 ? : ( + + {data.drift.findings.map((d, i) => ( + + ))}
RuleOperationMessage
{d.rule_id}{d.operation_key}{d.message}
+ )} + + ) +} + +export function ReplayPage({ data }: { data: PageProps['data'] }) { + if (!data?.replay) return + const m = data.replay.manifest + const d = data.replay.dry_run + return ( + <> + + + + + + + + +
Target{m.target}
Safety class{m.safety_class}
Corpus{m.corpus} ({m.entries} GET entries)
Rate limit{m.rate_per_second} req/s max
Destructive methods {m.destructive_methods_allowed ? 'allowed' : 'blocked (allowlist required)'}
Dry-run result{d.sent} sent · {d.skipped} skipped (dry-run)
+ + + ) +} + +export function PerfPage({ data }: { data: PageProps['data'] }) { + if (!data) return + const ops = data.performance.operations + const max = Math.max(...ops.map((o) => o.p99_ms), 1) + return ( + <> + + [ + { label: `${o.operation_key} p50`, value: o.p50_ms, max }, + { label: `${o.operation_key} p95`, value: o.p95_ms, max }, + { label: `${o.operation_key} p99`, value: o.p99_ms, max }, + ])} /> + + {ops.map((o) => ( + + + + ))}
Operationp50 msp95 msp99 msErrorsreq/s
{o.operation_key}{o.p50_ms.toFixed(1)}{o.p95_ms.toFixed(1)}{o.p99_ms.toFixed(1)}{o.errors}{o.throughput_rps.toFixed(1)}
+ + ) +} + +export function MockPage({ data }: { data: PageProps['data'] }) { + if (!data) return + return ( + <> + + + + {data.contract.operations.map((o) => ( + + + ))}
OperationMethodPathDeterministic responses
{o.key}{o.method}{o.path}{o.responses.join(', ')}
+ + ) +} diff --git a/web/src/pages/team.tsx b/web/src/pages/team.tsx new file mode 100644 index 0000000..268af18 --- /dev/null +++ b/web/src/pages/team.tsx @@ -0,0 +1,132 @@ +/* Team / enterprise pages: org dashboard, environments, approvals, policies, + * runs/jobs, audit log, webhooks, users. */ +import { Badge, Empty, PageHead } from '../components/ui' +import type { PageProps } from './types' + +export function OrgDashboard({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + const org = data.org + const cards: [string, string | number][] = [ + ['Organization', org.org.name], + ['Members', org.users.filter((u) => u.kind === 'user').length], + ['Service accounts', org.users.filter((u) => u.kind === 'service_account').length], + ['Contracts published', org.contracts.length], + ['Environments', org.environments.length], + ['Audit chain valid', org.chain_valid ? 'yes' : 'TAMPERED'], + ] + return ( + <> + +
{cards.map(([k, v]) => ( +
{String(v)}
{k}
+ ))}
+ + ) +} + +export function EnvironmentsPage({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + return ( + <> + + + {data.org.environments.map((e) => ( + + + + ))}
NameBase URLSafety classOwnerAllowed modes
{e.name}{e.base_url}{e.safety_class}{e.owner ?? '—'}{e.allowed_modes}
+ + ) +} + +export function ApprovalsPage({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + return ( + <> + + {data.org.approvals.length === 0 ? : ( + + {data.org.approvals.map((a) => ( + + + + + ))}
ContractTransitionJustificationStatusRequested byDecided by
{a.contract_title}v{a.from_version} → v{a.to_version}{a.justification}{a.status}{a.requested_by}{a.decided_by ?? '—'}
+ )} + + ) +} + +export function PoliciesPage({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + return ( + <> + + {data.org.policies.map((p) => ( +
+

{p.name}

+
{p.content}
+
+ ))} + + ) +} + +export function JobsPage({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + return ( + <> + + + {data.org.runs.map((r) => ( + + + + ))}
IDKindStatusRequested byVerifiesEnvironment
#{r.id}{r.kind}{r.status}{r.requested_by}{r.verification_for ?? '—'}{r.environment ?? '—'}
+ + ) +} + +export function AuditPage({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + return ( + <> + + + {data.org.audit_events.map((e) => ( + + + + ))}
#When (UTC)ActorActionTargetEntry hash
{e.id}{new Date(e.ts).toISOString()}{e.actor}{e.action}{e.target}{e.entry_hash.slice(0, 10)}…
+ + ) +} + +export function WebhooksPage({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + return ( + <> + + + {data.org.webhooks.map((w) => ( + + + ))}
URLSecret refEventsActive
{w.url}{w.secret_ref}{w.events.join(', ')}{w.active ? 'yes' : 'no'}
+ + ) +} + +export function UsersPage({ data }: { data: PageProps['data'] }) { + if (!data?.org) return + return ( + <> + + + {data.org.users.map((u) => ( + + + + ))}
SubjectNameRoleKind
{u.subject}{u.display_name}{u.role}{u.kind}
+ + ) +} diff --git a/web/src/pages/testing.tsx b/web/src/pages/testing.tsx new file mode 100644 index 0000000..ab21ba8 --- /dev/null +++ b/web/src/pages/testing.tsx @@ -0,0 +1,99 @@ +/* Testing pages: test runs, fuzz failures, minimizer, workflows, coverage. */ +import { BarChart, Empty, PageHead, StatusBadge, VirtualRows } from '../components/ui' +import type { PageProps } from './types' + +export function TestRunsPage({ data }: { data: PageProps['data'] }) { + if (!data) return + return ( + <> + + + ( + + + + + + )} />
CaseOperationKindStatusHTTPViolations
{r.case_id}{r.operation_key}{r.kind}{r.actual_status ?? '—'}{r.violations.join('; ') || '—'}
+ + ) +} + +export function FuzzPage({ data }: { data: PageProps['data'] }) { + if (!data) return + const failures = data.test.results.filter((r) => r.status !== 'pass') + return ( + <> + + {failures.length === 0 ? : ( + + {failures.map((r) => ( + + + ))}
CaseOperationKindHTTPViolations
{r.case_id}{r.operation_key}{r.kind}{r.actual_status ?? '—'}{r.violations.join('; ')}
+ )} + + ) +} + +export function MinimizerPage({ data }: { data: PageProps['data'] }) { + if (!data?.minimizer) return + return ( + <> + + {data.minimizer.results.length === 0 ? : ( + + {data.minimizer.results.map((r) => ( + + + + ))}
CaseOperationStatusReproduction
{r.case_id}{r.operation_key}{r.reproduction ?? '—'}
+ )} + + ) +} + +export function WorkflowsPage({ data }: { data: PageProps['data'] }) { + if (!data) return + const wf = data.workflow.result + return ( + <> + +

{data.workflow.description}

+

Status:

+

Steps

+ + {wf.steps.map((s) => ( + + + ))}
StepStatusHTTPmsNotes
{s.step}{s.actual_status ?? '—'}{s.duration_ms}{s.violations.join('; ') || '—'}
+ {wf.cleanup_steps.length > 0 && (<> +

Cleanup

+ + {wf.cleanup_steps.map((s) => ( + + ))}
StepStatusHTTP
{s.step}{s.status}{s.actual_status ?? '—'}
+ )} + + ) +} + +export function CoveragePage({ data }: { data: PageProps['data'] }) { + if (!data) return + return ( + <> + + ({ + label: o.operation_key, + value: o.statuses_seen.length, + max: Math.max(o.declared_statuses.length, 1), + }))} /> + + {data.coverage.operations.map((o) => ( + + + + ))}
OperationExercisedDeclared statusesSeen
{o.operation_key}{o.declared_statuses.join(', ')}{o.statuses_seen.join(', ') || '—'}
+ + ) +} diff --git a/web/src/pages/types.ts b/web/src/pages/types.ts new file mode 100644 index 0000000..5fd27bd --- /dev/null +++ b/web/src/pages/types.ts @@ -0,0 +1,5 @@ +import type { DemoData } from '../data' +import type { useRoute } from '../router' + +/** Props every page receives from the app shell. */ +export type PageProps = { data: DemoData | null; route: ReturnType }