Harden RC7 API response contracts
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-18 05:07:35 +02:00
parent 8e49b857dd
commit 0fae53a7de
36 changed files with 886 additions and 153 deletions
+13
View File
@@ -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:
+2 -1
View File
@@ -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),
+6 -5
View File
@@ -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,
+13 -4
View File
@@ -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,
+222 -52
View File
@@ -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,
+6 -1
View File
@@ -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())
+42 -13
View File
@@ -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,
+21 -8
View File
@@ -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"))
+39 -11
View File
@@ -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)
+5 -5
View File
@@ -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,
+11 -6
View File
@@ -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")
+2 -2
View File
@@ -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),
+15 -5
View File
@@ -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,
+38 -11
View File
@@ -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,
+8 -3
View File
@@ -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,
+24 -3
View File
@@ -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",
]
+1
View File
@@ -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}
+6
View File
@@ -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
+28 -5
View File
@@ -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]
+45
View File
@@ -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
+4
View File
@@ -41,3 +41,7 @@ class ProjectList(BaseModel):
total: int
limit: int
offset: int
class ProjectDeleteResult(BaseModel):
deleted: bool
+40
View File
@@ -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)
+13 -3
View File
@@ -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]:
@@ -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"),
}
@@ -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": []},
}
+35 -1
View File
@@ -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
@@ -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
@@ -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:
@@ -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
@@ -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