diff --git a/src/repo_registry/web_api/app.py b/src/repo_registry/web_api/app.py index b46ff46..06b87a2 100644 --- a/src/repo_registry/web_api/app.py +++ b/src/repo_registry/web_api/app.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import asdict from pathlib import Path +from typing import Any from fastapi import Depends, FastAPI, HTTPException, Query from pydantic import BaseModel, Field @@ -324,6 +325,220 @@ class CandidateEvidenceMerge(BaseModel): } +class RepositoryResponse(BaseModel): + id: int + name: str + url: str + description: str | None + branch: str + status: str + + +class RepositorySnapshotResponse(BaseModel): + id: int + repository_id: int + commit_hash: str + branch: str + source_path: str + file_count: int + + +class AnalysisRunResponse(BaseModel): + id: int + repository_id: int + snapshot_id: int | None + status: str + started_at: str + completed_at: str | None + error_message: str | None + scanner_version: str + + +class ReviewDecisionResponse(BaseModel): + id: int + repository_id: int + analysis_run_id: int | None + action: str + notes: str + created_at: str + + +class ObservedFactResponse(BaseModel): + id: int + repository_id: int + analysis_run_id: int + snapshot_id: int | None + kind: str + path: str + name: str + value: str + metadata: dict[str, Any] + + +class ContentChunkResponse(BaseModel): + id: int + repository_id: int + analysis_run_id: int + snapshot_id: int | None + path: str + kind: str + start_line: int + end_line: int + text: str + + +class ScanSummaryResponse(BaseModel): + analysis_run: AnalysisRunResponse + snapshot: RepositorySnapshotResponse | None + facts: list[ObservedFactResponse] + + +class SourceReferenceResponse(BaseModel): + fact_id: int | None + path: str + kind: str + name: str + line: int | None = None + + +class CandidateEvidenceResponse(BaseModel): + id: int + type: str + reference: str + strength: str + status: str + source_refs: list[SourceReferenceResponse] + + +class CandidateFeatureResponse(BaseModel): + id: int + name: str + type: str + location: str + confidence: float + status: str + source_refs: list[SourceReferenceResponse] + confidence_label: str + + +class CandidateCapabilityResponse(BaseModel): + id: int + name: str + description: str + inputs: list[str] + outputs: list[str] + confidence: float + status: str + source_refs: list[SourceReferenceResponse] + confidence_label: str + features: list[CandidateFeatureResponse] + evidence: list[CandidateEvidenceResponse] + + +class CandidateAbilityResponse(BaseModel): + id: int + name: str + description: str + confidence: float + status: str + source_refs: list[SourceReferenceResponse] + confidence_label: str + capabilities: list[CandidateCapabilityResponse] + + +class CandidateGraphResponse(BaseModel): + repository: RepositoryResponse + analysis_run: AnalysisRunResponse + abilities: list[CandidateAbilityResponse] + + +class EvidenceResponse(BaseModel): + id: int + type: str + reference: str + strength: str + source_refs: list[SourceReferenceResponse] + + +class FeatureResponse(BaseModel): + id: int + name: str + type: str + location: str + confidence: float + confidence_label: str + source_refs: list[SourceReferenceResponse] + + +class CapabilityResponse(BaseModel): + id: int + name: str + description: str + inputs: list[str] + outputs: list[str] + confidence: float + confidence_label: str + features: list[FeatureResponse] + evidence: list[EvidenceResponse] + + +class AbilityResponse(BaseModel): + id: int + name: str + description: str + confidence: float + confidence_label: str + capabilities: list[CapabilityResponse] + + +class RepositoryAbilityMapResponse(BaseModel): + repository: RepositoryResponse + abilities: list[AbilityResponse] + + +class IdResponse(BaseModel): + id: int + + +class SearchResultResponse(BaseModel): + repository_id: int + repository_name: str + match_type: str + match_name: str + confidence: float + confidence_label: str + match_description: str + matched_field: str + ability_id: int | None = None + ability_name: str | None = None + capability_id: int | None = None + capability_name: str | None = None + evidence_level: str | None = None + source_reference: str | None = None + + +class AbilitySummaryResponse(BaseModel): + id: int + repository_id: int + repository_name: str + name: str + description: str + confidence: float + confidence_label: str + + +class CapabilitySummaryResponse(BaseModel): + id: int + repository_id: int + repository_name: str + ability_id: int + ability_name: str + name: str + description: str + confidence: float + confidence_label: str + + API_DESCRIPTION = ( "Register repositories, analyze their observable implementation facts, " "curate reviewable ability graphs, and search approved repository abilities." @@ -356,7 +571,12 @@ def health() -> dict[str, str]: return {"status": "ok"} -@app.post("/repos", status_code=201, tags=["repositories"]) +@app.post( + "/repos", + status_code=201, + tags=["repositories"], + response_model=RepositoryResponse, +) def create_repository( payload: RepositoryCreate, service: RegistryService = Depends(get_service), @@ -368,14 +588,18 @@ def create_repository( return asdict(repository) -@app.get("/repos", tags=["repositories"]) +@app.get("/repos", tags=["repositories"], response_model=list[RepositoryResponse]) def list_repositories( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [asdict(repository) for repository in service.list_repositories()] -@app.get("/repos/{repository_id}", tags=["repositories"]) +@app.get( + "/repos/{repository_id}", + tags=["repositories"], + response_model=RepositoryResponse, +) def get_repository( repository_id: int, service: RegistryService = Depends(get_service), @@ -386,7 +610,11 @@ def get_repository( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.patch("/repos/{repository_id}", tags=["repositories"]) +@app.patch( + "/repos/{repository_id}", + tags=["repositories"], + response_model=RepositoryResponse, +) def update_repository( repository_id: int, payload: RepositoryUpdate, @@ -414,7 +642,12 @@ def delete_repository( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.post("/repos/{repository_id}/analysis-runs", status_code=201, tags=["analysis"]) +@app.post( + "/repos/{repository_id}/analysis-runs", + status_code=201, + tags=["analysis"], + response_model=ScanSummaryResponse, +) def create_analysis_run( repository_id: int, payload: AnalysisRunCreate, @@ -430,7 +663,11 @@ def create_analysis_run( return asdict(summary) -@app.get("/repos/{repository_id}/analysis-runs", tags=["analysis"]) +@app.get( + "/repos/{repository_id}/analysis-runs", + tags=["analysis"], + response_model=list[AnalysisRunResponse], +) def list_analysis_runs( repository_id: int, service: RegistryService = Depends(get_service), @@ -441,7 +678,11 @@ def list_analysis_runs( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.get("/repos/{repository_id}/analysis-runs/{analysis_run_id}", tags=["analysis"]) +@app.get( + "/repos/{repository_id}/analysis-runs/{analysis_run_id}", + tags=["analysis"], + response_model=AnalysisRunResponse, +) def get_analysis_run( repository_id: int, analysis_run_id: int, @@ -453,7 +694,11 @@ def get_analysis_run( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.get("/repos/{repository_id}/review-decisions", tags=["review"]) +@app.get( + "/repos/{repository_id}/review-decisions", + tags=["review"], + response_model=list[ReviewDecisionResponse], +) def list_repository_review_decisions( repository_id: int, service: RegistryService = Depends(get_service), @@ -470,6 +715,7 @@ def list_repository_review_decisions( @app.get( "/repos/{repository_id}/analysis-runs/{analysis_run_id}/review-decisions", tags=["review"], + response_model=list[ReviewDecisionResponse], ) def list_analysis_run_review_decisions( repository_id: int, @@ -488,7 +734,11 @@ def list_analysis_run_review_decisions( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.get("/repos/{repository_id}/observed-facts", tags=["analysis"]) +@app.get( + "/repos/{repository_id}/observed-facts", + tags=["analysis"], + response_model=list[ObservedFactResponse], +) def list_observed_facts( repository_id: int, analysis_run_id: int | None = None, @@ -503,7 +753,11 @@ def list_observed_facts( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.get("/repos/{repository_id}/content-chunks", tags=["analysis"]) +@app.get( + "/repos/{repository_id}/content-chunks", + tags=["analysis"], + response_model=list[ContentChunkResponse], +) def list_content_chunks( repository_id: int, analysis_run_id: int | None = None, @@ -521,6 +775,7 @@ def list_content_chunks( @app.get( "/repos/{repository_id}/analysis-runs/{analysis_run_id}/content-chunks", tags=["analysis"], + response_model=list[ContentChunkResponse], ) def list_analysis_run_content_chunks( repository_id: int, @@ -539,6 +794,7 @@ def list_analysis_run_content_chunks( @app.get( "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph", tags=["review"], + response_model=CandidateGraphResponse, ) def get_candidate_graph( repository_id: int, @@ -554,6 +810,7 @@ def get_candidate_graph( @app.post( "/repos/{repository_id}/analysis-runs/{analysis_run_id}/candidate-graph/approve", tags=["review"], + response_model=RepositoryAbilityMapResponse, ) def approve_candidate_graph( repository_id: int, @@ -577,6 +834,7 @@ def approve_candidate_graph( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{candidate_ability_id}/reject", tags=["review"], + response_model=CandidateGraphResponse, ) def reject_candidate_ability( repository_id: int, @@ -602,6 +860,7 @@ def reject_candidate_ability( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-capabilities/{candidate_capability_id}/reject", tags=["review"], + response_model=CandidateGraphResponse, ) def reject_candidate_capability( repository_id: int, @@ -627,6 +886,7 @@ def reject_candidate_capability( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-features/{candidate_feature_id}/reject", tags=["review"], + response_model=CandidateGraphResponse, ) def reject_candidate_feature( repository_id: int, @@ -652,6 +912,7 @@ def reject_candidate_feature( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-evidence/{candidate_evidence_id}/reject", tags=["review"], + response_model=CandidateGraphResponse, ) def reject_candidate_evidence( repository_id: int, @@ -677,6 +938,7 @@ def reject_candidate_evidence( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{candidate_ability_id}", tags=["review"], + response_model=CandidateGraphResponse, ) def edit_candidate_ability( repository_id: int, @@ -702,6 +964,7 @@ def edit_candidate_ability( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-capabilities/{candidate_capability_id}", tags=["review"], + response_model=CandidateGraphResponse, ) def edit_candidate_capability( repository_id: int, @@ -727,6 +990,7 @@ def edit_candidate_capability( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-capabilities/{candidate_capability_id}/relink", tags=["review"], + response_model=CandidateGraphResponse, ) def relink_candidate_capability( repository_id: int, @@ -752,6 +1016,7 @@ def relink_candidate_capability( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-features/{candidate_feature_id}/relink", tags=["review"], + response_model=CandidateGraphResponse, ) def relink_candidate_feature( repository_id: int, @@ -777,6 +1042,7 @@ def relink_candidate_feature( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-evidence/{candidate_evidence_id}/relink", tags=["review"], + response_model=CandidateGraphResponse, ) def relink_candidate_evidence( repository_id: int, @@ -802,6 +1068,7 @@ def relink_candidate_evidence( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-abilities/{source_ability_id}/merge", tags=["review"], + response_model=CandidateGraphResponse, ) def merge_candidate_ability( repository_id: int, @@ -827,6 +1094,7 @@ def merge_candidate_ability( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-capabilities/{source_capability_id}/merge", tags=["review"], + response_model=CandidateGraphResponse, ) def merge_candidate_capability( repository_id: int, @@ -852,6 +1120,7 @@ def merge_candidate_capability( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-features/{source_feature_id}/merge", tags=["review"], + response_model=CandidateGraphResponse, ) def merge_candidate_feature( repository_id: int, @@ -877,6 +1146,7 @@ def merge_candidate_feature( "/repos/{repository_id}/analysis-runs/{analysis_run_id}" "/candidate-evidence/{source_evidence_id}/merge", tags=["review"], + response_model=CandidateGraphResponse, ) def merge_candidate_evidence( repository_id: int, @@ -898,7 +1168,12 @@ def merge_candidate_evidence( raise HTTPException(status_code=400, detail=str(exc)) from exc -@app.post("/repos/{repository_id}/abilities", status_code=201, tags=["registry"]) +@app.post( + "/repos/{repository_id}/abilities", + status_code=201, + tags=["registry"], + response_model=IdResponse, +) def create_ability( repository_id: int, payload: AbilityCreate, @@ -911,7 +1186,11 @@ def create_ability( return {"id": ability_id} -@app.patch("/repos/{repository_id}/abilities/{ability_id}", tags=["registry"]) +@app.patch( + "/repos/{repository_id}/abilities/{ability_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def update_ability( repository_id: int, ability_id: int, @@ -930,7 +1209,11 @@ def update_ability( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.delete("/repos/{repository_id}/abilities/{ability_id}", tags=["registry"]) +@app.delete( + "/repos/{repository_id}/abilities/{ability_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def delete_ability( repository_id: int, ability_id: int, @@ -942,7 +1225,12 @@ def delete_ability( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.post("/repos/{repository_id}/capabilities", status_code=201, tags=["registry"]) +@app.post( + "/repos/{repository_id}/capabilities", + status_code=201, + tags=["registry"], + response_model=IdResponse, +) def create_capability( repository_id: int, payload: CapabilityCreate, @@ -955,7 +1243,11 @@ def create_capability( return {"id": capability_id} -@app.patch("/repos/{repository_id}/capabilities/{capability_id}", tags=["registry"]) +@app.patch( + "/repos/{repository_id}/capabilities/{capability_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def update_capability( repository_id: int, capability_id: int, @@ -974,7 +1266,11 @@ def update_capability( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.delete("/repos/{repository_id}/capabilities/{capability_id}", tags=["registry"]) +@app.delete( + "/repos/{repository_id}/capabilities/{capability_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def delete_capability( repository_id: int, capability_id: int, @@ -986,7 +1282,12 @@ def delete_capability( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.post("/repos/{repository_id}/features", status_code=201, tags=["registry"]) +@app.post( + "/repos/{repository_id}/features", + status_code=201, + tags=["registry"], + response_model=IdResponse, +) def create_feature( repository_id: int, payload: FeatureCreate, @@ -999,7 +1300,11 @@ def create_feature( return {"id": feature_id} -@app.patch("/repos/{repository_id}/features/{feature_id}", tags=["registry"]) +@app.patch( + "/repos/{repository_id}/features/{feature_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def update_feature( repository_id: int, feature_id: int, @@ -1018,7 +1323,11 @@ def update_feature( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.delete("/repos/{repository_id}/features/{feature_id}", tags=["registry"]) +@app.delete( + "/repos/{repository_id}/features/{feature_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def delete_feature( repository_id: int, feature_id: int, @@ -1030,7 +1339,12 @@ def delete_feature( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.post("/repos/{repository_id}/evidence", status_code=201, tags=["registry"]) +@app.post( + "/repos/{repository_id}/evidence", + status_code=201, + tags=["registry"], + response_model=IdResponse, +) def create_evidence( repository_id: int, payload: EvidenceCreate, @@ -1043,7 +1357,11 @@ def create_evidence( return {"id": evidence_id} -@app.patch("/repos/{repository_id}/evidence/{evidence_id}", tags=["registry"]) +@app.patch( + "/repos/{repository_id}/evidence/{evidence_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def update_evidence( repository_id: int, evidence_id: int, @@ -1062,7 +1380,11 @@ def update_evidence( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.delete("/repos/{repository_id}/evidence/{evidence_id}", tags=["registry"]) +@app.delete( + "/repos/{repository_id}/evidence/{evidence_id}", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def delete_evidence( repository_id: int, evidence_id: int, @@ -1074,7 +1396,11 @@ def delete_evidence( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.get("/repos/{repository_id}/ability-map", tags=["registry"]) +@app.get( + "/repos/{repository_id}/ability-map", + tags=["registry"], + response_model=RepositoryAbilityMapResponse, +) def get_ability_map( repository_id: int, service: RegistryService = Depends(get_service), @@ -1085,7 +1411,7 @@ def get_ability_map( raise HTTPException(status_code=404, detail=str(exc)) from exc -@app.get("/search", tags=["search"]) +@app.get("/search", tags=["search"], response_model=list[SearchResultResponse]) def search( q: str = Query( ..., @@ -1127,14 +1453,18 @@ def search( ] -@app.get("/abilities", tags=["search"]) +@app.get("/abilities", tags=["search"], response_model=list[AbilitySummaryResponse]) def list_abilities( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: return [asdict(ability) for ability in service.list_abilities()] -@app.get("/capabilities", tags=["search"]) +@app.get( + "/capabilities", + tags=["search"], + response_model=list[CapabilitySummaryResponse], +) def list_capabilities( service: RegistryService = Depends(get_service), ) -> list[dict[str, object]]: diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 247a0ac..c19f7e9 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -20,10 +20,18 @@ def test_openapi_groups_agent_facing_endpoints(): } search_operation = schema["paths"]["/search"]["get"] assert search_operation["tags"] == ["search"] + search_response = search_operation["responses"]["200"]["content"][ + "application/json" + ]["schema"] + assert search_response["items"]["$ref"].endswith("/SearchResultResponse") assert { parameter["name"]: parameter["description"] for parameter in search_operation["parameters"] }["q"].startswith("Natural-language") + ability_map_response = schema["paths"]["/repos/{repository_id}/ability-map"][ + "get" + ]["responses"]["200"]["content"]["application/json"]["schema"] + assert ability_map_response["$ref"].endswith("/RepositoryAbilityMapResponse") def test_api_manual_registry_loop(tmp_path):