mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 21:31: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 System.Text.Json;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -60,6 +61,30 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
{
|
||||
httpContext.Response.StatusCode = badRequest.StatusCode;
|
||||
|
||||
// AZ-795: deserialization failures (unknown field via UnmappedMemberHandling.Disallow,
|
||||
// type mismatch, malformed JSON) surface here as BadHttpRequestException with a
|
||||
// System.Text.Json `JsonException` somewhere in the inner-exception chain. Convert
|
||||
// them to RFC 7807 ValidationProblemDetails so wire-format errors share the same
|
||||
// shape as FluentValidation business-rule errors — see
|
||||
// `_docs/02_document/contracts/api/error-shape.md`.
|
||||
var deserializationErrors = TryExtractDeserializationErrors(badRequest);
|
||||
if (deserializationErrors is not null && badRequest.StatusCode == StatusCodes.Status400BadRequest)
|
||||
{
|
||||
var validation = new ValidationProblemDetails(deserializationErrors)
|
||||
{
|
||||
Status = badRequest.StatusCode,
|
||||
Title = "One or more validation errors occurred.",
|
||||
Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
||||
};
|
||||
|
||||
await httpContext.Response.WriteAsJsonAsync(
|
||||
validation,
|
||||
options: null,
|
||||
contentType: "application/problem+json",
|
||||
cancellationToken: cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = badRequest.StatusCode,
|
||||
@@ -73,4 +98,36 @@ public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
contentType: "application/problem+json",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static IDictionary<string, string[]>? TryExtractDeserializationErrors(BadHttpRequestException ex)
|
||||
{
|
||||
var current = ex.InnerException;
|
||||
while (current is not null)
|
||||
{
|
||||
if (current is JsonException jsonEx)
|
||||
{
|
||||
var path = NormalizeJsonPath(jsonEx.Path);
|
||||
var message = string.IsNullOrEmpty(jsonEx.Message)
|
||||
? "Invalid JSON."
|
||||
: jsonEx.Message;
|
||||
|
||||
return new Dictionary<string, string[]>
|
||||
{
|
||||
[path] = new[] { message }
|
||||
};
|
||||
}
|
||||
|
||||
current = current.InnerException;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string NormalizeJsonPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return "$";
|
||||
return path.StartsWith("$.", StringComparison.Ordinal)
|
||||
? path.Substring(2)
|
||||
: path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="12.0.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7"/>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-795 / AZ-796: process-wide FluentValidation configuration shared by the
|
||||
// API host and unit tests. Tests must call ApplyOnce() in their fixture setup
|
||||
// so the property-name casing they assert against matches what the running
|
||||
// API will produce — see `_docs/02_document/contracts/api/error-shape.md`
|
||||
// invariant Inv-4 (camelCase paths in `errors` map).
|
||||
public static class GlobalValidatorConfig
|
||||
{
|
||||
private static readonly object _gate = new();
|
||||
private static bool _applied;
|
||||
|
||||
public static void ApplyOnce()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_applied) return;
|
||||
|
||||
ValidatorOptions.Global.PropertyNameResolver = (type, member, expression) =>
|
||||
{
|
||||
var name = member?.Name;
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
return char.ToLowerInvariant(name[0]) + name[1..];
|
||||
};
|
||||
|
||||
_applied = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using FluentValidation;
|
||||
using SatelliteProvider.Common.DTO;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-796: FluentValidation rules for POST /api/satellite/tiles/inventory.
|
||||
// Wired through ValidationEndpointFilter<TileInventoryRequest> at endpoint
|
||||
// registration time (`WithValidation<TileInventoryRequest>()` in Program.cs).
|
||||
// Failures are converted to RFC 7807 ValidationProblemDetails per
|
||||
// `_docs/02_document/contracts/api/error-shape.md` v1.0.0.
|
||||
//
|
||||
// Required-field detection (rules 5+) is partially handled at the deserializer
|
||||
// level via `[JsonRequired]` on TileCoord.Z/X/Y plus
|
||||
// `JsonSerializerOptions.UnmappedMemberHandling.Disallow` (AZ-795). This
|
||||
// validator covers the non-deserializer-detectable rules: XOR populated,
|
||||
// per-array entry caps, and slippy-map range constraints.
|
||||
public sealed class InventoryRequestValidator : AbstractValidator<TileInventoryRequest>
|
||||
{
|
||||
public InventoryRequestValidator()
|
||||
{
|
||||
RuleFor(req => req).Custom((req, ctx) =>
|
||||
{
|
||||
var hasTiles = req.Tiles is { Count: > 0 };
|
||||
var hasHashes = req.LocationHashes is { Count: > 0 };
|
||||
if (hasTiles == hasHashes)
|
||||
{
|
||||
ctx.AddFailure(
|
||||
"$",
|
||||
"Populate exactly one of `tiles` or `locationHashes` (sending both, or neither, is not allowed).");
|
||||
}
|
||||
});
|
||||
|
||||
RuleFor(req => req.Tiles!.Count)
|
||||
.LessThanOrEqualTo(TileInventoryLimits.MaxEntriesPerRequest)
|
||||
.OverridePropertyName("tiles")
|
||||
.WithMessage($"`tiles` must contain at most {TileInventoryLimits.MaxEntriesPerRequest} entries.")
|
||||
.When(req => req.Tiles is not null);
|
||||
|
||||
RuleFor(req => req.LocationHashes!.Count)
|
||||
.LessThanOrEqualTo(TileInventoryLimits.MaxEntriesPerRequest)
|
||||
.OverridePropertyName("locationHashes")
|
||||
.WithMessage($"`locationHashes` must contain at most {TileInventoryLimits.MaxEntriesPerRequest} entries.")
|
||||
.When(req => req.LocationHashes is not null);
|
||||
|
||||
RuleForEach(req => req.Tiles)
|
||||
.SetValidator(new TileCoordValidator())
|
||||
.When(req => req.Tiles is not null);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class TileCoordValidator : AbstractValidator<TileCoord>
|
||||
{
|
||||
private const int MaxZoom = 22;
|
||||
|
||||
public TileCoordValidator()
|
||||
{
|
||||
RuleFor(c => c.Z)
|
||||
.InclusiveBetween(0, MaxZoom)
|
||||
.WithMessage($"`z` must be between 0 and {MaxZoom} (slippy-map zoom range).");
|
||||
|
||||
RuleFor(c => c.X)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("`x` must be ≥ 0.")
|
||||
.Must((coord, x) => coord.Z >= 0 && coord.Z <= MaxZoom && x < (1L << coord.Z))
|
||||
.WithMessage(coord => $"`x` must be < 2^z = {(coord.Z >= 0 && coord.Z <= MaxZoom ? (1L << coord.Z).ToString() : "<invalid z>")} for z={coord.Z}.");
|
||||
|
||||
RuleFor(c => c.Y)
|
||||
.GreaterThanOrEqualTo(0)
|
||||
.WithMessage("`y` must be ≥ 0.")
|
||||
.Must((coord, y) => coord.Z >= 0 && coord.Z <= MaxZoom && y < (1L << coord.Z))
|
||||
.WithMessage(coord => $"`y` must be < 2^z = {(coord.Z >= 0 && coord.Z <= MaxZoom ? (1L << coord.Z).ToString() : "<invalid z>")} for z={coord.Z}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-795: shared validation infrastructure. A generic IEndpointFilter that
|
||||
// resolves IValidator<T> from DI for the first argument of type T in the
|
||||
// invoked endpoint and returns RFC 7807 ValidationProblemDetails (HTTP 400)
|
||||
// with a structured `errors` map when the validator rejects. When validation
|
||||
// passes, the filter forwards to the next stage unchanged.
|
||||
//
|
||||
// The filter is generic per request type; per-endpoint wire-up is done via
|
||||
// `RouteHandlerBuilder.WithValidation<T>()` (see ValidationEndpointFilterExtensions).
|
||||
// Per AZ-795 Outcome: callers must NOT need per-endpoint try/catch boilerplate;
|
||||
// the filter provides the uniform error contract documented in
|
||||
// `_docs/02_document/contracts/api/error-shape.md`.
|
||||
public sealed class ValidationEndpointFilter<T> : IEndpointFilter where T : class
|
||||
{
|
||||
public async ValueTask<object?> InvokeAsync(
|
||||
EndpointFilterInvocationContext context,
|
||||
EndpointFilterDelegate next)
|
||||
{
|
||||
var argument = context.Arguments.OfType<T>().FirstOrDefault();
|
||||
if (argument is null)
|
||||
{
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
var validator = context.HttpContext.RequestServices.GetService<IValidator<T>>();
|
||||
if (validator is null)
|
||||
{
|
||||
return await next(context);
|
||||
}
|
||||
|
||||
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return Results.ValidationProblem(result.ToDictionary());
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace SatelliteProvider.Api.Validators;
|
||||
|
||||
// AZ-795: ergonomic extension method for opting an endpoint into
|
||||
// FluentValidation. Applied at MapPost/MapGet registration time:
|
||||
//
|
||||
// app.MapPost("/api/satellite/tiles/inventory", GetTilesInventory)
|
||||
// .WithValidation<TileInventoryRequest>();
|
||||
//
|
||||
// One line per endpoint; no per-handler try/catch boilerplate; uniform
|
||||
// RFC 7807 error shape — see `_docs/02_document/contracts/api/error-shape.md`.
|
||||
public static class ValidationEndpointFilterExtensions
|
||||
{
|
||||
public static RouteHandlerBuilder WithValidation<T>(this RouteHandlerBuilder builder)
|
||||
where T : class
|
||||
{
|
||||
builder.AddEndpointFilter<ValidationEndpointFilter<T>>();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=satelliteprovider;Username=postgres;Password=postgres"
|
||||
"DefaultConnection": "Host=localhost;Port=5433;Database=satelliteprovider;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "DEV-ONLY-DO-NOT-USE-IN-PROD-replace-with-real-secret-via-JWT_SECRET-env-var",
|
||||
|
||||
Reference in New Issue
Block a user