[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;
}
}