API-completeness polish

This commit is contained in:
2026-04-26 09:12:27 +02:00
parent 70feabe965
commit d786589dd1
2 changed files with 364 additions and 26 deletions

View File

@@ -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]]:

View File

@@ -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):