mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 17:11:15 +00:00
865dfdb3b9
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>
134 lines
4.7 KiB
C#
134 lines
4.7 KiB
C#
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Diagnostics;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace SatelliteProvider.Api;
|
|
|
|
public sealed class GlobalExceptionHandler : IExceptionHandler
|
|
{
|
|
private readonly ILogger<GlobalExceptionHandler> _logger;
|
|
|
|
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public async ValueTask<bool> TryHandleAsync(
|
|
HttpContext httpContext,
|
|
Exception exception,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Framework-level request-binding/parsing failures carry their own HTTP status
|
|
// (typically 400/415). Honor that status so we don't promote a client error to 5xx.
|
|
if (exception is BadHttpRequestException badRequest)
|
|
{
|
|
await WriteClientErrorAsync(httpContext, badRequest, cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
var correlationId = httpContext.TraceIdentifier;
|
|
|
|
_logger.LogError(
|
|
exception,
|
|
"Unhandled exception while processing {Method} {Path} (correlationId={CorrelationId})",
|
|
httpContext.Request.Method,
|
|
httpContext.Request.Path,
|
|
correlationId);
|
|
|
|
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
|
|
|
var problem = new ProblemDetails
|
|
{
|
|
Status = StatusCodes.Status500InternalServerError,
|
|
Title = "Internal Server Error",
|
|
Detail = "An unexpected error occurred. Use the correlationId to look up the server log entry.",
|
|
Type = "https://datatracker.ietf.org/doc/html/rfc9110#name-500-internal-server-error",
|
|
};
|
|
problem.Extensions["correlationId"] = correlationId;
|
|
|
|
await httpContext.Response.WriteAsJsonAsync(
|
|
problem,
|
|
options: null,
|
|
contentType: "application/problem+json",
|
|
cancellationToken: cancellationToken);
|
|
return true;
|
|
}
|
|
|
|
private static async Task WriteClientErrorAsync(
|
|
HttpContext httpContext,
|
|
BadHttpRequestException badRequest,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
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,
|
|
Title = "Bad Request",
|
|
Detail = badRequest.Message,
|
|
};
|
|
|
|
await httpContext.Response.WriteAsJsonAsync(
|
|
problem,
|
|
options: null,
|
|
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;
|
|
}
|
|
}
|