diff --git a/.github/actions/docker-build-and-push/action.yml b/.github/actions/docker-build-and-push/action.yml new file mode 100644 index 00000000..94fc3521 --- /dev/null +++ b/.github/actions/docker-build-and-push/action.yml @@ -0,0 +1,48 @@ +name: Docker Build & Push +description: Composite GitHub Action to build and push Docker images to the DLCS GitHub Packages repositories. + +inputs: + image-name: + description: "Name of the image to push to the GHCR repository." + required: true + dockerfile: + description: "The Dockerfile to build and push." + required: true + context: + description: "The context to use when building the Dockerfile." + required: true + github-token: + description: "The GitHub token used when interacting with GCHR." + required: true + +runs: + using: "composite" + steps: + - id: checkout + uses: actions/checkout@v4 + - id: docker-meta + uses: docker/metadata-action@v3 + with: + images: ghcr.io/dlcs/${{ inputs.image-name }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,enable=true,prefix=,format=long + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - id: docker-login + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.github-token }} + - id: docker-build-push + uses: docker/build-push-action@v2 + with: + context: ${{ inputs.context }} + file: ${{ inputs.dockerfile }} + builder: ${{ steps.docker-setup-buildx.outputs.name }} + tags: ${{ steps.docker-meta.outputs.tags }} + labels: ${{ steps.docker-meta.outputs.labels }} + push: ${{ github.actor != 'dependabot[bot]' }} diff --git a/.github/workflows/run_build.yml b/.github/workflows/run_build.yml new file mode 100644 index 00000000..8577a781 --- /dev/null +++ b/.github/workflows/run_build.yml @@ -0,0 +1,58 @@ +name: DLCS Build, Test & Publish + +on: + push: + branches: [ "main", "develop" ] + tags: [ "v*" ] + pull_request: + branches: [ "main", "develop" ] + paths-ignore: + - "docs/**" + - "scripts/**" + +jobs: + test-dotnet: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/IIIFPresentation + env: + BUILD_CONFIG: "Release" + SOLUTION: "IIIFPresentation.sln" + steps: + - id: checkout + uses: actions/checkout@v4 + - id: setup-dotnet + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - id: restore-dotnet-dependencies + run: dotnet restore $SOLUTION + - id: build-dotnet + run: | + dotnet build $SOLUTION --configuration $BUILD_CONFIG --no-restore + dotnet test --configuration $BUILD_CONFIG --no-restore --no-build --verbosity normal + + build-push-api: + runs-on: ubuntu-latest + needs: test-dotnet + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/docker-build-and-push + with: + image-name: "presentation-api" + dockerfile: "Dockerfile.API" + context: "./src/IIIFPresentation" + github-token: ${{ secrets.GITHUB_TOKEN }} + + build-push-migrator: + runs-on: ubuntu-latest + needs: test-dotnet + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/docker-build-and-push + with: + image-name: "presentation-migrator" + dockerfile: "Dockerfile.Migrator" + context: "./src/IIIFPresentation" + github-token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/Dockerfile.API b/Dockerfile.API new file mode 100644 index 00000000..7357d4bc --- /dev/null +++ b/Dockerfile.API @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["API/API.csproj", "API/"] +COPY ["Repository/Repository.csproj", "Repository/"] +COPY ["Models/Models.csproj", "Model/"] +COPY ["Core/Core.csproj", "Core/"] +COPY ["AWS/AWS.csproj", "AWS/"] + +RUN dotnet restore "API/API.csproj" + +COPY . . +WORKDIR "/src/API" +RUN dotnet build "API.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "API.csproj" -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base + +LABEL maintainer="Donald Gray ,Tom Crane , Jack Lewis " +LABEL org.opencontainers.image.source=https://github.com/dlcs/iiif-presentation +LABEL org.opencontainers.image.description="HTTP API for iiif presentation interactions." + +WORKDIR /app +EXPOSE 80 +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "API.dll"] \ No newline at end of file diff --git a/Dockerfile.Migrator b/Dockerfile.Migrator new file mode 100644 index 00000000..66b75ec2 --- /dev/null +++ b/Dockerfile.Migrator @@ -0,0 +1,28 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +COPY ["Migrator/Migrator.csproj", "Migrator/"] +COPY ["Repository/Repository.csproj", "Repository/"] +COPY ["AWS/AWS.csproj", "AWS/"] +COPY ["Models/Models.csproj", "Model/"] +COPY ["Core/Core.csproj", "Core/"] + +RUN dotnet restore "Migrator/Migrator.csproj" + +COPY . . +WORKDIR "/src/Migrator" +RUN dotnet build "Migrator.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Migrator.csproj" -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base + +LABEL maintainer="Donald Gray , Jack Lewis " +LABEL org.opencontainers.image.source=https://github.com/dlcs/iiif-presentation +LABEL org.opencontainers.image.description="EF Migration runner for iiif presentation DB" + +WORKDIR /app +EXPOSE 80 +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Migrator.dll"] \ No newline at end of file diff --git a/src/IIIFPresentation/API.Tests/Converters/CollectionConverterTests.cs b/src/IIIFPresentation/API.Tests/Converters/CollectionConverterTests.cs index a17a325d..2db2555c 100644 --- a/src/IIIFPresentation/API.Tests/Converters/CollectionConverterTests.cs +++ b/src/IIIFPresentation/API.Tests/Converters/CollectionConverterTests.cs @@ -1,9 +1,10 @@ using API.Converters; using FluentAssertions; using IIIF.Presentation.V3.Strings; -using Microsoft.EntityFrameworkCore.Query.Internal; +using Models.API.Collection; using Models.Database.Collections; -using Models.Response; + +#nullable disable namespace API.Tests.Converters; @@ -28,7 +29,6 @@ public void ToHierarchicalCollection_ConvertsStorageCollection() storageRoot.ToHierarchicalCollection(urlRoots, new EnumerableQuery(CreateTestItems())); // Assert hierarchicalCollection.Id.Should().Be("http://base/1"); - hierarchicalCollection.Type.Should().Be(PresentationType.Collection); hierarchicalCollection.Label.Count.Should().Be(1); hierarchicalCollection.Label["en"].Should().Contain("repository root"); hierarchicalCollection.Items.Count.Should().Be(1); @@ -45,7 +45,6 @@ public void ToHierarchicalCollection_ConvertsStorageCollectionWithFullPath() storageRoot.ToHierarchicalCollection(urlRoots, new EnumerableQuery(CreateTestItems())); // Assert hierarchicalCollection.Id.Should().Be("http://base/1/top/some-id"); - hierarchicalCollection.Type.Should().Be(PresentationType.Collection); hierarchicalCollection.Label.Count.Should().Be(1); hierarchicalCollection.Label["en"].Should().Contain("repository root"); hierarchicalCollection.Items.Count.Should().Be(1); @@ -64,7 +63,6 @@ public void ToFlatCollection_ConvertsStorageCollection() // Assert flatCollection.Id.Should().Be("http://base/1/collections/some-id"); flatCollection.PublicId.Should().Be("http://base/1"); - flatCollection.Type.Should().Be(PresentationType.Collection); flatCollection.Label.Count.Should().Be(1); flatCollection.Label["en"].Should().Contain("repository root"); flatCollection.Slug.Should().Be("root"); @@ -89,7 +87,6 @@ public void ToFlatCollection_ConvertsStorageCollection_WithFullPath() // Assert flatCollection.Id.Should().Be("http://base/1/collections/some-id"); flatCollection.PublicId.Should().Be("http://base/1/top/some-id"); - flatCollection.Type.Should().Be(PresentationType.Collection); flatCollection.Label.Count.Should().Be(1); flatCollection.Label["en"].Should().Contain("repository root"); flatCollection.Slug.Should().Be("root"); diff --git a/src/IIIFPresentation/API.Tests/Integration/CreateCollectionTests.cs b/src/IIIFPresentation/API.Tests/Integration/CreateCollectionTests.cs new file mode 100644 index 00000000..99309795 --- /dev/null +++ b/src/IIIFPresentation/API.Tests/Integration/CreateCollectionTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using API.Tests.Integration.Infrastucture; +using Core.Response; +using FluentAssertions; +using IIIF.Presentation.V3.Strings; +using Microsoft.AspNetCore.Mvc.Testing; +using Models.API.Collection; +using Models.API.General; +using Models.Infrastucture; +using Repository; +using Test.Helpers.Integration; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace API.Tests.Integration; + +[Trait("Category", "Integration")] +[Collection(CollectionDefinitions.DatabaseCollection.CollectionName)] +public class CreateCollectionTests : IClassFixture> +{ + private readonly HttpClient httpClient; + + private readonly PresentationContext dbContext; + + private const int Customer = 1; + + private readonly string parent; + + public CreateCollectionTests(PresentationContextFixture dbFixture, PresentationAppFactory factory) + { + dbContext = dbFixture.DbContext; + + httpClient = factory.WithConnectionString(dbFixture.ConnectionString) + .CreateClient(new WebApplicationFactoryClientOptions()); + + parent = dbContext.Collections.FirstOrDefault(x => x.CustomerId == Customer && x.Slug == string.Empty)! + .Id!; + + dbFixture.CleanUp(); + } + + [Fact] + public async Task CreateCollection_CreatesCollection_WhenAllValuesProvided() + { + // Arrange + var collection = new FlatCollection() + { + Behavior = new List() + { + Behavior.IsPublic, + Behavior.IsStorageCollection + }, + Label = new LanguageMap("en", new []{"test collection"}), + Slug = "programmatic-child", + Parent = parent + }; + + // Act + var response = await httpClient.AsCustomer(Customer).PostAsync($"{Customer}/collections", + new StringContent(JsonSerializer.Serialize(collection), Encoding.UTF8, + new MediaTypeHeaderValue("application/json"))); + + var responseCollection = await response.ReadAsPresentationResponseAsync(); + + var fromDatabase = dbContext.Collections.First(c => c.Id == responseCollection!.Id!.Split('/', StringSplitOptions.TrimEntries).Last()); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + fromDatabase.Parent.Should().Be(parent); + fromDatabase.Label.Values.First()[0].Should().Be("test collection"); + fromDatabase.Slug.Should().Be("programmatic-child"); + fromDatabase.IsPublic.Should().BeTrue(); + fromDatabase.IsStorageCollection.Should().BeTrue(); + } + + [Fact] + public async Task CreateCollection_FailsToCreateCollection_WhenDuplicateSlug() + { + // Arrange + var collection = new FlatCollection() + { + Behavior = new List() + { + Behavior.IsPublic, + Behavior.IsStorageCollection + }, + Label = new LanguageMap("en", new []{"test collection"}), + Slug = "first-child", + Parent = parent + }; + + // Act + var response = await httpClient.AsCustomer(Customer).PostAsync($"{Customer}/collections", + new StringContent(JsonSerializer.Serialize(collection), Encoding.UTF8, + new MediaTypeHeaderValue("application/json"))); + + var error = await response.ReadAsPresentationResponseAsync(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error!.Detail.Should().Be("The collection could not be created due to a duplicate slug value"); + } + + [Fact] + public async Task CreateCollection_FailsToCreateCollection_WhenCalledWithoutAuth() + { + // Arrange + var collection = new FlatCollection() + { + Behavior = new List() + { + Behavior.IsPublic, + Behavior.IsStorageCollection + }, + Label = new LanguageMap("en", new []{"test collection"}), + Slug = "first-child", + Parent = parent + }; + + // Act + var response = await httpClient.PostAsync($"{Customer}/collections", + new StringContent(JsonSerializer.Serialize(collection), Encoding.UTF8, + new MediaTypeHeaderValue("application/json"))); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } +} \ No newline at end of file diff --git a/src/IIIFPresentation/API.Tests/Integration/GetCollectionTests.cs b/src/IIIFPresentation/API.Tests/Integration/GetCollectionTests.cs index 1df4ae7d..ee645e49 100644 --- a/src/IIIFPresentation/API.Tests/Integration/GetCollectionTests.cs +++ b/src/IIIFPresentation/API.Tests/Integration/GetCollectionTests.cs @@ -3,11 +3,17 @@ using API.Tests.Integration.Infrastucture; using Core.Response; using FluentAssertions; +using IIIF.Presentation.V3; +using IIIF.Serialisation; +using IIIF.Serialisation.Deserialisation; using Microsoft.AspNetCore.Mvc.Testing; -using Models.Response; +using Models.API.Collection; +using Newtonsoft.Json; using Repository; using Test.Helpers.Integration; +#nullable disable + namespace API.Tests.Integration; [Trait("Category", "Integration")] @@ -15,18 +21,13 @@ namespace API.Tests.Integration; public class GetCollectionTests : IClassFixture> { private readonly HttpClient httpClient; - - private readonly PresentationContext dbContext; - + public GetCollectionTests(PresentationContextFixture dbFixture, PresentationAppFactory factory) { - dbContext = dbFixture.DbContext; - httpClient = factory.WithConnectionString(dbFixture.ConnectionString) - .CreateClient(new WebApplicationFactoryClientOptions - { - AllowAutoRedirect = false - }); + .CreateClient(new WebApplicationFactoryClientOptions()); + + dbFixture.CleanUp(); } [Fact] @@ -35,13 +36,25 @@ public async Task Get_RootHierarchical_Returns_EntryPoint() // Act var response = await httpClient.GetAsync("1"); - var collection = await response.ReadAsJsonAsync(); + var stuff = await response.Content.ReadAsStringAsync(); - // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); - collection!.Id.Should().Be("http://localhost/1"); - collection.Items.Count.Should().Be(1); - collection.Items[0].Id.Should().Be("http://localhost/1/first-child"); + try + { + // var collection = stuff.FromJson(); + + var collection = await response.ReadAsIIIFJsonAsync(); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + collection!.Id.Should().Be("http://localhost/1"); + collection.Items.Count.Should().Be(1); + var firstItem = (Collection)collection.Items[0]; + firstItem.Id.Should().Be("http://localhost/1/first-child"); + } + catch (Exception ex) + { + + } } [Fact] @@ -50,13 +63,15 @@ public async Task Get_ChildHierarchical_Returns_Child() // Act var response = await httpClient.GetAsync("1/first-child"); - var collection = await response.ReadAsJsonAsync(); + var collection = await response.ReadAsIIIFJsonAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); collection!.Id.Should().Be("http://localhost/1/first-child"); collection.Items.Count.Should().Be(1); - collection.Items[0].Id.Should().Be("http://localhost/1/first-child/second-child"); + + var firstItem = (Collection)collection.Items[0]; + firstItem.Id.Should().Be("http://localhost/1/first-child/second-child"); } [Fact] @@ -65,13 +80,14 @@ public async Task Get_RootFlat_ReturnsEntryPointHierarchical_WhenNoAuthAndCsHead // Act var response = await httpClient.GetAsync("1/collections/root"); - var collection = await response.ReadAsJsonAsync(); + var collection = await response.ReadAsIIIFJsonAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); collection!.Id.Should().Be("http://localhost/1"); collection.Items.Count.Should().Be(1); - collection.Items[0].Id.Should().Be("http://localhost/1/first-child"); + var firstItem = (Collection)collection.Items[0]; + firstItem.Id.Should().Be("http://localhost/1/first-child"); } [Fact] @@ -79,18 +95,18 @@ public async Task Get_RootFlat_ReturnsEntryPointHierarchical_WhenNoCsHeader() { // Arrange var requestMessage = new HttpRequestMessage(HttpMethod.Get, "1/collections/root"); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "some-token"); - + // Act - var response = await httpClient.SendAsync(requestMessage); + var response = await httpClient.AsCustomer(1).SendAsync(requestMessage); - var collection = await response.ReadAsJsonAsync(); + var collection = await response.ReadAsIIIFJsonAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); collection!.Id.Should().Be("http://localhost/1"); collection.Items.Count.Should().Be(1); - collection.Items[0].Id.Should().Be("http://localhost/1/first-child"); + var firstItem = (Collection)collection.Items[0]; + firstItem.Id.Should().Be("http://localhost/1/first-child"); } [Fact] @@ -103,13 +119,14 @@ public async Task Get_RootFlat_ReturnsEntryPointHierarchical_WhenNoAuth() // Act var response = await httpClient.SendAsync(requestMessage); - var collection = await response.ReadAsJsonAsync(); + var collection = await response.ReadAsIIIFJsonAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); collection!.Id.Should().Be("http://localhost/1"); collection.Items.Count.Should().Be(1); - collection.Items[0].Id.Should().Be("http://localhost/1/first-child"); + var firstItem = (Collection)collection.Items[0]; + firstItem.Id.Should().Be("http://localhost/1/first-child"); } [Fact] @@ -118,18 +135,17 @@ public async Task Get_RootFlat_ReturnsEntryPointFlat_WhenAuthAndHeader() // Arrange var requestMessage = new HttpRequestMessage(HttpMethod.Get, "1/collections/root"); requestMessage.Headers.Add("IIIF-CS-Show-Extra", "value"); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "some-token"); - + // Act - var response = await httpClient.SendAsync(requestMessage); + var response = await httpClient.AsCustomer(1).SendAsync(requestMessage); - var collection = await response.ReadAsJsonAsync(); + var collection = await response.ReadAsPresentationJsonAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); collection!.Id.Should().Be("http://localhost/1/collections/RootStorage"); collection.PublicId.Should().Be("http://localhost/1"); - collection.Items.Count.Should().Be(1); + collection.Items!.Count.Should().Be(1); collection.Items[0].Id.Should().Be("http://localhost/1/collections/FirstChildCollection"); collection.TotalItems.Should().Be(1); collection.CreatedBy.Should().Be("admin"); @@ -142,18 +158,17 @@ public async Task Get_RootFlat_ReturnsEntryPointFlat_WhenCalledById() // Arrange var requestMessage = new HttpRequestMessage(HttpMethod.Get, "1/collections/RootStorage"); requestMessage.Headers.Add("IIIF-CS-Show-Extra", "value"); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "some-token"); - + // Act - var response = await httpClient.SendAsync(requestMessage); + var response = await httpClient.AsCustomer(1).SendAsync(requestMessage); - var collection = await response.ReadAsJsonAsync(); + var collection = await response.ReadAsPresentationJsonAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); collection!.Id.Should().Be("http://localhost/1/collections/RootStorage"); collection.PublicId.Should().Be("http://localhost/1"); - collection.Items.Count.Should().Be(1); + collection.Items!.Count.Should().Be(1); collection.Items[0].Id.Should().Be("http://localhost/1/collections/FirstChildCollection"); collection.TotalItems.Should().Be(1); collection.CreatedBy.Should().Be("admin"); @@ -166,18 +181,17 @@ public async Task Get_ChildFlat_ReturnsEntryPointFlat_WhenCalledByChildId() // Arrange var requestMessage = new HttpRequestMessage(HttpMethod.Get, "1/collections/FirstChildCollection"); requestMessage.Headers.Add("IIIF-CS-Show-Extra", "value"); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "some-token"); - + // Act - var response = await httpClient.SendAsync(requestMessage); + var response = await httpClient.AsCustomer(1).SendAsync(requestMessage); - var collection = await response.ReadAsJsonAsync(); + var collection = await response.ReadAsPresentationJsonAsync(); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); collection!.Id.Should().Be("http://localhost/1/collections/FirstChildCollection"); collection.PublicId.Should().Be("http://localhost/1/first-child"); - collection.Items.Count.Should().Be(1); + collection.Items!.Count.Should().Be(1); collection.Items[0].Id.Should().Be("http://localhost/1/collections/SecondChildCollection"); collection.TotalItems.Should().Be(1); collection.CreatedBy.Should().Be("admin"); diff --git a/src/IIIFPresentation/API.Tests/appsettings.Testing.json b/src/IIIFPresentation/API.Tests/appsettings.Testing.json index bf46c9a9..9b126fac 100644 --- a/src/IIIFPresentation/API.Tests/appsettings.Testing.json +++ b/src/IIIFPresentation/API.Tests/appsettings.Testing.json @@ -18,6 +18,7 @@ }, "RunMigrations": true, "ApiSettings": { - "ResourceRoot": "https://localhost:7230" + "ResourceRoot": "https://localhost:7230", + "PageSize": 20 } } diff --git a/src/IIIFPresentation/API/API.csproj b/src/IIIFPresentation/API/API.csproj index bf81adf9..f06703f6 100644 --- a/src/IIIFPresentation/API/API.csproj +++ b/src/IIIFPresentation/API/API.csproj @@ -10,10 +10,11 @@ - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,6 +28,9 @@ .dockerignore + + Always + diff --git a/src/IIIFPresentation/API/Converters/CollectionConverter.cs b/src/IIIFPresentation/API/Converters/CollectionConverter.cs index 5398af47..427d11a6 100644 --- a/src/IIIFPresentation/API/Converters/CollectionConverter.cs +++ b/src/IIIFPresentation/API/Converters/CollectionConverter.cs @@ -1,31 +1,34 @@ using API.Infrastructure.Helpers; -using Models.Database.Collections; -using Models.Response; +using IIIF.Presentation.V3; +using IIIF.Presentation.V3.Annotation; +using IIIF.Presentation.V3.Content; +using Models.API.Collection; +using Models.Infrastucture; namespace API.Converters; public static class CollectionConverter { - public static HierarchicalCollection ToHierarchicalCollection(this Collection dbAsset, UrlRoots urlRoots, - IQueryable? items) + public static Collection ToHierarchicalCollection(this Models.Database.Collections.Collection dbAsset, UrlRoots urlRoots, + IQueryable? items) { - return new HierarchicalCollection() + var stuff = new Collection() { Id = $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}{(dbAsset.FullPath != null ? $"/{dbAsset.FullPath}" : "")}", Context = "http://iiif.io/api/presentation/3/context.json", Label = dbAsset.Label, - Items = items != null ? items.Select(x => new Item + Items = items != null ? items.Select(x => new Collection() { Id = $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}/{x.FullPath}", - Label = x.Label, - Type = PresentationType.Collection - }).ToList() : new List(), - Type = PresentationType.Collection + Label = x.Label + }).ToList() : new List() }; + + return stuff; } - public static FlatCollection ToFlatCollection(this Collection dbAsset, UrlRoots urlRoots, int pageSize, - IQueryable? items) + public static FlatCollection ToFlatCollection(this Models.Database.Collections.Collection dbAsset, UrlRoots urlRoots, int pageSize, + IQueryable? items) { var itemCount = items?.Count() ?? 0; @@ -40,8 +43,8 @@ public static FlatCollection ToFlatCollection(this Collection dbAsset, UrlRoots Label = dbAsset.Label, PublicId = $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}{(dbAsset.FullPath != null ? $"/{dbAsset.FullPath}" : "")}", Behavior = new List() - .AppendIf(dbAsset.IsPublic, "public-iiif") - .AppendIf(dbAsset.IsStorageCollection, "storage-collection"), + .AppendIf(dbAsset.IsPublic, Behavior.IsPublic) + .AppendIf(dbAsset.IsStorageCollection, Behavior.IsStorageCollection), Type = PresentationType.Collection, Slug = dbAsset.Slug, Parent = dbAsset.Parent != null ? $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}/collections/{dbAsset.Parent}" : null, @@ -52,9 +55,9 @@ public static FlatCollection ToFlatCollection(this Collection dbAsset, UrlRoots Id = $"{urlRoots.BaseUrl}/{i.CustomerId}/collections/{i.Id}", Label = i.Label, Type = PresentationType.Collection - }).ToList() : new List(), + }).ToList() : [], - PartOf = dbAsset.Parent != null ? new List() + PartOf = dbAsset.Parent != null ? new List { new() { @@ -66,7 +69,7 @@ public static FlatCollection ToFlatCollection(this Collection dbAsset, UrlRoots TotalItems = itemCount, - View = new View() + View = new View { Id = $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}/collections/{dbAsset.Id}?page=1&pageSize={pageSize}", Type = PresentationType.PartialCollectionView, @@ -75,29 +78,26 @@ public static FlatCollection ToFlatCollection(this Collection dbAsset, UrlRoots TotalPages = itemCount % pageSize }, - SeeAlso = new List() - { + SeeAlso = + [ new() { - Id = $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}{(dbAsset.FullPath != null ? $"/{dbAsset.FullPath}" : "")}", + Id = + $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}{(dbAsset.FullPath != null ? $"/{dbAsset.FullPath}" : "")}", Type = PresentationType.Collection, Label = dbAsset.Label, - Profile = new List() - { - "Public" - } + Profile = ["Public"] }, + new() { - Id = $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}{(dbAsset.FullPath != null ? $"/{dbAsset.FullPath}" : "")}/iiif", + Id = + $"{urlRoots.BaseUrl}/{dbAsset.CustomerId}{(dbAsset.FullPath != null ? $"/{dbAsset.FullPath}" : "")}/iiif", Type = PresentationType.Collection, Label = dbAsset.Label, - Profile = new List() - { - "api-hierarchical" - } + Profile = ["api-hierarchical"] } - }, + ], Created = dbAsset.Created, Modified = dbAsset.Modified, diff --git a/src/IIIFPresentation/API/Converters/UrlRoots.cs b/src/IIIFPresentation/API/Converters/UrlRoots.cs index 0444f909..d795f6fa 100644 --- a/src/IIIFPresentation/API/Converters/UrlRoots.cs +++ b/src/IIIFPresentation/API/Converters/UrlRoots.cs @@ -9,10 +9,10 @@ public class UrlRoots /// /// The base URI for current request - this is the full URI excluding path and query string /// - public string BaseUrl { get; set; } + public string? BaseUrl { get; set; } /// /// The base URI for image services and other public-facing resources /// - public string ResourceRoot { get; set; } + public string? ResourceRoot { get; set; } } \ No newline at end of file diff --git a/src/IIIFPresentation/API/Exceptions/ApiException.cs b/src/IIIFPresentation/API/Exceptions/ApiException.cs index 100d11cf..097dbf98 100644 --- a/src/IIIFPresentation/API/Exceptions/ApiException.cs +++ b/src/IIIFPresentation/API/Exceptions/ApiException.cs @@ -7,11 +7,7 @@ public class APIException : Exception public APIException() { } - - protected APIException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } - + public APIException(string? message) : base(message) { } @@ -22,5 +18,5 @@ public APIException(string? message, Exception? innerException) : base(message, public virtual int? StatusCode { get; set; } - public virtual string Label { get; set; } + public virtual string? Label { get; set; } } \ No newline at end of file diff --git a/src/IIIFPresentation/API/Features/Storage/Requests/CreateCollection.cs b/src/IIIFPresentation/API/Features/Storage/Requests/CreateCollection.cs index 214c1487..6f578eb6 100644 --- a/src/IIIFPresentation/API/Features/Storage/Requests/CreateCollection.cs +++ b/src/IIIFPresentation/API/Features/Storage/Requests/CreateCollection.cs @@ -1,26 +1,98 @@ -using API.Infrastructure.Requests; +using API.Converters; +using API.Infrastructure.Requests; +using API.Settings; +using Core; using MediatR; -using Models.Response; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Models.API.Collection; +using Models.Database.Collections; +using Models.Infrastucture; +using Repository; namespace API.Features.Storage.Requests; public class CreateCollection : IRequest> { - public CreateCollection(int customerId, FlatCollection collection) + public CreateCollection(int customerId, FlatCollection collection, UrlRoots urlRoots) { CustomerId = customerId; Collection = collection; + UrlRoots = urlRoots; } public int CustomerId { get; } public FlatCollection Collection { get; } + + public UrlRoots UrlRoots { get; } } public class CreateCollectionHandler : IRequestHandler> { - public Task> Handle(CreateCollection request, CancellationToken cancellationToken) + private readonly PresentationContext dbContext; + + private readonly ILogger logger; + + private readonly ApiSettings settings; + + public CreateCollectionHandler( + PresentationContext dbContext, + ILogger logger, + IOptions options) + { + this.dbContext = dbContext; + this.logger = logger; + settings = options.Value; + } + + public async Task> Handle(CreateCollection request, CancellationToken cancellationToken) + { + var collection = new Collection() + { + Id = Guid.NewGuid().ToString(), + Created = DateTime.UtcNow, + Modified = DateTime.UtcNow, + CreatedBy = GetUser(), //TODO: update this to get a real user + CustomerId = request.CustomerId, + IsPublic = request.Collection.Behavior.Contains(Behavior.IsPublic), + IsStorageCollection = request.Collection.Behavior.Contains(Behavior.IsStorageCollection), + ItemsOrder = request.Collection.ItemsOrder, + Label = request.Collection.Label, + Parent = request.Collection.Parent!.Split('/').Last(), + Slug = request.Collection.Slug, + Thumbnail = request.Collection.Thumbnail, + Tags = request.Collection.Tags + }; + + dbContext.Collections.Add(collection); + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) + { + logger.LogError(ex,"Error creating collection for customer {Customer} in the database", request.CustomerId); + + if (ex.InnerException != null && ex.InnerException.Message.Contains("duplicate key value violates unique constraint \"ix_collections_customer_id_slug_parent\"")) + { + return ModifyEntityResult.Failure( + $"The collection could not be created due to a duplicate slug value", WriteResult.BadRequest); + } + + return ModifyEntityResult.Failure( + $"The collection could not be created"); + } + + return ModifyEntityResult.Success( + collection.ToFlatCollection(request.UrlRoots, settings.PageSize, + new EnumerableQuery(Enumerable.Empty())), // there can be no items attached to this, as it's just been created + WriteResult.Created); + } + + private string? GetUser() { - throw new NotImplementedException(); + return "Admin"; } } \ No newline at end of file diff --git a/src/IIIFPresentation/API/Features/Storage/Requests/GetHierarchicalCollection.cs b/src/IIIFPresentation/API/Features/Storage/Requests/GetHierarchicalCollection.cs index 23b0d247..05e88c12 100644 --- a/src/IIIFPresentation/API/Features/Storage/Requests/GetHierarchicalCollection.cs +++ b/src/IIIFPresentation/API/Features/Storage/Requests/GetHierarchicalCollection.cs @@ -135,7 +135,7 @@ INNER JOIN var storage = await dbContext.Collections.FromSqlRaw(query).OrderBy(i => i.CustomerId) .FirstOrDefaultAsync(cancellationToken); - IQueryable items = null; + IQueryable? items = null; if (storage != null) { diff --git a/src/IIIFPresentation/API/Features/Storage/StorageController.cs b/src/IIIFPresentation/API/Features/Storage/StorageController.cs index d0b98bde..b04c43bd 100644 --- a/src/IIIFPresentation/API/Features/Storage/StorageController.cs +++ b/src/IIIFPresentation/API/Features/Storage/StorageController.cs @@ -3,13 +3,15 @@ using API.Auth; using API.Converters; using API.Features.Storage.Requests; +using API.Features.Storage.Validators; using API.Infrastructure; -using API.Infrastructure.Requests; using API.Settings; using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using Models.Response; +using FluentValidation; +using Models.API.Collection; +using NuGet.Protocol; namespace API.Features.Storage; @@ -31,7 +33,7 @@ public async Task GetHierarchicalRootCollection(int customerId) if (storageRoot.Collection == null) return NotFound(); - return Ok( storageRoot.Collection.ToHierarchicalCollection(GetUrlRoots(), storageRoot.Items)); + return Ok(storageRoot.Collection.ToHierarchicalCollection(GetUrlRoots(), storageRoot.Items)); } [HttpGet("{*slug}")] @@ -63,28 +65,20 @@ public async Task Get(int customerId, string id) [HttpPost("collections")] [EtagCaching] - public async Task Post(int customerId, [FromBody] FlatCollection collection) + public async Task Post(int customerId, [FromBody] FlatCollection collection, [FromServices] FlatCollectionValidator validator) { if (!Authorizer.CheckAuthorized(Request)) { return Problem(statusCode: (int)HttpStatusCode.Forbidden); } - - var created = await Mediator.Send(new CreateCollection(customerId, collection)); - return Ok(created); - } + var validation = await validator.ValidateAsync(collection, policy => policy.IncludeRuleSets("create")); - /// - /// Used by derived controllers to construct correct fully qualified URLs in returned objects. - /// - /// - protected UrlRoots GetUrlRoots() - { - return new UrlRoots + if (!validation.IsValid) { - BaseUrl = Request.GetBaseUrl(), - ResourceRoot = Settings.ResourceRoot.ToString() - }; + return this.ValidationFailed(validation); + } + + return await HandleUpsert(new CreateCollection(customerId, collection, GetUrlRoots())); } } \ No newline at end of file diff --git a/src/IIIFPresentation/API/Features/Storage/Validators/FlatCollectionValidator.cs b/src/IIIFPresentation/API/Features/Storage/Validators/FlatCollectionValidator.cs index 6a7af295..c87707b3 100644 --- a/src/IIIFPresentation/API/Features/Storage/Validators/FlatCollectionValidator.cs +++ b/src/IIIFPresentation/API/Features/Storage/Validators/FlatCollectionValidator.cs @@ -1,5 +1,5 @@ using FluentValidation; -using Models.Response; +using Models.API.Collection; namespace API.Features.Storage.Validators; @@ -11,9 +11,16 @@ public FlatCollectionValidator() { RuleFor(a => a.Created).Empty().WithMessage("Created cannot be set"); RuleFor(a => a.Modified).Empty().WithMessage("Modified cannot be set"); + RuleFor(a => a.Id).Empty().WithMessage("Id cannot be set"); + RuleFor(a => a.Parent).NotEmpty().WithMessage("Creating a new collection requires a parent"); }); - - + RuleSet("update", () => + { + RuleFor(a => a.Created).Empty().WithMessage("Created cannot be set"); + RuleFor(a => a.Modified).Empty().WithMessage("Modified cannot be set"); + RuleFor(a => a.Id).Empty().WithMessage("Id cannot be set"); + RuleFor(a => a.Parent).NotEmpty().WithMessage("Updating a collection requires a parent to be set"); + }); } } \ No newline at end of file diff --git a/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs b/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs index 60d5207d..854ffa55 100644 --- a/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs +++ b/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs @@ -1,5 +1,8 @@ -using API.Infrastructure.Requests; +using System.Net; +using System.Runtime.InteropServices.JavaScript; +using API.Infrastructure.Requests; using Core; +using FluentValidation.Results; using Microsoft.AspNetCore.Mvc; namespace API.Infrastructure; @@ -58,14 +61,24 @@ public static IActionResult ModifyResultToHttpResult(this ControllerBase cont WriteResult.Updated => controller.Ok(entityResult.Entity), WriteResult.Created => controller.Created(controller.Request.GetDisplayUrl(), entityResult.Entity), WriteResult.NotFound => controller.NotFound(entityResult.Error), - WriteResult.Error => controller.Problem(entityResult.Error, instance, 500, errorTitle), - WriteResult.BadRequest => controller.Problem(entityResult.Error, instance, 400, errorTitle), - WriteResult.Conflict => controller.Problem(entityResult.Error, instance, 409, + WriteResult.Error => controller.Problem(entityResult.Error, instance, (int)HttpStatusCode.InternalServerError, errorTitle), + WriteResult.BadRequest => controller.Problem(entityResult.Error, instance, (int)HttpStatusCode.BadRequest, errorTitle), + WriteResult.Conflict => controller.Problem(entityResult.Error, instance, (int)HttpStatusCode.Conflict, $"{errorTitle}: Conflict"), - WriteResult.FailedValidation => controller.Problem(entityResult.Error, instance, 400, + WriteResult.FailedValidation => controller.Problem(entityResult.Error, instance, (int)HttpStatusCode.BadRequest, $"{errorTitle}: Validation failed"), - WriteResult.StorageLimitExceeded => controller.Problem(entityResult.Error, instance, 507, + WriteResult.StorageLimitExceeded => controller.Problem(entityResult.Error, instance, (int)HttpStatusCode.InsufficientStorage, $"{errorTitle}: Storage limit exceeded"), - _ => controller.Problem(entityResult.Error, instance, 500, errorTitle), + _ => controller.Problem(entityResult.Error, instance, (int)HttpStatusCode.InternalServerError, errorTitle), }; + + /// + /// Creates an that produces a response with 404 status code. + /// + /// The created for the response. + public static ObjectResult ValidationFailed(this ControllerBase controller, ValidationResult validationResult) + { + var message = string.Join(". ", validationResult.Errors.Select(s => s.ErrorMessage).Distinct()); + return controller.Problem(message, null, (int)HttpStatusCode.BadRequest, "Bad request"); + } } \ No newline at end of file diff --git a/src/IIIFPresentation/API/Infrastructure/PresentationController.cs b/src/IIIFPresentation/API/Infrastructure/PresentationController.cs index 770848f2..cc43becc 100644 --- a/src/IIIFPresentation/API/Infrastructure/PresentationController.cs +++ b/src/IIIFPresentation/API/Infrastructure/PresentationController.cs @@ -20,7 +20,7 @@ public abstract class PresentationController : Controller /// protected PresentationController(ApiSettings settings, IMediator mediator) { - Settings = settings; + this.Settings = settings; Mediator = mediator; } diff --git a/src/IIIFPresentation/API/Program.cs b/src/IIIFPresentation/API/Program.cs index 29001e23..23879e8a 100644 --- a/src/IIIFPresentation/API/Program.cs +++ b/src/IIIFPresentation/API/Program.cs @@ -1,6 +1,8 @@ using System.Text.Json.Serialization; +using API.Features.Storage.Validators; using API.Infrastructure; using API.Settings; +using Newtonsoft.Json; using Repository; using Serilog; @@ -12,15 +14,15 @@ .CreateLogger(); builder.Services.AddSerilog(lc => lc - .WriteTo.Console() .ReadFrom.Configuration(builder.Configuration)); -builder.Services.AddControllers().AddJsonOptions(opt => +builder.Services.AddControllers().AddNewtonsoftJson(options => { - opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; - opt.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; }); +builder.Services.AddScoped(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); diff --git a/src/IIIFPresentation/API/Settings/ApiSettings.cs b/src/IIIFPresentation/API/Settings/ApiSettings.cs index 81cf602e..e61c5536 100644 --- a/src/IIIFPresentation/API/Settings/ApiSettings.cs +++ b/src/IIIFPresentation/API/Settings/ApiSettings.cs @@ -5,12 +5,12 @@ public class ApiSettings /// /// The base URI for image services and other public-facing resources /// - public Uri ResourceRoot { get; set; } + public required Uri ResourceRoot { get; set; } /// /// Page size for paged collections /// public int PageSize { get; set; } = 100; - public string PathBase { get; set; } + public string? PathBase { get; set; } } \ No newline at end of file diff --git a/src/IIIFPresentation/AWS/AWS.csproj b/src/IIIFPresentation/AWS/AWS.csproj new file mode 100644 index 00000000..9699d05a --- /dev/null +++ b/src/IIIFPresentation/AWS/AWS.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/IIIFPresentation/AWS/SSM/ConfigurationBuilderX.cs b/src/IIIFPresentation/AWS/SSM/ConfigurationBuilderX.cs new file mode 100644 index 00000000..82f6b6f9 --- /dev/null +++ b/src/IIIFPresentation/AWS/SSM/ConfigurationBuilderX.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace AWS.SSM; + +public static class ConfigurationBuilderX +{ + /// + /// Add AWS SystemsManager (SSM) as a configuration source if Production hosting environment. + /// By default prefix is /iiif-presentation/ but this can be overriden via SSM_PREFIX envvar + /// + public static IConfigurationBuilder AddSystemsManager(this IConfigurationBuilder builder, + HostBuilderContext builderContext) + { + if (!builderContext.HostingEnvironment.IsProduction()) return builder; + + var path = Environment.GetEnvironmentVariable("SSM_PREFIX") ?? "iiif-presentation"; + return builder.AddSystemsManager(configureSource => + { + configureSource.Path = $"/{path}/"; + configureSource.ReloadAfter = TimeSpan.FromMinutes(90); + }); + } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Core/Core.csproj b/src/IIIFPresentation/Core/Core.csproj index a9c778a7..826a5366 100644 --- a/src/IIIFPresentation/Core/Core.csproj +++ b/src/IIIFPresentation/Core/Core.csproj @@ -7,6 +7,7 @@ + diff --git a/src/IIIFPresentation/Core/Exceptions/PresentationException.cs b/src/IIIFPresentation/Core/Exceptions/PresentationException.cs new file mode 100644 index 00000000..debf9e84 --- /dev/null +++ b/src/IIIFPresentation/Core/Exceptions/PresentationException.cs @@ -0,0 +1,18 @@ +using System.Runtime.Serialization; + +namespace Core.Exceptions; + +public class PresentationException : Exception +{ + public PresentationException() + { + } + + public PresentationException(string? message) : base(message) + { + } + + public PresentationException(string? message, Exception? innerException) : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Core/Response/HttpResponseMessageX.cs b/src/IIIFPresentation/Core/Response/HttpResponseMessageX.cs index 0b3f050a..62cdc21c 100644 --- a/src/IIIFPresentation/Core/Response/HttpResponseMessageX.cs +++ b/src/IIIFPresentation/Core/Response/HttpResponseMessageX.cs @@ -1,4 +1,7 @@ -using Newtonsoft.Json; +using Core.Exceptions; +using IIIF; +using IIIF.Serialisation; +using Newtonsoft.Json; namespace Core.Response; @@ -12,15 +15,35 @@ public static class HttpResponseMessageX /// /// Type to convert response to /// Converted Http response. - public static async Task ReadAsJsonAsync(this HttpResponseMessage response, - bool ensureSuccess = true, JsonSerializerSettings? settings = null) + public static async Task ReadAsIIIFJsonAsync(this HttpResponseMessage response, + bool ensureSuccess = true, JsonSerializerSettings? settings = null) where T : JsonLdBase { if (ensureSuccess) response.EnsureSuccessStatusCode(); if (!response.IsJsonResponse()) return default; var contentStream = await response.Content.ReadAsStreamAsync(); + + return contentStream.FromJsonStream(); + } + + /// + /// Read HttpResponseMessage as JSON using Newtonsoft for conversion. + /// + /// object + /// If true, will validate that the response is a 2xx. + /// + /// Type to convert response to + /// Converted Http response. + public static async Task ReadAsPresentationJsonAsync(this HttpResponseMessage response, + bool ensureSuccess = true, JsonSerializerSettings? settings = null) + { + if (ensureSuccess) response.EnsureSuccessStatusCode(); + if (!response.IsJsonResponse()) return default; + + var contentStream = await response.Content.ReadAsStreamAsync(); + using var streamReader = new StreamReader(contentStream); using var jsonReader = new JsonTextReader(streamReader); @@ -32,6 +55,7 @@ public static class HttpResponseMessageX serializer.ContractResolver = settings.ContractResolver; } serializer.NullValueHandling = settings.NullValueHandling; + return serializer.Deserialize(jsonReader); } @@ -46,4 +70,58 @@ public static bool IsJsonResponse(this HttpResponseMessage response) var mediaType = response.Content.Headers.ContentType?.MediaType; return mediaType != null && mediaType.Contains("json"); } + + public static async Task ReadAsIIIFResponseAsync(this HttpResponseMessage response, + JsonSerializerSettings? settings = null) where T : JsonLdBase + { + if ((int)response.StatusCode < 400) + { + return await response.ReadWithIIIFContext(true, settings); + } + + try + { + return await response.ReadAsIIIFJsonAsync(false, settings); + } + catch (Exception ex) + { + throw new PresentationException("Could not convert response JSON to error", ex); + } + } + + public static async Task ReadAsPresentationResponseAsync(this HttpResponseMessage response, + JsonSerializerSettings? settings = null) + { + if ((int)response.StatusCode < 400) + { + return await response.ReadWithContext(true, settings); + } + + try + { + return await response.ReadAsPresentationJsonAsync(false, settings); + } + catch (Exception ex) + { + throw new PresentationException("Could not convert response JSON to error", ex); + } + } + + private static async Task ReadWithIIIFContext( + this HttpResponseMessage response, + bool ensureSuccess, + JsonSerializerSettings? settings) where T : JsonLdBase + { + var json = await response.ReadAsIIIFJsonAsync(ensureSuccess, settings ?? new JsonSerializerSettings()); + return json; + } + + private static async Task ReadWithContext( + this HttpResponseMessage response, + bool ensureSuccess, + JsonSerializerSettings? settings) + { + var json = await response.ReadAsPresentationJsonAsync(ensureSuccess, settings ?? new JsonSerializerSettings()); + return json; + } } \ No newline at end of file diff --git a/src/IIIFPresentation/IIIFPresentation.sln b/src/IIIFPresentation/IIIFPresentation.sln index 694a473e..e7a96f7a 100644 --- a/src/IIIFPresentation/IIIFPresentation.sln +++ b/src/IIIFPresentation/IIIFPresentation.sln @@ -14,6 +14,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API.Tests", "API.Tests\API. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Test.Helpers", "Test.Helpers\Test.Helpers.csproj", "{C8CDD6D9-EC7D-4366-9C41-5B1C93777AC8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utils", "Utils", "{CFEA077C-542A-44A2-A4BD-1725A2FD40D5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Migrator", "Migrator\Migrator.csproj", "{B6491452-9798-42B9-8573-1ED2454DE1BD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS", "AWS\AWS.csproj", "{3F9BB7E8-E0F3-4891-8765-28EB50669C58}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,9 +50,18 @@ Global {C8CDD6D9-EC7D-4366-9C41-5B1C93777AC8}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8CDD6D9-EC7D-4366-9C41-5B1C93777AC8}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8CDD6D9-EC7D-4366-9C41-5B1C93777AC8}.Release|Any CPU.Build.0 = Release|Any CPU + {B6491452-9798-42B9-8573-1ED2454DE1BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6491452-9798-42B9-8573-1ED2454DE1BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6491452-9798-42B9-8573-1ED2454DE1BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6491452-9798-42B9-8573-1ED2454DE1BD}.Release|Any CPU.Build.0 = Release|Any CPU + {3F9BB7E8-E0F3-4891-8765-28EB50669C58}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3F9BB7E8-E0F3-4891-8765-28EB50669C58}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3F9BB7E8-E0F3-4891-8765-28EB50669C58}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3F9BB7E8-E0F3-4891-8765-28EB50669C58}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {CDB60D14-DA74-486F-AE46-F4D6F801DE21} = {23CF47D6-E80B-4B6A-828D-4635800CD326} {C8CDD6D9-EC7D-4366-9C41-5B1C93777AC8} = {23CF47D6-E80B-4B6A-828D-4635800CD326} + {B6491452-9798-42B9-8573-1ED2454DE1BD} = {CFEA077C-542A-44A2-A4BD-1725A2FD40D5} EndGlobalSection EndGlobal diff --git a/src/IIIFPresentation/Migrator/Migrator.csproj b/src/IIIFPresentation/Migrator/Migrator.csproj new file mode 100644 index 00000000..4ad62e42 --- /dev/null +++ b/src/IIIFPresentation/Migrator/Migrator.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + enable + enable + Linux + + + + + + + + + + + + + + + diff --git a/src/IIIFPresentation/Migrator/Program.cs b/src/IIIFPresentation/Migrator/Program.cs new file mode 100644 index 00000000..e51e7864 --- /dev/null +++ b/src/IIIFPresentation/Migrator/Program.cs @@ -0,0 +1,67 @@ +// See https://aka.ms/new-console-template for more information + +using AWS.SSM; +using Serilog; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Repository; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateLogger(); + +try +{ + Log.Information("Configuring IHost"); + var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(collection => { collection.AddSingleton(); }) + .ConfigureAppConfiguration((context, builder) => { builder.AddSystemsManager(context); }) + .UseSerilog() + .Build(); + + Log.Information("Executing Migrator"); + var migrator = host.Services.GetRequiredService(); + migrator.Execute(); + Log.Information("Migrator Ran"); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Migrator failed"); +} +finally +{ + Log.CloseAndFlush(); +} + +class Migrator +{ + private readonly ILogger logger; + private readonly IConfiguration configuration; + + public Migrator(ILogger logger, IConfiguration configuration) + { + this.logger = logger; + this.configuration = configuration; + } + + public void Execute() + { + var connStr = configuration.GetConnectionString("PostgreSQLConnection"); + if (connStr != null) + { + foreach (var part in connStr.Split(";")) + { + var lowered = part.ToLower(); + if (lowered.StartsWith("server") || lowered.StartsWith("database")) + { + logger.LogInformation("Got connstr part {StringPart}", lowered); + } + } + } + + IIIFPresentationContextConfiguration.TryRunMigrations(configuration, logger); + } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/API/Collection/FlatCollection.cs b/src/IIIFPresentation/Models/API/Collection/FlatCollection.cs new file mode 100644 index 00000000..8e12f1ed --- /dev/null +++ b/src/IIIFPresentation/Models/API/Collection/FlatCollection.cs @@ -0,0 +1,48 @@ +using IIIF.Presentation.V3.Strings; +using Newtonsoft.Json; + +namespace Models.API.Collection; + +public class FlatCollection +{ + [JsonProperty("@context")] + public List? Context { get; set; } + + public string? Id { get; set; } + + public string? PublicId { get; set; } + + public PresentationType Type { get; set; } + + public List Behavior { get; set; } = new (); + + public required LanguageMap Label { get; set; } + + public required string Slug { get; set; } + + public string? Parent { get; set; } + + public int? ItemsOrder { get; set; } + + public List? Items { get; set; } + + public List? PartOf { get; set; } + + public int TotalItems { get; set; } + + public View? View { get; set; } + + public List? SeeAlso { get; set; } + + public DateTime Created { get; set; } + + public DateTime Modified { get; set; } + + public string? CreatedBy { get; set; } + + public string? ModifiedBy { get; set; } + + public string? Tags { get; set; } + + public string? Thumbnail { get; set; } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/API/Collection/Item.cs b/src/IIIFPresentation/Models/API/Collection/Item.cs new file mode 100644 index 00000000..1edd3cf6 --- /dev/null +++ b/src/IIIFPresentation/Models/API/Collection/Item.cs @@ -0,0 +1,12 @@ +using IIIF.Presentation.V3.Strings; + +namespace Models.API.Collection; + +public class Item +{ + public required string Id { get; set; } + + public PresentationType Type { get; set; } + + public LanguageMap? Label { get; set; } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/API/Collection/PartOf.cs b/src/IIIFPresentation/Models/API/Collection/PartOf.cs new file mode 100644 index 00000000..8c3e53ae --- /dev/null +++ b/src/IIIFPresentation/Models/API/Collection/PartOf.cs @@ -0,0 +1,12 @@ +using IIIF.Presentation.V3.Strings; + +namespace Models.API.Collection; + +public class PartOf +{ + public required string Id { get; set; } + + public PresentationType Type { get; set; } + + public LanguageMap? Label { get; set; } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Response/PresentationType.cs b/src/IIIFPresentation/Models/API/Collection/PresentationType.cs similarity index 67% rename from src/IIIFPresentation/Models/Response/PresentationType.cs rename to src/IIIFPresentation/Models/API/Collection/PresentationType.cs index a26ffd95..ca7a869e 100644 --- a/src/IIIFPresentation/Models/Response/PresentationType.cs +++ b/src/IIIFPresentation/Models/API/Collection/PresentationType.cs @@ -1,4 +1,4 @@ -namespace Models.Response; +namespace Models.API.Collection; public enum PresentationType { diff --git a/src/IIIFPresentation/Models/API/Collection/SeeAlso.cs b/src/IIIFPresentation/Models/API/Collection/SeeAlso.cs new file mode 100644 index 00000000..596637e5 --- /dev/null +++ b/src/IIIFPresentation/Models/API/Collection/SeeAlso.cs @@ -0,0 +1,14 @@ +using IIIF.Presentation.V3.Strings; + +namespace Models.API.Collection; + +public class SeeAlso +{ + public required string Id { get; set; } + + public PresentationType Type { get; set; } + + public LanguageMap? Label { get; set; } + + public List? Profile { get; set; } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Response/View.cs b/src/IIIFPresentation/Models/API/Collection/View.cs similarity index 79% rename from src/IIIFPresentation/Models/Response/View.cs rename to src/IIIFPresentation/Models/API/Collection/View.cs index a2f49497..1de10104 100644 --- a/src/IIIFPresentation/Models/Response/View.cs +++ b/src/IIIFPresentation/Models/API/Collection/View.cs @@ -1,11 +1,11 @@ using System.Text.Json.Serialization; -namespace Models.Response; +namespace Models.API.Collection; public class View { [JsonPropertyName("@id")] - public string Id { get; set; } + public required string Id { get; set; } [JsonPropertyName("@type")] public PresentationType Type { get; set; } diff --git a/src/IIIFPresentation/Models/API/General/Error.cs b/src/IIIFPresentation/Models/API/General/Error.cs new file mode 100644 index 00000000..84475bb5 --- /dev/null +++ b/src/IIIFPresentation/Models/API/General/Error.cs @@ -0,0 +1,12 @@ +using IIIF; + +namespace Models.API.General; + +public class Error : JsonLdBase +{ + public string Type => "Error"; + + public string? Detail { get; set; } + + public int Status { get; set; } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Infrastucture/Behavior.cs b/src/IIIFPresentation/Models/Infrastucture/Behavior.cs new file mode 100644 index 00000000..2f8f1c6f --- /dev/null +++ b/src/IIIFPresentation/Models/Infrastucture/Behavior.cs @@ -0,0 +1,8 @@ +namespace Models.Infrastucture; + +public static class Behavior +{ + public static string IsPublic = "public-iiif"; + + public static string IsStorageCollection = "storage-collection"; +} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Models.csproj b/src/IIIFPresentation/Models/Models.csproj index 5685f78b..1cae4226 100644 --- a/src/IIIFPresentation/Models/Models.csproj +++ b/src/IIIFPresentation/Models/Models.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/IIIFPresentation/Models/Response/FlatCollection.cs b/src/IIIFPresentation/Models/Response/FlatCollection.cs deleted file mode 100644 index acb89af6..00000000 --- a/src/IIIFPresentation/Models/Response/FlatCollection.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Models.Response; - -public class FlatCollection -{ - [JsonPropertyName("@context")] - public List Context { get; set; } - - public string Id { get; set; } - - public string PublicId { get; set; } - - public PresentationType Type { get; set; } - - public List Behavior { get; set; } - - public Dictionary> Label { get; set; } - - public string Slug { get; set; } - - public string? Parent { get; set; } - - public int? ItemsOrder { get; set; } - - public List Items { get; set; } - - public List? PartOf { get; set; } - - public int TotalItems { get; set; } - - public View View { get; set; } = new View(); - - public List SeeAlso { get; set; } - - public DateTime Created { get; set; } - - public DateTime Modified { get; set; } - - public string? CreatedBy { get; set; } - - public string? ModifiedBy { get; set; } -} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Response/HierarchicalCollection.cs b/src/IIIFPresentation/Models/Response/HierarchicalCollection.cs deleted file mode 100644 index 38c22dcf..00000000 --- a/src/IIIFPresentation/Models/Response/HierarchicalCollection.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Models.Response; - -public class HierarchicalCollection -{ - [JsonPropertyName("@context")] - public string Context { get; set; } - - public string Id { get; set; } - - public PresentationType Type { get; set; } - - public Dictionary> Label { get; set; } - - public List Items { get; set; } -} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Response/Item.cs b/src/IIIFPresentation/Models/Response/Item.cs deleted file mode 100644 index 889ed8ed..00000000 --- a/src/IIIFPresentation/Models/Response/Item.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Models.Response; - -public class Item -{ - public string Id { get; set; } - - public PresentationType Type { get; set; } - - public Dictionary> Label { get; set; } -} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Response/PartOf.cs b/src/IIIFPresentation/Models/Response/PartOf.cs deleted file mode 100644 index b187ed56..00000000 --- a/src/IIIFPresentation/Models/Response/PartOf.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Models.Response; - -public class PartOf -{ - public string Id { get; set; } - - public PresentationType Type { get; set; } - - public Dictionary> Label { get; set; } -} \ No newline at end of file diff --git a/src/IIIFPresentation/Models/Response/SeeAlso.cs b/src/IIIFPresentation/Models/Response/SeeAlso.cs deleted file mode 100644 index 71aa3b15..00000000 --- a/src/IIIFPresentation/Models/Response/SeeAlso.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Models.Response; - -public class SeeAlso -{ - public string Id { get; set; } - - public PresentationType Type { get; set; } - - public Dictionary> Label { get; set; } - - public List Profile { get; set; } -} \ No newline at end of file diff --git a/src/IIIFPresentation/Repository/Migrations/20240820142445_addingCustomerSlugIndex.Designer.cs b/src/IIIFPresentation/Repository/Migrations/20240820142445_addingCustomerSlugIndex.Designer.cs new file mode 100644 index 00000000..f2b5c809 --- /dev/null +++ b/src/IIIFPresentation/Repository/Migrations/20240820142445_addingCustomerSlugIndex.Designer.cs @@ -0,0 +1,108 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Repository; + +#nullable disable + +namespace Repository.Migrations +{ + [DbContext(typeof(PresentationContext))] + [Migration("20240820142445_addingCustomerSlugIndex")] + partial class addingCustomerSlugIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Models.Database.Collections.Collection", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomerId") + .HasColumnType("integer") + .HasColumnName("customer_id"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("IsStorageCollection") + .HasColumnType("boolean") + .HasColumnName("is_storage_collection"); + + b.Property("ItemsOrder") + .HasColumnType("integer") + .HasColumnName("items_order"); + + b.Property("Label") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("label"); + + b.Property("LockedBy") + .HasColumnType("text") + .HasColumnName("locked_by"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("Parent") + .HasColumnType("text") + .HasColumnName("parent"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.Property("Thumbnail") + .HasColumnType("text") + .HasColumnName("thumbnail"); + + b.Property("UsePath") + .HasColumnType("boolean") + .HasColumnName("use_path"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("CustomerId", "Slug") + .IsUnique() + .HasDatabaseName("ix_collections_customer_id_slug"); + + b.ToTable("collections", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IIIFPresentation/Repository/Migrations/20240820142445_addingCustomerSlugIndex.cs b/src/IIIFPresentation/Repository/Migrations/20240820142445_addingCustomerSlugIndex.cs new file mode 100644 index 00000000..4ce08d5f --- /dev/null +++ b/src/IIIFPresentation/Repository/Migrations/20240820142445_addingCustomerSlugIndex.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Repository.Migrations +{ + /// + public partial class addingCustomerSlugIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "ix_collections_customer_id_slug", + table: "collections", + columns: new[] { "customer_id", "slug" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_collections_customer_id_slug", + table: "collections"); + } + } +} diff --git a/src/IIIFPresentation/Repository/Migrations/20240821102322_addParentToIndex.Designer.cs b/src/IIIFPresentation/Repository/Migrations/20240821102322_addParentToIndex.Designer.cs new file mode 100644 index 00000000..a83fcba8 --- /dev/null +++ b/src/IIIFPresentation/Repository/Migrations/20240821102322_addParentToIndex.Designer.cs @@ -0,0 +1,108 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Repository; + +#nullable disable + +namespace Repository.Migrations +{ + [DbContext(typeof(PresentationContext))] + [Migration("20240821102322_addParentToIndex")] + partial class addParentToIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Models.Database.Collections.Collection", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CreatedBy") + .HasColumnType("text") + .HasColumnName("created_by"); + + b.Property("CustomerId") + .HasColumnType("integer") + .HasColumnName("customer_id"); + + b.Property("IsPublic") + .HasColumnType("boolean") + .HasColumnName("is_public"); + + b.Property("IsStorageCollection") + .HasColumnType("boolean") + .HasColumnName("is_storage_collection"); + + b.Property("ItemsOrder") + .HasColumnType("integer") + .HasColumnName("items_order"); + + b.Property("Label") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("label"); + + b.Property("LockedBy") + .HasColumnType("text") + .HasColumnName("locked_by"); + + b.Property("Modified") + .HasColumnType("timestamp with time zone") + .HasColumnName("modified"); + + b.Property("ModifiedBy") + .HasColumnType("text") + .HasColumnName("modified_by"); + + b.Property("Parent") + .HasColumnType("text") + .HasColumnName("parent"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text") + .HasColumnName("slug"); + + b.Property("Tags") + .HasColumnType("text") + .HasColumnName("tags"); + + b.Property("Thumbnail") + .HasColumnType("text") + .HasColumnName("thumbnail"); + + b.Property("UsePath") + .HasColumnType("boolean") + .HasColumnName("use_path"); + + b.HasKey("Id") + .HasName("pk_collections"); + + b.HasIndex("CustomerId", "Slug", "Parent") + .IsUnique() + .HasDatabaseName("ix_collections_customer_id_slug_parent"); + + b.ToTable("collections", (string)null); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/IIIFPresentation/Repository/Migrations/20240821102322_addParentToIndex.cs b/src/IIIFPresentation/Repository/Migrations/20240821102322_addParentToIndex.cs new file mode 100644 index 00000000..6566b65f --- /dev/null +++ b/src/IIIFPresentation/Repository/Migrations/20240821102322_addParentToIndex.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Repository.Migrations +{ + /// + public partial class addParentToIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_collections_customer_id_slug", + table: "collections"); + + migrationBuilder.CreateIndex( + name: "ix_collections_customer_id_slug_parent", + table: "collections", + columns: new[] { "customer_id", "slug", "parent" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_collections_customer_id_slug_parent", + table: "collections"); + + migrationBuilder.CreateIndex( + name: "ix_collections_customer_id_slug", + table: "collections", + columns: new[] { "customer_id", "slug" }, + unique: true); + } + } +} diff --git a/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs b/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs index 01171a79..8ee7b3a3 100644 --- a/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs +++ b/src/IIIFPresentation/Repository/Migrations/PresentationContextModelSnapshot.cs @@ -93,6 +93,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_collections"); + b.HasIndex("CustomerId", "Slug", "Parent") + .IsUnique() + .HasDatabaseName("ix_collections_customer_id_slug_parent"); + b.ToTable("collections", (string)null); }); #pragma warning restore 612, 618 diff --git a/src/IIIFPresentation/Repository/PresentationContext.cs b/src/IIIFPresentation/Repository/PresentationContext.cs index 874d8ffe..5b5d3089 100644 --- a/src/IIIFPresentation/Repository/PresentationContext.cs +++ b/src/IIIFPresentation/Repository/PresentationContext.cs @@ -29,6 +29,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => { + entity.HasIndex(e => new { e.CustomerId, e.Slug, e.Parent }).IsUnique(); + entity.Property(e => e.Label).HasColumnType("jsonb"); }); } diff --git a/src/IIIFPresentation/Repository/Repository.csproj b/src/IIIFPresentation/Repository/Repository.csproj index 0eb0939e..13426bb3 100644 --- a/src/IIIFPresentation/Repository/Repository.csproj +++ b/src/IIIFPresentation/Repository/Repository.csproj @@ -10,9 +10,9 @@ - + - + diff --git a/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactory.cs b/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactory.cs index 5780f5d6..78ec99cf 100644 --- a/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactory.cs +++ b/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactory.cs @@ -4,6 +4,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +#nullable disable + namespace Test.Helpers.Integration; /// diff --git a/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactoryX.cs b/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactoryX.cs index 1f5a5925..f8bfbea4 100644 --- a/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactoryX.cs +++ b/src/IIIFPresentation/Test.Helpers/Integration/PresentationAppFactoryX.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Mvc.Testing; +#nullable disable + namespace Test.Helpers.Integration; public static class PresentationAppFactoryX diff --git a/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextFixture.cs b/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextFixture.cs index b95e2038..2d463fd0 100644 --- a/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextFixture.cs +++ b/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextFixture.cs @@ -4,6 +4,8 @@ using Repository; using Testcontainers.PostgreSql; +#nullable disable + namespace Test.Helpers.Integration; public class PresentationContextFixture : IAsyncLifetime @@ -122,4 +124,9 @@ private void SetPropertiesFromContainer() ); DbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; } + + public void CleanUp() + { + DbContext.Database.ExecuteSqlRawAsync("DELETE FROM collections WHERE id NOT IN ('RootStorage','FirstChildCollection','SecondChildCollection')"); + } } \ No newline at end of file diff --git a/src/IIIFPresentation/Test.Helpers/Integration/TestAuthHandlerX.cs b/src/IIIFPresentation/Test.Helpers/Integration/TestAuthHandlerX.cs new file mode 100644 index 00000000..8235d8ee --- /dev/null +++ b/src/IIIFPresentation/Test.Helpers/Integration/TestAuthHandlerX.cs @@ -0,0 +1,13 @@ +using System.Net.Http.Headers; + +namespace Test.Helpers.Integration; + +public static class TestAuthHandlerX +{ + public static HttpClient AsCustomer(this HttpClient client, int customer = 2) + { + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue($"user|{customer}"); + return client; + } +} \ No newline at end of file diff --git a/src/IIIFPresentation/Test.Helpers/Test.Helpers.csproj b/src/IIIFPresentation/Test.Helpers/Test.Helpers.csproj index ee3cc6af..2e4bd920 100644 --- a/src/IIIFPresentation/Test.Helpers/Test.Helpers.csproj +++ b/src/IIIFPresentation/Test.Helpers/Test.Helpers.csproj @@ -26,4 +26,8 @@ + + + +