mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-23 02:11:14 +00:00
[AZ-353][AZ-354][AZ-356] Refactor 03 batch 2: harden API surface
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:
@@ -0,0 +1,41 @@
|
||||
namespace SatelliteProvider.Api;
|
||||
|
||||
public static class CorsConfigurationValidator
|
||||
{
|
||||
public const string MissingOriginsMessage =
|
||||
"CORS is misconfigured: CorsConfig:AllowedOrigins is empty and CorsConfig:AllowAnyOrigin is not true. " +
|
||||
"Refusing to start in Production with a permissive CORS policy. " +
|
||||
"Set CorsConfig:AllowedOrigins to a non-empty array, or set CorsConfig:AllowAnyOrigin=true to opt in.";
|
||||
|
||||
public const string PermissiveDefaultWarning =
|
||||
"CorsConfig:AllowedOrigins is empty and CorsConfig:AllowAnyOrigin is not true. " +
|
||||
"Permissive CORS is being applied for environment {Environment}; do not run with this configuration in Production.";
|
||||
|
||||
public static void EnsureSafeForEnvironment(
|
||||
string[] allowedOrigins,
|
||||
bool allowAnyOrigin,
|
||||
string environmentName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(allowedOrigins);
|
||||
ArgumentNullException.ThrowIfNull(environmentName);
|
||||
|
||||
if (allowedOrigins.Length == 0
|
||||
&& !allowAnyOrigin
|
||||
&& string.Equals(environmentName, "Production", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(MissingOriginsMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ShouldUsePermissivePolicy(string[] allowedOrigins, bool allowAnyOrigin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(allowedOrigins);
|
||||
return allowAnyOrigin || allowedOrigins.Length == 0;
|
||||
}
|
||||
|
||||
public static bool ShouldWarnAboutPermissiveDefault(string[] allowedOrigins, bool allowAnyOrigin)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(allowedOrigins);
|
||||
return allowedOrigins.Length == 0 && !allowAnyOrigin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user