mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 09:41:14 +00:00
[AZ-794] [AZ-795] [AZ-796] Strict input validation + z/x/y rename
AZ-794: rename inventory wire fields tileZoom/tileX/tileY -> z/x/y to match the slippy-map URL convention. Contract bumped to v2.0.0. AZ-795: shared validation infrastructure -- FluentValidation + ValidationEndpointFilter + GlobalValidatorConfig (camelCase paths). GlobalExceptionHandler now converts JsonException (UnmappedMember + JsonRequired) into RFC 7807 ValidationProblemDetails. JSON layer hardened with UnmappedMemberHandling.Disallow + camelCase naming policy. New error-shape.md contract. AZ-796: InventoryRequestValidator covers 9 rules (XOR tiles vs locationHashes, cap 1000, z 0..22, x/y in slippy bounds, hash length/charset). 16 unit tests + 16 integration tests + a manual curl probe script. Adjacent fixes uncovered by the new strict layer: - IdempotentPostTests RoutePoint payload corrected to lat/lon (the DTO has used JsonPropertyName for ages; previously silently ignored under PascalCase fallback). - TileInventoryTests slippy x/y reduced to fit z=18 bounds. - docker-compose.yml host port for Postgres moved 5432 -> 5433 to avoid sibling-project conflict; appsettings.Development + README + AGENTS + architecture + containerization docs aligned. New coderule (suite + repo): API consumer-facing OpenAPI descriptions must not contain task IDs, contract filenames, or version-bump history -- internal change tracking belongs in commits/contract docs/changelogs. Existing offending descriptions in Program.cs cleaned up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -8,6 +9,7 @@ using SatelliteProvider.Api;
|
||||
using SatelliteProvider.Api.Authentication;
|
||||
using SatelliteProvider.Api.DTOs;
|
||||
using SatelliteProvider.Api.Swagger;
|
||||
using SatelliteProvider.Api.Validators;
|
||||
using SatelliteProvider.DataAccess;
|
||||
using SatelliteProvider.DataAccess.Repositories;
|
||||
using SatelliteProvider.DataAccess.TypeHandlers;
|
||||
@@ -98,14 +100,28 @@ builder.Services.AddCors(options =>
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
|
||||
// AZ-795: strict JSON parsing — unknown fields are rejected at the deserializer
|
||||
// level instead of being silently dropped. Pairs with the per-endpoint
|
||||
// FluentValidation filter (`WithValidation<T>()`) so the API has a single
|
||||
// uniform RFC 7807 error contract for both wire-format failures and
|
||||
// business-rule failures (`_docs/02_document/contracts/api/error-shape.md`).
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
options.SerializerOptions.UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow;
|
||||
options.SerializerOptions.Converters.Add(
|
||||
new System.Text.Json.Serialization.JsonStringEnumConverter(System.Text.Json.JsonNamingPolicy.CamelCase));
|
||||
});
|
||||
|
||||
// AZ-795: register every IValidator<T> in this assembly with DI so the
|
||||
// generic ValidationEndpointFilter<T> can resolve them at request time.
|
||||
// GlobalValidatorConfig.ApplyOnce() centralizes process-wide FluentValidation
|
||||
// configuration (camelCase property paths, etc.) so the API host and the
|
||||
// unit-test fixture share one source of truth — see error-shape.md Inv-4.
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
GlobalValidatorConfig.ApplyOnce();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
@@ -199,13 +215,14 @@ app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
|
||||
|
||||
app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory)
|
||||
.RequireAuthorization()
|
||||
.WithValidation<TileInventoryRequest>()
|
||||
.Accepts<TileInventoryRequest>("application/json")
|
||||
.Produces<TileInventoryResponse>(StatusCodes.Status200OK)
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Bulk tile inventory lookup by (z,x,y) coords or location_hash",
|
||||
Description = "AZ-505 / `tile-inventory.md` v1.0.0. Body MUST populate exactly one of `tiles` (array of `{tileZoom,tileX,tileY}`) OR `locationHashes` (array of UUIDv5). Response order matches request order. Returns one entry per request item with `present: true|false`; when present, identity + recency fields are included. Hard cap: 5000 entries per call (HTTP 400 above)."
|
||||
Description = "Body MUST populate exactly one of `tiles` (array of `{z, x, y}` slippy-map coordinates) OR `locationHashes` (array of UUIDv5 hashes) — sending both, or neither, is HTTP 400. Response order matches request order; each entry reports `present: true|false`, and when present includes `id`, `capturedAt`, `source`, `flightId`, `resolutionMPerPx`. Hard cap: 5000 entries per request."
|
||||
});
|
||||
|
||||
app.MapPost("/api/satellite/upload", UploadUavTileBatch)
|
||||
@@ -216,7 +233,7 @@ app.MapPost("/api/satellite/upload", UploadUavTileBatch)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Upload a batch of UAV-captured satellite tiles",
|
||||
Description = "AZ-488 / `uav-tile-upload.md` v1.0.0. Multipart form: a JSON `metadata` field and an aligned `files` collection. Each item is graded by the 5-rule quality gate and persisted with `source='uav'` when accepted. Returns 200 with per-item results (mixed accept/reject), 400 for envelope-level errors (malformed metadata, missing files, oversized batch), 401 without a valid JWT, 403 without the `GPS` permission claim."
|
||||
Description = "Multipart form: a JSON `metadata` field and an aligned `files` collection. Each item is graded by the 5-rule quality gate and persisted with `source='uav'` when accepted. Returns 200 with per-item results (mixed accept/reject), 400 for envelope-level errors (malformed metadata, missing files, oversized batch), 401 without a valid JWT, 403 without the `GPS` permission claim."
|
||||
})
|
||||
.DisableAntiforgery();
|
||||
|
||||
@@ -225,7 +242,7 @@ app.MapPost("/api/satellite/request", RequestRegion)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Request tiles for a region",
|
||||
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing region resource with HTTP 200 and does not enqueue duplicate background processing.",
|
||||
Description = "Idempotent: POSTing the same `id` twice returns the existing region resource with HTTP 200 and does not enqueue duplicate background processing.",
|
||||
});
|
||||
|
||||
app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
|
||||
@@ -237,7 +254,7 @@ app.MapPost("/api/satellite/route", CreateRoute)
|
||||
.WithOpenApi(op => new(op)
|
||||
{
|
||||
Summary = "Create a route with intermediate points",
|
||||
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing route resource with HTTP 200 and does not regenerate intermediate points or re-queue geofence regions.",
|
||||
Description = "Idempotent: POSTing the same `id` twice returns the existing route resource with HTTP 200 and does not regenerate intermediate points or re-queue geofence regions.",
|
||||
});
|
||||
|
||||
app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
|
||||
@@ -285,37 +302,10 @@ IResult GetSatelliteTilesByMgrs(string mgrs, double squareSideMeters)
|
||||
}
|
||||
|
||||
async Task<IResult> GetTilesInventory(
|
||||
[FromBody] TileInventoryRequest? request,
|
||||
[FromBody] TileInventoryRequest request,
|
||||
HttpContext httpContext,
|
||||
ITileService tileService)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Invalid tile inventory request",
|
||||
detail: "Request body is required.");
|
||||
}
|
||||
|
||||
var tileCount = request.Tiles?.Count ?? 0;
|
||||
var hashCount = request.LocationHashes?.Count ?? 0;
|
||||
if ((tileCount == 0) == (hashCount == 0))
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Invalid tile inventory request",
|
||||
detail: "Populate exactly one of `tiles` or `locationHashes`. Sending both, or neither, is not allowed.");
|
||||
}
|
||||
|
||||
var totalCount = Math.Max(tileCount, hashCount);
|
||||
if (totalCount > TileInventoryLimits.MaxEntriesPerRequest)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Invalid tile inventory request",
|
||||
detail: $"Inventory request capped at {TileInventoryLimits.MaxEntriesPerRequest} entries; got {totalCount}.");
|
||||
}
|
||||
|
||||
var response = await tileService.GetInventoryAsync(request, httpContext.RequestAborted);
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user