Files
satellite-provider/SatelliteProvider.Api/Validators/InventoryRequestValidator.cs
T
Oleksandr Bezdieniezhnykh 865dfdb3b9
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful
[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>
2026-05-22 10:02:02 +03:00

74 lines
3.2 KiB
C#

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}.");
}
}