From 0fae53a7de7319669421e12be1bb87fcc6f0b419 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 18 Jul 2026 05:07:35 +0200 Subject: [PATCH] Harden RC7 API response contracts --- CHANGELOG.md | 8 + backend/README.md | 13 + backend/app/api/routes/analysis.py | 3 +- backend/app/api/routes/areas.py | 11 +- backend/app/api/routes/assistant.py | 17 +- backend/app/api/routes/datasets.py | 274 ++++++++++++++---- backend/app/api/routes/demo.py | 7 +- backend/app/api/routes/detection.py | 55 +++- backend/app/api/routes/exports.py | 29 +- backend/app/api/routes/external.py | 50 +++- backend/app/api/routes/jobs.py | 10 +- backend/app/api/routes/projects.py | 17 +- backend/app/api/routes/qa.py | 4 +- backend/app/api/routes/quality_checks.py | 20 +- backend/app/api/routes/segmentation.py | 49 +++- backend/app/api/routes/temporal.py | 11 +- backend/app/schemas/__init__.py | 27 +- backend/app/schemas/area.py | 1 + backend/app/schemas/assistant.py | 6 + backend/app/schemas/common.py | 33 ++- backend/app/schemas/detection.py | 45 +++ backend/app/schemas/project.py | 4 + backend/app/schemas/qa.py | 40 +++ backend/app/services/area_service.py | 16 +- .../tests/test_rc7_api_response_contracts.py | 53 ++++ .../test_sprint112_qa_evidence_overlay.py | 5 + backend/tests/test_sprint205_dhmv_terrain.py | 36 ++- .../tests/test_sprint208_vmm_flood_hazard.py | 40 ++- .../tests/test_sprint213_thematic_rasters.py | 24 +- ...test_sprint219_regional_raster_explorer.py | 12 +- .../test_sprint236_bathymetry_expansion.py | 6 + docs/API_CONTRACTS.md | 6 + docs/CODEX_EXECUTION_LOG.md | 30 ++ docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md | 11 +- docs/TODO.md | 2 +- scripts/audit_api_contracts.py | 64 +++- 36 files changed, 886 insertions(+), 153 deletions(-) create mode 100644 backend/tests/test_rc7_api_response_contracts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aa624bb9..a544f9f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,14 @@ reports zero reachable fixed HIGH/CRITICAL vulnerabilities. - Folded fresh-install, upgrade, rollback and runtime proof into RC-5 and RC-11 instead of creating a separate RC-12 phase. +- Completed RC-7 response hardening across every successful JSON route with + concrete Pydantic/OpenAPI schemas and the canonical data envelope. +- Added an executable OpenAPI contract audit covering 124 implemented routes + and 228 component schemas. The eight explicit non-envelope operations are + limited to health probes, persisted raster PNG responses and export + download. +- Passed the RC-7 release gate with 1,008 backend tests, frontend + typecheck/build, one Alembic head and offline migration SQL generation. - Added a read-only release-evidence manifest command with Git, migration, dependency, configuration checksum and optional live endpoint evidence. - Replaced the obsolete pre-build status with the current implemented diff --git a/backend/README.md b/backend/README.md index 73e4591c..75a6f904 100644 --- a/backend/README.md +++ b/backend/README.md @@ -661,6 +661,19 @@ bash scripts/run_readiness_check.sh That readiness gate also runs the API contract smoke check before backend/frontend compilation and tests. +The RC contract gate loads the generated FastAPI OpenAPI document and requires +every successful JSON operation to expose a concrete Pydantic response schema +inside the canonical `{"data": ...}` envelope. Run it directly with: + +```bash +python scripts/audit_api_contracts.py +``` + +The only tracked non-envelope operations are the three health probes, the four +persisted raster PNG responses and the streamed export download. A newly added +free-form JSON response or undocumented exception fails both the focused RC-7 +test and the repository readiness gate. + ### Golden QA/QC benchmark Sprint 12 includes a deterministic QA/QC regression benchmark using explicit fixture data: diff --git a/backend/app/api/routes/analysis.py b/backend/app/api/routes/analysis.py index 76e158f2..fea2d265 100644 --- a/backend/app/api/routes/analysis.py +++ b/backend/app/api/routes/analysis.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.core.errors import AppError from app.db.session import get_db from app.models import Dataset +from app.schemas import Envelope, JobRead from app.schemas.analysis import ChangeDetectionRequest from app.services.change_detection_service import ChangeDetectionService from app.services.job_service import JobService @@ -14,7 +15,7 @@ from app.utils.response import envelope router = APIRouter(prefix="/analysis", tags=["analysis"]) -@router.post("/change-detection", response_model=dict) +@router.post("/change-detection", response_model=Envelope[JobRead]) def run_change_detection( payload: ChangeDetectionRequest, db: Session = Depends(get_db), diff --git a/backend/app/api/routes/areas.py b/backend/app/api/routes/areas.py index 677d63a5..08f708c6 100644 --- a/backend/app/api/routes/areas.py +++ b/backend/app/api/routes/areas.py @@ -8,14 +8,15 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.models import Area -from app.schemas.area import AreaCreate, AreaUpdate +from app.schemas import Envelope +from app.schemas.area import AreaCreate, AreaList, AreaRead, AreaUpdate from app.services.area_service import AreaService from app.utils.response import envelope router = APIRouter(prefix="/projects/{project_id}/areas", tags=["areas"]) -@router.get("", response_model=dict) +@router.get("", response_model=Envelope[AreaList]) def list_areas( project_id: UUID, limit: int = Query(default=50, ge=1, le=200), @@ -26,13 +27,13 @@ def list_areas( return envelope({"items": [AreaService.serialize_area(area) for area in areas], "total": total, "limit": limit, "offset": offset}) -@router.post("", status_code=201, response_model=dict) +@router.post("", status_code=201, response_model=Envelope[AreaRead]) def create_area(project_id: UUID, payload: AreaCreate, db: Session = Depends(get_db)): area = AreaService.create_area(db, project_id, payload) return envelope(AreaService.serialize_area(area)) -@router.get("/{area_id}", response_model=dict) +@router.get("/{area_id}", response_model=Envelope[AreaRead]) def get_area( project_id: UUID, area_id: UUID, @@ -44,7 +45,7 @@ def get_area( return envelope(AreaService.serialize_area(area)) -@router.patch("/{area_id}", response_model=dict) +@router.patch("/{area_id}", response_model=Envelope[AreaRead]) def update_area( project_id: UUID, area_id: UUID, diff --git a/backend/app/api/routes/assistant.py b/backend/app/api/routes/assistant.py index d8820117..842bb213 100644 --- a/backend/app/api/routes/assistant.py +++ b/backend/app/api/routes/assistant.py @@ -6,7 +6,13 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas.assistant import AssistantQueryRequest +from app.schemas import Envelope +from app.schemas.assistant import ( + AssistantModelList, + AssistantQueryRequest, + AssistantQueryResponse, + AssistantStatus, +) from app.services.geo_assistant_service import GeoAssistantService from app.utils.response import envelope @@ -14,12 +20,12 @@ from app.utils.response import envelope router = APIRouter(tags=["assistant"]) -@router.get("/assistant/status", response_model=dict) +@router.get("/assistant/status", response_model=Envelope[AssistantStatus]) def assistant_status() -> dict: return envelope(GeoAssistantService().status().model_dump()) -@router.get("/assistant/models", response_model=dict) +@router.get("/assistant/models", response_model=Envelope[AssistantModelList]) def assistant_models() -> dict: service = GeoAssistantService() models = service.list_models() @@ -32,7 +38,10 @@ def assistant_models() -> dict: ) -@router.post("/projects/{project_id}/assistant/query", response_model=dict) +@router.post( + "/projects/{project_id}/assistant/query", + response_model=Envelope[AssistantQueryResponse], +) def assistant_query( project_id: UUID, payload: AssistantQueryRequest, diff --git a/backend/app/api/routes/datasets.py b/backend/app/api/routes/datasets.py index 96435431..b5ce4613 100644 --- a/backend/app/api/routes/datasets.py +++ b/backend/app/api/routes/datasets.py @@ -13,6 +13,24 @@ from app.models import Area, Project from app.core.errors import AppError from app.db.session import get_db from app.schemas import ( + BathymetryPartitionFinalizationResult, + BathymetrySourceProbeRead, + BathymetrySourceRead, + DatasetList, + DhmvProductRead, + Envelope, + FloodHazardProductRead, + FloodHazardSelectionResponse, + GeoJsonFeatureCollection, + GrbProductRead, + GrbRefreshPlan, + ItemList, + JobRead, + OfficialVectorProductRead, + OrthophotoProductRead, + RasterMetadataResponse, + RasterOperationResult, + RasterPreviewResponse, RasterClipRequest, RasterStatsResponse, RasterReprojectRequest, @@ -23,6 +41,7 @@ from app.schemas import ( OrthophotoAcquireRequest, DhmvAcquireRequest, TerrainPartitionSelectionRequest, + TerrainSelectionResponse, TerrainSelectionRequest, FloodHazardAcquireRequest, FloodHazardPartitionSelectionRequest, @@ -30,6 +49,8 @@ from app.schemas import ( BathymetryPartitionFinalizeRequest, BathymetryProfileAcquireRequest, ThematicRasterAcquireRequest, + ThematicRasterProductRead, + ThematicRasterSelectionResponse, ThematicRasterSelectionRequest, GrbAcquireRequest, OfficialVectorAcquireRequest, @@ -37,12 +58,21 @@ from app.schemas import ( VectorBufferRequest, VectorClipRequest, VectorIntersectRequest, + VectorOperationResult, VectorSelectionBBox, # noqa: F401 - retained as a route-module compatibility export VectorSelectionDeriveRequest, VectorSelectionRequest, VectorSelectionResponse, + VectorStatsResponse, ) -from app.schemas.dataset import DatasetCreateResponse, DatasetTemporalUpdate +from app.schemas.dataset import ( + DatasetCreateResponse, + DatasetTemporalUpdate, + DatasetVectorSummary, + DatasetVersionRead, +) +from app.schemas.source_catalog import SourceCatalogProbeReport +from app.schemas.source_freshness import SourceFreshnessReport from app.services.job_service import JobService from app.services.raster_operations_service import RasterOperationsService from app.services.vector_operations_service import VectorOperationsService @@ -100,7 +130,11 @@ def _run_job_sync( ) -@router.post("/datasets/upload", status_code=201, response_model=dict) +@router.post( + "/datasets/upload", + status_code=201, + response_model=Envelope[DatasetCreateResponse], +) async def upload_dataset( project_id: UUID, file: UploadFile = File(...), @@ -149,7 +183,7 @@ async def upload_dataset( return envelope(created.model_dump()) -@router.post("/datasets/orthophoto/acquire", response_model=dict) +@router.post("/datasets/orthophoto/acquire", response_model=Envelope[JobRead]) def acquire_bounded_orthophoto( project_id: UUID, payload: OrthophotoAcquireRequest, @@ -165,7 +199,10 @@ def acquire_bounded_orthophoto( return envelope(job) -@router.get("/datasets/orthophoto/products", response_model=dict) +@router.get( + "/datasets/orthophoto/products", + response_model=Envelope[ItemList[OrthophotoProductRead]], +) def list_orthophoto_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -173,7 +210,7 @@ def list_orthophoto_products(project_id: UUID, db: Session = Depends(get_db)): return envelope({"items": items, "total": len(items)}) -@router.post("/datasets/dhmv/acquire", response_model=dict) +@router.post("/datasets/dhmv/acquire", response_model=Envelope[JobRead]) def acquire_bounded_dhmv( project_id: UUID, payload: DhmvAcquireRequest, @@ -189,7 +226,10 @@ def acquire_bounded_dhmv( return envelope(job) -@router.get("/datasets/dhmv/products", response_model=dict) +@router.get( + "/datasets/dhmv/products", + response_model=Envelope[ItemList[DhmvProductRead]], +) def list_dhmv_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -197,7 +237,7 @@ def list_dhmv_products(project_id: UUID, db: Session = Depends(get_db)): return envelope({"items": items, "total": len(items)}) -@router.post("/datasets/grb/acquire", response_model=dict) +@router.post("/datasets/grb/acquire", response_model=Envelope[JobRead]) def acquire_bounded_grb( project_id: UUID, payload: GrbAcquireRequest, @@ -213,7 +253,10 @@ def acquire_bounded_grb( return envelope(job) -@router.get("/datasets/grb/products", response_model=dict) +@router.get( + "/datasets/grb/products", + response_model=Envelope[ItemList[GrbProductRead]], +) def list_grb_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -221,7 +264,7 @@ def list_grb_products(project_id: UUID, db: Session = Depends(get_db)): return envelope({"items": items, "total": len(items)}) -@router.post("/datasets/official-vector/acquire", response_model=dict) +@router.post("/datasets/official-vector/acquire", response_model=Envelope[JobRead]) def acquire_bounded_official_vector( project_id: UUID, payload: OfficialVectorAcquireRequest, @@ -237,7 +280,10 @@ def acquire_bounded_official_vector( return envelope(job) -@router.get("/datasets/official-vector/products", response_model=dict) +@router.get( + "/datasets/official-vector/products", + response_model=Envelope[ItemList[OfficialVectorProductRead]], +) def list_official_vector_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -245,7 +291,7 @@ def list_official_vector_products(project_id: UUID, db: Session = Depends(get_db return envelope({"items": items, "total": len(items)}) -@router.post("/datasets/flood-hazard/acquire", response_model=dict) +@router.post("/datasets/flood-hazard/acquire", response_model=Envelope[JobRead]) def acquire_bounded_flood_hazard( project_id: UUID, payload: FloodHazardAcquireRequest, @@ -261,7 +307,10 @@ def acquire_bounded_flood_hazard( return envelope(job) -@router.get("/datasets/flood-hazard/products", response_model=dict) +@router.get( + "/datasets/flood-hazard/products", + response_model=Envelope[ItemList[FloodHazardProductRead]], +) def list_flood_hazard_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -269,7 +318,10 @@ def list_flood_hazard_products(project_id: UUID, db: Session = Depends(get_db)): return envelope({"items": items, "total": len(items)}) -@router.get("/datasets/bathymetry/sources", response_model=dict) +@router.get( + "/datasets/bathymetry/sources", + response_model=Envelope[ItemList[BathymetrySourceRead]], +) def list_bathymetry_sources(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -277,14 +329,20 @@ def list_bathymetry_sources(project_id: UUID, db: Session = Depends(get_db)): return envelope({"items": items, "total": len(items)}) -@router.get("/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness", response_model=dict) +@router.get( + "/datasets/bathymetry/sources/mdk_bcp_bathymetry/readiness", + response_model=Envelope[BathymetrySourceProbeRead], +) def probe_mdk_bathymetry_readiness(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) return envelope(MdkBathymetryProbeService.probe()) -@router.post("/datasets/bathymetry/profiles/acquire", response_model=dict) +@router.post( + "/datasets/bathymetry/profiles/acquire", + response_model=Envelope[JobRead], +) def acquire_bounded_bathymetry_profiles( project_id: UUID, payload: BathymetryProfileAcquireRequest, @@ -300,7 +358,10 @@ def acquire_bounded_bathymetry_profiles( return envelope(job) -@router.post("/datasets/bathymetry/profiles/partitions/finalize", response_model=dict) +@router.post( + "/datasets/bathymetry/profiles/partitions/finalize", + response_model=Envelope[BathymetryPartitionFinalizationResult], +) def finalize_bathymetry_profile_partitions( project_id: UUID, payload: BathymetryPartitionFinalizeRequest, @@ -309,7 +370,10 @@ def finalize_bathymetry_profile_partitions( return envelope(BathymetryProfileAcquisitionService.finalize_partitions(db, project_id, payload)) -@router.post("/datasets/bathymetry/profiles/partitions/select", response_model=dict) +@router.post( + "/datasets/bathymetry/profiles/partitions/select", + response_model=Envelope[VectorSelectionResponse], +) def select_bathymetry_profile_partitions( project_id: UUID, payload: VectorSelectionRequest, @@ -344,7 +408,7 @@ def select_bathymetry_profile_partitions( return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True)) -@router.post("/datasets/thematic-raster/acquire", response_model=dict) +@router.post("/datasets/thematic-raster/acquire", response_model=Envelope[JobRead]) def acquire_bounded_thematic_raster( project_id: UUID, payload: ThematicRasterAcquireRequest, @@ -360,7 +424,10 @@ def acquire_bounded_thematic_raster( return envelope(job) -@router.get("/datasets/thematic-raster/products", response_model=dict) +@router.get( + "/datasets/thematic-raster/products", + response_model=Envelope[ItemList[ThematicRasterProductRead]], +) def list_thematic_raster_products(project_id: UUID, db: Session = Depends(get_db)): if not db.get(Project, project_id): raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404) @@ -368,7 +435,7 @@ def list_thematic_raster_products(project_id: UUID, db: Session = Depends(get_db return envelope({"items": items, "total": len(items)}) -@router.get("/datasets", response_model=dict) +@router.get("/datasets", response_model=Envelope[DatasetList]) def list_datasets( project_id: UUID, limit: int = Query(default=50, ge=1, le=200), @@ -379,7 +446,10 @@ def list_datasets( return envelope({"items": [item.model_dump() for item in datasets], "total": total, "limit": limit, "offset": offset}) -@router.get("/datasets/source-freshness", response_model=dict) +@router.get( + "/datasets/source-freshness", + response_model=Envelope[SourceFreshnessReport], +) def audit_dataset_source_freshness( project_id: UUID, db: Session = Depends(get_db), @@ -388,7 +458,10 @@ def audit_dataset_source_freshness( return envelope(report.model_dump()) -@router.get("/datasets/source-catalog-probes", response_model=dict) +@router.get( + "/datasets/source-catalog-probes", + response_model=Envelope[SourceCatalogProbeReport], +) def probe_dataset_source_catalogs( project_id: UUID, refresh: bool = Query(default=False), @@ -398,7 +471,10 @@ def probe_dataset_source_catalogs( return envelope(report.model_dump()) -@router.get("/datasets/grb-refresh-plan", response_model=dict) +@router.get( + "/datasets/grb-refresh-plan", + response_model=Envelope[GrbRefreshPlan], +) def plan_grb_dataset_refresh( project_id: UUID, scope: str = Query(default=GrbRefreshPlanService.SCOPE), @@ -414,7 +490,7 @@ def plan_grb_dataset_refresh( return envelope(report.model_dump()) -@router.get("/datasets/{dataset_id}", response_model=dict) +@router.get("/datasets/{dataset_id}", response_model=Envelope[DatasetCreateResponse]) def get_dataset( project_id: UUID, dataset_id: UUID, @@ -426,7 +502,10 @@ def get_dataset( return envelope(DatasetCreateResponse.model_validate(dataset).model_dump()) -@router.patch("/datasets/{dataset_id}/temporal", response_model=dict) +@router.patch( + "/datasets/{dataset_id}/temporal", + response_model=Envelope[DatasetCreateResponse], +) def update_dataset_temporal_metadata( project_id: UUID, dataset_id: UUID, @@ -440,7 +519,10 @@ def update_dataset_temporal_metadata( return envelope(updated.model_dump()) -@router.get("/datasets/{dataset_id}/versions", response_model=dict) +@router.get( + "/datasets/{dataset_id}/versions", + response_model=Envelope[ItemList[DatasetVersionRead]], +) def list_dataset_versions( project_id: UUID, dataset_id: UUID, @@ -453,7 +535,10 @@ def list_dataset_versions( return envelope({"items": [item.model_dump() for item in versions], "total": len(versions)}) -@router.post("/datasets/{dataset_id}/metadata/refresh", response_model=dict) +@router.post( + "/datasets/{dataset_id}/metadata/refresh", + response_model=Envelope[DatasetCreateResponse], +) def refresh_dataset_metadata( project_id: UUID, dataset_id: UUID, @@ -466,7 +551,10 @@ def refresh_dataset_metadata( return envelope(refreshed.model_dump()) -@router.get("/datasets/{dataset_id}/vector/inspect", response_model=dict) +@router.get( + "/datasets/{dataset_id}/vector/inspect", + response_model=Envelope[VectorOperationResult], +) def inspect_vector_dataset( project_id: UUID, dataset_id: UUID, @@ -478,7 +566,10 @@ def inspect_vector_dataset( return envelope(VectorOperationsService.inspect(db, dataset_id).model_dump()) -@router.get("/datasets/{dataset_id}/vector/bbox", response_model=dict) +@router.get( + "/datasets/{dataset_id}/vector/bbox", + response_model=Envelope[VectorBBoxResponse], +) def vector_bbox( project_id: UUID, dataset_id: UUID, @@ -491,7 +582,10 @@ def vector_bbox( return envelope(VectorBBoxResponse(**payload).model_dump()) -@router.get("/datasets/{dataset_id}/vector/stats", response_model=dict) +@router.get( + "/datasets/{dataset_id}/vector/stats", + response_model=Envelope[VectorStatsResponse], +) def vector_stats( project_id: UUID, dataset_id: UUID, @@ -503,7 +597,10 @@ def vector_stats( return envelope(VectorOperationsService.stats(db, dataset_id)) -@router.post("/datasets/{dataset_id}/vector/select", response_model=dict) +@router.post( + "/datasets/{dataset_id}/vector/select", + response_model=Envelope[VectorSelectionResponse], +) def select_vector_features( project_id: UUID, dataset_id: UUID, @@ -562,7 +659,11 @@ def select_vector_features( return envelope(VectorSelectionResponse(**result).model_dump(exclude_none=True)) -@router.post("/datasets/{dataset_id}/vector/select/derive", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/vector/select/derive", + status_code=201, + response_model=Envelope[DatasetCreateResponse], +) def derive_vector_selection_dataset( project_id: UUID, dataset_id: UUID, @@ -597,7 +698,11 @@ def derive_vector_selection_dataset( return envelope(derived.model_dump()) -@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/vector/clip", + status_code=201, + response_model=Envelope[JobRead], +) def clip_vector_dataset( project_id: UUID, dataset_id: UUID, @@ -623,7 +728,11 @@ def clip_vector_dataset( return envelope(job) -@router.post("/datasets/{dataset_id}/vector/buffer", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/vector/buffer", + status_code=201, + response_model=Envelope[JobRead], +) def buffer_vector_dataset( project_id: UUID, dataset_id: UUID, @@ -650,7 +759,11 @@ def buffer_vector_dataset( return envelope(job) -@router.post("/datasets/{dataset_id}/vector/intersect", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/vector/intersect", + status_code=201, + response_model=Envelope[JobRead], +) def intersect_vector_dataset( project_id: UUID, dataset_id: UUID, @@ -676,7 +789,10 @@ def intersect_vector_dataset( return envelope(job) -@router.get("/datasets/{dataset_id}/vector/summary", response_model=dict) +@router.get( + "/datasets/{dataset_id}/vector/summary", + response_model=Envelope[DatasetVectorSummary], +) def vector_dataset_summary( project_id: UUID, dataset_id: UUID, @@ -688,7 +804,10 @@ def vector_dataset_summary( return envelope(DatasetService.vector_summary(db, dataset_id)) -@router.get("/datasets/{dataset_id}/raster/inspect", response_model=dict) +@router.get( + "/datasets/{dataset_id}/raster/inspect", + response_model=Envelope[RasterOperationResult], +) def raster_dataset_inspect( project_id: UUID, dataset_id: UUID, @@ -701,7 +820,10 @@ def raster_dataset_inspect( return envelope(payload) -@router.get("/datasets/{dataset_id}/raster/preview", response_model=dict) +@router.get( + "/datasets/{dataset_id}/raster/preview", + response_model=Envelope[RasterPreviewResponse], +) def raster_preview_readiness( project_id: UUID, dataset_id: UUID, @@ -727,7 +849,10 @@ def raster_orthophoto_image( ) -@router.post("/datasets/{dataset_id}/raster/terrain/select", response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/terrain/select", + response_model=Envelope[TerrainSelectionResponse], +) def raster_terrain_selection( project_id: UUID, dataset_id: UUID, @@ -737,7 +862,10 @@ def raster_terrain_selection( return envelope(TerrainAnalysisService.analyze(db, project_id, dataset_id, payload)) -@router.post("/datasets/raster/terrain/select", response_model=dict) +@router.post( + "/datasets/raster/terrain/select", + response_model=Envelope[TerrainSelectionResponse], +) def partitioned_raster_terrain_selection( project_id: UUID, payload: TerrainPartitionSelectionRequest, @@ -760,7 +888,10 @@ def raster_terrain_image( ) -@router.post("/datasets/{dataset_id}/raster/flood-hazard/select", response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/flood-hazard/select", + response_model=Envelope[FloodHazardSelectionResponse], +) def raster_flood_hazard_selection( project_id: UUID, dataset_id: UUID, @@ -770,7 +901,10 @@ def raster_flood_hazard_selection( return envelope(FloodHazardAnalysisService.analyze(db, project_id, dataset_id, payload)) -@router.post("/datasets/raster/flood-hazard/select", response_model=dict) +@router.post( + "/datasets/raster/flood-hazard/select", + response_model=Envelope[FloodHazardSelectionResponse], +) def partitioned_raster_flood_hazard_selection( project_id: UUID, payload: FloodHazardPartitionSelectionRequest, @@ -793,7 +927,10 @@ def raster_flood_hazard_image( ) -@router.post("/datasets/{dataset_id}/raster/thematic/select", response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/thematic/select", + response_model=Envelope[ThematicRasterSelectionResponse], +) def raster_thematic_selection( project_id: UUID, dataset_id: UUID, @@ -817,7 +954,10 @@ def raster_thematic_image( ) -@router.get("/datasets/{dataset_id}/raster/stats", response_model=dict) +@router.get( + "/datasets/{dataset_id}/raster/stats", + response_model=Envelope[RasterStatsResponse], +) def raster_stats( project_id: UUID, dataset_id: UUID, @@ -830,7 +970,11 @@ def raster_stats( return envelope(RasterStatsResponse(**payload).model_dump()) -@router.post("/datasets/{dataset_id}/raster/reproject", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/reproject", + status_code=201, + response_model=Envelope[JobRead], +) def raster_reproject_dataset( project_id: UUID, dataset_id: UUID, @@ -857,7 +1001,11 @@ def raster_reproject_dataset( return envelope(job) -@router.post("/datasets/{dataset_id}/raster/clip", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/clip", + status_code=201, + response_model=Envelope[JobRead], +) def raster_clip_dataset( project_id: UUID, dataset_id: UUID, @@ -878,7 +1026,11 @@ def raster_clip_dataset( return envelope(job) -@router.post("/datasets/{dataset_id}/raster/tile", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/tile", + status_code=201, + response_model=Envelope[JobRead], +) def raster_tile_dataset( project_id: UUID, dataset_id: UUID, @@ -905,7 +1057,11 @@ def raster_tile_dataset( return envelope(job) -@router.post("/datasets/{dataset_id}/raster/indices/ndvi", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/indices/ndvi", + status_code=201, + response_model=Envelope[JobRead], +) def raster_ndvi_dataset( project_id: UUID, dataset_id: UUID, @@ -932,7 +1088,11 @@ def raster_ndvi_dataset( return envelope(job) -@router.post("/datasets/{dataset_id}/raster/indices/ndwi", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/indices/ndwi", + status_code=201, + response_model=Envelope[JobRead], +) def raster_ndwi_dataset( project_id: UUID, dataset_id: UUID, @@ -959,7 +1119,11 @@ def raster_ndwi_dataset( return envelope(job) -@router.post("/datasets/{dataset_id}/raster/indices/ndbi", status_code=201, response_model=dict) +@router.post( + "/datasets/{dataset_id}/raster/indices/ndbi", + status_code=201, + response_model=Envelope[JobRead], +) def raster_ndbi_dataset( project_id: UUID, dataset_id: UUID, @@ -986,7 +1150,10 @@ def raster_ndbi_dataset( return envelope(job) -@router.get("/datasets/{dataset_id}/raster/metadata", response_model=dict) +@router.get( + "/datasets/{dataset_id}/raster/metadata", + response_model=Envelope[RasterMetadataResponse], +) def raster_dataset_metadata( project_id: UUID, dataset_id: UUID, @@ -998,7 +1165,10 @@ def raster_dataset_metadata( return envelope(RasterOperationsService.metadata(db, dataset_id)) -@router.get("/datasets/{dataset_id}/content", response_model=dict) +@router.get( + "/datasets/{dataset_id}/content", + response_model=Envelope[GeoJsonFeatureCollection], +) def dataset_content( project_id: UUID, dataset_id: UUID, diff --git a/backend/app/api/routes/demo.py b/backend/app/api/routes/demo.py index a0666507..4d24e125 100644 --- a/backend/app/api/routes/demo.py +++ b/backend/app/api/routes/demo.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session from app.db.session import get_db +from app.schemas import Envelope from app.schemas.demo import DemoWorkflowResponse from app.services.demo_workflow_service import DemoWorkflowService from app.utils.response import envelope @@ -11,7 +12,11 @@ from app.utils.response import envelope router = APIRouter(prefix="/demo", tags=["demo"]) -@router.post("/workflow", status_code=status.HTTP_201_CREATED, response_model=dict) +@router.post( + "/workflow", + status_code=status.HTTP_201_CREATED, + response_model=Envelope[DemoWorkflowResponse], +) def seed_demo_workflow(db: Session = Depends(get_db)) -> dict: result: DemoWorkflowResponse = DemoWorkflowService.seed(db) return envelope(result.model_dump()) diff --git a/backend/app/api/routes/detection.py b/backend/app/api/routes/detection.py index 29ba8d62..8afbffb5 100644 --- a/backend/app/api/routes/detection.py +++ b/backend/app/api/routes/detection.py @@ -6,7 +6,21 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas import DetectionQaRequest, DetectionRunRequest +from app.schemas import ( + AnalysisQaResponse, + DetectionListResponse, + DetectionModelsResponse, + DetectionQaRequest, + DetectionRead, + DetectionRunListResponse, + DetectionRunRead, + DetectionRunRequest, + DetectionRunResponse, + Envelope, + GeoJsonFeatureCollection, + ModelAssetListResponse, + YoloPreflightResponse, +) from app.services.detection_service import DetectionService from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.model_registry_service import ModelRegistryService @@ -16,17 +30,17 @@ from app.utils.response import envelope router = APIRouter(prefix="/detection", tags=["detection"]) -@router.get("/models", response_model=dict) +@router.get("/models", response_model=Envelope[DetectionModelsResponse]) def list_detection_models() -> dict: return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]}) -@router.get("/model-assets", response_model=dict) +@router.get("/model-assets", response_model=Envelope[ModelAssetListResponse]) def list_detection_model_assets() -> dict: return envelope(ModelAssetCatalogService.list_assets().model_dump()) -@router.get("/yolo/preflight", response_model=dict) +@router.get("/yolo/preflight", response_model=Envelope[YoloPreflightResponse]) def get_yolo_preflight( tile_manifest_path: str | None = None, check_model_load: bool = False, @@ -41,7 +55,7 @@ def get_yolo_preflight( ) -@router.post("/run", response_model=dict) +@router.post("/run", response_model=Envelope[DetectionRunResponse]) def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict: result = DetectionService.run_detection( db=db, @@ -57,7 +71,7 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) - return envelope(result.model_dump()) -@router.get("/runs", response_model=dict) +@router.get("/runs", response_model=Envelope[DetectionRunListResponse]) def list_detection_runs( project_id: UUID | None = None, dataset_id: UUID | None = None, @@ -66,12 +80,15 @@ def list_detection_runs( return envelope(DetectionService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump()) -@router.get("/runs/{analysis_run_id}", response_model=dict) +@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead]) def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: return envelope(DetectionService.get_run(db, analysis_run_id).model_dump()) -@router.get("/runs/{analysis_run_id}/detections", response_model=dict) +@router.get( + "/runs/{analysis_run_id}/detections", + response_model=Envelope[DetectionListResponse], +) def list_detection_run_detections( analysis_run_id: UUID, dataset_id: UUID | None = None, @@ -90,7 +107,10 @@ def list_detection_run_detections( ) -@router.get("/datasets/{dataset_id}/detections", response_model=dict) +@router.get( + "/datasets/{dataset_id}/detections", + response_model=Envelope[DetectionListResponse], +) def list_dataset_detections( dataset_id: UUID, analysis_run_id: UUID | None = None, @@ -109,12 +129,15 @@ def list_dataset_detections( ) -@router.get("/detections/{detection_id}", response_model=dict) +@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead]) def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict: return envelope(DetectionService.get_detection(db, detection_id).model_dump()) -@router.get("/runs/{analysis_run_id}/geojson", response_model=dict) +@router.get( + "/runs/{analysis_run_id}/geojson", + response_model=Envelope[GeoJsonFeatureCollection], +) def get_detection_run_geojson( analysis_run_id: UUID, class_name: str | None = None, @@ -131,7 +154,10 @@ def get_detection_run_geojson( ) -@router.get("/datasets/{dataset_id}/geojson", response_model=dict) +@router.get( + "/datasets/{dataset_id}/geojson", + response_model=Envelope[GeoJsonFeatureCollection], +) def get_dataset_detection_geojson( dataset_id: UUID, analysis_run_id: UUID | None = None, @@ -150,7 +176,10 @@ def get_dataset_detection_geojson( ) -@router.post("/runs/{analysis_run_id}/qa/reference", response_model=dict) +@router.post( + "/runs/{analysis_run_id}/qa/reference", + response_model=Envelope[AnalysisQaResponse], +) def compare_detection_run_with_reference( analysis_run_id: UUID, payload: DetectionQaRequest, diff --git a/backend/app/api/routes/exports.py b/backend/app/api/routes/exports.py index 537410da..f14a253d 100644 --- a/backend/app/api/routes/exports.py +++ b/backend/app/api/routes/exports.py @@ -7,14 +7,24 @@ from fastapi.responses import FileResponse from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas.export import GeoJsonExportRequest, MapResultExportRequest, MetadataExportRequest, ReportExportRequest +from app.schemas import Envelope +from app.schemas.export import ( + ExportContentResponse, + ExportCreateResponse, + ExportListResponse, + ExportRead, + GeoJsonExportRequest, + MapResultExportRequest, + MetadataExportRequest, + ReportExportRequest, +) from app.services.export_service import ExportService from app.utils.response import envelope router = APIRouter(prefix="/exports", tags=["exports"]) -@router.post("/geojson", response_model=dict) +@router.post("/geojson", response_model=Envelope[ExportCreateResponse]) def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)): if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None: return envelope( @@ -40,22 +50,25 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)) return envelope({}) -@router.post("/metadata", response_model=dict) +@router.post("/metadata", response_model=Envelope[ExportCreateResponse]) def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)): return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json")) -@router.post("/report", response_model=dict) +@router.post("/report", response_model=Envelope[ExportCreateResponse]) def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)): return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json")) -@router.post("/map-result", response_model=dict) +@router.post("/map-result", response_model=Envelope[ExportCreateResponse]) def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)): return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json")) -@router.get("/projects/{project_id}/exports", response_model=dict) +@router.get( + "/projects/{project_id}/exports", + response_model=Envelope[ExportListResponse], +) def list_project_exports( project_id: UUID, limit: int = Query(default=50, ge=1, le=100), @@ -65,7 +78,7 @@ def list_project_exports( return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json")) -@router.get("/{export_id}", response_model=dict) +@router.get("/{export_id}", response_model=Envelope[ExportRead]) def get_export(export_id: UUID, db: Session = Depends(get_db)): return envelope(ExportService.get_export(db, export_id).model_dump(mode="json")) @@ -77,6 +90,6 @@ def download_export(export_id: UUID, db: Session = Depends(get_db)): return FileResponse(path, filename=path.name, media_type=media_type) -@router.get("/{export_id}/content", response_model=dict) +@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse]) def get_export_content(export_id: UUID, db: Session = Depends(get_db)): return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json")) diff --git a/backend/app/api/routes/external.py b/backend/app/api/routes/external.py index 959230ba..963366bd 100644 --- a/backend/app/api/routes/external.py +++ b/backend/app/api/routes/external.py @@ -7,7 +7,20 @@ from app.core.errors import AppError from app.db.session import get_db from app.models import Area, Project from app.providers.registry import fetch_provider_data, get_provider, import_provider_dataset, list_provider_capabilities -from app.schemas import CoverageResolveRequest, ExternalFetchRequest, ExternalFetchResponse, ProviderImportRequest +from app.schemas import ( + CoverageCatalogResponse, + CoverageResolveRequest, + CoverageResolveResponse, + Envelope, + ExternalFetchRequest, + ExternalFetchResponse, + ProviderCapabilitiesResponse, + ProviderCapabilityResponse, + ProviderImportRequest, + ProviderImportResponse, + ProviderLayersResponse, + ProviderStatusResponse, +) from app.services.coverage_registry_service import CoverageRegistryService from app.utils.response import envelope @@ -40,19 +53,19 @@ def _provider_payload(provider_name: str) -> dict: return get_provider(provider_name).capability.to_dict() -@router.get("/providers") +@router.get("/providers", response_model=Envelope[ProviderCapabilitiesResponse]) def list_external_providers() -> dict: return envelope({ "providers": [provider.to_dict() for provider in list_provider_capabilities()], }) -@router.get("/coverage/catalog") +@router.get("/coverage/catalog", response_model=Envelope[CoverageCatalogResponse]) def get_coverage_catalog() -> dict: return envelope(CoverageRegistryService.catalog().model_dump()) -@router.post("/coverage/resolve") +@router.post("/coverage/resolve", response_model=Envelope[CoverageResolveResponse]) def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depends(get_db)) -> dict: result = CoverageRegistryService.resolve( db, @@ -63,19 +76,28 @@ def resolve_project_coverage(payload: CoverageResolveRequest, db: Session = Depe return envelope(result.model_dump()) -@router.get("/providers/capabilities") +@router.get( + "/providers/capabilities", + response_model=Envelope[ProviderCapabilitiesResponse], +) def get_external_provider_capabilities() -> dict: return envelope({ "providers": [provider.to_dict() for provider in list_provider_capabilities()], }) -@router.get("/providers/{provider_name}") +@router.get( + "/providers/{provider_name}", + response_model=Envelope[ProviderCapabilityResponse], +) def get_external_provider(provider_name: str) -> dict: return envelope(_provider_payload(provider_name)) -@router.get("/providers/{provider_name}/layers") +@router.get( + "/providers/{provider_name}/layers", + response_model=Envelope[ProviderLayersResponse], +) def get_external_provider_layers(provider_name: str) -> dict: provider = get_provider(provider_name) return envelope({ @@ -84,7 +106,10 @@ def get_external_provider_layers(provider_name: str) -> dict: }) -@router.get("/providers/{provider_name}/status") +@router.get( + "/providers/{provider_name}/status", + response_model=Envelope[ProviderStatusResponse], +) def get_external_provider_status(provider_name: str) -> dict: provider = get_provider(provider_name) return envelope({ @@ -95,7 +120,10 @@ def get_external_provider_status(provider_name: str) -> dict: }) -@router.post("/providers/{provider_name}/import") +@router.post( + "/providers/{provider_name}/import", + response_model=Envelope[ProviderImportResponse], +) def import_external_provider_dataset(provider_name: str, payload: ProviderImportRequest) -> dict: result = import_provider_dataset( provider_name=provider_name, @@ -125,14 +153,14 @@ def _run_fetch(payload: ExternalFetchRequest, provider_name: str) -> ExternalFet ) -@router.post("/osm/fetch") +@router.post("/osm/fetch", response_model=Envelope[ExternalFetchResponse]) def fetch_osm(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict: _assert_project_exists(db, payload.project_id) _validate_area_in_project(db, payload.project_id, payload.area_id) return envelope(_run_fetch(payload, "osm").model_dump()) -@router.post("/grb/fetch") +@router.post("/grb/fetch", response_model=Envelope[ExternalFetchResponse]) def fetch_grb(payload: ExternalFetchRequest, db: Session = Depends(get_db)) -> dict: _assert_project_exists(db, payload.project_id) _validate_area_in_project(db, payload.project_id, payload.area_id) diff --git a/backend/app/api/routes/jobs.py b/backend/app/api/routes/jobs.py index f1ae7a96..90271f87 100644 --- a/backend/app/api/routes/jobs.py +++ b/backend/app/api/routes/jobs.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas import JobCreate, JobList, JobRead, JobStatus +from app.schemas import Envelope, JobCreate, JobList, JobRead, JobStatus from app.services.job_service import JobService from app.utils.response import envelope @@ -14,7 +14,7 @@ from app.utils.response import envelope router = APIRouter(prefix="/projects/{project_id}", tags=["jobs"]) -@router.post("/jobs", status_code=201, response_model=dict) +@router.post("/jobs", status_code=201, response_model=Envelope[JobRead]) def create_job( project_id: UUID, payload: JobCreate, @@ -25,7 +25,7 @@ def create_job( return envelope(JobService.create_job(db, payload).model_dump()) -@router.get("/jobs", response_model=dict) +@router.get("/jobs", response_model=Envelope[JobList]) def list_jobs( project_id: UUID, dataset_id: UUID | None = Query(default=None), @@ -43,7 +43,7 @@ def list_jobs( return envelope(JobList(items=items, total=total, limit=limit, offset=offset).model_dump()) -@router.get("/jobs/{job_id}", response_model=dict) +@router.get("/jobs/{job_id}", response_model=Envelope[JobRead]) def read_job( project_id: UUID, job_id: UUID, @@ -55,7 +55,7 @@ def read_job( return envelope(job.model_dump()) -@router.get("/jobs/{job_id}/status", response_model=dict) +@router.get("/jobs/{job_id}/status", response_model=Envelope[JobStatus]) def read_job_status( project_id: UUID, job_id: UUID, diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py index 3947f824..8f34329b 100644 --- a/backend/app/api/routes/projects.py +++ b/backend/app/api/routes/projects.py @@ -7,14 +7,15 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas.project import ProjectCreate, ProjectRead, ProjectUpdate +from app.schemas import Envelope +from app.schemas.project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate from app.services.project_service import ProjectService from app.utils.response import envelope router = APIRouter(prefix="/projects", tags=["projects"]) -@router.get("", response_model=dict) +@router.get("", response_model=Envelope[ProjectList]) def list_projects( limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), @@ -32,13 +33,13 @@ def list_projects( return envelope({"items": [ProjectRead.model_validate(item).model_dump() for item in projects], "total": total, "limit": limit, "offset": offset}) -@router.post("", status_code=status.HTTP_201_CREATED, response_model=dict) +@router.post("", status_code=status.HTTP_201_CREATED, response_model=Envelope[ProjectRead]) def create_project(payload: ProjectCreate, db: Session = Depends(get_db)): project = ProjectService.create_project(db, payload) return envelope(ProjectRead.model_validate(project).model_dump()) -@router.get("/{project_id}", response_model=dict) +@router.get("/{project_id}", response_model=Envelope[ProjectRead]) def get_project(project_id: UUID, db: Session = Depends(get_db)): project = ProjectService.get_project(db, project_id) if not project: @@ -46,7 +47,7 @@ def get_project(project_id: UUID, db: Session = Depends(get_db)): return envelope(ProjectRead.model_validate(project).model_dump()) -@router.patch("/{project_id}", response_model=dict) +@router.patch("/{project_id}", response_model=Envelope[ProjectRead]) def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depends(get_db)): project = ProjectService.update_project(db, project_id, payload) if not project: @@ -54,7 +55,11 @@ def update_project(project_id: UUID, payload: ProjectUpdate, db: Session = Depen return envelope(ProjectRead.model_validate(project).model_dump()) -@router.delete("/{project_id}", status_code=status.HTTP_200_OK, response_model=dict) +@router.delete( + "/{project_id}", + status_code=status.HTTP_200_OK, + response_model=Envelope[ProjectDeleteResult], +) def delete_project(project_id: UUID, db: Session = Depends(get_db)): if not ProjectService.delete_project(db, project_id): raise HTTPException(status_code=404, detail="Project not found") diff --git a/backend/app/api/routes/qa.py b/backend/app/api/routes/qa.py index 2e535548..c22f7e0d 100644 --- a/backend/app/api/routes/qa.py +++ b/backend/app/api/routes/qa.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.core.errors import AppError from app.models import Dataset, Job -from app.schemas import QaProviderComparisonRequest +from app.schemas import Envelope, JobRead, QaProviderComparisonRequest from app.services.qa_service import QaService from app.services.job_service import JobService from app.services.quality_service import QualityService @@ -17,7 +17,7 @@ from app.utils.response import envelope router = APIRouter(prefix="/qa", tags=["qa"]) -@router.post("/detections-vs-reference") +@router.post("/detections-vs-reference", response_model=Envelope[JobRead]) def compare_candidate_with_reference( payload: QaProviderComparisonRequest, db: Session = Depends(get_db), diff --git a/backend/app/api/routes/quality_checks.py b/backend/app/api/routes/quality_checks.py index ac22316c..64908d42 100644 --- a/backend/app/api/routes/quality_checks.py +++ b/backend/app/api/routes/quality_checks.py @@ -6,7 +6,8 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas.detection_review import DetectionReviewUpsert +from app.schemas import Envelope, QualityEvidenceResponse +from app.schemas.detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewUpsert from app.schemas.qa import QualityCheckList from app.services.detection_review_service import DetectionReviewService from app.services.quality_evidence_service import QualityEvidenceService @@ -16,7 +17,7 @@ from app.utils.response import envelope router = APIRouter(prefix="/projects/{project_id}", tags=["quality-checks"]) -@router.get("/quality-checks", response_model=dict) +@router.get("/quality-checks", response_model=Envelope[QualityCheckList]) def list_quality_checks( project_id: UUID, limit: int = Query(default=50, ge=1, le=200), @@ -32,7 +33,10 @@ def list_quality_checks( return envelope(QualityCheckList(items=items, total=total, limit=limit, offset=offset).model_dump()) -@router.get("/quality-checks/{quality_check_id}/evidence/geojson", response_model=dict) +@router.get( + "/quality-checks/{quality_check_id}/evidence/geojson", + response_model=Envelope[QualityEvidenceResponse], +) def get_quality_check_evidence_geojson( project_id: UUID, quality_check_id: UUID, @@ -41,7 +45,10 @@ def get_quality_check_evidence_geojson( return envelope(QualityEvidenceService.evidence_geojson(db, project_id=project_id, quality_check_id=quality_check_id)) -@router.get("/quality-checks/{quality_check_id}/reviews", response_model=dict) +@router.get( + "/quality-checks/{quality_check_id}/reviews", + response_model=Envelope[DetectionReviewList], +) def list_detection_reviews( project_id: UUID, quality_check_id: UUID, @@ -66,7 +73,10 @@ def list_detection_reviews( ) -@router.post("/quality-checks/{quality_check_id}/reviews", response_model=dict) +@router.post( + "/quality-checks/{quality_check_id}/reviews", + response_model=Envelope[DetectionReviewRead], +) def upsert_detection_review( project_id: UUID, quality_check_id: UUID, diff --git a/backend/app/api/routes/segmentation.py b/backend/app/api/routes/segmentation.py index bfb4fe96..baeb595f 100644 --- a/backend/app/api/routes/segmentation.py +++ b/backend/app/api/routes/segmentation.py @@ -6,7 +6,19 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas import SegmentationQaRequest, SegmentationRunRequest +from app.schemas import ( + AnalysisQaResponse, + Envelope, + GeoJsonFeatureCollection, + SegmentationListResponse, + SegmentationModelsResponse, + SegmentationQaRequest, + SegmentationRead, + SegmentationRunListResponse, + SegmentationRunRead, + SegmentationRunRequest, + SegmentationRunResponse, +) from app.services.model_registry_service import ModelRegistryService from app.services.segmentation_service import SegmentationService from app.utils.response import envelope @@ -14,12 +26,12 @@ from app.utils.response import envelope router = APIRouter(prefix="/segmentation", tags=["segmentation"]) -@router.get("/models", response_model=dict) +@router.get("/models", response_model=Envelope[SegmentationModelsResponse]) def list_segmentation_models() -> dict: return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities(task_type="segmentation")]}) -@router.post("/run", response_model=dict) +@router.post("/run", response_model=Envelope[SegmentationRunResponse]) def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict: result = SegmentationService.run_segmentation( db=db, @@ -34,7 +46,7 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_ return envelope(result.model_dump()) -@router.get("/runs", response_model=dict) +@router.get("/runs", response_model=Envelope[SegmentationRunListResponse]) def list_segmentation_runs( project_id: UUID | None = None, dataset_id: UUID | None = None, @@ -43,12 +55,15 @@ def list_segmentation_runs( return envelope(SegmentationService.list_runs(db, project_id=project_id, dataset_id=dataset_id).model_dump()) -@router.get("/runs/{analysis_run_id}", response_model=dict) +@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead]) def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict: return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump()) -@router.get("/runs/{analysis_run_id}/segmentations", response_model=dict) +@router.get( + "/runs/{analysis_run_id}/segmentations", + response_model=Envelope[SegmentationListResponse], +) def list_segmentation_run_outputs( analysis_run_id: UUID, dataset_id: UUID | None = None, @@ -67,7 +82,10 @@ def list_segmentation_run_outputs( ) -@router.get("/datasets/{dataset_id}/segmentations", response_model=dict) +@router.get( + "/datasets/{dataset_id}/segmentations", + response_model=Envelope[SegmentationListResponse], +) def list_dataset_segmentations( dataset_id: UUID, analysis_run_id: UUID | None = None, @@ -86,12 +104,15 @@ def list_dataset_segmentations( ) -@router.get("/segmentations/{segmentation_id}", response_model=dict) +@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead]) def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict: return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump()) -@router.get("/runs/{analysis_run_id}/geojson", response_model=dict) +@router.get( + "/runs/{analysis_run_id}/geojson", + response_model=Envelope[GeoJsonFeatureCollection], +) def get_segmentation_run_geojson( analysis_run_id: UUID, class_name: str | None = None, @@ -108,7 +129,10 @@ def get_segmentation_run_geojson( ) -@router.get("/datasets/{dataset_id}/geojson", response_model=dict) +@router.get( + "/datasets/{dataset_id}/geojson", + response_model=Envelope[GeoJsonFeatureCollection], +) def get_dataset_segmentation_geojson( dataset_id: UUID, analysis_run_id: UUID | None = None, @@ -127,7 +151,10 @@ def get_dataset_segmentation_geojson( ) -@router.post("/runs/{analysis_run_id}/qa/reference", response_model=dict) +@router.post( + "/runs/{analysis_run_id}/qa/reference", + response_model=Envelope[AnalysisQaResponse], +) def compare_segmentation_run_with_reference( analysis_run_id: UUID, payload: SegmentationQaRequest, diff --git a/backend/app/api/routes/temporal.py b/backend/app/api/routes/temporal.py index ac576de6..74e87e5a 100644 --- a/backend/app/api/routes/temporal.py +++ b/backend/app/api/routes/temporal.py @@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.db.session import get_db -from app.schemas.temporal import TemporalComparisonRequest +from app.schemas import Envelope, ItemList +from app.schemas.temporal import ( + TemporalComparisonRequest, + TemporalComparisonResponse, + TemporalSeriesRead, +) from app.services.temporal_analysis_service import TemporalAnalysisService from app.utils.response import envelope @@ -14,13 +19,13 @@ from app.utils.response import envelope router = APIRouter(prefix="/projects/{project_id}/temporal", tags=["temporal"]) -@router.get("/series", response_model=dict) +@router.get("/series", response_model=Envelope[ItemList[TemporalSeriesRead]]) def list_temporal_series(project_id: UUID, db: Session = Depends(get_db)): series = TemporalAnalysisService.list_series(db, project_id) return envelope({"items": [item.model_dump() for item in series], "total": len(series)}) -@router.post("/compare", response_model=dict) +@router.post("/compare", response_model=Envelope[TemporalComparisonResponse]) def compare_temporal_snapshots( project_id: UUID, payload: TemporalComparisonRequest, diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 8c81d7a2..ea8c12a8 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,6 +1,14 @@ from __future__ import annotations -from .common import ApiErrorEnvelope, ApiErrorItem, Envelope, PaginationEnvelope +from .common import ( + ApiErrorEnvelope, + ApiErrorItem, + Envelope, + GeoJsonFeature, + GeoJsonFeatureCollection, + ItemList, + PaginationEnvelope, +) from .coverage import ( CoverageBBox, CoverageCatalogResponse, @@ -9,7 +17,7 @@ from .coverage import ( CoverageResolveResponse, CoverageSourceContract, ) -from .project import ProjectCreate, ProjectList, ProjectRead, ProjectUpdate +from .project import ProjectCreate, ProjectDeleteResult, ProjectList, ProjectRead, ProjectUpdate from .area import AreaCreate, AreaList, AreaRead, AreaUpdate from .analysis import ChangeDetectionRequest, ChangeDetectionSummary from .dataset import DatasetCreateResponse, DatasetList @@ -43,6 +51,7 @@ from .detection import ( DetectionRunResponse, ModelAssetListResponse, ModelAssetRead, + YoloPreflightResponse, ) from .detection_review import DetectionReviewList, DetectionReviewRead, DetectionReviewSummary, DetectionReviewUpsert from .segmentation import ( @@ -115,7 +124,12 @@ from .export import ( MetadataExportRequest, ReportExportRequest, ) -from .qa import QaProviderComparisonRequest, QaProviderComparisonResult +from .qa import ( + AnalysisQaResponse, + QaProviderComparisonRequest, + QaProviderComparisonResult, + QualityEvidenceResponse, +) from .operations import ( RasterClipRequest, RasterIndexBaseRequest, @@ -150,6 +164,9 @@ from .operations import ( __all__ = [ "Envelope", + "ItemList", + "GeoJsonFeature", + "GeoJsonFeatureCollection", "ApiErrorEnvelope", "ApiErrorItem", "PaginationEnvelope", @@ -163,6 +180,7 @@ __all__ = [ "ProjectRead", "ProjectUpdate", "ProjectList", + "ProjectDeleteResult", "AreaCreate", "AreaRead", "AreaUpdate", @@ -198,6 +216,7 @@ __all__ = [ "DetectionRunResponse", "ModelAssetListResponse", "ModelAssetRead", + "YoloPreflightResponse", "DetectionReviewList", "DetectionReviewRead", "DetectionReviewSummary", @@ -295,4 +314,6 @@ __all__ = [ "ExportContentResponse", "QaProviderComparisonRequest", "QaProviderComparisonResult", + "AnalysisQaResponse", + "QualityEvidenceResponse", ] diff --git a/backend/app/schemas/area.py b/backend/app/schemas/area.py index de801218..049a59de 100644 --- a/backend/app/schemas/area.py +++ b/backend/app/schemas/area.py @@ -25,6 +25,7 @@ class AreaRead(BaseModel): area_m2: float | None created_at: datetime | None = None geometry_type: str | None = None + geometry: dict | None = None model_config = {"from_attributes": True} diff --git a/backend/app/schemas/assistant.py b/backend/app/schemas/assistant.py index 71f3eaa4..cffb78ae 100644 --- a/backend/app/schemas/assistant.py +++ b/backend/app/schemas/assistant.py @@ -30,6 +30,12 @@ class AssistantModelRead(BaseModel): capabilities: list[str] = Field(default_factory=list) +class AssistantModelList(BaseModel): + items: list[AssistantModelRead] + total: int + default_model: str | None = None + + class AssistantStatus(BaseModel): enabled: bool reachable: bool diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py index 7640c741..b8007206 100644 --- a/backend/app/schemas/common.py +++ b/backend/app/schemas/common.py @@ -1,15 +1,23 @@ from __future__ import annotations +from typing import Any, Generic, Literal, TypeVar + from pydantic import BaseModel, Field -class Envelope(BaseModel): - data: object +DataT = TypeVar("DataT") -class PaginatedEnvelope(BaseModel): - items: list +class Envelope(BaseModel, Generic[DataT]): + data: DataT + + +class ItemList(BaseModel, Generic[DataT]): + items: list[DataT] total: int + + +class PaginatedEnvelope(ItemList[DataT], Generic[DataT]): limit: int offset: int @@ -28,4 +36,19 @@ class ApiErrorItem(BaseModel): class ApiErrorEnvelope(BaseModel): - error: ApiErrorItem + error: str + message: str + details: dict | list = Field(default_factory=dict) + request_id: str | None = None + + +class GeoJsonFeature(BaseModel): + type: Literal["Feature"] + id: str | int | None = None + geometry: dict[str, Any] | None + properties: dict[str, Any] = Field(default_factory=dict) + + +class GeoJsonFeatureCollection(BaseModel): + type: Literal["FeatureCollection"] + features: list[GeoJsonFeature] diff --git a/backend/app/schemas/detection.py b/backend/app/schemas/detection.py index fd8546ea..59ccbe58 100644 --- a/backend/app/schemas/detection.py +++ b/backend/app/schemas/detection.py @@ -129,3 +129,48 @@ class DetectionRead(BaseModel): class DetectionListResponse(BaseModel): items: list[DetectionRead] total: int + + +class YoloPreflightChecks(BaseModel): + model_config = ConfigDict(protected_namespaces=()) + + enabled: bool + dependencies_available: bool | None = None + model_path_set: bool | None = None + model_file_exists: bool | None = None + model_load_requested: bool + model_load_ok: bool | None = None + manifest_path_set: bool | None = None + manifest_valid: bool | None = None + tile_paths_exist: bool | None = None + tile_limit_ok: bool | None = None + + +class YoloRuntimeDetails(BaseModel): + model_config = ConfigDict(protected_namespaces=()) + + dependencies_assumed: bool + model_directory: str | None = None + yolo_config_dir: str | None = None + torch_version: str | None = None + ultralytics_version: str | None = None + cuda_available: bool | None = None + + +class YoloPreflightResponse(BaseModel): + model_config = ConfigDict(protected_namespaces=()) + + model_id: str + model_asset_id: str | None = None + model_path: str | None = None + tile_manifest_path: str | None = None + status: str + message: str + checks: YoloPreflightChecks + tile_count: int + max_tiles: int + will_download_models: bool + will_run_inference: bool + runtime: YoloRuntimeDetails + error_code: str | None = None + details: dict | None = None diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 0469c5f3..7964a3ac 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -41,3 +41,7 @@ class ProjectList(BaseModel): total: int limit: int offset: int + + +class ProjectDeleteResult(BaseModel): + deleted: bool diff --git a/backend/app/schemas/qa.py b/backend/app/schemas/qa.py index 076afc11..624ef8ec 100644 --- a/backend/app/schemas/qa.py +++ b/backend/app/schemas/qa.py @@ -1,10 +1,13 @@ from __future__ import annotations from datetime import datetime +from typing import Any from uuid import UUID from pydantic import BaseModel, Field +from app.schemas.common import GeoJsonFeatureCollection + class QaProviderComparisonRequest(BaseModel): candidate_dataset_id: UUID @@ -72,3 +75,40 @@ class QualityCheckList(BaseModel): total: int limit: int offset: int + + +class QualityEvidenceResponse(BaseModel): + quality_check_id: UUID + project_id: UUID + candidate_dataset_id: UUID | None = None + reference_dataset_id: UUID + analysis_run_id: UUID | None = None + feature_count: int + warnings: list[str] = Field(default_factory=list) + geojson: GeoJsonFeatureCollection + + +class AnalysisQaResponse(BaseModel): + status: str + quality_check_id: UUID + analysis_run_id: UUID + reference_dataset_id: UUID + candidate_feature_count: int + reference_feature_count: int + candidate_feature_count_raw: int | None = None + reference_feature_count_raw: int | None = None + matches: int + false_positives: int + false_negatives: int + precision: float | None = None + recall: float | None = None + f1_score: float | None = None + mean_iou: float | None = None + iou_threshold: float + warnings: list[str] = Field(default_factory=list) + coverage: dict[str, Any] | None = None + temporal_compatibility: dict[str, Any] | None = None + box_to_footprint_diagnostics: dict[str, Any] | None = None + match_evidence: list[dict[str, Any]] = Field(default_factory=list) + false_positive_evidence: list[dict[str, Any]] = Field(default_factory=list) + false_negative_evidence: list[dict[str, Any]] = Field(default_factory=list) diff --git a/backend/app/services/area_service.py b/backend/app/services/area_service.py index 3a0baa59..f809476b 100644 --- a/backend/app/services/area_service.py +++ b/backend/app/services/area_service.py @@ -15,9 +15,19 @@ from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_mult class AreaService: @staticmethod def serialize_area(area: Area) -> dict: - payload = AreaRead.model_validate(area).model_dump() - payload["geometry"] = mapping(to_shape(area.geometry)) if area.geometry else None - return payload + geometry = to_shape(area.geometry) if area.geometry else None + return AreaRead.model_validate( + { + "id": area.id, + "project_id": area.project_id, + "name": area.name, + "original_crs": area.original_crs, + "area_m2": area.area_m2, + "created_at": area.created_at, + "geometry_type": geometry.geom_type if geometry else None, + "geometry": mapping(geometry) if geometry else None, + } + ).model_dump() @staticmethod def list_areas(db: Session, project_id: uuid.UUID, limit: int = 50, offset: int = 0) -> tuple[list[Area], int]: diff --git a/backend/tests/test_rc7_api_response_contracts.py b/backend/tests/test_rc7_api_response_contracts.py new file mode 100644 index 00000000..2403e5e4 --- /dev/null +++ b/backend/tests/test_rc7_api_response_contracts.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +BACKEND = ROOT / "backend" + + +def test_no_untyped_fastapi_response_models_remain() -> None: + route_root = BACKEND / "app" / "api" / "routes" + route_sources = "\n".join( + path.read_text(encoding="utf-8") + for path in sorted(route_root.glob("*.py")) + ) + + assert "response_model=dict" not in route_sources + + +def test_every_json_success_response_has_a_concrete_canonical_schema() -> None: + sys.path.insert(0, str(ROOT)) + sys.path.insert(0, str(BACKEND)) + from app.main import create_app + + from scripts.audit_api_contracts import ( + ALLOWED_NON_ENVELOPE_ENDPOINTS, + _validate_response_contracts, + ) + + openapi = create_app().openapi() + assert _validate_response_contracts(openapi) == [] + + untyped_successes: set[tuple[str, str]] = set() + for path, path_item in openapi["paths"].items(): + for method, operation in path_item.items(): + if method.upper() not in {"GET", "POST", "PATCH", "DELETE"}: + continue + has_json_schema = any( + response.get("content", {}) + .get("application/json", {}) + .get("schema") + for code, response in operation.get("responses", {}).items() + if str(code).startswith("2") + ) + if not has_json_schema: + untyped_successes.add((method.upper(), path)) + + assert untyped_successes == ALLOWED_NON_ENVELOPE_ENDPOINTS - { + ("GET", "/health"), + ("GET", "/health/live"), + ("GET", "/health/ready"), + } diff --git a/backend/tests/test_sprint112_qa_evidence_overlay.py b/backend/tests/test_sprint112_qa_evidence_overlay.py index 788bbfab..a8d4df8b 100644 --- a/backend/tests/test_sprint112_qa_evidence_overlay.py +++ b/backend/tests/test_sprint112_qa_evidence_overlay.py @@ -125,10 +125,15 @@ def test_quality_check_evidence_geojson_rejects_cross_project_access() -> None: def test_quality_check_evidence_geojson_api_uses_canonical_envelope(monkeypatch) -> None: project_id = uuid4() quality_check_id = uuid4() + reference_dataset_id = uuid4() payload = { "quality_check_id": str(quality_check_id), "project_id": str(project_id), + "candidate_dataset_id": None, + "reference_dataset_id": str(reference_dataset_id), + "analysis_run_id": None, "feature_count": 0, + "warnings": [], "geojson": {"type": "FeatureCollection", "features": []}, } diff --git a/backend/tests/test_sprint205_dhmv_terrain.py b/backend/tests/test_sprint205_dhmv_terrain.py index a25d6002..8a15c66e 100644 --- a/backend/tests/test_sprint205_dhmv_terrain.py +++ b/backend/tests/test_sprint205_dhmv_terrain.py @@ -495,9 +495,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None: "analyze", lambda *_args, **_kwargs: { "dataset_id": str(output_dataset_id), + "product_key": "dtm_1m", + "surface_model": "terrain", + "selection_bbox": lambert_bbox_payload().bbox.model_dump(), "sample_count": 100, - "summary": {"metric_value": 25.0, "metric_unit": "m TAW", "metrics": []}, + "slope_sample_count": 81, + "coverage_ratio": 1.0, + "resolution_m": 5.0, + "vertical_reference": "TAW", + "summary": { + "metric_label": "Gemiddelde terreinhoogte", + "metric_value": 25.0, + "metric_unit": "m TAW", + "aggregation_method": "mean", + "primary_metric_key": "terrain_elevation_mean_m", + "metrics": [], + }, "unsupported_metrics": ["water_depth_m", "water_volume_m3"], + "limitation_message": "Terrain height is not water depth.", + "generated_at": "2026-07-18T00:00:00Z", }, ) monkeypatch.setattr( @@ -507,7 +523,25 @@ def test_dhmv_endpoints_use_canonical_envelopes(monkeypatch) -> None: "dataset_id": str(output_dataset_id), "dataset_ids": [str(output_dataset_id)], "partition_count": 1, + "product_key": "dtm_1m", + "surface_model": "terrain", + "selection_bbox": lambert_bbox_payload().bbox.model_dump(), "sample_count": 100, + "slope_sample_count": 81, + "coverage_ratio": 1.0, + "resolution_m": 5.0, + "vertical_reference": "TAW", + "summary": { + "metric_label": "Gemiddelde terreinhoogte", + "metric_value": 25.0, + "metric_unit": "m TAW", + "aggregation_method": "mean", + "primary_metric_key": "terrain_elevation_mean_m", + "metrics": [], + }, + "unsupported_metrics": ["water_depth_m", "water_volume_m3"], + "limitation_message": "Terrain height is not water depth.", + "generated_at": "2026-07-18T00:00:00Z", }, ) app.dependency_overrides[get_db] = lambda: db diff --git a/backend/tests/test_sprint208_vmm_flood_hazard.py b/backend/tests/test_sprint208_vmm_flood_hazard.py index 336bfb14..44311b1e 100644 --- a/backend/tests/test_sprint208_vmm_flood_hazard.py +++ b/backend/tests/test_sprint208_vmm_flood_hazard.py @@ -385,9 +385,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None: "analyze", lambda *_args, **_kwargs: { "dataset_id": str(output_dataset_id), + "product_key": "pluviaal_current_t100", + "mechanism": "pluviaal", + "climate_context": "huidig klimaat", + "probability_class": "middelgrote kans", + "return_period_years": 100, + "selection_bbox": flood_payload().bbox.model_dump(), + "selected_cell_count": 10, "inundated_cell_count": 4, - "summary": {"metric_value": 0.01, "metric_unit": "ha", "metrics": []}, + "inundated_fraction": 0.4, + "resolution_m": 5.0, + "summary": { + "metric_label": "Overstroomde oppervlakte", + "metric_value": 0.01, + "metric_unit": "ha", + "aggregation_method": "positive_depth_area", + "primary_metric_key": "inundated_area_ha", + "metrics": [], + }, "unsupported_metrics": ["permanent_water_volume_m3"], + "limitation_message": "Scenario depth is not bathymetry.", + "generated_at": "2026-07-18T00:00:00Z", }, ) monkeypatch.setattr( @@ -397,7 +415,27 @@ def test_flood_hazard_api_uses_canonical_envelopes(monkeypatch) -> None: "dataset_id": str(output_dataset_id), "dataset_ids": [str(output_dataset_id)], "partition_count": 1, + "product_key": "pluviaal_current_t100", + "mechanism": "pluviaal", + "climate_context": "huidig klimaat", + "probability_class": "middelgrote kans", + "return_period_years": 100, + "selection_bbox": flood_payload().bbox.model_dump(), + "selected_cell_count": 10, "inundated_cell_count": 4, + "inundated_fraction": 0.4, + "resolution_m": 5.0, + "summary": { + "metric_label": "Overstroomde oppervlakte", + "metric_value": 0.01, + "metric_unit": "ha", + "aggregation_method": "positive_depth_area", + "primary_metric_key": "inundated_area_ha", + "metrics": [], + }, + "unsupported_metrics": ["permanent_water_volume_m3"], + "limitation_message": "Scenario depth is not bathymetry.", + "generated_at": "2026-07-18T00:00:00Z", }, ) app.dependency_overrides[get_db] = lambda: db diff --git a/backend/tests/test_sprint213_thematic_rasters.py b/backend/tests/test_sprint213_thematic_rasters.py index ff34250e..168e730d 100644 --- a/backend/tests/test_sprint213_thematic_rasters.py +++ b/backend/tests/test_sprint213_thematic_rasters.py @@ -514,7 +514,29 @@ def test_api_uses_canonical_envelopes(monkeypatch) -> None: monkeypatch.setattr( ThematicRasterAnalysisService, "analyze", - lambda *_args, **_kwargs: {"dataset_id": str(dataset_id), "theme": "population", "summary": {"metric_value": 10.0}}, + lambda *_args, **_kwargs: { + "dataset_id": str(dataset_id), + "product_key": "population_density_2019", + "theme": "population", + "metric_kind": "population_density", + "selection_bbox": payload().bbox.model_dump(), + "selected_cell_count": 10, + "valid_cell_count": 10, + "coverage_ratio": 1.0, + "resolution_m": 100.0, + "observation_year": 2019, + "summary": { + "metric_label": "Geraamd aantal inwoners", + "metric_value": 10.0, + "metric_unit": "inwoners", + "aggregation_method": "sum_density_cells", + "primary_metric_key": "estimated_inhabitants", + "metrics": [], + }, + "unsupported_metrics": ["current_population"], + "limitation_message": "2019 density estimate.", + "generated_at": "2026-07-18T00:00:00Z", + }, ) app.dependency_overrides[get_db] = lambda: db try: diff --git a/backend/tests/test_sprint219_regional_raster_explorer.py b/backend/tests/test_sprint219_regional_raster_explorer.py index 30a302db..e5b928d4 100644 --- a/backend/tests/test_sprint219_regional_raster_explorer.py +++ b/backend/tests/test_sprint219_regional_raster_explorer.py @@ -8,12 +8,12 @@ def test_partitioned_raster_routes_are_canonical_and_documented() -> None: routes = (ROOT / "backend/app/api/routes/datasets.py").read_text(encoding="utf-8") contracts = (ROOT / "docs/API_CONTRACTS.md").read_text(encoding="utf-8") - for path in ( - "/datasets/raster/terrain/select", - "/datasets/raster/flood-hazard/select", - ): - assert f'@router.post("{path}", response_model=dict)' in routes - assert path in contracts + assert '"/datasets/raster/terrain/select",' in routes + assert "response_model=Envelope[TerrainSelectionResponse]" in routes + assert '"/datasets/raster/flood-hazard/select",' in routes + assert "response_model=Envelope[FloodHazardSelectionResponse]" in routes + assert "/datasets/raster/terrain/select" in contracts + assert "/datasets/raster/flood-hazard/select" in contracts assert "envelope(TerrainAnalysisService.analyze_partitions" in routes assert "envelope(FloodHazardAnalysisService.analyze_partitions" in routes diff --git a/backend/tests/test_sprint236_bathymetry_expansion.py b/backend/tests/test_sprint236_bathymetry_expansion.py index 2a98e8c8..b4f6f64e 100644 --- a/backend/tests/test_sprint236_bathymetry_expansion.py +++ b/backend/tests/test_sprint236_bathymetry_expansion.py @@ -165,7 +165,13 @@ def test_mdk_readiness_api_uses_canonical_envelope(monkeypatch) -> None: lambda: { "source_key": "mdk_bcp_bathymetry", "status": "tls_error", + "configured_url": "https://example.invalid/wcs", + "tls_verified": False, + "capabilities_reachable": False, "acquisition_supported": False, + "checked_at": "2026-07-18T00:00:00Z", + "message": "TLS validation failed.", + "limitation_message": "No insecure fallback is permitted.", }, ) app.dependency_overrides[get_db] = lambda: db diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index c8863d43..ac7d0be8 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -9,6 +9,12 @@ This document freezes the first API shape. Codex may add implementation details - GeoJSON accepted for geometries where possible. - Long processing tasks return a job or analysis run record instead of blocking. - Error responses use the shared `ApiError` schema. +- Every successful JSON endpoint has a concrete Pydantic response model and + uses the canonical `{"data": ...}` envelope. Readiness runs an OpenAPI audit + that rejects free-form dictionary responses and envelope drift. +- The only successful non-envelope responses are `/health`, `/health/live`, + `/health/ready`, the four documented persisted-raster PNG endpoints and the + export artifact download endpoint. ## Shared schemas diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index ad0c9740..a71c7fb0 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -10516,3 +10516,33 @@ Decision: - RC-6 is complete. RC-7 critical API response typing and OpenAPI validation is active. + +## 2026-07-18 - Belgium/North Sea RC-7 API contract hardening + +Implemented: + +- Replaced every remaining free-form successful JSON response model with a + concrete Pydantic schema while retaining the canonical `{"data": ...}` + payload shape. +- Added reusable generic envelope, list, pagination and GeoJSON schemas plus + concrete project, area, assistant, YOLO preflight and QA evidence models. +- Corrected Area serialization so persisted PostGIS geometries are converted + to GeoJSON before response validation. +- Added an executable OpenAPI contract audit and focused regression tests that + reject missing, free-dictionary or undocumented non-envelope responses. +- Tracked exactly eight deliberate exceptions: three health probes, four + persisted PNG endpoints and the streamed export download. + +Validation: + +- The API audit passed 124 implemented routes and 228 OpenAPI component + schemas. +- Repository readiness passed backend compilation, 1,008 backend tests, + Alembic head `202607160001`, frontend typecheck and production build. +- Offline `alembic upgrade head --sql` completed and generated 31,548 bytes of + migration evidence. + +Decision: + +- RC-7 is complete. RC-8 automated frontend and browser release journeys are + active. diff --git a/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md b/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md index 8509faac..38d725b5 100644 --- a/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md +++ b/docs/RC_ROADMAP_BELGIUM_NORTH_SEA.md @@ -150,8 +150,8 @@ editions and licences must still pass source-specific probes before activation. | RC-4 | complete | national/maritime scope and provider coverage contracts | | RC-5 | complete | deployment, secrets, configuration, fresh install and rollback | | RC-6 | complete | complete CI, dependency and supply-chain gates | -| RC-7 | in progress | critical API envelope typing and contract validation | -| RC-8 | pending | frontend and browser E2E release journeys | +| RC-7 | complete | critical API envelope typing and contract validation | +| RC-8 | in progress | frontend and browser E2E release journeys | | RC-9 | pending | loading, accessibility and performance hardening | | RC-10 | pending | retention, cleanup and national data operations | | RC-11 | pending | final package, upgrade proof, release tag and handoff | @@ -405,6 +405,13 @@ UID 999. ## RC-7 - Critical API contract hardening +**State: complete.** All successful JSON routes now publish concrete OpenAPI +response schemas. The contract audit covers 124 implemented routes and 228 +component schemas; the eight non-envelope operations are the three health +probes, four persisted PNG responses and the export download. Readiness passed +with 1,008 backend tests, frontend typecheck/build, one Alembic head and +offline migration SQL generation. + ### Work - replace `response_model=dict` first on health, projects, areas, datasets, diff --git a/docs/TODO.md b/docs/TODO.md index d851c6f9..1d995501 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -25,7 +25,7 @@ maritieme zones. - [x] RC-5: secrets/configuratie/uploadlimieten/immutable deploy en rollback bewijzen. - [x] RC-6: volledige CI, dependency-audit, containerscan en SBOM toevoegen. -- [ ] RC-7: kritieke API-routes concrete responsemodellen geven. +- [x] RC-7: kritieke API-routes concrete responsemodellen geven. - [ ] RC-8: echte frontend- en browser-E2E-releaseflows toevoegen. - [ ] RC-9: loading, toegankelijkheid, widescreen/mobile en performance afronden. - [ ] RC-10: dataretentie, diskdruk en veilige cleanup operationaliseren. diff --git a/scripts/audit_api_contracts.py b/scripts/audit_api_contracts.py index 4dc65e60..150bf761 100644 --- a/scripts/audit_api_contracts.py +++ b/scripts/audit_api_contracts.py @@ -15,6 +15,9 @@ ALLOWED_NON_ENVELOPE_ENDPOINTS = { ("GET", "/health/ready"), ("GET", "/api/v1/exports/{export_id}/download"), ("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/image"), + ("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/terrain/image"), + ("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/flood-hazard/image"), + ("GET", "/api/v1/projects/{project_id}/datasets/{dataset_id}/raster/thematic/image"), } IGNORED_OPENAPI_PATHS = { @@ -25,11 +28,14 @@ IGNORED_OPENAPI_PATHS = { } -def _load_app_routes() -> set[tuple[str, str]]: +def _load_openapi() -> dict: sys.path.insert(0, str(BACKEND)) from app.main import create_app - schema = create_app().openapi() + return create_app().openapi() + + +def _load_app_routes(schema: dict) -> set[tuple[str, str]]: routes: set[tuple[str, str]] = set() for path, path_item in schema.get("paths", {}).items(): if path in IGNORED_OPENAPI_PATHS or not isinstance(path_item, dict): @@ -41,13 +47,64 @@ def _load_app_routes() -> set[tuple[str, str]]: return routes +def _resolve_schema(openapi: dict, schema: dict) -> dict: + reference = schema.get("$ref") + if not isinstance(reference, str): + return schema + prefix = "#/components/schemas/" + if not reference.startswith(prefix): + return {} + return openapi.get("components", {}).get("schemas", {}).get(reference[len(prefix):], {}) + + +def _validate_response_contracts(openapi: dict) -> list[str]: + errors: list[str] = [] + for path, path_item in openapi.get("paths", {}).items(): + if path in IGNORED_OPENAPI_PATHS or not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + normalized_method = method.upper() + if normalized_method not in {"GET", "POST", "PATCH", "DELETE"}: + continue + if (normalized_method, path) in ALLOWED_NON_ENVELOPE_ENDPOINTS: + continue + responses = operation.get("responses", {}) + success_responses = [ + response + for status_code, response in responses.items() + if str(status_code).startswith("2") + ] + if not success_responses: + errors.append(f"Missing successful OpenAPI response: {normalized_method} {path}") + continue + for response in success_responses: + schema = ( + response.get("content", {}) + .get("application/json", {}) + .get("schema", {}) + ) + if not schema: + errors.append(f"Missing concrete JSON response schema: {normalized_method} {path}") + continue + resolved = _resolve_schema(openapi, schema) + if resolved.get("additionalProperties") is True: + errors.append(f"Untyped dictionary response schema: {normalized_method} {path}") + continue + if path not in {"/health", "/health/live", "/health/ready"}: + properties = resolved.get("properties", {}) + if "data" not in properties: + errors.append(f"Missing canonical data envelope: {normalized_method} {path}") + return errors + + def _load_documented_routes() -> set[tuple[str, str]]: docs = DOCS.read_text(encoding="utf-8") return set(re.findall(r"###\s+(GET|POST|PATCH|DELETE)\s+`([^`]+)`", docs)) def main() -> None: - implemented_routes = _load_app_routes() + openapi = _load_openapi() + implemented_routes = _load_app_routes(openapi) documented_routes = _load_documented_routes() missing_docs = sorted(implemented_routes - documented_routes) @@ -61,6 +118,7 @@ def main() -> None: errors.append(f"Documented API route is not implemented: {method} {path}") for method, path in missing_non_envelope: errors.append(f"Allowed non-envelope endpoint is not implemented: {method} {path}") + errors.extend(_validate_response_contracts(openapi)) if errors: raise SystemExit("\n".join(errors))