Files
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

296 lines
11 KiB
C#

using System.Globalization;
using System.Security.Cryptography;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Common.DTO;
using SatelliteProvider.Common.Enums;
using SatelliteProvider.Common.Interfaces;
using SatelliteProvider.Common.Utils;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.DataAccess.Repositories;
namespace SatelliteProvider.Services.TileDownloader;
public class TileService : ITileService
{
private static readonly TimeSpan TileCacheAbsolute = TimeSpan.FromHours(1);
private static readonly TimeSpan TileCacheSliding = TimeSpan.FromMinutes(30);
private static readonly TimeSpan TileResponseMaxAge = TimeSpan.FromDays(1);
private const string TileImageContentType = "image/jpeg";
private readonly ISatelliteDownloader _downloader;
private readonly ITileRepository _tileRepository;
private readonly IMemoryCache _cache;
private readonly MapConfig _mapConfig;
private readonly ILogger<TileService> _logger;
public TileService(
ISatelliteDownloader downloader,
ITileRepository tileRepository,
IMemoryCache cache,
IOptions<MapConfig> mapConfig,
ILogger<TileService> logger)
{
_downloader = downloader;
_tileRepository = tileRepository;
_cache = cache;
_mapConfig = mapConfig.Value;
_logger = logger;
}
public async Task<List<TileMetadata>> DownloadAndStoreTilesAsync(
double latitude,
double longitude,
double sizeMeters,
int zoomLevel,
CancellationToken cancellationToken = default)
{
var existingTiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
var existingTilesList = existingTiles.ToList();
var centerPoint = new GeoPoint(latitude, longitude);
var existingTileInfos = existingTilesList
.Select(t => new ExistingTileInfo(t.Latitude, t.Longitude, t.TileZoom))
.ToList();
var downloadedTiles = await _downloader.GetTilesWithMetadataAsync(
centerPoint,
sizeMeters / 2,
zoomLevel,
existingTileInfos,
cancellationToken);
var result = new List<TileMetadata>();
foreach (var existingTile in existingTilesList)
{
result.Add(MapToMetadata(existingTile));
}
foreach (var downloadedTile in downloadedTiles)
{
var tileEntity = BuildTileEntity(downloadedTile);
await _tileRepository.InsertAsync(tileEntity);
result.Add(MapToMetadata(tileEntity));
}
return result;
}
public async Task<TileMetadata?> GetTileAsync(Guid id)
{
var tile = await _tileRepository.GetByIdAsync(id);
return tile != null ? MapToMetadata(tile) : null;
}
public async Task<IEnumerable<TileMetadata>> GetTilesByRegionAsync(
double latitude,
double longitude,
double sizeMeters,
int zoomLevel)
{
var tiles = await _tileRepository.GetTilesByRegionAsync(latitude, longitude, sizeMeters, zoomLevel);
return tiles.Select(MapToMetadata);
}
public async Task<TileBytes> GetOrDownloadTileAsync(int z, int x, int y, CancellationToken cancellationToken = default)
{
var cacheKey = $"tile_{z}_{x}_{y}";
var etag = $"\"{z}_{x}_{y}\"";
if (_cache.TryGetValue(cacheKey, out byte[]? cachedBytes) && cachedBytes != null)
{
return new TileBytes(cachedBytes, TileImageContentType, etag, TileResponseMaxAge);
}
string filePath;
var existing = await _tileRepository.GetByTileCoordinatesAsync(z, x, y);
if (existing != null && File.Exists(existing.FilePath))
{
filePath = existing.FilePath;
}
else
{
var tileCenter = GeoUtils.TileToWorldPos(x, y, z);
var downloaded = await _downloader.DownloadSingleTileAsync(tileCenter.Lat, tileCenter.Lon, z, cancellationToken);
var entity = BuildTileEntity(downloaded);
await _tileRepository.InsertAsync(entity);
filePath = entity.FilePath;
}
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
_cache.Set(cacheKey, bytes, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TileCacheAbsolute,
SlidingExpiration = TileCacheSliding
});
return new TileBytes(bytes, TileImageContentType, etag, TileResponseMaxAge);
}
public async Task<TileMetadata> DownloadAndStoreSingleTileAsync(
double latitude,
double longitude,
int zoomLevel,
CancellationToken cancellationToken = default)
{
var downloaded = await _downloader.DownloadSingleTileAsync(latitude, longitude, zoomLevel, cancellationToken);
var entity = BuildTileEntity(downloaded);
await _tileRepository.InsertAsync(entity);
return MapToMetadata(entity);
}
public async Task<TileInventoryResponse> GetInventoryAsync(TileInventoryRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var tiles = request.Tiles;
var hashes = request.LocationHashes;
// Defensive guards. The HTTP handler rejects these cases with HTTP
// 400 before reaching the service; this preserves the same invariant
// for any future non-HTTP caller (and keeps unit-tests grounded).
var hasTiles = tiles is { Count: > 0 };
var hasHashes = hashes is { Count: > 0 };
if (hasTiles == hasHashes)
{
throw new ArgumentException(
"TileInventoryRequest must populate exactly one of `Tiles` or `LocationHashes` (and not both).",
nameof(request));
}
// Build the (request entry → location_hash) mapping. When the caller
// supplied coords, compute UUIDv5 server-side; when they supplied
// pre-computed hashes, use them verbatim. We keep both representations
// in lockstep so the response can echo the request entry's coord
// triple back to the caller (Tiles input branch) or zero them out
// (LocationHashes input branch).
var entries = new List<(int Zoom, int X, int Y, Guid Hash)>(hasTiles ? tiles!.Count : hashes!.Count);
if (hasTiles)
{
foreach (var coord in tiles!)
{
var hash = Uuidv5.LocationHashForTile(coord.Z, coord.X, coord.Y);
entries.Add((coord.Z, coord.X, coord.Y, hash));
}
}
else
{
foreach (var hash in hashes!)
{
entries.Add((0, 0, 0, hash));
}
}
var distinctHashes = entries.Select(e => e.Hash).Distinct().ToArray();
var rows = await _tileRepository.GetTilesByLocationHashesAsync(distinctHashes);
var results = new List<TileInventoryEntry>(entries.Count);
foreach (var (zoom, x, y, hash) in entries)
{
if (rows.TryGetValue(hash, out var tile))
{
results.Add(new TileInventoryEntry
{
Z = hasTiles ? zoom : tile.TileZoom,
X = hasTiles ? x : tile.TileX,
Y = hasTiles ? y : tile.TileY,
LocationHash = hash,
Present = true,
Id = tile.Id,
CapturedAt = tile.CapturedAt,
Source = tile.Source,
FlightId = tile.FlightId,
ResolutionMPerPx = tile.TileSizePixels > 0 ? tile.TileSizeMeters / tile.TileSizePixels : null
});
}
else
{
results.Add(new TileInventoryEntry
{
Z = zoom,
X = x,
Y = y,
LocationHash = hash,
Present = false
});
}
}
return new TileInventoryResponse { Results = results };
}
private TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded)
{
var now = DateTime.UtcNow;
var source = TileSourceConverter.ToWireValue(TileSource.GoogleMaps);
// AZ-503: deterministic UUIDv5 over (z, x, y, source, flight_id-or-zero).
// google_maps tiles have no flight_id so the name fragment uses the
// canonical all-zeros UUID; the same Python-side serialization in
// gps-denied-onboard produces byte-identical IDs.
var idName = string.Create(CultureInfo.InvariantCulture,
$"{downloaded.ZoomLevel}/{downloaded.X}/{downloaded.Y}/{source}/{Guid.Empty}");
var id = Uuidv5.Create(Uuidv5.TileNamespace, idName);
var locationHash = Uuidv5.LocationHashForTile(downloaded.ZoomLevel, downloaded.X, downloaded.Y);
// content_sha256 is computed from the actual JPEG body on disk. Google Maps
// downloads land on disk before this method runs (FilePath is set by the
// downloader), so a single read here is safe and avoids re-streaming. If
// the file is missing for any reason, leave ContentSha256 null and rely on
// the application invariant of "NOT NULL for AZ-503+ inserts" surfacing
// the problem in tests rather than silently inserting a sentinel digest.
byte[]? contentSha256 = null;
if (File.Exists(downloaded.FilePath))
{
using var stream = File.OpenRead(downloaded.FilePath);
contentSha256 = SHA256.HashData(stream);
}
return new TileEntity
{
Id = id,
TileZoom = downloaded.ZoomLevel,
TileX = downloaded.X,
TileY = downloaded.Y,
Latitude = downloaded.CenterLatitude,
Longitude = downloaded.CenterLongitude,
TileSizeMeters = downloaded.TileSizeMeters,
TileSizePixels = _mapConfig.TileSizePixels,
ImageType = "jpg",
MapsVersion = null,
Version = null,
FilePath = downloaded.FilePath,
Source = source,
CapturedAt = now,
CreatedAt = now,
UpdatedAt = now,
FlightId = null,
LocationHash = locationHash,
ContentSha256 = contentSha256,
LegacyId = null
};
}
private static TileMetadata MapToMetadata(TileEntity entity)
{
return new TileMetadata
{
Id = entity.Id,
TileZoom = entity.TileZoom,
TileX = entity.TileX,
TileY = entity.TileY,
Latitude = entity.Latitude,
Longitude = entity.Longitude,
TileSizeMeters = entity.TileSizeMeters,
TileSizePixels = entity.TileSizePixels,
ImageType = entity.ImageType,
Version = entity.Version,
FilePath = entity.FilePath,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
}