mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 14:31:13 +00:00
2393bff1f2
Both POST /api/satellite/request and POST /api/satellite/route accept a caller-supplied id (Guid). Before this change, a retried POST with the same id would either crash with a unique-key violation (regions) or quietly create a divergent row (routes), neither of which matched the documented intent of caller-supplied GUIDs. RegionService.RequestRegionAsync and RouteService.CreateRouteAsync now check for an existing row by id at the top of the method. If one is found, the existing resource is returned with HTTP 200 and the side effects (insert + enqueue + point regeneration + geofence-region queueing) are all skipped. The Information-level log line on the idempotent path makes retries observable. OpenAPI Description metadata documents the contract on both endpoints so client integrators see it in Swagger. Coverage: - 2 new unit tests (one per service) assert that on duplicate id no insert / enqueue / point-generation / region-queueing call is made. - 2 new integration tests (IdempotentPostTests.cs) exercise the contract end-to-end via HTTP, asserting both calls return 200 and CreatedAt matches within 1ms (PostgreSQL truncates TIMESTAMP to microseconds while .NET DateTime keeps 100ns ticks; a real re-insertion would shift CreatedAt by milliseconds at minimum). Note: the check-first pattern leaves a TOCTOU window for concurrent retries. The repository unique key still surfaces the race as a PostgresException which AZ-353 maps to a clean error. Acceptable for realistic sequential-retry patterns; recorded in batch report as a non-blocking observation. Co-authored-by: Cursor <cursoragent@cursor.com>
367 lines
13 KiB
C#
367 lines
13 KiB
C#
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;
|
|
using SatelliteProvider.Common.DTO;
|
|
using SatelliteProvider.Common.Interfaces;
|
|
using SatelliteProvider.Services.RegionProcessing;
|
|
using SatelliteProvider.Services.RouteManagement;
|
|
using SatelliteProvider.Services.TileDownloader;
|
|
using Serilog;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Host.UseSerilog((context, configuration) =>
|
|
configuration.ReadFrom.Configuration(context.Configuration));
|
|
|
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
|
?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
|
|
|
|
builder.Services.Configure<MapConfig>(builder.Configuration.GetSection("MapConfig"));
|
|
builder.Services.Configure<StorageConfig>(builder.Configuration.GetSection("StorageConfig"));
|
|
builder.Services.Configure<ProcessingConfig>(builder.Configuration.GetSection("ProcessingConfig"));
|
|
|
|
builder.Services.AddSingleton<ITileRepository>(sp => new TileRepository(connectionString, sp.GetRequiredService<ILogger<TileRepository>>()));
|
|
builder.Services.AddSingleton<IRegionRepository>(sp => new RegionRepository(connectionString, sp.GetRequiredService<ILogger<RegionRepository>>()));
|
|
builder.Services.AddSingleton<IRouteRepository>(sp => new RouteRepository(connectionString, sp.GetRequiredService<ILogger<RouteRepository>>()));
|
|
|
|
builder.Services.AddHttpClient();
|
|
|
|
builder.Services.AddTileDownloader();
|
|
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 (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;
|
|
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
|
});
|
|
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen(c =>
|
|
{
|
|
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Satellite Provider API", Version = "v1" });
|
|
|
|
c.MapType<UploadImageRequest>(() => new OpenApiSchema
|
|
{
|
|
Type = "object",
|
|
Properties = new Dictionary<string, OpenApiSchema>
|
|
{
|
|
["timestamp"] = new() { Type = "string", Format = "date-time", Description = "Image capture timestamp" },
|
|
["image"] = new() { Type = "string", Format = "binary", Description = "Image file to upload" },
|
|
["lat"] = new() { Type = "number", Format = "double", Description = "Latitude coordinate where image was captured" },
|
|
["lon"] = new() { Type = "number", Format = "double", Description = "Longitude coordinate where image was captured" },
|
|
["height"] = new() { Type = "number", Format = "double", Description = "Height/altitude in meters where image was captured" },
|
|
["focalLength"] = new() { Type = "number", Format = "double", Description = "Camera focal length in millimeters" },
|
|
["sensorWidth"] = new() { Type = "number", Format = "double", Description = "Camera sensor width in millimeters" },
|
|
["sensorHeight"] = new() { Type = "number", Format = "double", Description = "Camera sensor height in millimeters" }
|
|
},
|
|
Required = new HashSet<string> { "timestamp", "image", "lat", "lon", "height", "focalLength", "sensorWidth", "sensorHeight" }
|
|
});
|
|
|
|
c.OperationFilter<ParameterDescriptionFilter>();
|
|
});
|
|
|
|
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())
|
|
{
|
|
throw new Exception("Database migration failed. Application cannot start.");
|
|
}
|
|
|
|
var storageConfig = app.Configuration.GetSection("StorageConfig").Get<StorageConfig>() ?? new StorageConfig();
|
|
Directory.CreateDirectory(storageConfig.TilesDirectory);
|
|
Directory.CreateDirectory(storageConfig.ReadyDirectory);
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
|
|
app.UseExceptionHandler();
|
|
app.UseHttpsRedirection();
|
|
app.UseCors("TilesCors");
|
|
|
|
app.MapGet("/tiles/{z:int}/{x:int}/{y:int}", ServeTile)
|
|
.WithOpenApi(op => new(op) { Summary = "Get satellite tile image by z/x/y coordinates (Slippy Map tile server)" });
|
|
|
|
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)
|
|
.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")
|
|
.ProducesProblem(StatusCodes.Status501NotImplemented)
|
|
.WithOpenApi(op => new(op) { Summary = "Upload image with metadata (NOT IMPLEMENTED)" })
|
|
.DisableAntiforgery();
|
|
|
|
app.MapPost("/api/satellite/request", RequestRegion)
|
|
.WithOpenApi(op => new(op)
|
|
{
|
|
Summary = "Request tiles for a region",
|
|
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing region resource with HTTP 200 and does not enqueue duplicate background processing.",
|
|
});
|
|
|
|
app.MapGet("/api/satellite/region/{id:guid}", GetRegionStatus)
|
|
.WithOpenApi(op => new(op) { Summary = "Get region status and file paths" });
|
|
|
|
app.MapPost("/api/satellite/route", CreateRoute)
|
|
.WithOpenApi(op => new(op)
|
|
{
|
|
Summary = "Create a route with intermediate points",
|
|
Description = "Idempotent (AZ-362): POSTing the same `id` twice returns the existing route resource with HTTP 200 and does not regenerate intermediate points or re-queue geofence regions.",
|
|
});
|
|
|
|
app.MapGet("/api/satellite/route/{id:guid}", GetRoute)
|
|
.WithOpenApi(op => new(op) { Summary = "Get route information with calculated points" });
|
|
|
|
app.Run();
|
|
|
|
async Task<IResult> ServeTile(int z, int x, int y, HttpContext httpContext, ITileService tileService)
|
|
{
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
|
|
IResult GetSatelliteTilesByMgrs(string mgrs, double squareSideMeters)
|
|
{
|
|
return Results.Problem(
|
|
statusCode: StatusCodes.Status501NotImplemented,
|
|
title: "Not implemented",
|
|
detail: "MGRS-based tile retrieval is not implemented.");
|
|
}
|
|
|
|
IResult UploadImage([FromForm] UploadImageRequest request)
|
|
{
|
|
return Results.Problem(
|
|
statusCode: StatusCodes.Status501NotImplemented,
|
|
title: "Not implemented",
|
|
detail: "Image upload is not implemented.");
|
|
}
|
|
|
|
async Task<IResult> RequestRegion([FromBody] RequestRegionRequest request, IRegionService regionService)
|
|
{
|
|
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);
|
|
}
|
|
|
|
async Task<IResult> GetRegionStatus(Guid id, IRegionService regionService)
|
|
{
|
|
var status = await regionService.GetRegionStatusAsync(id);
|
|
|
|
if (status == null)
|
|
{
|
|
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)
|
|
{
|
|
try
|
|
{
|
|
var route = await routeService.CreateRouteAsync(request);
|
|
return Results.Ok(route);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
logger.LogWarning(ex, "Invalid route request");
|
|
return Results.BadRequest(new { error = ex.Message });
|
|
}
|
|
}
|
|
|
|
async Task<IResult> GetRoute(Guid id, IRouteService routeService)
|
|
{
|
|
var route = await routeService.GetRouteAsync(id);
|
|
|
|
if (route == null)
|
|
{
|
|
return Results.NotFound(new { error = $"Route {id} not found" });
|
|
}
|
|
|
|
return Results.Ok(route);
|
|
}
|
|
|
|
public record GetSatelliteTilesResponse
|
|
{
|
|
public List<SatelliteTile> Tiles { get; set; } = new();
|
|
}
|
|
|
|
public record SatelliteTile
|
|
{
|
|
public string TileId { get; set; } = string.Empty;
|
|
public byte[] ImageData { get; set; } = Array.Empty<byte>();
|
|
public double Lat { get; set; }
|
|
public double Lon { get; set; }
|
|
public int ZoomLevel { get; set; }
|
|
}
|
|
|
|
public record UploadImageRequest
|
|
{
|
|
[Required]
|
|
public DateTime Timestamp { get; set; }
|
|
|
|
[Required]
|
|
public IFormFile? Image { get; set; }
|
|
|
|
[Required]
|
|
public double Lat { get; set; }
|
|
|
|
[Required]
|
|
public double Lon { get; set; }
|
|
|
|
[Required]
|
|
public double Height { get; set; }
|
|
|
|
[Required]
|
|
public double FocalLength { get; set; }
|
|
|
|
[Required]
|
|
public double SensorWidth { get; set; }
|
|
|
|
[Required]
|
|
public double SensorHeight { get; set; }
|
|
}
|
|
|
|
public record SaveResult
|
|
{
|
|
public bool Success { get; set; }
|
|
public string? Exception { get; set; }
|
|
}
|
|
|
|
public record DownloadTileResponse
|
|
{
|
|
public Guid Id { get; set; }
|
|
public int ZoomLevel { get; set; }
|
|
public double Latitude { get; set; }
|
|
public double Longitude { get; set; }
|
|
public double TileSizeMeters { get; set; }
|
|
public int TileSizePixels { get; set; }
|
|
public string ImageType { get; set; } = string.Empty;
|
|
public string? MapsVersion { get; set; }
|
|
public int? Version { get; set; }
|
|
public string FilePath { get; set; } = string.Empty;
|
|
public DateTime CreatedAt { get; set; }
|
|
public DateTime UpdatedAt { get; set; }
|
|
}
|
|
|
|
public record RequestRegionRequest
|
|
{
|
|
[Required]
|
|
public Guid Id { get; set; }
|
|
|
|
[Required]
|
|
public double Latitude { get; set; }
|
|
|
|
[Required]
|
|
public double Longitude { get; set; }
|
|
|
|
[Required]
|
|
public double SizeMeters { get; set; }
|
|
|
|
[Required]
|
|
public int ZoomLevel { get; set; } = 18;
|
|
|
|
public bool StitchTiles { get; set; } = false;
|
|
}
|
|
|
|
public class ParameterDescriptionFilter : IOperationFilter
|
|
{
|
|
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
|
{
|
|
if (operation.Parameters == null) return;
|
|
|
|
var parameterDescriptions = new Dictionary<string, string>
|
|
{
|
|
["lat"] = "Latitude coordinate where image was captured",
|
|
["lon"] = "Longitude coordinate where image was captured",
|
|
["mgrs"] = "MGRS coordinate string",
|
|
["squareSideMeters"] = "Square side size in meters",
|
|
["Latitude"] = "Latitude coordinate of the tile center",
|
|
["Longitude"] = "Longitude coordinate of the tile center",
|
|
["ZoomLevel"] = "Zoom level for the tile (higher values = more detail)"
|
|
};
|
|
|
|
foreach (var parameter in operation.Parameters)
|
|
{
|
|
if (parameterDescriptions.TryGetValue(parameter.Name, out var description))
|
|
{
|
|
parameter.Description = description;
|
|
}
|
|
}
|
|
}
|
|
}
|