diff --git a/src/GeoCop.Api/Context.cs b/src/GeoCop.Api/Context.cs index fee5d5c8..e67fa78c 100644 --- a/src/GeoCop.Api/Context.cs +++ b/src/GeoCop.Api/Context.cs @@ -11,7 +11,7 @@ public class Context : DbContext /// /// Database context to manage the database. /// - /// + /// Configuration options for the Context. public Context(DbContextOptions options) : base(options) { @@ -32,11 +32,41 @@ public Context(DbContextOptions options) /// public DbSet Deliveries { get; set; } + /// + /// Gets the entity with all includes. + /// + public List DeliveriesWithIncludes + { + get + { + return Deliveries + .Include(d => d.DeliveryMandate) + .Include(d => d.Assets) + .AsNoTracking() + .ToList(); + } + } + /// /// Set of all . /// public DbSet DeliveryMandates { get; set; } + /// + /// Gets the entity with all includes. + /// + public List DeliveryMandatesWithIncludes + { + get + { + return DeliveryMandates + .Include(d => d.Deliveries) + .ThenInclude(d => d.Assets) + .AsNoTracking() + .ToList(); + } + } + /// /// Set of all . /// diff --git a/src/GeoCop.Api/Controllers/DeliveryController.cs b/src/GeoCop.Api/Controllers/DeliveryController.cs index 8d413bc7..0c1d4b36 100644 --- a/src/GeoCop.Api/Controllers/DeliveryController.cs +++ b/src/GeoCop.Api/Controllers/DeliveryController.cs @@ -2,6 +2,7 @@ using GeoCop.Api.Contracts; using GeoCop.Api.Models; using GeoCop.Api.Validation; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -11,6 +12,7 @@ namespace GeoCop.Api.Controllers /// Controller for declaring deliveries. /// [ApiController] + [Authorize] [Route("api/v{version:apiVersion}/[controller]")] public class DeliveryController : ControllerBase { diff --git a/src/GeoCop.Api/Controllers/DownloadController.cs b/src/GeoCop.Api/Controllers/DownloadController.cs index 34bb26e6..3db98038 100644 --- a/src/GeoCop.Api/Controllers/DownloadController.cs +++ b/src/GeoCop.Api/Controllers/DownloadController.cs @@ -60,7 +60,7 @@ public IActionResult Download(Guid jobId, string file) } var logFile = fileProvider.Open(file); - var contentType = contentTypeProvider.TryGetContentType(file, out var type) ? type : "application/octet-stream"; + var contentType = contentTypeProvider.GetContentTypeAsString(file); var logFileName = Path.GetFileNameWithoutExtension(validationJob.OriginalFileName) + "_log" + Path.GetExtension(file); return File(logFile, contentType, logFileName); } diff --git a/src/GeoCop.Api/GeoCop.Api.csproj b/src/GeoCop.Api/GeoCop.Api.csproj index 63f837c4..023e6734 100644 --- a/src/GeoCop.Api/GeoCop.Api.csproj +++ b/src/GeoCop.Api/GeoCop.Api.csproj @@ -13,6 +13,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,6 +24,8 @@ + + diff --git a/src/GeoCop.Api/IContentTypeProviderExtensions.cs b/src/GeoCop.Api/IContentTypeProviderExtensions.cs new file mode 100644 index 00000000..1ed546dd --- /dev/null +++ b/src/GeoCop.Api/IContentTypeProviderExtensions.cs @@ -0,0 +1,52 @@ +using GeoCop.Api.Models; +using Microsoft.AspNetCore.StaticFiles; +using System.Net.Mime; + +namespace GeoCop.Api +{ + /// + /// Provides access to file content types. + /// + public static class IContentTypeProviderExtensions + { + private const string DefaultContentType = "application/octet-stream"; + + /// + /// Returns the for the specified . + /// + /// The IContentTypeProvider to extend. + /// The asset from which the content type should be read. + /// The . + public static ContentType GetContentType(this IContentTypeProvider contentTypeProvider, Asset asset) + { + return contentTypeProvider.GetContentType(asset.OriginalFilename); + } + + /// + /// Returns the for the specified file extension. + /// + /// The IContentTypeProvider to extend. + /// The file from which the content type should be read. + /// The . + public static ContentType GetContentType(this IContentTypeProvider contentTypeProvider, string fileName) + { + return new ContentType(contentTypeProvider.GetContentTypeAsString(fileName)); + } + + /// + /// Returns the for the specified file extension. + /// + /// The IContentTypeProvider to extend. + /// The file from which the content type should be read. + /// The content type as string. + public static string GetContentTypeAsString(this IContentTypeProvider contentTypeProvider, string fileName) + { + if (!contentTypeProvider.TryGetContentType(fileName, out var contentType)) + { + contentType = DefaultContentType; + } + + return contentType; + } + } +} diff --git a/src/GeoCop.Api/Program.cs b/src/GeoCop.Api/Program.cs index e1fa5950..fa0fd314 100644 --- a/src/GeoCop.Api/Program.cs +++ b/src/GeoCop.Api/Program.cs @@ -1,5 +1,6 @@ using Asp.Versioning; using GeoCop.Api; +using GeoCop.Api.StacServices; using GeoCop.Api.Validation; using GeoCop.Api.Validation.Interlis; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -15,13 +16,21 @@ var builder = WebApplication.CreateBuilder(args); +builder.Services.AddCors(options => +{ + // DotNetStac.Api uses the "All" policy for access in the STAC browser. + options.AddPolicy("All", + policy => + { + policy.AllowAnyOrigin() + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); + builder.Services .AddControllers(options => { - var policy = new AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .Build(); - options.Filters.Add(new AuthorizeFilter(policy)); }) .AddJsonOptions(options => { @@ -66,7 +75,7 @@ var contentTypeProvider = new FileExtensionContentTypeProvider(); contentTypeProvider.Mappings.TryAdd(".log", "text/plain"); -contentTypeProvider.Mappings.TryAdd(".xtf", "text/xml; charset=utf-8"); +contentTypeProvider.Mappings.TryAdd(".xtf", "application/interlis+xml"); builder.Services.AddSingleton(contentTypeProvider); builder.Services.AddSingleton(); @@ -87,14 +96,18 @@ httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); }); -builder.Services.AddDbContext(options => +var configureContextOptions = (DbContextOptionsBuilder options) => { options.UseNpgsql(builder.Configuration.GetConnectionString("Context"), o => { o.UseNetTopologySuite(); o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); }); -}); +}; +builder.Services.AddDbContextFactory(configureContextOptions); +builder.Services.AddDbContext(configureContextOptions); + +builder.Services.AddStacData(builder => { }); var app = builder.Build(); @@ -111,6 +124,8 @@ if (app.Environment.IsDevelopment()) { + app.UseCors("All"); + if (!context.DeliveryMandates.Any()) context.SeedTestData(); } diff --git a/src/GeoCop.Api/StacServices/StacCollectionsProvider.cs b/src/GeoCop.Api/StacServices/StacCollectionsProvider.cs new file mode 100644 index 00000000..4f1a0792 --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacCollectionsProvider.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using Stac; +using Stac.Api.Interfaces; +using Stac.Api.WebApi.Implementations.Default; + +namespace GeoCop.Api.StacServices +{ + /// + /// Provides access to STAC collections. + /// + public class StacCollectionsProvider : ICollectionsProvider + { + private readonly ILogger logger; + private readonly IDbContextFactory contextFactory; + private readonly StacConverter stacConverter; + + /// + /// Initializes a new instance of the class. + /// + public StacCollectionsProvider(ILogger logger, IDbContextFactory contextFactory, StacConverter stacConverter) + { + this.logger = logger; + this.contextFactory = contextFactory; + this.stacConverter = stacConverter; + } + + /// + public Task GetCollectionByIdAsync(string collectionId, IStacApiContext stacApiContext, CancellationToken cancellationToken = default) + { + try + { + using var db = contextFactory.CreateDbContext(); + var deliveryMandate = db.DeliveryMandatesWithIncludes.FirstOrDefault(dm => stacConverter.GetCollectionId(dm) == collectionId) + ?? throw new InvalidOperationException($"Collection with id {collectionId} does not exist."); + var collection = stacConverter.ToStacCollection(deliveryMandate); + return Task.FromResult(collection); + } + catch (Exception ex) + { + var message = $"Error while getting collection with id {collectionId}"; + logger.LogError(ex, message); + throw new InvalidOperationException(message, ex); + } + } + + /// + public Task> GetCollectionsAsync(IStacApiContext stacApiContext, CancellationToken cancellationToken = default) + { + using var db = contextFactory.CreateDbContext(); + var collections = db.DeliveryMandatesWithIncludes.Select(stacConverter.ToStacCollection); + stacApiContext.Properties.SetProperty(DefaultConventions.MatchedCountPropertiesKey, collections.Count()); + + return Task.FromResult>(collections); + } + } +} diff --git a/src/GeoCop.Api/StacServices/StacConverter.cs b/src/GeoCop.Api/StacServices/StacConverter.cs new file mode 100644 index 00000000..f6de6e04 --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacConverter.cs @@ -0,0 +1,158 @@ +using GeoCop.Api.Models; +using Itenso.TimePeriod; +using Microsoft.AspNetCore.StaticFiles; +using NetTopologySuite.Geometries; +using Stac; +using Stac.Api.Interfaces; +using Stac.Api.WebApi.Services; +using Stac.Collection; + +namespace GeoCop.Api.StacServices +{ + /// + /// Converts objects of different types to STAC objects. + /// + public class StacConverter + { + private IStacLinker StacLinker { get; } + private IContentTypeProvider FileContentTypeProvider { get; } + private IStacApiContextFactory StacApiContextFactory { get; } + + private const string DeliveryNamePrefix = "Datenabgabe_"; + + /// + /// Initializes a new instance of the class. + /// + public StacConverter(IStacLinker stacLinker, IStacApiContextFactory stacApiContextFactory, IContentTypeProvider fileContentTypeProvider) + { + StacLinker = stacLinker; + FileContentTypeProvider = fileContentTypeProvider; + StacApiContextFactory = stacApiContextFactory; + } + + /// + /// Returns the collection id for the specified . + /// + /// The . + /// Collection id. + public string GetCollectionId(DeliveryMandate mandate) => "coll_" + mandate.Id; + + /// + /// Returns the item id for the specified . + /// + /// The . + /// Item id. + public string GetItemId(Delivery delivery) => "item_" + delivery.Id; + + /// + /// Converts a to a . + /// + /// The to convert. + /// A STAC collection. + public StacCollection ToStacCollection(DeliveryMandate mandate) + { + var collectionId = GetCollectionId(mandate); + var items = mandate.Deliveries + .Select(ToStacItem) + .ToDictionary(i => i.Links.First(l => l.RelationshipType.ToLowerInvariant() == "self").Uri); + + if (items.Values.Count == 0) + { + var nowTimestamp = DateTime.Now.ToUniversalTime(); + var extent = new StacExtent(ToStacSpatialExtent(mandate.SpatialExtent), new StacTemporalExtent(nowTimestamp, nowTimestamp)); + return new StacCollection(collectionId, string.Empty, extent, null, null) + { + Title = mandate.Name, + }; + } + else + { + var collection = StacCollection.Create(collectionId, string.Empty, items); + collection.Title = mandate.Name; + return collection; + } + } + + /// + /// Converts a to a . + /// + /// The to convert. + /// The STAC item. + public StacItem ToStacItem(Delivery delivery) + { + var stacId = GetItemId(delivery); + + var item = new StacItem(stacId, ToGeoJsonPolygon(delivery.DeliveryMandate.SpatialExtent)) + { + Collection = GetCollectionId(delivery.DeliveryMandate), + Title = DeliveryNamePrefix + delivery.Date.ToString("s"), + Description = string.Empty, + DateTime = new TimePeriodChain(), + }; + item.DateTime.Setup(delivery.Date, delivery.Date); + + var assets = delivery.Assets.Select(file => ToStacAsset(file, item)).ToDictionary(asset => asset.Title); + item.Assets.AddRange(assets); + + var stacApiContext = StacApiContextFactory.Create(); + StacLinker.Link(item, stacApiContext); + + return item; + } + + /// + /// Converts a to a . + /// + /// The to convert. + /// The parent to which the asset belongs. + /// The STAC asset. + public StacAsset ToStacAsset(Asset asset, IStacObject parent) + { + // TODO: Set correct Url with https://github.com/GeoWerkstatt/geocop/issues/56 + return new StacAsset(parent, new Uri("https://github.com/GeoWerkstatt/geocop/issues/56"), new List() { asset.AssetType.ToString() }, asset.OriginalFilename, FileContentTypeProvider.GetContentType(asset)); + } + + /// + /// Converts a to a . + /// + /// The . + /// The . + public GeoJSON.Net.Geometry.Polygon ToGeoJsonPolygon(Geometry geometry) + { + var (xMin, yMin, xMax, yMax) = GetCoordinatesBounds(geometry); + return new GeoJSON.Net.Geometry.Polygon(new List() + { + new (new List() + { + new (xMin, yMin), + new (xMax, yMin), + new (xMax, yMax), + new (xMin, yMax), + new (xMin, yMin), + }), + }); + } + + /// + /// Converts a to a (bounding box of a StacObject). + /// + /// The . + /// The . + public StacSpatialExtent ToStacSpatialExtent(Geometry geometry) + { + var (xMin, yMin, xMax, yMax) = GetCoordinatesBounds(geometry); + return new StacSpatialExtent(xMin, yMin, xMax, yMax); + } + + private (double xMin, double yMin, double xMax, double yMax) GetCoordinatesBounds(Geometry geometry) + { + var coordinates = geometry.Coordinates; + var xMin = coordinates.Min((Coordinate c) => c.X); + var yMin = coordinates.Min((Coordinate c) => c.Y); + var xMax = coordinates.Max((Coordinate c) => c.X); + var yMax = coordinates.Max((Coordinate c) => c.Y); + + return (xMin, yMin, xMax, yMax); + } + } +} diff --git a/src/GeoCop.Api/StacServices/StacDataServicesProvider.cs b/src/GeoCop.Api/StacServices/StacDataServicesProvider.cs new file mode 100644 index 00000000..c9d3f048 --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacDataServicesProvider.cs @@ -0,0 +1,35 @@ +using Stac.Api.Interfaces; + +namespace GeoCop.Api.StacServices +{ + /// + /// Provides access to STAC data services. + /// + public class StacDataServicesProvider : IDataServicesProvider + { + private readonly IServiceProvider serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + public StacDataServicesProvider(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + /// + public ICollectionsProvider GetCollectionsProvider() => serviceProvider.GetRequiredService(); + + /// + public IItemsBroker GetItemsBroker() => serviceProvider.GetRequiredService(); + + /// + public IItemsProvider GetItemsProvider() => serviceProvider.GetRequiredService(); + + /// + public IRootCatalogProvider GetRootCatalogProvider() => serviceProvider.GetRequiredService(); + + /// + public IStacQueryProvider GetStacQueryProvider(IStacApiContext stacApiContext) => serviceProvider.GetRequiredService(); + } +} diff --git a/src/GeoCop.Api/StacServices/StacItemsBroker.cs b/src/GeoCop.Api/StacServices/StacItemsBroker.cs new file mode 100644 index 00000000..19981e6b --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacItemsBroker.cs @@ -0,0 +1,35 @@ +using Stac; +using Stac.Api.Interfaces; + +namespace GeoCop.Api.StacServices +{ + /// + /// Handles STAC items changes. + /// + public class StacItemsBroker : IItemsBroker + { + /// + public Task CreateItemAsync(StacItem stacItem, IStacApiContext stacApiContext, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + /// + public Task DeleteItemAsync(string featureId, IStacApiContext stacApiContext, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + /// + public Task> RefreshStacCollectionsAsync(IStacApiContext stacApiContext, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + /// + Task IItemsBroker.UpdateItemAsync(StacItem newItem, string featureId, IStacApiContext stacApiContext, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } +} diff --git a/src/GeoCop.Api/StacServices/StacItemsProvider.cs b/src/GeoCop.Api/StacServices/StacItemsProvider.cs new file mode 100644 index 00000000..707e87fd --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacItemsProvider.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Stac; +using Stac.Api.Interfaces; + +namespace GeoCop.Api.StacServices +{ + /// + /// Provides access to STAC items. + /// + public class StacItemsProvider : IItemsProvider + { + private readonly ILogger logger; + private readonly IDbContextFactory contextFactory; + private readonly StacConverter stacConverter; + + /// + /// Initializes a new instance of the class. + /// + public StacItemsProvider(ILogger logger, IDbContextFactory contextFactory, StacConverter stacConverter) + { + this.logger = logger; + this.contextFactory = contextFactory; + this.stacConverter = stacConverter; + } + + /// + public bool AnyItemsExist(IEnumerable items, IStacApiContext stacApiContext) + { + using var db = contextFactory.CreateDbContext(); + foreach (var collection in stacApiContext.Collections) + { + try + { + return db.DeliveryMandatesWithIncludes.FirstOrDefault(dm => stacConverter.GetCollectionId(dm) == collection)?.Deliveries?.Any() ?? false; + } + catch (Exception ex) + { + logger.LogError(ex, "Error while checking if items exist."); + return false; + } + } + + return false; + } + + /// + public Task GetItemByIdAsync(string featureId, IStacApiContext stacApiContext, CancellationToken cancellationToken) + { + try + { + using var db = contextFactory.CreateDbContext(); + var delivery = db.DeliveriesWithIncludes + .FirstOrDefault(d => stacConverter.GetItemId(d) == featureId && (stacConverter.GetCollectionId(d.DeliveryMandate) == stacApiContext.Collections.First())) + ?? throw new InvalidOperationException($"Item with id {featureId} does not exist."); + var item = stacConverter.ToStacItem(delivery); + return Task.FromResult(item); + } + catch (Exception ex) + { + var message = $"Error while getting item with id {featureId}."; + logger.LogError(ex, message); + throw new InvalidOperationException(message, ex); + } + } + + /// + public string GetItemEtag(string featureId, IStacApiContext stacApiContext) + { + throw new NotImplementedException(); + } + + /// + public Task> GetItemsAsync(IStacApiContext stacApiContext, CancellationToken cancellationToken) + { + IEnumerable items = new List(); + + var collectionIds = stacApiContext.Collections?.ToList(); + using var db = contextFactory.CreateDbContext(); + var deliveryMandates = db.DeliveryMandatesWithIncludes; + + if (collectionIds?.Any() == true) + { + deliveryMandates = deliveryMandates.FindAll(dm => collectionIds.Contains(stacConverter.GetCollectionId(dm))); + } + + deliveryMandates.ToList().ForEach(dm => items.Concat(dm.Deliveries.Select(d => stacConverter.ToStacItem(d)))); + return Task.FromResult(items); + } + } +} diff --git a/src/GeoCop.Api/StacServices/StacLinker.cs b/src/GeoCop.Api/StacServices/StacLinker.cs new file mode 100644 index 00000000..4d205616 --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacLinker.cs @@ -0,0 +1,249 @@ +using System.Collections.Specialized; +using System.Runtime.CompilerServices; +using System.Web; +using Microsoft.Extensions.Primitives; +using Stac; +using Stac.Api.Clients.Collections; +using Stac.Api.Interfaces; +using Stac.Api.Models; +using Stac.Api.Models.Core; +using Stac.Api.WebApi.Services; + +namespace GeoCop.Api.StacServices +{ + /// + /// Custom linker for STAC. Based on https://github.com/Terradue/DotNetStac.Api/blob/main/src/Stac.Api.WebApi/Implementations/Default/CollectionBasedStacLinker.cs, + /// with some modifications to return correct 'self' links for collections. + /// + public class StacLinker : IStacLinker + { + /// + public void Link(LandingPage landingPage, IStacApiContext stacApiContext) + { + var uri = stacApiContext.LinkGenerator.GetUriByAction(stacApiContext.HttpContext, "GetLandingPage", "Core"); + if (uri == null) throw new InvalidOperationException("Could not generate URL for action GetLandingPage on controller Core."); + landingPage.Links.Add(StacLink.CreateSelfLink(new Uri(uri), "application/json")); + landingPage.Links.Add(StacLink.CreateRootLink(new Uri(uri), "application/json")); + } + + /// + public void Link(StacCollection collection, IStacApiContext stacApiContext) + { + collection.Links.Add(GetSelfLink(collection, stacApiContext)); + collection.Links.Add(GetRootLink(stacApiContext)); + } + + /// + public void Link(StacCollections collections, IStacApiContext stacApiContext) + { + collections.Links.Add(GetSelfLink(collections, stacApiContext)); + collections.Links.Add(GetRootLink(stacApiContext)); + foreach (StacCollection collection in collections.Collections) + { + Link(collection, stacApiContext); + } + } + + /// + public void Link(Stac.StacItem item, IStacApiContext stacApiContext) + { + item.Links.Add(GetSelfLink(item, stacApiContext)); + item.Links.Add(GetRootLink(stacApiContext)); + } + + /// + public void Link(StacFeatureCollection collection, IStacApiContext stacApiContext) + { + collection.Links.Add(GetSelfLink(collection, stacApiContext)); + collection.Links.Add(GetRootLink(stacApiContext)); + collection.Links.Add(GetParentLink(collection, stacApiContext)); + AddAdditionalLinks(collection, stacApiContext); + } + + private StacApiLink GetRootLink(IStacApiContext stacApiContext) + { + return new StacApiLink(GetUriByAction(stacApiContext, "GetLandingPage", "Core", new { }, null), "root", null, "application/json"); + } + + /// + /// Create self link for collections. + /// + /// The for which to create the link. + /// The to build the link with. + /// A with relationshipType 'self'. + protected StacApiLink GetSelfLink(StacCollections stacCollections, IStacApiContext stacApiContext) + { + return new StacApiLink(GetUriByAction(stacApiContext, "GetCollections", "Collections", new { }, null), "self", "Collections", "application/json"); + } + + /// + /// Create self link for items. + /// + /// The for which to create the link. + /// The to build the link with. + /// A with relationshipType 'self'. + protected StacApiLink GetSelfLink(Stac.StacItem stacItem, IStacApiContext stacApiContext) + { + return new StacApiLink( + GetUriByAction( + stacApiContext, + "GetFeature", + "Features", + new + { + collectionId = stacItem.Collection, + featureId = stacItem.Id, + }, + null), + "self", + stacItem.Title, + stacItem.MediaType.ToString()); + } + + /// + /// Create self link for collection. + /// + /// The for which to create the link. + /// The to build the link with. + /// A with relationshipType 'self'. + protected StacApiLink GetSelfLink(StacCollection collection, IStacApiContext stacApiContext) + { + var link = new StacApiLink(GetUriByAction( + stacApiContext, + "GetCollections", + "Collections", + new + { + collectionId = collection.Id, + }, + null), + "self", + collection.Title, + collection.MediaType.ToString()); + link.Uri = new Uri(link.Uri.ToString().Replace("?collectionId=", "/")); + return link; + } + + /// + /// Create self link for feature collection. + /// + /// The for which to create the link. + /// The to build the link with. + /// A with relationshipType 'self'. + protected StacApiLink GetSelfLink(StacFeatureCollection collection, IStacApiContext stacApiContext) + { + var uri = stacApiContext.LinkGenerator.GetUriByRouteValues(stacApiContext.HttpContext, null, stacApiContext.HttpContext.Request.Query.ToDictionary((KeyValuePair x) => x.Key, (KeyValuePair x) => x.Value.ToString())); + if (uri == null) throw new InvalidOperationException("Could not generate URL for action GetFeatureCollection on controller Features."); + return new StacApiLink(new Uri(uri), "self", null, "application/geo+json"); + } + + private StacLink GetParentLink(StacFeatureCollection collection, IStacApiContext stacApiContext) + { + IList collections = stacApiContext.Collections; + if (collections != null && collections.Count == 1) + { + return new StacApiLink( + GetUriByAction( + stacApiContext, + "DescribeCollection", + "Collections", + new + { + collectionId = stacApiContext.Collections.First(), + }, + null), + "parent", + null, + "application/json"); + } + + return new StacApiLink(GetUriByAction(stacApiContext, "GetCollections", "Collections", new { }, null), "parent", null, "application/json"); + } + + private Uri GetUriByAction(IStacApiContext stacApiContext, string actionName, string controllerName, object? value, IDictionary? queryValues) + { + string? uriByAction = stacApiContext.LinkGenerator.GetUriByAction(stacApiContext.HttpContext, actionName, controllerName, value); + if (uriByAction == null) + { + DefaultInterpolatedStringHandler defaultInterpolatedStringHandler = new DefaultInterpolatedStringHandler(61, 3); + defaultInterpolatedStringHandler.AppendLiteral("Could not generate URL for action "); + defaultInterpolatedStringHandler.AppendFormatted(actionName); + defaultInterpolatedStringHandler.AppendLiteral(" on controller "); + defaultInterpolatedStringHandler.AppendFormatted(controllerName); + defaultInterpolatedStringHandler.AppendLiteral(" with value "); + if (value != null) defaultInterpolatedStringHandler.AppendFormatted(value); + throw new InvalidOperationException(defaultInterpolatedStringHandler.ToStringAndClear()); + } + + UriBuilder uriBuilder = new UriBuilder(uriByAction); + NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(uriBuilder.Query); + foreach (KeyValuePair item in (queryValues ?? new Dictionary()) !) + { + nameValueCollection[item.Key] = item.Value.ToString(); + } + + uriBuilder.Query = nameValueCollection.ToString(); + return new Uri(uriBuilder.ToString()); + } + + internal static void AddAdditionalLinks(ILinksCollectionObject linksCollectionObject, IStacApiContext stacApiContext) + { + foreach (ILinkValues linkValue in stacApiContext.LinkValues) + { + StacApiLink item = CreateStacApiLink(stacApiContext, linkValue); + linksCollectionObject.Links.Add(item); + } + } + + private static StacApiLink CreateStacApiLink(IStacApiContext stacApiContext, ILinkValues linkValue) + { + var uriByAction = stacApiContext.LinkGenerator.GetUriByAction(stacApiContext.HttpContext, linkValue.ActionName, linkValue.ControllerName); + if (uriByAction == null) throw new InvalidOperationException($"Could not generate URL for action {linkValue.ActionName} on controller {linkValue.ControllerName}."); + UriBuilder uriBuilder = new UriBuilder(uriByAction); + NameValueCollection nameValueCollection = HttpUtility.ParseQueryString(uriBuilder.Query); + foreach (KeyValuePair item in linkValue.QueryValues ?? new Dictionary()) + { + nameValueCollection[item.Key] = item.Value.ToString(); + } + + uriBuilder.Query = nameValueCollection.ToString(); + return new StacApiLink(new Uri(uriBuilder.ToString()), linkValue.RelationshipType.GetEnumMemberValue(), linkValue.Title, linkValue.MediaType); + } + + /// + /// Get self link for a STAC object. + /// + /// The for which to create the link. + /// The to build the link with. + /// A with relationshipType 'self'. + /// Exception if self link cannot be retrieved. + public StacApiLink GetSelfLink(IStacObject stacObject, IStacApiContext stacApiContext) + { + Stac.StacItem? stacItem = stacObject as Stac.StacItem; + if (stacItem != null) + { + return GetSelfLink(stacItem, stacApiContext); + } + + StacCollection? stacCollection = stacObject as StacCollection; + if (stacCollection != null) + { + return GetSelfLink(stacCollection, stacApiContext); + } + + StacFeatureCollection? stacFeatureCollection = stacObject as StacFeatureCollection; + if (stacFeatureCollection != null) + { + return GetSelfLink(stacFeatureCollection, stacApiContext); + } + + StacCollections? stacCollections = stacObject as StacCollections; + if (stacCollections != null) + { + return GetSelfLink(stacCollections, stacApiContext); + } + + throw new InvalidOperationException("Cannot get self link for " + stacObject.GetType().Name); + } + } +} diff --git a/src/GeoCop.Api/StacServices/StacRootCatalogProvider.cs b/src/GeoCop.Api/StacServices/StacRootCatalogProvider.cs new file mode 100644 index 00000000..e359eae2 --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacRootCatalogProvider.cs @@ -0,0 +1,18 @@ +using Stac; +using Stac.Api.Interfaces; + +namespace GeoCop.Api.StacServices +{ + /// + /// Provides access to STAC root catalog. + /// + public class StacRootCatalogProvider : IRootCatalogProvider + { + /// + public Task GetRootCatalogAsync(IStacApiContext stacApiContext, CancellationToken cancellationToken = default) + { + var catalog = new StacCatalog("geocop", "Root catalog for geocop"); + return Task.FromResult(catalog); + } + } +} diff --git a/src/GeoCop.Api/StacServices/StacWebApiExtensions.cs b/src/GeoCop.Api/StacServices/StacWebApiExtensions.cs new file mode 100644 index 00000000..e14955f6 --- /dev/null +++ b/src/GeoCop.Api/StacServices/StacWebApiExtensions.cs @@ -0,0 +1,71 @@ +using Stac.Api.Interfaces; +using Stac.Api.Models.Extensions.Sort.Context; +using Stac.Api.Services.Debugging; +using Stac.Api.Services.Default; +using Stac.Api.WebApi.Extensions; +using Stac.Api.WebApi.Services; +using Stac.Api.WebApi.Services.Context; + +namespace GeoCop.Api.StacServices +{ + /// + /// Provides access to STAC data services. + /// + public static class StacWebApiExtensions + { + /// + /// Adds services required for STAC. + /// + public static IServiceCollection AddStacData(this IServiceCollection services, Action configure) + { + services.AddStacWebApi(); + + // Add the Http Stac Api context factory + services.AddSingleton(); + + // Add the default context filters provider + services.AddSingleton(); + + // Register the HTTP pagination filter + services.AddSingleton(); + + // Register the sorting filter + services.AddSingleton(); + + // Register the debug filter + services.AddSingleton(); + + // Register the default collections provider + // TODO: Replace StacLinker with CollectionBasedStacLinker once https://github.com/Terradue/DotNetStac.Api/issues/1 is resolved + services.AddSingleton(); + + // Add the default controllers + services.AddDefaultControllers(); + + // Add the default extensions + services.AddDefaultStacApiExtensions(); + + // Add converters to create Stac Objects + services.AddTransient(); + + // Add the stac data services + services.AddSingleton(); + + // Add the stac root catalog provider + services.AddSingleton(); + + // Add the stac collections provider + services.AddSingleton(); + + // Add the stac items provider + services.AddSingleton(); + + // Add the stac items broker + services.AddSingleton(); + + var builder = new StacWebApiBuilder(services); + configure(builder); + return services; + } + } +} diff --git a/tests/GeoCop.Api.Test/StacConverterTest.cs b/tests/GeoCop.Api.Test/StacConverterTest.cs new file mode 100644 index 00000000..f81b3807 --- /dev/null +++ b/tests/GeoCop.Api.Test/StacConverterTest.cs @@ -0,0 +1,177 @@ +using GeoCop.Api.Models; +using Microsoft.AspNetCore.StaticFiles; +using Moq; +using NetTopologySuite.Geometries; +using Stac; +using Stac.Api.Interfaces; +using Stac.Api.Models; +using Stac.Api.WebApi.Services; + +namespace GeoCop.Api.StacServices +{ + [TestClass] + public class StacConverterTest + { + private readonly Delivery testDelivery = new () + { + Id = 1, + Date = new DateTime(2023, 11, 6, 10, 45, 18), + DeclaringUser = new User() + { + Id = 2, + }, + DeliveryMandate = new DeliveryMandate() + { + Id = 3, + SpatialExtent = new Polygon(new LinearRing(new Coordinate[] + { + new (1, 1), + new (3, 1), + new (3, 3), + new (1, 3), + new (1, 1), + })), + }, + Assets = new List() + { + new Asset() + { + Id = 4, + OriginalFilename = "TestFile.xtf", + AssetType = AssetType.PrimaryData, + }, + new Asset() + { + Id = 5, + OriginalFilename = "log.txt", + AssetType = AssetType.ValidationReport, + }, + }, + }; + private readonly DeliveryMandate mandate = new () + { + Id = 1, + Name = "Test Mandate", + SpatialExtent = new Polygon(new LinearRing(new Coordinate[] + { + new (10, 10), + new (30, 10), + new (30, 30), + new (10, 30), + new (10, 10), + })), + }; + + private Mock contentTypeProviderMock; + private Mock contextMock; + private Mock contextFactoryMock; + private Mock stacLinkerMock; + private StacConverter converter; + + [TestInitialize] + public void Initialize() + { + stacLinkerMock = new Mock(MockBehavior.Strict); + contextMock = new Mock(MockBehavior.Strict); + contextFactoryMock = new Mock(MockBehavior.Strict); + contentTypeProviderMock = new Mock(MockBehavior.Strict); + converter = new StacConverter(stacLinkerMock.Object, contextFactoryMock.Object, contentTypeProviderMock.Object); + } + + [TestCleanup] + public void Cleanup() + { + contentTypeProviderMock.VerifyAll(); + contextFactoryMock.VerifyAll(); + stacLinkerMock.VerifyAll(); + mandate.Deliveries.Clear(); + } + + [TestMethod] + public void GetCollectionId() + { + Assert.AreEqual("coll_1", converter.GetCollectionId(mandate)); + } + + [TestMethod] + public void GetItemId() + { + Assert.AreEqual("item_1", converter.GetItemId(testDelivery)); + } + + [TestMethod] + public void ConvertToStacCollectionWithoutItems() + { + var collection = converter.ToStacCollection(mandate); + Assert.IsNotNull(collection, "StacCollection should not be null."); + Assert.AreEqual(converter.GetCollectionId(mandate), collection.Id); + Assert.AreEqual("Test Mandate", collection.Title); + Assert.AreEqual(string.Empty, collection.Description); + Assert.AreEqual(0, collection.Links.Count); + var expectedExtent = converter.ToStacSpatialExtent(mandate.SpatialExtent).BoundingBoxes[0]; + var actualExtent = collection.Extent.Spatial.BoundingBoxes[0]; + Assert.AreEqual(true, Enumerable.Range(0, expectedExtent.GetLength(0)) + .All(i => expectedExtent[i] == actualExtent[i])); + } + + [TestMethod] + public void ConvertToStacCollectionWithItems() + { + stacLinkerMock.Setup(linker => linker.Link(It.IsAny(), It.IsAny())) + .Callback((StacItem item, IStacApiContext context) => + { + item.Links.Add(new StacApiLink( + new Uri("https://iamalink.com"), + "self", + "Title", + "application/octet-stream")); + }); + contextFactoryMock.Setup(factory => factory.Create()).Returns(contextMock.Object); + var contentType = "text/plain"; + contentTypeProviderMock.Setup(x => x.TryGetContentType(It.IsAny(), out contentType)).Returns(true); + mandate.Deliveries.Add(testDelivery); + var collection = converter.ToStacCollection(mandate); + Assert.IsNotNull(collection, "StacCollection should not be null."); + Assert.AreEqual(converter.GetCollectionId(mandate), collection.Id); + Assert.AreEqual("Test Mandate", collection.Title); + Assert.AreEqual(string.Empty, collection.Description); + Assert.AreEqual(1, collection.Links.Count); + Assert.AreEqual("item", collection.Links.First().RelationshipType); + var expectedExtent = converter.ToStacSpatialExtent(testDelivery.DeliveryMandate.SpatialExtent).BoundingBoxes[0]; + var actualExtent = collection.Extent.Spatial.BoundingBoxes[0]; + Assert.AreEqual(true, Enumerable.Range(0, expectedExtent.GetLength(0)) + .All(i => expectedExtent[i] == actualExtent[i])); + } + + [TestMethod] + public void ConvertToStacItem() + { + stacLinkerMock.Setup(linker => linker.Link(It.IsAny(), It.IsAny())) + .Callback((StacItem item, IStacApiContext context) => + { + item.Links.Add(new StacApiLink( + new Uri("https://iamalink.com"), + "self", + "Title", + "application/octet-stream")); + }); + contextFactoryMock.Setup(factory => factory.Create()).Returns(contextMock.Object); + var contentType = "application/interlis+xml"; + contentTypeProviderMock.Setup(x => x.TryGetContentType(It.IsAny(), out contentType)).Returns(true); + var item = converter.ToStacItem(testDelivery); + Assert.IsNotNull(item, "StacItem should not be null."); + Assert.AreEqual(converter.GetItemId(testDelivery), item.Id); + Assert.AreEqual(converter.GetCollectionId(testDelivery.DeliveryMandate), item.Collection); + Assert.AreEqual("Datenabgabe_2023-11-06T10:45:18", item.Title); + Assert.AreEqual(string.Empty, item.Description); + Assert.AreEqual(true, item.Links.Any()); + + Assert.AreEqual(2, item.Assets.Count); + var stacAsset = item.Assets[testDelivery.Assets[0].OriginalFilename]; + Assert.AreEqual(item, stacAsset.ParentStacObject); + Assert.AreEqual("TestFile.xtf", stacAsset.Title); + Assert.AreEqual("PrimaryData", stacAsset.Roles.First()); + Assert.AreEqual("application/interlis+xml", stacAsset.MediaType.MediaType); + } + } +}