From 2c2bd4347afedf05c9cb3ec29b4c1178734cdaab Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 26 Apr 2026 17:32:01 +0200 Subject: [PATCH] API contract hardening --- README.md | 2 + docs/api-contract.md | 34 +++ src/repo_registry/web_api/app.py | 5 + src/repo_registry/web_api/schemas.py | 144 +++++++++++ tests/test_web_api.py | 229 ++++++++++++++++++ .../RREG-WP-0002-production-hardening.md | 2 +- 6 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 docs/api-contract.md diff --git a/README.md b/README.md index a802bf1..673ae82 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,8 @@ Search results include `match_type`, `matched_field`, `confidence`, source/evidence context when the match comes from implementation evidence. The generated OpenAPI schema at `/openapi.json` and docs at `/docs` include typed response schemas and examples for the main agent-facing responses. +The API compatibility policy is documented in `docs/api-contract.md`; stable +agent-facing paths are guarded by an OpenAPI contract snapshot test. Discovery helpers are available for production-readiness workflows that compare approved profiles, find simple capability gaps, or export a registry entry: diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..b91dd49 --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,34 @@ +# API Contract Policy + +The public HTTP API is currently version `0.1.0`. Until a versioned path such as +`/api/v1` is introduced, compatibility is governed by the OpenAPI contract +published at `/openapi.json` and covered by the test suite. + +Agent-facing endpoints are considered stable when their path, method, tag, and +success response model appear in the OpenAPI contract snapshot test. A change to +one of those fields is allowed, but it must update the snapshot test in the same +change so the break is explicit during review. + +Compatible changes include: + +- adding optional request fields with defaults +- adding response fields that clients may ignore +- adding new endpoints +- adding new examples or descriptions + +Breaking changes include: + +- removing or renaming an endpoint +- changing an endpoint method or tag +- changing the success response model +- removing or renaming a response field +- making an optional request field required +- changing the shape of common error responses + +Common application errors use: + +```json +{"detail": "repository 999 was not found"} +``` + +Validation errors remain FastAPI's standard `422` validation response. diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index 9e8c9da..9a752d2 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -40,6 +40,7 @@ from repo_registry.web_api.schemas import ( ContentChunkResponse, EvidenceCreate, EvidenceUpdate, + ErrorResponse, FeatureCreate, FeatureUpdate, IdResponse, @@ -116,6 +117,10 @@ app = FastAPI( version="0.1.0", description=API_DESCRIPTION, openapi_tags=OPENAPI_TAGS, + responses={ + 400: {"model": ErrorResponse, "description": "Bad request."}, + 404: {"model": ErrorResponse, "description": "Resource not found."}, + }, ) diff --git a/src/repo_registry/web_api/schemas.py b/src/repo_registry/web_api/schemas.py index 0329b5a..b665750 100644 --- a/src/repo_registry/web_api/schemas.py +++ b/src/repo_registry/web_api/schemas.py @@ -34,6 +34,19 @@ SOURCE_REFERENCE_EXAMPLE = { } +class ErrorResponse(BaseModel): + detail: str + + model_config = { + "json_schema_extra": { + "examples": [ + {"detail": "repository 999 was not found"}, + {"detail": "target candidate must be different from source"}, + ] + } + } + + class RepositoryCreate(BaseModel): url: str name: str | None = None @@ -540,6 +553,61 @@ class AnalysisRunDiffResponse(BaseModel): candidates: AnalysisRunDiffSectionResponse approved_entries: AnalysisRunDiffSectionResponse + model_config = { + "json_schema_extra": { + "examples": [ + { + "repository": REPOSITORY_EXAMPLE, + "base_run": {**ANALYSIS_RUN_EXAMPLE, "id": 1}, + "target_run": {**ANALYSIS_RUN_EXAMPLE, "id": 2}, + "facts": { + "added": [ + { + "change_type": "added", + "item_type": "fact", + "key": "api_route:src/routes/status.py:GET /status", + "target": { + "kind": "api_route", + "path": "src/routes/status.py", + "name": "GET /status", + }, + } + ], + "removed": [], + "changed": [], + "weakened": [], + }, + "chunks": { + "added": [], + "removed": [], + "changed": [], + "weakened": [], + }, + "candidates": { + "added": [], + "removed": [], + "changed": [], + "weakened": [ + { + "change_type": "weakened", + "item_type": "capability", + "key": "classify incoming email", + "base": {"confidence": 0.9}, + "target": {"confidence": 0.62}, + } + ], + }, + "approved_entries": { + "added": [], + "removed": [], + "changed": [], + "weakened": [], + }, + } + ] + } + } + class EvidenceResponse(BaseModel): id: int @@ -760,6 +828,48 @@ class RepositoryComparisonResponse(BaseModel): abilities: list[ComparedAbilityResponse] unique_capabilities: list[UniqueCapabilityResponse] + model_config = { + "json_schema_extra": { + "examples": [ + { + "repositories": [ + REPOSITORY_EXAMPLE, + {**REPOSITORY_EXAMPLE, "id": 2, "name": "InboxWorker"}, + ], + "abilities": [ + { + "name": "Business Email Routing", + "repositories": [ + { + "repository_id": 1, + "repository_name": "MailRouter", + "confidence": 0.92, + "confidence_label": "high", + "capabilities": [ + { + "name": "Classify Incoming Email", + "confidence": 0.88, + "confidence_label": "high", + "evidence_count": 2, + } + ], + } + ], + } + ], + "unique_capabilities": [ + { + "repository_id": 1, + "repository_name": "MailRouter", + "ability_name": "Business Email Routing", + "capability_name": "Classify Incoming Email", + } + ], + } + ] + } + } + class CapabilityGapMatchResponse(BaseModel): capability: str @@ -787,3 +897,37 @@ class CapabilityGapResponse(BaseModel): missing_capabilities: list[str] weakly_evidenced_capabilities: list[WeakCapabilityEvidenceResponse] duplicate_capabilities: list[DuplicateCapabilityResponse] + + model_config = { + "json_schema_extra": { + "examples": [ + { + "desired_ability": "Business Email Routing", + "matched_capabilities": [ + { + "capability": "Classify Incoming Email", + "repositories": ["MailRouter"], + } + ], + "missing_capabilities": ["Route Email to Team"], + "weakly_evidenced_capabilities": [ + { + "capability": "Classify Incoming Email", + "repository_id": 1, + "repository_name": "MailRouter", + "evidence_count": 0, + "strongest_evidence": None, + "confidence": 0.62, + "confidence_label": "medium", + } + ], + "duplicate_capabilities": [ + { + "capability": "Classify Incoming Email", + "repositories": ["MailRouter", "InboxWorker"], + } + ], + } + ] + } + } diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 11ec8b7..f07cc73 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -52,6 +52,235 @@ def test_openapi_groups_agent_facing_endpoints(): assert ( "/repos/{repository_id}/analysis-runs/{analysis_run_id}/changes/approve" ) in schema["paths"] + assert components["ErrorResponse"]["examples"][0]["detail"] + assert ( + schema["paths"]["/repos/{repository_id}"]["get"]["responses"]["404"][ + "content" + ]["application/json"]["schema"]["$ref"] + ).endswith("/ErrorResponse") + assert ( + schema["paths"][ + "/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/" + "{target_analysis_run_id}" + ]["get"]["responses"]["200"]["content"]["application/json"]["schema"]["$ref"] + ).endswith("/AnalysisRunDiffResponse") + assert ( + components["RepositoryComparisonResponse"]["examples"][0][ + "unique_capabilities" + ][0]["capability_name"] + == "Classify Incoming Email" + ) + assert ( + components["CapabilityGapResponse"]["examples"][0]["missing_capabilities"][0] + == "Route Email to Team" + ) + + +def test_openapi_contract_snapshot_for_stable_agent_paths(): + client = TestClient(app) + + schema = client.get("/openapi.json").json() + + def success_schema(operation): + responses = operation["responses"] + status_code = "201" if "201" in responses else "200" + content = responses.get(status_code, {}).get("content", {}) + if "application/json" in content: + response_schema = content["application/json"]["schema"] + if "$ref" in response_schema: + return response_schema["$ref"].split("/")[-1] + if response_schema.get("type") == "array": + items = response_schema["items"] + if "$ref" in items: + return f"list[{items['$ref'].split('/')[-1]}]" + return "list" + return response_schema.get("type") + if "application/x-yaml" in content: + return "application/x-yaml" + return None + + stable_contract = { + path: { + method: { + "tags": operation["tags"], + "success_schema": success_schema(operation), + } + for method, operation in sorted(methods.items()) + } + for path, methods in sorted(schema["paths"].items()) + if not path.startswith("/ui") + } + + assert stable_contract == { + "/abilities": { + "get": {"tags": ["search"], "success_schema": "list[AbilitySummaryResponse]"} + }, + "/capabilities": { + "get": { + "tags": ["search"], + "success_schema": "list[CapabilitySummaryResponse]", + } + }, + "/capability-gaps": { + "post": {"tags": ["discovery"], "success_schema": "CapabilityGapResponse"} + }, + "/health": {"get": {"tags": ["health"], "success_schema": "object"}}, + "/repos": { + "get": {"tags": ["repositories"], "success_schema": "list[RepositoryResponse]"}, + "post": {"tags": ["repositories"], "success_schema": "RepositoryResponse"}, + }, + "/repos/{repository_id}": { + "delete": {"tags": ["repositories"], "success_schema": None}, + "get": {"tags": ["repositories"], "success_schema": "RepositoryResponse"}, + "patch": {"tags": ["repositories"], "success_schema": "RepositoryResponse"}, + }, + "/repos/{repository_id}/abilities": { + "post": {"tags": ["registry"], "success_schema": "IdResponse"} + }, + "/repos/{repository_id}/abilities/{ability_id}": { + "delete": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + "patch": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + }, + "/repos/{repository_id}/ability-map": { + "get": {"tags": ["registry"], "success_schema": "RepositoryAbilityMapResponse"} + }, + "/repos/{repository_id}/analysis-runs": { + "get": {"tags": ["analysis"], "success_schema": "list[AnalysisRunResponse]"}, + "post": {"tags": ["analysis"], "success_schema": "ScanSummaryResponse"}, + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}": { + "get": {"tags": ["analysis"], "success_schema": "AnalysisRunResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph": { + "get": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve": { + "post": { + "tags": ["review"], + "success_schema": "RepositoryAbilityMapResponse", + } + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/changes/approve": { + "post": { + "tags": ["review"], + "success_schema": "RepositoryAbilityMapResponse", + } + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/content-chunks": { + "get": {"tags": ["analysis"], "success_schema": "list[ContentChunkResponse]"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/review-decisions": { + "get": {"tags": ["review"], "success_schema": "list[ReviewDecisionResponse]"} + }, + "/repos/{repository_id}/analysis-runs/{base_analysis_run_id}/diff/{target_analysis_run_id}": { + "get": {"tags": ["review"], "success_schema": "AnalysisRunDiffResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-abilities/{candidate_ability_id}": { + "patch": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-abilities/{candidate_ability_id}/reject": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-abilities/{source_ability_id}/merge": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-capabilities/{candidate_capability_id}": { + "patch": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-capabilities/{candidate_capability_id}/reject": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-capabilities/{candidate_capability_id}/relink": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-capabilities/{source_capability_id}/merge": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-evidence/{candidate_evidence_id}/reject": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-evidence/{candidate_evidence_id}/relink": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-evidence/{source_evidence_id}/merge": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-features/{candidate_feature_id}/reject": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-features/{candidate_feature_id}/relink": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-features/{source_feature_id}/merge": { + "post": {"tags": ["review"], "success_schema": "CandidateGraphResponse"} + }, + "/repos/{repository_id}/capabilities": { + "post": {"tags": ["registry"], "success_schema": "IdResponse"} + }, + "/repos/{repository_id}/capabilities/{capability_id}": { + "delete": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + "patch": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + }, + "/repos/{repository_id}/content-chunks": { + "get": {"tags": ["analysis"], "success_schema": "list[ContentChunkResponse]"} + }, + "/repos/{repository_id}/evidence": { + "post": {"tags": ["registry"], "success_schema": "IdResponse"} + }, + "/repos/{repository_id}/evidence/{evidence_id}": { + "delete": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + "patch": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + }, + "/repos/{repository_id}/export": { + "get": {"tags": ["discovery"], "success_schema": "application/x-yaml"} + }, + "/repos/{repository_id}/features": { + "post": {"tags": ["registry"], "success_schema": "IdResponse"} + }, + "/repos/{repository_id}/features/{feature_id}": { + "delete": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + "patch": { + "tags": ["registry"], + "success_schema": "RepositoryAbilityMapResponse", + }, + }, + "/repos/{repository_id}/observed-facts": { + "get": {"tags": ["analysis"], "success_schema": "list[ObservedFactResponse]"} + }, + "/repos/{repository_id}/review-decisions": { + "get": {"tags": ["review"], "success_schema": "list[ReviewDecisionResponse]"} + }, + "/repository-comparisons": { + "get": { + "tags": ["discovery"], + "success_schema": "RepositoryComparisonResponse", + } + }, + "/search": { + "get": {"tags": ["search"], "success_schema": "list[SearchResultResponse]"} + }, + } def test_docs_endpoint_is_available(): diff --git a/workplans/RREG-WP-0002-production-hardening.md b/workplans/RREG-WP-0002-production-hardening.md index 395c2fe..6f4bb66 100644 --- a/workplans/RREG-WP-0002-production-hardening.md +++ b/workplans/RREG-WP-0002-production-hardening.md @@ -97,7 +97,7 @@ restore guidance for SQLite deployments. Migration strategy notes for PostgreSQL ```task id: RREG-WP-0002-T06 -status: todo +status: done priority: low state_hub_task_id: "271a4fc4-d966-40ef-bc6f-a5fd1c445a16" ```