[AZ-794] [AZ-795] [AZ-796] Strict input validation + z/x/y rename
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful

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:
Oleksandr Bezdieniezhnykh
2026-05-22 10:02:02 +03:00
parent dceaddc436
commit 865dfdb3b9
33 changed files with 1824 additions and 118 deletions
@@ -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;
}
}
+22 -32
View File
@@ -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",