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
+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,