[AZ-353][AZ-354][AZ-356] Refactor 03 batch 2: harden API surface
ci/woodpecker/push/01-test Pipeline was successful
ci/woodpecker/push/02-build-push Pipeline was successful

AZ-353: Centralize 500 handling via GlobalExceptionHandler /
AddProblemDetails / UseExceptionHandler. Sanitized ProblemDetails
body carries a generic title, RFC9110 type link, and the request's
TraceIdentifier as correlationId; the leaky exception message stays
server-side in the ERR log entry. Strip per-endpoint
try/catch (Exception) wrappers and the unused ILogger<Program>
parameters they served. Preserve the typed ArgumentException catch
in CreateRoute (AC-3). The handler maps BadHttpRequestException
back to its framework-supplied StatusCode so model-binding /
malformed-body failures stay 4xx instead of being promoted to 500.

AZ-354: Extract CorsConfigurationValidator (pure static helpers)
and wire it into Program.cs. Production with empty
CorsConfig:AllowedOrigins and no CorsConfig:AllowAnyOrigin opt-in
now throws InvalidOperationException at host startup. Development
keeps the permissive default but logs a warning post-build. Adds
the explicit CorsConfig:AllowAnyOrigin escape hatch.

AZ-356: GetSatelliteTilesByMgrs and UploadImage now return
Results.Problem(StatusCode 501) with ProblemDetails. Added
.ProducesProblem(501) so swagger.json documents the
not-implemented status.

Tests: SatelliteProvider.Tests now references SatelliteProvider.Api
(downward, idiomatic) so unit tests can reach the new helpers.
+9 CorsConfigurationValidator unit tests, +3
GlobalExceptionHandler unit tests, +3 StubAndErrorContractTests
integration tests (added to smoke + full suites).

58/58 unit + 5/5 smoke + 3/3 stub-contract pass.
Code review verdict: PASS.
Batch report: _docs/03_implementation/batch_08_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-10 23:52:52 +03:00
parent de4d4fa760
commit 1d89cd9997
14 changed files with 602 additions and 104 deletions
+78 -100
View File
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using SatelliteProvider.Api;
using SatelliteProvider.DataAccess;
using SatelliteProvider.DataAccess.Repositories;
using SatelliteProvider.Common.Configs;
@@ -35,17 +36,23 @@ builder.Services.AddRegionProcessing();
builder.Services.AddRouteManagement();
var allowedOrigins = builder.Configuration.GetSection("CorsConfig:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
var allowAnyOrigin = builder.Configuration.GetValue<bool>("CorsConfig:AllowAnyOrigin");
CorsConfigurationValidator.EnsureSafeForEnvironment(allowedOrigins, allowAnyOrigin, builder.Environment.EnvironmentName);
builder.Services.AddCors(options =>
{
options.AddPolicy("TilesCors", policy =>
{
if (allowedOrigins.Length > 0)
policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod();
else
if (CorsConfigurationValidator.ShouldUsePermissivePolicy(allowedOrigins, allowAnyOrigin))
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
else
policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod();
});
});
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
@@ -79,6 +86,13 @@ builder.Services.AddSwaggerGen(c =>
var app = builder.Build();
if (CorsConfigurationValidator.ShouldWarnAboutPermissiveDefault(allowedOrigins, allowAnyOrigin))
{
app.Services
.GetRequiredService<ILogger<Program>>()
.LogWarning(CorsConfigurationValidator.PermissiveDefaultWarning, app.Environment.EnvironmentName);
}
var migratorLogger = app.Services.GetRequiredService<ILogger<DatabaseMigrator>>();
var migrator = new DatabaseMigrator(connectionString, migratorLogger);
if (!migrator.RunMigrations())
@@ -96,6 +110,7 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseCors("TilesCors");
@@ -106,11 +121,13 @@ app.MapGet("/api/satellite/tiles/latlon", GetTileByLatLon)
.WithOpenApi(op => new(op) { Summary = "Get satellite tile by latitude and longitude coordinates" });
app.MapGet("/api/satellite/tiles/mgrs", GetSatelliteTilesByMgrs)
.WithOpenApi(op => new(op) { Summary = "Get satellite tiles by MGRS coordinates" });
.ProducesProblem(StatusCodes.Status501NotImplemented)
.WithOpenApi(op => new(op) { Summary = "Get satellite tiles by MGRS coordinates (NOT IMPLEMENTED)" });
app.MapPost("/api/satellite/upload", UploadImage)
.Accepts<UploadImageRequest>("multipart/form-data")
.WithOpenApi(op => new(op) { Summary = "Upload image with metadata and save to /maps folder" })
.ProducesProblem(StatusCodes.Status501NotImplemented)
.WithOpenApi(op => new(op) { Summary = "Upload image with metadata (NOT IMPLEMENTED)" })
.DisableAntiforgery();
app.MapPost("/api/satellite/request", RequestRegion)
@@ -127,107 +144,81 @@ app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
app.Run();
async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITileService tileService, ILogger<Program> logger)
async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITileService tileService)
{
try
{
var tile = await tileService.GetOrDownloadTileAsync(z, x, y, httpContext.RequestAborted);
httpContext.Response.Headers.CacheControl = $"public, max-age={(long)tile.MaxAge.TotalSeconds}";
httpContext.Response.Headers.ETag = tile.ETag;
return Results.Bytes(tile.Bytes, tile.ContentType);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to serve tile {Z}/{X}/{Y}", z, x, y);
return Results.Problem(detail: ex.Message, statusCode: 500);
}
var tile = await tileService.GetOrDownloadTileAsync(z, x, y, httpContext.RequestAborted);
httpContext.Response.Headers.CacheControl = $"public, max-age={(long)tile.MaxAge.TotalSeconds}";
httpContext.Response.Headers.ETag = tile.ETag;
return Results.Bytes(tile.Bytes, tile.ContentType);
}
async Task<IResult> GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, ITileService tileService, ILogger<Program> logger)
async Task<IResult> GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, ITileService tileService)
{
try
{
var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel);
var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel);
var response = new DownloadTileResponse
{
Id = tile.Id,
ZoomLevel = tile.TileZoom,
Latitude = tile.Latitude,
Longitude = tile.Longitude,
TileSizeMeters = tile.TileSizeMeters,
TileSizePixels = tile.TileSizePixels,
ImageType = tile.ImageType,
MapsVersion = tile.MapsVersion,
Version = tile.Version,
FilePath = tile.FilePath,
CreatedAt = tile.CreatedAt,
UpdatedAt = tile.UpdatedAt
};
return Results.Ok(response);
}
catch (Exception ex)
var response = new DownloadTileResponse
{
logger.LogError(ex, "Failed to get tile");
return Results.Problem(detail: ex.Message, statusCode: 500);
}
Id = tile.Id,
ZoomLevel = tile.TileZoom,
Latitude = tile.Latitude,
Longitude = tile.Longitude,
TileSizeMeters = tile.TileSizeMeters,
TileSizePixels = tile.TileSizePixels,
ImageType = tile.ImageType,
MapsVersion = tile.MapsVersion,
Version = tile.Version,
FilePath = tile.FilePath,
CreatedAt = tile.CreatedAt,
UpdatedAt = tile.UpdatedAt
};
return Results.Ok(response);
}
IResult GetSatelliteTilesByMgrs(string mgrs, double squareSideMeters)
{
return Results.Ok(new GetSatelliteTilesResponse());
return Results.Problem(
statusCode: StatusCodes.Status501NotImplemented,
title: "Not implemented",
detail: "MGRS-based tile retrieval is not implemented.");
}
IResult UploadImage([FromForm] UploadImageRequest request)
{
return Results.Ok(new SaveResult { Success = false });
return Results.Problem(
statusCode: StatusCodes.Status501NotImplemented,
title: "Not implemented",
detail: "Image upload is not implemented.");
}
async Task<IResult> RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService, ILogger<Program> logger)
async Task<IResult> RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService)
{
try
if (request.SizeMeters < 100 || request.SizeMeters > 10000)
{
if (request.SizeMeters < 100 || request.SizeMeters > 10000)
{
return Results.BadRequest(new { error = "Size must be between 100 and 10000 meters" });
}
var status = await regionService.RequestRegionAsync(
request.Id,
request.Latitude,
request.Longitude,
request.SizeMeters,
request.ZoomLevel,
request.StitchTiles);
return Results.Ok(status);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to request region");
return Results.Problem(detail: ex.Message, statusCode: 500);
return Results.BadRequest(new { error = "Size must be between 100 and 10000 meters" });
}
var status = await regionService.RequestRegionAsync(
request.Id,
request.Latitude,
request.Longitude,
request.SizeMeters,
request.ZoomLevel,
request.StitchTiles);
return Results.Ok(status);
}
async Task<IResult> GetRegionStatus(Guid id, IRegionService regionService, ILogger<Program> logger)
async Task<IResult> GetRegionStatus(Guid id, IRegionService regionService)
{
try
{
var status = await regionService.GetRegionStatusAsync(id);
if (status == null)
{
return Results.NotFound(new { error = $"Region {id} not found" });
}
var status = await regionService.GetRegionStatusAsync(id);
return Results.Ok(status);
}
catch (Exception ex)
if (status == null)
{
logger.LogError(ex, "Failed to get region status");
return Results.Problem(detail: ex.Message, statusCode: 500);
return Results.NotFound(new { error = $"Region {id} not found" });
}
return Results.Ok(status);
}
async Task<IResult> CreateRoute([FromBody] CreateRouteRequest request, IRouteService routeService, ILogger<Program> logger)
@@ -242,31 +233,18 @@ async Task<IResult> CreateRoute([FromBody] CreateRouteRequest request, IRouteSer
logger.LogWarning(ex, "Invalid route request");
return Results.BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create route");
return Results.Problem(detail: ex.Message, statusCode: 500);
}
}
async Task<IResult> GetRoute(Guid id, IRouteService routeService, ILogger<Program> logger)
async Task<IResult> GetRoute(Guid id, IRouteService routeService)
{
try
{
var route = await routeService.GetRouteAsync(id);
if (route == null)
{
return Results.NotFound(new { error = $"Route {id} not found" });
}
var route = await routeService.GetRouteAsync(id);
return Results.Ok(route);
}
catch (Exception ex)
if (route == null)
{
logger.LogError(ex, "Failed to get route");
return Results.Problem(detail: ex.Message, statusCode: 500);
return Results.NotFound(new { error = $"Route {id} not found" });
}
return Results.Ok(route);
}
public record GetSatelliteTilesResponse