diff --git a/src/IIIFPresentation/API.Tests/Converters/FastJsonPropertyReadTests.cs b/src/IIIFPresentation/API.Tests/Converters/FastJsonPropertyReadTests.cs new file mode 100644 index 00000000..5f63c4fa --- /dev/null +++ b/src/IIIFPresentation/API.Tests/Converters/FastJsonPropertyReadTests.cs @@ -0,0 +1,106 @@ +using API.Converters; + +namespace API.Tests.Converters; + +public class FastJsonPropertyReadTests +{ + [Fact] + public void FindAtLevel_FindsValue_DefaultTopLevel() + { + FastJsonPropertyRead.FindAtLevel(Json, "searchedProperty").Should() + .Be("fnord", "it's the value of 'searchedProperty' in sample JSON"); + } + + [Fact] + public void FindAtLevel_FindsValue_Deeper() + { + FastJsonPropertyRead.FindAtLevel(Json, "priority", 3).Should() + .Be("high", "it's the value of first instance of 'priority' property in sample JSON"); + } + + [Fact] + public void FindAtLevel_ReturnsNull_IfNotFound() + { + FastJsonPropertyRead.FindAtLevel(Json, "i don't exist in the json").Should() + .BeNull("no such property in the JSON"); + } + + // just some arbitrary JSON - the method under test is not IIIF specific + private const string Json = + + #region + + """ + { + "id": 42, + "name": "Sample Root", + "enabled": true, + "tags": ["alpha", "beta", "gamma"], + "metadata": { + "created": "2025-02-15T12:34:56Z", + "updated": "2025-03-01T08:12:45Z", + "attributes": { + "priority": "high", + "flags": { + "archived": false, + "requiresReview": true, + "internal": { + "level": 3, + "notes": ["check format", "verify fields"] + } + } + } + }, + "items": [ + { + "id": "a1", + "quantity": 10, + "details": { + "manufacturer": "Acme Corp", + "dimensions": { + "width": 10.5, + "height": 20.0, + "depth": 5.75 + } + } + }, + { + "id": "b2", + "quantity": 4, + "details": { + "manufacturer": "Globex", + "dimensions": { + "width": 5.0, + "height": 8.0, + "depth": 1.25, + "history": [ + { + "revision": 1, + "notes": { "editor": "system", "timestamp": "2025-01-01T00:00:00Z" } + }, + { + "revision": 2, + "notes": { "editor": "qa", "timestamp": "2025-02-01T00:00:00Z" } + } + ] + } + } + } + ], + "config": { + "mode": "full", + "retry": 3, + "options": { + "validate": true, + "paths": [ + { "name": "input", "value": "/var/data/in" }, + { "name": "output", "value": "/var/data/out" } + ] + } + }, + "searchedProperty": "fnord" + } + """; + + #endregion +} diff --git a/src/IIIFPresentation/API.Tests/Converters/PresentationIIIFCleanerTests.cs b/src/IIIFPresentation/API.Tests/Converters/PresentationIIIFCleanerTests.cs new file mode 100644 index 00000000..78f3053e --- /dev/null +++ b/src/IIIFPresentation/API.Tests/Converters/PresentationIIIFCleanerTests.cs @@ -0,0 +1,87 @@ +using API.Converters; +using IIIF.Presentation.V3; +using IIIF.Presentation.V3.Content; +using IIIF.Presentation.V3.Strings; +using Models.API.Collection; +using Models.API.Manifest; + +namespace API.Tests.Converters; + +public class PresentationIIIFCleanerTests +{ + [Fact] + public void TestCleanManifest() + { + var manifest = new PresentationManifest + { + // From IIIF + Id = "this/is/some/Id", + Label = new LanguageMap("en", "some label"), + Thumbnail = + [ + new Image + { + Id = "https://localhost/thumbs/12/23/blabla/full/143,200/0/default.jpg" + } + ], + Rights = "https://creativecommons.org/licenses/by/4.0/", + Items = + [ + new Canvas + { + Id = "some id" + } + ] + // outside IIIF + , + Slug = "some slug", + Parent = "some parent", + PublicId = "some public id" + }; + + var clean = PresentationIIIFCleaner.OnlyIIIFProperties(manifest); + + clean.Slug.Should().BeNull("not in IIIF"); + clean.Parent.Should().BeNull("not in IIIF"); + clean.PublicId.Should().BeNull("not in IIIF"); + + clean.Rights.Should().Be(manifest.Rights, "in the IIIF"); + clean.Id.Should().Be(manifest.Id, "in the IIIF"); + clean.Thumbnail.Should().BeEquivalentTo(manifest.Thumbnail, "in the IIIF"); + clean.Items.Should().BeEquivalentTo(manifest.Items, "in the IIIF"); + } + + [Fact] + public void TestCleanCollection() + { + var collection = new PresentationCollection + { + // From IIIF + Id = "this/is/some/Id", + Label = new LanguageMap("en", "some label"), + Thumbnail = + [ + new Image + { + Id = "https://localhost/thumbs/12/23/blabla/full/143,200/0/default.jpg" + } + ], + Rights = "https://creativecommons.org/licenses/by/4.0/" + // outside IIIF + , + Slug = "some slug", + Parent = "some parent", + PublicId = "some public id" + }; + + var clean = PresentationIIIFCleaner.OnlyIIIFProperties(collection); + + clean.Slug.Should().BeNull("not in IIIF"); + clean.Parent.Should().BeNull("not in IIIF"); + clean.PublicId.Should().BeNull("not in IIIF"); + + clean.Rights.Should().Be(collection.Rights, "in the IIIF"); + clean.Id.Should().Be(collection.Id, "in the IIIF"); + clean.Thumbnail.Should().BeEquivalentTo(collection.Thumbnail, "in the IIIF"); + } +} diff --git a/src/IIIFPresentation/API.Tests/Integration/Infrastructure/CollectionDefinition.cs b/src/IIIFPresentation/API.Tests/Integration/Infrastructure/CollectionDefinition.cs index aec5a1c7..d7f49de1 100644 --- a/src/IIIFPresentation/API.Tests/Integration/Infrastructure/CollectionDefinition.cs +++ b/src/IIIFPresentation/API.Tests/Integration/Infrastructure/CollectionDefinition.cs @@ -15,4 +15,10 @@ public class StorageCollection : ICollectionFixture { public const string CollectionName = "Storage Collection"; } -} \ No newline at end of file + + [CollectionDefinition(CollectionName, DisableParallelization = true)] + public class RestCollection : ICollectionFixture + { + public const string CollectionName = "Rest Collection"; + } +} diff --git a/src/IIIFPresentation/API.Tests/Integration/Infrastructure/HttpRequestMessageBuilder.cs b/src/IIIFPresentation/API.Tests/Integration/Infrastructure/HttpRequestMessageBuilder.cs index 51f43516..362d52ca 100644 --- a/src/IIIFPresentation/API.Tests/Integration/Infrastructure/HttpRequestMessageBuilder.cs +++ b/src/IIIFPresentation/API.Tests/Integration/Infrastructure/HttpRequestMessageBuilder.cs @@ -15,6 +15,16 @@ public static HttpRequestMessage GetPrivateRequest(HttpMethod method, string pat return requestMessage; } + + public static HttpRequestMessage GetPlainRequest(HttpMethod method, string path, string content, + Guid? etag = null) + { + var requestMessage = new HttpRequestMessage(method, path).WithJsonContent(content); + if (etag is not null) + requestMessage.Headers.IfMatch.Add(new EntityTagHeaderValue($"\"{etag:N}\"")); + + return requestMessage; + } public static HttpRequestMessage GetPrivateRequest(HttpMethod method, string path, Guid? etag = null) { diff --git a/src/IIIFPresentation/API.Tests/Integration/ModifyCollectionTests.cs b/src/IIIFPresentation/API.Tests/Integration/ModifyCollectionTests.cs index 4d6d2748..a0bedbe6 100644 --- a/src/IIIFPresentation/API.Tests/Integration/ModifyCollectionTests.cs +++ b/src/IIIFPresentation/API.Tests/Integration/ModifyCollectionTests.cs @@ -610,7 +610,7 @@ public async Task CreateCollection_ReturnsError_WhenIsStorageCollectionFalseAndU // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error!.Detail.Should().Be("Could not deserialize collection"); + error!.Detail.Should().Be("Could not deserialize collection."); error.ErrorTypeUri.Should().Be("http://localhost/errors/ModifyCollectionType/CannotDeserialize"); } @@ -1590,7 +1590,7 @@ public async Task UpdateCollection_CreatesCollection_WhenUnknownCollectionIdProv var hierarchyFromDatabase = dbContext.Hierarchy.First(h => h.CustomerId == 1 && h.CollectionId == id); // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.Created); fromDatabase.Id.Should().Be("createFromUpdate"); hierarchyFromDatabase.Parent.Should().Be(parent); fromDatabase.Label!.Values.First()[0].Should().Be("test collection - create from update"); @@ -1820,7 +1820,7 @@ await dbContext.Hierarchy.AddAsync(new Hierarchy }; var updateRequestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put, - $"{Customer}/collections/{initialCollection.Id}", updatedCollection.AsJson()); + $"{Customer}/collections/{initialCollection.Id}", updatedCollection.AsJson(), initialCollection.Etag); // Act var response = await httpClient.AsCustomer().SendAsync(updateRequestMessage); @@ -1881,7 +1881,7 @@ await dbContext.Hierarchy.AddAsync(new() }; var updateRequestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put, - $"{Customer}/collections/{initialCollection.Id}", JsonSerializer.Serialize(updatedCollection)); + $"{Customer}/collections/{initialCollection.Id}", JsonSerializer.Serialize(updatedCollection),dbContext.GetETag(initialCollection)); // Act var response = await httpClient.AsCustomer().SendAsync(updateRequestMessage); @@ -2191,7 +2191,7 @@ public async Task UpdateCollection_FailsToUpdateIiifCollection_WhenInvalidJson() // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Detail.Should().Be("Could not deserialize collection"); + error.Detail.Should().Be("Could not deserialize collection."); error.ErrorTypeUri.Should().Be("http://localhost/errors/ModifyCollectionType/CannotDeserialize"); } @@ -2226,7 +2226,7 @@ public async Task UpdateCollection_CreatesNonPublicIIIFCollection_WhenNoBehavior var hierarchyFromDatabase = dbContext.Hierarchy.First(h => h.CustomerId == 1 && h.CollectionId == id); // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.Created); fromDatabase.Id.Should().Be(collectionId); hierarchyFromDatabase.Parent.Should().Be(parent); fromDatabase.Label!.Values.First()[0].Should().Be("test collection - create from update"); @@ -2252,20 +2252,25 @@ public async Task CreateCollection_CreatesMinimalCollection_ViaHierarchicalColle // Arrange var slug = "iiif-collection-post"; - var collection = @"{ - ""type"": ""Collection"", - ""behavior"": [ - ""public-iiif"" - ], - ""label"": { - ""en"": [ - ""iiif hierarchical post"" - ] - } -}"; + // Note setting the `id` to the "desired path with slug" - otherwise we CANNOT create it. + // This is per https://deploy-preview-3--dlcs-docs.netlify.app/api-doc/iiif#example-create-a-iiif-collection-within-a-storage-collection + var collection = $$""" + { + "id": "http://localhost/1/{{slug}}", + "type": "Collection", + "behavior": [ + "public-iiif" + ], + "label": { + "en": [ + "iiif hierarchical post" + ] + } + } + """; var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Post, - $"{Customer}/{slug}", collection); + $"{Customer}/", collection); // Act var response = await httpClient.AsCustomer().SendAsync(requestMessage); @@ -2282,7 +2287,7 @@ await amazonS3.GetObjectAsync(LocalStackFixture.StorageBucketName, // Assert response.StatusCode.Should().Be(HttpStatusCode.Created); responseCollection!.Items.Should().BeNull(); - responseCollection.Id.Should().Be(requestMessage.RequestUri!.AbsoluteUri); + responseCollection.Id.Should().Be("http://localhost/1/"+slug); hierarchyFromDatabase.Parent.Should().Be(parent); fromDatabase.Label!.Values.First()[0].Should().Be("iiif hierarchical post"); hierarchyFromDatabase.Slug.Should().Be(slug); @@ -2300,20 +2305,23 @@ public async Task CreateCollection_CreatesMultipleNestedIIIFCollection_ViaHierar // Arrange var slug = nameof(CreateCollection_CreatesMultipleNestedIIIFCollection_ViaHierarchicalCollection); - var collection = @"{ - ""type"": ""Collection"", - ""behavior"": [ - ""public-iiif"" - ], - ""label"": { - ""en"": [ - ""iiif hierarchical post"" - ] - } -}"; + var collection = $$""" + { + "slug": "{{slug}}", + "type": "Collection", + "behavior": [ + "public-iiif" + ], + "label": { + "en": [ + "iiif hierarchical post" + ] + } + } + """; var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Post, - $"{Customer}/first-child/second-child/{slug}", collection); + $"{Customer}/first-child/second-child/", collection); // Act var response = await httpClient.AsCustomer().SendAsync(requestMessage); @@ -2347,50 +2355,53 @@ public async Task CreateCollection_CreatesCollectionWithThumbnailAndItems_ViaHie // Arrange var slug = "iiif-collection-post-2"; - var collection = @"{ - ""type"": ""Collection"", - ""behavior"": [ - ""public-iiif"" - ], - ""label"": { - ""en"": [ - ""iiif hierarchical post"" - ] - }, - ""thumbnail"": [ - { - ""id"": ""https://example.org/img/thumb.jpg"", - ""type"": ""Image"", - ""format"": ""image/jpeg"", - ""width"": 300, - ""height"": 200 - } - ], - ""items"": [ - { - ""id"": ""https://some.id/iiif/collection"", - ""type"": ""Collection"", - } - ], -""homepage"": [ - { - ""id"": ""https://www.getty.edu/art/collection/object/103RQQ"", - ""type"": ""Text"", - ""label"": { - ""en"": [ - ""Home page at the Getty Museum Collection"" - ] - }, - ""format"": ""text/html"", - ""language"": [ - ""en"" - ] - } -] -}"; + var collection = $$""" + { + "type": "Collection", + "slug": "{{slug}}", + "behavior": [ + "public-iiif" + ], + "label": { + "en": [ + "iiif hierarchical post" + ] + }, + "thumbnail": [ + { + "id": "https://example.org/img/thumb.jpg", + "type": "Image", + "format": "image/jpeg", + "width": 300, + "height": 200 + } + ], + "items": [ + { + "id": "https://some.id/iiif/collection", + "type": "Collection", + } + ], + "homepage": [ + { + "id": "https://www.getty.edu/art/collection/object/103RQQ", + "type": "Text", + "label": { + "en": [ + "Home page at the Getty Museum Collection" + ] + }, + "format": "text/html", + "language": [ + "en" + ] + } + ] + } + """; var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Post, - $"{Customer}/{slug}", collection); + $"{Customer}/", collection); // Act var response = await httpClient.AsCustomer().SendAsync(requestMessage); @@ -2427,53 +2438,57 @@ await amazonS3.GetObjectAsync(LocalStackFixture.StorageBucketName, [Fact] public async Task CreateCollection_CreatesCollection_SavesInS3Correctly() { + //Requires a 'parent' to be set. Requires a 'slug' to be set. 'public ID' is required if the 'slug' and 'parent' are not specified // Arrange var slug = nameof(CreateCollection_CreatesCollection_SavesInS3Correctly); - var collection = @"{ - ""type"": ""Collection"", - ""behavior"": [ - ""public-iiif"", ""auto-advance"" - ], - ""label"": { - ""en"": [ - ""iiif hierarchical post"" - ] - }, - ""thumbnail"": [ - { - ""id"": ""https://example.org/img/thumb.jpg"", - ""type"": ""Image"", - ""format"": ""image/jpeg"", - ""width"": 300, - ""height"": 200 - } - ], - ""items"": [ - { - ""id"": ""https://some.id/iiif/collection"", - ""type"": ""Collection"", - } - ], -""homepage"": [ - { - ""id"": ""https://presentation.example.com"", - ""type"": ""Text"", - ""label"": { - ""en"": [ - ""Foo"" - ] - }, - ""format"": ""text/html"", - ""language"": [ - ""en"" - ] - } -] -}"; - - var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Post, - $"{Customer}/{slug}", collection); + var collection = $$""" + { + "type": "Collection", + "slug": "{{slug}}", + "behavior": [ + "public-iiif", "auto-advance" + ], + "label": { + "en": [ + "iiif hierarchical post" + ] + }, + "thumbnail": [ + { + "id": "https://example.org/img/thumb.jpg", + "type": "Image", + "format": "image/jpeg", + "width": 300, + "height": 200 + } + ], + "items": [ + { + "id": "https://some.id/iiif/collection", + "type": "Collection", + } + ], + "homepage": [ + { + "id": "https://presentation.example.com", + "type": "Text", + "label": { + "en": [ + "Foo" + ] + }, + "format": "text/html", + "language": [ + "en" + ] + } + ] + } + """; + + var requestMessage = HttpRequestMessageBuilder.GetPlainRequest(HttpMethod.Post, + $"{Customer}/", collection); // Act var response = await httpClient.AsCustomer().SendAsync(requestMessage); diff --git a/src/IIIFPresentation/API.Tests/Integration/ModifyRootCollectionTests.cs b/src/IIIFPresentation/API.Tests/Integration/ModifyRootCollectionTests.cs index dbffefc5..e96ecb2a 100644 --- a/src/IIIFPresentation/API.Tests/Integration/ModifyRootCollectionTests.cs +++ b/src/IIIFPresentation/API.Tests/Integration/ModifyRootCollectionTests.cs @@ -50,7 +50,7 @@ public async Task Put_400_IfTryToModifySlug() }; var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put, - $"{PresentationContextFixture.CustomerId}/collections/{RootCollection.Id}", collection.AsJson()); + $"{PresentationContextFixture.CustomerId}/collections/{RootCollection.Id}", collection.AsJson(),dbContext.GetETagById(1, RootCollection.Id)); var response = await httpClient.AsCustomer().SendAsync(requestMessage); response.StatusCode.Should().Be(HttpStatusCode.BadRequest, "Unable to change root slug"); @@ -71,7 +71,7 @@ public async Task Put_400_IfTryToModifyParent() }; var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put, - $"{PresentationContextFixture.CustomerId}/collections/{RootCollection.Id}", collection.AsJson()); + $"{PresentationContextFixture.CustomerId}/collections/{RootCollection.Id}", collection.AsJson(),dbContext.GetETagById(1, RootCollection.Id)); var response = await httpClient.AsCustomer().SendAsync(requestMessage); response.StatusCode.Should().Be(HttpStatusCode.BadRequest, "Unable to change root slug"); @@ -90,7 +90,7 @@ public async Task Put_400_IfTryToChangeFromStorageCollection() }; var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put, - $"{PresentationContextFixture.CustomerId}/collections/{RootCollection.Id}", collection.AsJson()); + $"{PresentationContextFixture.CustomerId}/collections/{RootCollection.Id}", collection.AsJson(),dbContext.GetETagById(1, RootCollection.Id)); var response = await httpClient.AsCustomer().SendAsync(requestMessage); response.StatusCode.Should().Be(HttpStatusCode.BadRequest, "Unable to change root slug"); diff --git a/src/IIIFPresentation/API.Tests/Integration/RestPathBehaviourTests.cs b/src/IIIFPresentation/API.Tests/Integration/RestPathBehaviourTests.cs new file mode 100644 index 00000000..e873e0e6 --- /dev/null +++ b/src/IIIFPresentation/API.Tests/Integration/RestPathBehaviourTests.cs @@ -0,0 +1,281 @@ +using System.Net; +using Amazon.S3; +using API.Tests.Integration.Infrastructure; +using Core.Infrastructure; +using Core.Response; +using IIIF.Presentation.V3.Content; +using IIIF.Presentation.V3.Strings; +using IIIF.Serialisation; +using Models.API.Collection; +using Models.API.Manifest; +using Repository; +using Test.Helpers.Helpers; +using Test.Helpers.Integration; + +namespace API.Tests.Integration; + +// We want to prep a unique grandparent->parent path for all the subsequent tests +public class RestPathFixture : PresentationAppFactory +{ + public readonly HttpClient httpClient; + private const int Customer = RestPathBehaviourTests.Customer; + + public RestPathFixture(StorageFixture storageFixture) + { + httpClient = this.ConfigureBasicIntegrationTestHttpClient(storageFixture.DbFixture, + appFactory => appFactory.WithLocalStack(storageFixture.LocalStackFixture)); + + SetupTargetCollection(); + } + + private void SetupTargetCollection() + { + // 1. Grandparent + var collection = new PresentationCollection + { + Behavior = + [ + Behavior.IsPublic, + Behavior.IsStorageCollection + ], + Label = new LanguageMap("en", ["the grandparent collection"]), + Slug = ManifestPathTestProvider.Grandparent, + Parent = $"http://localhost/{Customer}/collections/{RootCollection.Id}", + Thumbnail = [new Image { Id = "some/thumbnail" }], + Tags = "some, tags", + ItemsOrder = 1, + }; + + var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Post, $"{Customer}/collections", + collection.AsJson()); + + // Act + var response = httpClient.AsCustomer().SendAsync(requestMessage).Result; + if (!response.IsSuccessStatusCode) + throw new("Can't create grandparent collection"); + + var responseCollection = response.ReadAsPresentationResponseAsync().Result; + var grandparentId = responseCollection!.Id!.Split('/', StringSplitOptions.TrimEntries).Last(); + + // 2. Parent + collection = new PresentationCollection + { + Behavior = new List + { + Behavior.IsPublic, + Behavior.IsStorageCollection + }, + Label = new LanguageMap("en", ["test collection"]), + Slug = ManifestPathTestProvider.Parent, + Parent = $"http://localhost/{Customer}/collections/{grandparentId}", + Thumbnail = [new Image { Id = "some/thumbnail" }], + Tags = "some, tags", + ItemsOrder = 1, + }; + requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Put, + $"{Customer}/collections/{ManifestPathTestProvider.ParentId}", + collection.AsJson()); + + // Act + response = httpClient.AsCustomer().SendAsync(requestMessage).Result; + if (!response.IsSuccessStatusCode) + throw new("Can't create parent collection"); + } +} + +[Trait("Category", "Integration")] +[Collection(CollectionDefinitions.RestCollection.CollectionName)] +public class RestPathBehaviourTests : IClassFixture +{ + private readonly PresentationContext dbContext; + private readonly IAmazonS3 amazonS3; + private readonly HttpClient httpClient; + + public const int Customer = 1; + + public RestPathBehaviourTests(StorageFixture storageFixture, RestPathFixture fixture) + { + dbContext = storageFixture.DbFixture.DbContext; + + amazonS3 = storageFixture.LocalStackFixture.AWSS3ClientFactory(); + + httpClient = fixture.httpClient; + // parent = dbContext.Collections + // .First(x => x.CustomerId == Customer && x.Hierarchy!.Any(h => h.Slug == string.Empty)).Id; + + storageFixture.DbFixture.CleanUp(); + } + + [Theory] + [ClassData(typeof(ManifestPathTestProvider))] + public async Task TestManifestPaths(string method, string url, string? id, string? parent, string? slug, string? publicId) + { + // passing as string because XUnit asked me to - parsing back to typed obj + var httpMethod = HttpMethod.Parse(method); + + // Arrange + var manifest = new PresentationManifest(); + if (parent != null) + manifest.Parent = $"http://localhost/{Customer}/{parent}"; + + if (slug != null) + manifest.Slug = slug; + if (id != null) + manifest.Id = $"http://localhost/{Customer}/{id}"; + if (publicId != null) + manifest.PublicId = $"http://localhost/{Customer}/{publicId}"; + + var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(httpMethod, + $"{Customer}/{url}", + manifest.AsJson()); + var response = await httpClient.AsCustomer().SendAsync(requestMessage); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var responseManifest = await response.ReadAsPresentationResponseAsync(); + var manifestId = responseManifest!.Id!.Split('/', StringSplitOptions.TrimEntries).Last(); + try + { + manifestId.Should().Be(ManifestPathTestProvider.Id); + responseManifest.Slug.Should().Be(ManifestPathTestProvider.Slug); + } + finally + { + // clean up + requestMessage = + HttpRequestMessageBuilder.GetPrivateRequest(HttpMethod.Delete, $"{Customer}/manifests/{manifestId}"); + _ = await httpClient.AsCustomer().SendAsync(requestMessage); + } + } + + [Theory] + [ClassData(typeof(CollectionPathTestProvider))] + public async Task TestCreateCollectionPaths(string method, string url, string? id, string? parent, string? slug, string? publicId, string expectedSlug) + { + + // passing as string because XUnit asked me to - parsing back to typed obj + var httpMethod = HttpMethod.Parse(method); + + // Arrange + var collection = new PresentationCollection(); + if (parent != null) + collection.Parent = $"http://localhost/{Customer}/{parent}"; + if (slug != null) + collection.Slug = slug; + if (id != null) + collection.Id = $"http://localhost/{Customer}/{id}"; + if (publicId != null) + collection.PublicId = $"http://localhost/{Customer}/{publicId}"; + + var requestMessage = HttpRequestMessageBuilder.GetPrivateRequest(httpMethod, + $"{Customer}/{url}", + collection.AsJson()); + var response = await httpClient.AsCustomer().SendAsync(requestMessage); + response.StatusCode.Should().Be(HttpStatusCode.Created); + + var responseCollection = await response.ReadAsPresentationResponseAsync(); + responseCollection!.Slug.Should().Be(expectedSlug); + } +} + +public class CollectionPathTestProvider : TheoryData +{ + public const string Collections = "collections/"; + + public const string Id = "example-abc"; + public const string Slug = "my-slug"; + public const string Parent = "parent"; + public const string ParentId = "foobar"; + public const string Grandparent = "grandparent"; + + private int _slugCounter = 1; + private string UniqueSlug() => $"{Slug}-{_slugCounter++}"; + + public CollectionPathTestProvider() + { + // ( method, url, id, parent, slug, publicId) + + // POST to flat collections endpoint, use FLAT parent + var slug = UniqueSlug(); + Add(HttpMethod.Post.ToString(),Collections,null,Collections+ParentId, slug,null, slug); + + + // POST to flat collections endpoint, use HIERARCHICAL parent + slug = UniqueSlug(); + Add(HttpMethod.Post.ToString(),Collections,null,$"{Grandparent}/{Parent}",slug,null, slug); + + // PUT to the desired PUBLIC hierarchical path + slug = UniqueSlug(); + Add(HttpMethod.Put.ToString(), $"{Grandparent}/{Parent}/{slug}", null, null, null, null, slug); + + // POST into the parent public hierarchical collection + slug = UniqueSlug(); + Add(HttpMethod.Post.ToString(),$"{Grandparent}/{Parent}", null, null, slug,null, slug); + + // PUT to the FLAT API Collections endpoint (2 variants, hier/flat parent) + slug = UniqueSlug(); + Add(HttpMethod.Put.ToString(), Collections + Id + "-1", null, $"{Grandparent}/{Parent}", slug, null, slug); + slug = UniqueSlug(); + Add(HttpMethod.Put.ToString(), Collections + Id + "-2", null, Collections+ParentId, slug, null, slug); + + // POST Vanilla IIIF into the parent public hierarchical collection + slug = UniqueSlug(); + Add(HttpMethod.Post.ToString(), $"{Grandparent}/{Parent}", $"{Grandparent}/{Parent}/{slug}", null, null, null, slug); + + // PUT Vanilla IIIF to the public hierarchical URL + slug = UniqueSlug(); + Add(HttpMethod.Put.ToString(), $"{Grandparent}/{Parent}/{slug}", $"{Grandparent}/{Parent}/{slug}", null, null, + null, slug); + } +} + +public class ManifestPathTestProvider : TheoryData +{ + private const string Collections = "collections/"; + private const string Manifests = "manifests/"; + + public const string Id = "example-abc"; + public const string Slug = "my-slug"; + public const string Parent = "parent"; + public const string ParentId = "foobar"; + public const string Grandparent = "grandparent"; + + public ManifestPathTestProvider() + { + // ( method, url, id, parent, slug, publicId) + + // API PUT + Add(HttpMethod.Put.ToString(), Manifests + Id, null, null, null, $"{Grandparent}/{Parent}/{Slug}"); + Add(HttpMethod.Put.ToString(), Manifests + Id, null, Collections + ParentId, Slug, null); + Add(HttpMethod.Put.ToString(), Manifests + Id, null, $"{Grandparent}/{Parent}", Slug, null); + Add(HttpMethod.Put.ToString(), Manifests + Id, Manifests + Id, Collections + ParentId, Slug, + $"/{Grandparent}/{Parent}/{Slug}"); + Add(HttpMethod.Put.ToString(), Manifests + Id, Manifests + Id, $"{Grandparent}/{Parent}", Slug, + $"/{Grandparent}/{Parent}/{Slug}"); + + // API POST + Add(HttpMethod.Post.ToString(), Manifests, Manifests + Id, null, null, + $"{Grandparent}/{Parent}/{Slug}"); + Add(HttpMethod.Post.ToString(), Manifests, Manifests + Id, Collections + ParentId, Slug, null); + Add(HttpMethod.Post.ToString(), Manifests, Manifests + Id, $"{Grandparent}/{Parent}", Slug, null); + Add(HttpMethod.Post.ToString(), Manifests, Manifests + Id, Collections + ParentId, Slug, + $"/{Grandparent}/{Parent}/{Slug}"); + Add(HttpMethod.Post.ToString(), Manifests, Manifests + Id, $"{Grandparent}/{Parent}", Slug, + $"/{Grandparent}/{Parent}/{Slug}"); + + // Hierarchical POST + Add(HttpMethod.Post.ToString(), $"{Grandparent}/{Parent}", Manifests + Id, null, null, + $"/{Grandparent}/{Parent}/{Slug}"); + Add(HttpMethod.Post.ToString(), $"{Grandparent}/{Parent}", Manifests + Id, Collections + ParentId, + Slug, + null); + Add(HttpMethod.Post.ToString(), $"{Grandparent}/{Parent}", Manifests + Id, + $"{Grandparent}/{Parent}", + Slug, null); + Add(HttpMethod.Post.ToString(), $"{Grandparent}/{Parent}", Manifests + Id, Collections + ParentId, + Slug, + $"/{Grandparent}/{Parent}/{Slug}"); + Add(HttpMethod.Post.ToString(), $"{Grandparent}/{Parent}", Manifests + Id, + $"{Grandparent}/{Parent}", + Slug, $"/{Grandparent}/{Parent}/{Slug}"); + } +} diff --git a/src/IIIFPresentation/API/Converters/FastJsonPropertyRead.cs b/src/IIIFPresentation/API/Converters/FastJsonPropertyRead.cs new file mode 100644 index 00000000..92753dda --- /dev/null +++ b/src/IIIFPresentation/API/Converters/FastJsonPropertyRead.cs @@ -0,0 +1,47 @@ +using System.Text; +using System.Text.Json; + +namespace API.Converters; + +public static class FastJsonPropertyRead +{ + /// + /// Uses to find a value of a specified property, at the specified level (1 - top level) + /// + /// JSON to read through + /// name of the property to find + /// Depth of the property - 1 is the top level + /// string representation of the property value or null if property is null or not present + /// + /// This can be reused with minor refactoring to allow for e.g. byte input, stream input etc., but as in the + /// current use case this is not required, I'm not overcomplicating this method. Same for other types (number, date...) + /// + public static string? FindAtLevel(string json, string targetPropertyName, int level = 1) + { + ReadOnlySpan utf8 = Encoding.UTF8.GetBytes(json); + + var reader = new Utf8JsonReader(utf8, isFinalBlock: true, state: default); + + // to avoid allocating strings for each comparison + ReadOnlySpan targetUtf8 = Encoding.UTF8.GetBytes(targetPropertyName); + + while (reader.Read()) + { + if (reader.TokenType != JsonTokenType.PropertyName || + reader.CurrentDepth != level) + { + continue; + } + + if (reader.ValueTextEquals(targetUtf8)) + { + if (!reader.Read()) + throw new JsonException("Unexpected end of JSON after property name."); + + return reader.GetString(); // return string representation of the value + } + } + + return null; // not found + } +} diff --git a/src/IIIFPresentation/API/Converters/PresentationIIIFCleaner.cs b/src/IIIFPresentation/API/Converters/PresentationIIIFCleaner.cs new file mode 100644 index 00000000..1f5681a7 --- /dev/null +++ b/src/IIIFPresentation/API/Converters/PresentationIIIFCleaner.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using Core.Helpers; +using Models.API.Collection; +using Models.API.Manifest; + +namespace API.Converters; + +public static class PresentationIIIFCleaner +{ + private static readonly Func OnlyIIIFManifestFunc; + private static readonly Func OnlyIIIFCollectionFunc; + + static PresentationIIIFCleaner() + { + // Manifests + var iiifManifestProps = + typeof(IIIF.Presentation.V3.Manifest).GetProperties(BindingFlags.FlattenHierarchy | BindingFlags.Public | + BindingFlags.Instance) + .Where(x => x is { CanRead: true, CanWrite: true }) + .Select(x => (get: x.GetGetMethod().ThrowIfNull(x.Name), set: x.GetSetMethod().ThrowIfNull(x.Name))) + .ToArray(); + + OnlyIIIFManifestFunc = input => + { + var output = new PresentationManifest(); + + foreach (var prop in iiifManifestProps) + prop.set.Invoke(output, [prop.get.Invoke(input, null)]); + + return output; + }; + + // Collections + var iiifCollectionProps = + typeof(IIIF.Presentation.V3.Collection).GetProperties(BindingFlags.FlattenHierarchy | BindingFlags.Public | + BindingFlags.Instance) + .Where(x => x is { CanRead: true, CanWrite: true }) + .Select(x => (get: x.GetGetMethod().ThrowIfNull(x.Name), set: x.GetSetMethod().ThrowIfNull(x.Name))) + .ToArray(); + + OnlyIIIFCollectionFunc = input => + { + var output = new PresentationCollection(); + + foreach (var prop in iiifCollectionProps) + prop.set.Invoke(output, [prop.get.Invoke(input, null)]); + + return output; + }; + } + + public static PresentationManifest OnlyIIIFProperties(PresentationManifest presentationManifest) + => OnlyIIIFManifestFunc(presentationManifest); + + public static PresentationCollection OnlyIIIFProperties(PresentationCollection presentationCollection) + => OnlyIIIFCollectionFunc(presentationCollection); +} diff --git a/src/IIIFPresentation/API/Features/Common/Helpers/ErrorHelper.cs b/src/IIIFPresentation/API/Features/Common/Helpers/ErrorHelper.cs index 67c00881..8a27006f 100644 --- a/src/IIIFPresentation/API/Features/Common/Helpers/ErrorHelper.cs +++ b/src/IIIFPresentation/API/Features/Common/Helpers/ErrorHelper.cs @@ -85,6 +85,11 @@ public static ModifyEntityResult SlugMustMatc => ModifyEntityResult.Failure("The slug must match the one specified in the public id", ModifyCollectionType.SlugMustMatchPublicId, WriteResult.BadRequest); + public static ModifyEntityResult ProhibitedSlug(string invalidSlug) + where TCollection : JsonLdBase + => ModifyEntityResult.Failure($"'slug' cannot be one of prohibited terms: '{invalidSlug}'", + ModifyCollectionType.ValidationFailed, WriteResult.BadRequest); + public static ModifyEntityResult InvalidCanvasId(string? canvasId, string reason) where TCollection : JsonLdBase => ModifyEntityResult.Failure($"The canvas id {canvasId} is invalid - {reason}", @@ -105,6 +110,21 @@ public static ModifyEntityResult IncorrectPub => ModifyEntityResult.Failure("publicId incorrect", ModifyCollectionType.PublicIdIncorrect, WriteResult.BadRequest); + public static ModifyEntityResult MismatchedId() + where TCollection : JsonLdBase + => ModifyEntityResult.Failure("id mismatch between URL and body", + ModifyCollectionType.PublicIdIncorrect, WriteResult.BadRequest); + + public static ModifyEntityResult MissingSlug() + where TCollection : JsonLdBase + => ModifyEntityResult.Failure("slug must be provided to create resource", + ModifyCollectionType.MissingSlug, WriteResult.BadRequest); + + public static ModifyEntityResult MismatchedParent() + where TCollection : JsonLdBase + => ModifyEntityResult.Failure("parent mismatch between URL and body", + ModifyCollectionType.PublicIdIncorrect, WriteResult.BadRequest); + public static ModifyEntityResult PaintableAssetError(string error) where TCollection : JsonLdBase => ModifyEntityResult.Failure(error, diff --git a/src/IIIFPresentation/API/Features/Manifest/ManifestController.cs b/src/IIIFPresentation/API/Features/Manifest/ManifestController.cs index f1e991c9..b783ff17 100644 --- a/src/IIIFPresentation/API/Features/Manifest/ManifestController.cs +++ b/src/IIIFPresentation/API/Features/Manifest/ManifestController.cs @@ -2,24 +2,20 @@ using API.Auth; using API.Features.Manifest.Requests; using API.Features.Manifest.Validators; -using API.Features.Storage.Helpers; using API.Infrastructure; using API.Infrastructure.Filters; using API.Infrastructure.Helpers; using API.Infrastructure.Http; using API.Infrastructure.Requests; using API.Settings; -using IIIF; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using Models.API.General; -using Models.API.Manifest; namespace API.Features.Manifest; -[Route("/{customerId:int}")] +[Route("/{customerId:int}/manifests")] [ApiController] public class ManifestController( IOptions options, @@ -29,14 +25,15 @@ public class ManifestController( ILogger logger) : PresentationController(options.Value, mediator, eTagCache, logger) { - [HttpGet("manifests/{id}")] + [HttpGet("{id}")] [VaryHeader] public async Task GetManifestFlat([FromRoute] int customerId, [FromRoute] string id) { var pathOnly = !Request.HasShowExtraHeader() || await authenticator.ValidateRequest(Request) != AuthResult.Success; - var entityResult = await Mediator.Send(new GetManifest(customerId, id, Request.Headers.IfNoneMatch.AsETagValues(), pathOnly)); + var entityResult = + await Mediator.Send(new GetManifest(customerId, id, Request.Headers.IfNoneMatch.AsETagValues(), pathOnly)); switch (entityResult) { @@ -64,15 +61,14 @@ public async Task GetManifestFlat([FromRoute] int customerId, [Fr /// Create a new Manifest on Flat URL /// [Authorize] - [HttpPost("manifests")] + [HttpPost("")] public async Task CreateManifest( [FromRoute] int customerId, [FromServices] PresentationManifestValidator validator, CancellationToken cancellationToken) - => await ManifestUpsert( - (presentationManifest, rawRequestBody) => new CreateManifest(customerId, presentationManifest, - rawRequestBody, Request.HasCreateSpaceHeader()), - validator, + => await HandleUpsert(new DispatchManifestRequest(customerId, HttpMethod.Post, string.Empty, + await Request.GetRawRequestBodyAsync(cancellationToken), + false, Request.HasShowExtraHeader(), Request.HasCreateSpaceHeader(), Request.Headers.IfMatch), cancellationToken: cancellationToken); /// @@ -80,57 +76,25 @@ public async Task CreateManifest( /// If id exists valid E-Tag must be provided /// [Authorize] - [HttpPut("manifests/{id}")] + [HttpPut("{id}")] public async Task UpsertManifest( [FromRoute] int customerId, [FromRoute] string id, [FromServices] PresentationManifestValidator validator, CancellationToken cancellationToken) - => await ManifestUpsert( - (presentationManifest, rawRequestBody) => - new UpsertManifest(customerId, id, Request.Headers.IfMatch, presentationManifest, rawRequestBody, - Request.HasCreateSpaceHeader()), - validator, - invalidatesEtag:Request.Headers.IfMatch, + => await HandleUpsert( + new DispatchManifestRequest(customerId, HttpMethod.Put, id, + await Request.GetRawRequestBodyAsync(cancellationToken), + false, Request.HasShowExtraHeader(), Request.HasCreateSpaceHeader(), Request.Headers.IfMatch), + invalidatesEtag: Request.Headers.IfMatch, cancellationToken: cancellationToken); [Authorize] - [HttpDelete("manifests/{id}")] + [HttpDelete("{id}")] public async Task Delete(int customerId, string id) { if (!Request.HasShowExtraHeader()) return this.Forbidden(); return await HandleDelete(new DeleteManifest(customerId, id)); } - - private async Task ManifestUpsert( - Func>> requestFactory, - PresentationManifestValidator validator, - string? instance = null, - string? errorTitle = "Operation failed", - string? invalidatesEtag = null, - CancellationToken cancellationToken = default) - where T : JsonLdBase - where TEnum : Enum - { - if (!Request.HasShowExtraHeader()) return this.Forbidden(); - - var rawRequestBody = await Request.GetRawRequestBodyAsync(cancellationToken); - var presentationManifest = await rawRequestBody.TryDeserializePresentation(logger); - - if (presentationManifest.Error) - { - return this.PresentationProblem("Could not deserialize manifest", null, (int)HttpStatusCode.BadRequest, - "Deserialization Error", this.GetErrorType(ModifyCollectionType.CannotDeserialize)); - } - - var validation = await validator.ValidateAsync(presentationManifest.ConvertedIIIF!, cancellationToken); - if (!validation.IsValid) - { - return this.ValidationFailed(validation); - } - - return await HandleUpsert(requestFactory(presentationManifest.ConvertedIIIF!, rawRequestBody), instance, - errorTitle, invalidatesEtag, cancellationToken); - } } diff --git a/src/IIIFPresentation/API/Features/Manifest/ManifestWriteService.cs b/src/IIIFPresentation/API/Features/Manifest/ManifestWriteService.cs index 880e1429..884d4656 100644 --- a/src/IIIFPresentation/API/Features/Manifest/ManifestWriteService.cs +++ b/src/IIIFPresentation/API/Features/Manifest/ManifestWriteService.cs @@ -23,7 +23,9 @@ using Services.Manifests.Helpers; using Services.Manifests.Model; using DbManifest = Models.Database.Collections.Manifest; -using PresUpdateResult = API.Infrastructure.Requests.ModifyEntityResult; +using PresUpdateResult = + API.Infrastructure.Requests.ModifyEntityResult; namespace API.Features.Manifest; @@ -54,13 +56,13 @@ public WriteManifestRequest(int customerId, { // removes presentation behaviors that aren't required for a manifest presentationManifest.RemovePresentationBehaviours(); - + CustomerId = customerId; PresentationManifest = presentationManifest; RawRequestBody = rawRequestBody; CreateSpace = createSpace; } - + public int CustomerId { get; } public PresentationManifest PresentationManifest { get; } public string RawRequestBody { get; } @@ -88,7 +90,7 @@ public class ManifestWriteService( IdentityManager identityManager, IIIIFS3Service iiifS3, CanvasPaintingResolver canvasPaintingResolver, - IPathGenerator pathGenerator, + IPathGenerator pathGenerator, SettingsBasedPathGenerator savedManifestPathGenerator, DlcsManifestCoordinator dlcsManifestCoordinator, IParentSlugParser parentSlugParser, @@ -153,7 +155,8 @@ public async Task Create(WriteManifestRequest request, Cancell } } - private async Task CreateInternal(WriteManifestRequest request, string? manifestId, CancellationToken cancellationToken) + private async Task CreateInternal(WriteManifestRequest request, string? manifestId, + CancellationToken cancellationToken) { using (logger.BeginScope("Creating Manifest for Customer {CustomerId}", request.CustomerId)) { @@ -162,36 +165,38 @@ private async Task CreateInternal(WriteManifestRequest request await canvasPaintingResolver.GenerateCanvasPaintings(request.CustomerId, request.PresentationManifest, cancellationToken); if (canvasPaintingsError != null) return canvasPaintingsError; - + // retrieve and validate the parent and slug on the request var parsedParentSlugResult = await parentSlugParser.Parse(request.PresentationManifest, request.CustomerId, null, cancellationToken); if (parsedParentSlugResult.IsError) return parsedParentSlugResult.Errors; var parsedParentSlug = parsedParentSlugResult.ParsedParentSlug; - + // Ensure we have a manifestId manifestId ??= await GenerateUniqueManifestId(request, cancellationToken); if (manifestId == null) return ErrorHelper.CannotGenerateUniqueId(); - + // Carry out any DLCS interactions (for paintedResources with _assets_) var dlcsInteractionResult = await dlcsManifestCoordinator.HandleDlcsInteractions(request, manifestId, itemCanvasPaintingsWithAssets: interimCanvasPaintings?.GetItemsWithSuspectedAssets(), cancellationToken: cancellationToken); if (dlcsInteractionResult.Error != null) return dlcsInteractionResult.Error; - + // convert and update the canvas paintings from the interim object, to the database format - var canvasPaintings = interimCanvasPaintings?.ConvertInterimCanvasPaintings(dlcsInteractionResult.SpaceId) ?? []; + var canvasPaintings = + interimCanvasPaintings?.ConvertInterimCanvasPaintings(dlcsInteractionResult.SpaceId) ?? []; canvasPaintings.SetAssetsToIngesting(dlcsInteractionResult.IngestedAssets); - + var (error, dbManifest) = - await CreateDatabaseRecord(request, parsedParentSlug, manifestId, dlcsInteractionResult.SpaceId, dlcsInteractionResult, canvasPaintings, cancellationToken); + await CreateDatabaseRecord(request, parsedParentSlug, manifestId, dlcsInteractionResult.SpaceId, + dlcsInteractionResult, canvasPaintings, cancellationToken); if (error != null) return error; Debug.Assert(dbManifest != null); - + var hasAssets = request.PresentationManifest.PaintedResources.HasAsset(); request.PresentationManifest.Items = await SaveToS3(dbManifest, request, hasAssets, dlcsInteractionResult.CanBeBuiltUpfront, cancellationToken); - + return await GeneratePresentationSuccessResult(request.PresentationManifest, request.CustomerId, dbManifest, hasAssets, dlcsInteractionResult, WriteResult.Created, cancellationToken); } @@ -211,34 +216,38 @@ private async Task UpdateInternal(UpsertManifestRequest reques var existingAssetIds = existingManifest.CanvasPaintings?.Where(cp => cp.AssetId != null) .Select(cp => cp.AssetId!).ToList(); // retrieve, update and validate canvas paintings using the request - var (canvasPaintingsError, interimCanvasPaintingsToAdd) = await canvasPaintingResolver.UpdateCanvasPaintings(request.CustomerId, - request.PresentationManifest, existingManifest, cancellationToken); + var (canvasPaintingsError, interimCanvasPaintingsToAdd) = + await canvasPaintingResolver.UpdateCanvasPaintings(request.CustomerId, + request.PresentationManifest, existingManifest, cancellationToken); if (canvasPaintingsError != null) return canvasPaintingsError; - + // retrieve + validate the parent and slug from the request var parsedParentSlugResult = await parentSlugParser.Parse(request.PresentationManifest, request.CustomerId, request.ManifestId, cancellationToken); if (parsedParentSlugResult.IsError) return parsedParentSlugResult.Errors; var parsedParentSlug = parsedParentSlugResult.ParsedParentSlug; - + // Carry out any DLCS interactions (for paintedResources with _assets_) var dlcsInteractionResult = await dlcsManifestCoordinator.HandleDlcsInteractions(request, - existingManifest.Id, existingAssetIds, existingManifest, + existingManifest.Id, existingAssetIds, existingManifest, interimCanvasPaintingsToAdd?.Where(icp => icp is - { SuspectedAssetId: not null, CanvasPaintingType: CanvasPaintingType.Items }).ToList(), cancellationToken); + { SuspectedAssetId: not null, CanvasPaintingType: CanvasPaintingType.Items }).ToList(), + cancellationToken); if (dlcsInteractionResult.Error != null) return dlcsInteractionResult.Error; - + // update existing manifest with canvas paintings following DLCS interactions - var canvasPaintings = interimCanvasPaintingsToAdd?.ConvertInterimCanvasPaintings(dlcsInteractionResult.SpaceId) ?? []; + var canvasPaintings = + interimCanvasPaintingsToAdd?.ConvertInterimCanvasPaintings(dlcsInteractionResult.SpaceId) ?? []; existingManifest.CanvasPaintings ??= []; existingManifest.CanvasPaintings.AddRange(canvasPaintings); existingManifest.CanvasPaintings.SetAssetsToIngesting(dlcsInteractionResult.IngestedAssets); - + var (error, dbManifest) = - await UpdateDatabaseRecord(request, parsedParentSlug!, existingManifest, dlcsInteractionResult, cancellationToken); + await UpdateDatabaseRecord(request, parsedParentSlug!, existingManifest, dlcsInteractionResult, + cancellationToken); if (error != null) return error; Debug.Assert(dbManifest != null); - + var hasAssets = request.PresentationManifest.PaintedResources.HasAsset(); request.PresentationManifest.Items = await SaveToS3(dbManifest, request, hasAssets, dlcsInteractionResult.CanBeBuiltUpfront, cancellationToken); @@ -248,8 +257,8 @@ private async Task UpdateInternal(UpsertManifestRequest reques } } - private async Task GeneratePresentationSuccessResult(PresentationManifest presentationManifest, - int customerId, DbManifest dbManifest, bool hasAssets, DlcsInteractionResult dlcsInteractionResult, + private async Task GeneratePresentationSuccessResult(PresentationManifest presentationManifest, + int customerId, DbManifest dbManifest, bool hasAssets, DlcsInteractionResult dlcsInteractionResult, WriteResult writeResult, CancellationToken cancellationToken) { return PresUpdateResult.Success( @@ -309,7 +318,7 @@ private static bool RequiresFurtherProcessing(DlcsInteractionResult dlcsInteract CancellationToken cancellationToken) { existingManifest.Label = request.PresentationManifest.Label; - + existingManifest.Modified = DateTime.UtcNow; existingManifest.ModifiedBy = Authorizer.GetUser(); @@ -318,7 +327,7 @@ private static bool RequiresFurtherProcessing(DlcsInteractionResult dlcsInteract existingManifest.LastProcessed = DateTime.UtcNow; } // else: BackgroundHandler will set the value - + var canonicalHierarchy = existingManifest.Hierarchy!.Single(c => c.Canonical); canonicalHierarchy.Slug = parsedParentSlug.Slug; canonicalHierarchy.Parent = parsedParentSlug.Parent.Id; @@ -326,7 +335,7 @@ private static bool RequiresFurtherProcessing(DlcsInteractionResult dlcsInteract var saveErrors = await SaveAndPopulateEntity(request, existingManifest, cancellationToken); return (saveErrors, existingManifest); } - + private async Task SaveAndPopulateEntity(WriteManifestRequest request, DbManifest dbManifest, CancellationToken cancellationToken) { @@ -363,7 +372,8 @@ await ManifestRetrieval.RetrieveFullPathForManifest(dbManifest.Id, dbManifest.Cu if (canBeBuiltUpfront) { - var manifest = await manifestStorageManager.UpsertManifestInStorage(iiifManifest, dbManifest, cancellationToken); + var manifest = + await manifestStorageManager.UpsertManifestInStorage(iiifManifest, dbManifest, cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); request.PresentationManifest.Items = manifest.Items; } @@ -371,8 +381,8 @@ await ManifestRetrieval.RetrieveFullPathForManifest(dbManifest.Id, dbManifest.Cu { if (hasAssets) { - var canvasPaintings = dbManifest.CanvasPaintings; - + var canvasPaintings = dbManifest.CanvasPaintings; + if (canvasPaintings is not null) { iiifManifest.Items = @@ -380,7 +390,7 @@ await ManifestRetrieval.RetrieveFullPathForManifest(dbManifest.Id, dbManifest.Cu pathRewriteParser); } } - + await iiifS3.SaveIIIFToS3(iiifManifest, dbManifest, pathGenerator.GenerateFlatManifestId(dbManifest), hasAssets, cancellationToken); } diff --git a/src/IIIFPresentation/API/Features/Manifest/Requests/CreateManifest.cs b/src/IIIFPresentation/API/Features/Manifest/Requests/CreateManifest.cs deleted file mode 100644 index aea30092..00000000 --- a/src/IIIFPresentation/API/Features/Manifest/Requests/CreateManifest.cs +++ /dev/null @@ -1,38 +0,0 @@ -using API.Infrastructure.Requests; -using MediatR; -using Models.API.General; -using Models.API.Manifest; - -namespace API.Features.Manifest.Requests; - -/// -/// Create a new Manifest in DB and upload provided JSON to S3 -/// -public class CreateManifest( - int customerId, - PresentationManifest presentationManifest, - string rawRequestBody, - bool createSpace) - : IRequest> -{ - public int CustomerId { get; } = customerId; - public PresentationManifest PresentationManifest { get; } = presentationManifest; - public string RawRequestBody { get; } = rawRequestBody; - public bool CreateSpace { get; } = createSpace; -} - -public class CreateManifestHandler( - IManifestWrite manifestService) : IRequestHandler> -{ - public Task> Handle(CreateManifest request, - CancellationToken cancellationToken) - { - var upsertRequest = new WriteManifestRequest(request.CustomerId, - request.PresentationManifest, - request.RawRequestBody, - request.CreateSpace); - - return manifestService.Create(upsertRequest, cancellationToken); - } -} diff --git a/src/IIIFPresentation/API/Features/Manifest/Requests/DispatchManifestRequest.cs b/src/IIIFPresentation/API/Features/Manifest/Requests/DispatchManifestRequest.cs new file mode 100644 index 00000000..3aa01b8c --- /dev/null +++ b/src/IIIFPresentation/API/Features/Manifest/Requests/DispatchManifestRequest.cs @@ -0,0 +1,334 @@ +using System.Diagnostics; +using API.Features.Manifest.Validators; +using API.Features.Storage.Helpers; +using API.Features.Storage.Models; +using API.Features.Storage.Requests; +using API.Infrastructure.Http; +using Core; +using MediatR; +using Models.API.General; +using Models.API.Manifest; +using Repository; +using Repository.Helpers; +using Repository.Paths; +using Result = + API.Infrastructure.Requests.ModifyEntityResult; + +namespace API.Features.Manifest.Requests; + +/// +/// Common encapsulation of data available in a controller action, including the caller's intent +/// +/// Caller's customer id - assumed verified by authentication +/// HTTP method used by caller, which implies intent (create at parent vs. upsert resource at location) +/// Entire path AFTER /{customerId} - might be flat, providing resource type and id, might be hierarchical showing parent and possibly the intended/desired slug +/// Raw string data - will be parsed, validated etc. +/// Could be deduced from , but this value is known by controller, so to simplify code it is provided explicitly +/// Whether the request included the "show extra properties" header +///// Controller which created and sent this request. Used to create a response. +public class DispatchManifestRequest( + int customerId, + HttpMethod requestMethod, + string requestPath, + string rawRequestBody, + bool isHierarchical, + bool isShowExtras, + bool isCreateSpace, + string? eTag +) : IRequest +{ + public int CustomerId { get; set; } = customerId; + public HttpMethod RequestMethod { get; set; } = requestMethod; + public string RequestPath { get; set; } = requestPath; + public string RawRequestBody { get; set; } = rawRequestBody; + public bool IsHierarchical { get; set; } = isHierarchical; + public bool IsShowExtras { get; set; } = isShowExtras; + public bool IsCreateSpace { get; set; } = isCreateSpace; + public string? ETag { get; set; } = eTag; +} + +public class DispatchManifestRequestHandler( + PresentationContext dbContext, + ILogger logger, + IPathGenerator pathGenerator, + IPathRewriteParser pathRewriteParser, + PresentationManifestValidator presentationManifestValidator, + IManifestWrite manifestService) + : IRequestHandler + +{ + public async Task Handle(DispatchManifestRequest request, CancellationToken cancellationToken) + { + // Pre-process user-supplied data to pass to ManifestWriteService + + // 1. Non-hierarchical requests must include the extra prop header + // source: pre-existing ManifestController.ManifestUpsert logic + if (request is { IsHierarchical: false, IsShowExtras: false }) + return Result.Failure($"This request requires '{CustomHttpHeaders.ShowExtras}' header", + ModifyCollectionType.ExtraHeaderRequired, WriteResult.Forbidden); + + var presentationManifest = + await request.RawRequestBody.TryDeserializePresentation(logger); + + if (presentationManifest.Error) + return Result.Failure("Could not deserialize manifest", ModifyCollectionType.CannotDeserialize, + WriteResult.BadRequest); + + // 2. Validation + // source: existing logic in ManifestController + var validation = + await presentationManifestValidator.ValidateAsync(presentationManifest.ConvertedIIIF!, cancellationToken); + if (!validation.IsValid) + { + var message = string.Join(". ", validation.Errors.Select(s => s.ErrorMessage).Distinct()); + return Result.Failure(message, ModifyCollectionType.ValidationFailed, WriteResult.FailedValidation); + } + + // 3. Determine the operation + // POST -> Create + // PUT -> Upsert + + // An id might either be one of existing manifest, or a "desired" id, if it's provided as a valid flatId + // A null is also acceptable - if so, we will treat it as "try creating new manifest with minted id" + string? manifestId; + + // Note: handling of `publicId` prop is done by MWS - don't think we need to do anything with it here. + + if (request.RequestMethod == HttpMethod.Post) + { + // easier case - create only + // in either hierarchical or flat case we do not have any extra knowledge about slug from path (points to parent collection only) + // however, we do have information about a parent in hierarchical case + if (request.IsHierarchical) + { + // We don't want to resolve full Hierarchy here, as it will be done as needed later + // But, we do want to ensure there's no conflict with existing `Parent` property, + // OR we want to add the `Parent` property that can be omitted, if it's already + // provided via it being a hierarchical request + var hierarchicalParentPath = + pathGenerator.GenerateHierarchicalFromFullPath(request.CustomerId, request.RequestPath); + + if (presentationManifest.ConvertedIIIF.Parent == null) + { + // set property for use by ManifestWriteService + presentationManifest.ConvertedIIIF.Parent = hierarchicalParentPath; + } + else + { + if (await CheckForParentMismatch(request, presentationManifest, hierarchicalParentPath, + cancellationToken) is + { } error) + return error; + // else no error, so proceed to calling MWS (it will use the matching value from parent property) + } + } + + // Before continuing we'll try to get a "manifestId" from the `id` prop + // Note: in this scenario this is essentially just to allow passing a "desired" id in a FLAT format + // we pass `true` as `flatOnly` to prevent hierarhical resolution + manifestId = await GetManifestId(request, presentationManifest.ConvertedIIIF, true, cancellationToken); + + return await CreateManifest(request, manifestId, presentationManifest.ConvertedIIIF, cancellationToken); + } + + // else + // PUT + + // If this is a hierarchical request, the parent and the slug might be provided via the path + if (request.IsHierarchical) + { + var splitPath = request.RequestPath.Split('/'); + var pathSlug = splitPath[^1]; + var pathParent = string.Join("/", splitPath.Take(..^1)); + + if (presentationManifest.ConvertedIIIF.Slug == null) + { + // Slug prop not provided, set from path + presentationManifest.ConvertedIIIF.Slug = pathSlug; + } + else + { + // Slug prop provided, verify it matches path + if (!string.Equals(presentationManifest.ConvertedIIIF.Slug, pathSlug)) + return Result.Failure( + "Slug property of posted manifest does not match the slug part of hierarchical path of the request.", + ModifyCollectionType.SlugMustMatchPublicId, WriteResult.BadRequest); + } + + var hierarchicalParentPath = + pathGenerator.GenerateHierarchicalFromFullPath(request.CustomerId, pathParent); + + + if (presentationManifest.ConvertedIIIF.Parent == null) + { + // set property for use by ManifestWriteService + presentationManifest.ConvertedIIIF.Parent = hierarchicalParentPath; + } + else + { + if (await CheckForParentMismatch(request, presentationManifest, hierarchicalParentPath, + cancellationToken) is + { } error) + return error; + // else no error, so proceed to calling MWS (it will use the matching value from parent property) + } + + // for a hierarchical PUT <>, the id has to be provided in the body. + if (presentationManifest.ConvertedIIIF.FlatId is { Length: > 0 } flatFromBody) + { + // Simplest case: this is an update of existing manifest, and flatId was provided "back". + manifestId = flatFromBody; + } + else + { + manifestId = await GetManifestId(request, presentationManifest.ConvertedIIIF, false, cancellationToken); + } + } + else + { + // it's a flat request, so we can just grab last path segment + manifestId = + request.RequestPath; // in flat controller it's manifests/, and is passed as path, skipping the manifests part + } + + // Finally, we're ready + // If we got manifestId it will be an upsert request, but if not - we should be able to treat this + // as a create request and save ourselves some effort + + return await CreateManifest(request, manifestId, presentationManifest.ConvertedIIIF, cancellationToken); + } + + private async Task GetManifestId(DispatchManifestRequest request, + PresentationManifest presentationManifest, bool flatOnly, CancellationToken cancellationToken) + { + if (TryParseProvidedId(request, presentationManifest) is + not { resourceId: not null } pathParts) return null; + + // we got a seemingly valid id - but what we need is a flat id (or null) + if (!pathParts.isHierarchical) + { + // it's flat, we're in luck + return pathParts.resourceId; + } + + if (flatOnly) + { + // for POST we don't want hierarchical resolution, it's not a supported scenario (and validation would complicate flow even more) + return null; + } + + // This is probably a rare scenario, so to not make it even more complicated we'll + // resolve it with DB into a flatId + var existingManifestHierarchy = await dbContext.RetrieveHierarchy(request.CustomerId, + pathParts.resourceId, + cancellationToken); + + return existingManifestHierarchy?.ManifestId; // <- if it's null, that's fine, it will be minted + } + + private (string? resourceId, bool isHierarchical) TryParseProvidedId(DispatchManifestRequest request, + PresentationManifest presentationManifest) + { + if (presentationManifest.Id is not { Length: > 0 } bodyId + || !Uri.TryCreate(bodyId, UriKind.Absolute, out var bodyUriId)) + { + return (null, false); + } + + // Can be flat or hierarchical, with rewrites or not, so let's use a rewrite parser + var pathParts = pathRewriteParser.ParsePathWithRewrites(bodyUriId.Host, bodyUriId.AbsolutePath, + request.CustomerId); + + return pathParts.Resource is not null ? (pathParts.Resource, pathParts.Hierarchical) : (null, false); + } + + private async Task CreateManifest(DispatchManifestRequest request, + string? manifestId, PresentationManifest presentationManifest, + CancellationToken cancellationToken) + { + if (manifestId is null) + { + return await WithCreateRequest(request, presentationManifest, cancellationToken); + } + + // else upsert + return await WithUpsertRequest(request, manifestId, presentationManifest, cancellationToken); + } + + private async Task WithUpsertRequest(DispatchManifestRequest request, + string manifestId, PresentationManifest presentationManifest, + CancellationToken cancellationToken) + { + var upsertRequest = new UpsertManifestRequest( + manifestId, + request.ETag, + request.CustomerId, + presentationManifest, + request.RawRequestBody, + request.IsCreateSpace); + + return await manifestService.Upsert(upsertRequest, cancellationToken); + } + + private async Task WithCreateRequest(DispatchManifestRequest request, + PresentationManifest presentationManifest, CancellationToken cancellationToken) + { + var upsertRequest = new WriteManifestRequest(request.CustomerId, + presentationManifest, + request.RawRequestBody, + request.IsCreateSpace); + + return await manifestService.Create(upsertRequest, cancellationToken); + } + + private async Task CheckForParentMismatch(DispatchManifestRequest request, + TryConvertIIIFResult presentationManifest, string pathParent, + CancellationToken cancellationToken) + { + // should already have been checked + Debug.Assert(presentationManifest.ConvertedIIIF != null, "presentationManifest.ConvertedIIIF != null"); + + // we're here because the property is already set, we're gonna check if it matches posted path + if (string.Equals(presentationManifest.ConvertedIIIF.Parent, pathParent)) return null; // no error + + // edge-case: parent might be flat and point at the same parent as the path + + // if not uri => not valid flat id => mismatch, return error + if (!Uri.TryCreate(presentationManifest.ConvertedIIIF.Parent, UriKind.Absolute, out var parentUri)) + return Result.Failure( + "Parent property of posted manifest does not match the hierarchical path of the request.", + ModifyCollectionType.ParentMustMatchPublicId, WriteResult.BadRequest); + + var parentPath = pathRewriteParser.ParsePathWithRewrites(parentUri.Host, parentUri.AbsolutePath, + request.CustomerId); + + // if is hierarchical, we would have not been here in the first place + // also, if no flat id resolved from URL then it's obviously not valid + if (parentPath is not { Hierarchical: false, Resource: { Length: > 0 } flatParentId }) + return Result.Failure( + "Parent property of posted manifest does not match the hierarchical path of the request or is not a valid parent collection.", + ModifyCollectionType.ParentMustMatchPublicId, WriteResult.BadRequest); + + // This is not great as it will be done again in MWS, but it's an edge case that we should handle here + // but before that we have to strip the URI `pathParent` to just the AbsolutePath + // Note: pathParent is //hierarchical/path - and as Segments includes the empty /, we skip / 0 } parentCollectionId + || !string.Equals(parentCollectionId, flatParentId)) + { + return Result.Failure( + "Parent property of posted manifest does not match the hierarchical path of the request or is not a valid parent collection.", + ModifyCollectionType.ParentMustMatchPublicId, WriteResult.BadRequest); + } + + // the flat parent from prop matches the hierarchical parent from path, no error + return null; + } +} diff --git a/src/IIIFPresentation/API/Features/Manifest/Requests/UpsertManifest.cs b/src/IIIFPresentation/API/Features/Manifest/Requests/UpsertManifest.cs deleted file mode 100644 index db9f5086..00000000 --- a/src/IIIFPresentation/API/Features/Manifest/Requests/UpsertManifest.cs +++ /dev/null @@ -1,44 +0,0 @@ -using API.Infrastructure.Requests; -using MediatR; -using Microsoft.Extensions.Primitives; -using Models.API.General; -using Models.API.Manifest; - -namespace API.Features.Manifest.Requests; - -/// -/// Upsert Manifest in DB and upload provided JSON to S3 -/// -public class UpsertManifest( - int customerId, - string manifestId, - StringValues etag, - PresentationManifest presentationManifest, - string rawRequestBody, - bool createSpace) : IRequest> -{ - public int CustomerId { get; } = customerId; - public string ManifestId { get; } = manifestId; - public string? Etag { get; } = etag.ToString(); - public PresentationManifest PresentationManifest { get; } = presentationManifest; - public string RawRequestBody { get; } = rawRequestBody; - public bool CreateSpace { get; } = createSpace; -} - -public class UpsertManifestHandler(IManifestWrite manifestService) - : IRequestHandler> -{ - public Task> Handle(UpsertManifest request, - CancellationToken cancellationToken) - { - var upsertRequest = new UpsertManifestRequest( - request.ManifestId, - request.Etag, - request.CustomerId, - request.PresentationManifest, - request.RawRequestBody, - request.CreateSpace); - - return manifestService.Upsert(upsertRequest, cancellationToken); - } -} diff --git a/src/IIIFPresentation/API/Features/Storage/CollectionController.cs b/src/IIIFPresentation/API/Features/Storage/CollectionController.cs index 0bb6a53f..f1da71ab 100644 --- a/src/IIIFPresentation/API/Features/Storage/CollectionController.cs +++ b/src/IIIFPresentation/API/Features/Storage/CollectionController.cs @@ -1,9 +1,7 @@ using System.Net; using API.Auth; using API.Features.Storage.Helpers; -using API.Features.Storage.Models; using API.Features.Storage.Requests; -using API.Features.Storage.Validators; using API.Infrastructure; using API.Infrastructure.Filters; using API.Infrastructure.Helpers; @@ -14,9 +12,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using Models; -using Models.API.Collection; -using Models.API.General; namespace API.Features.Storage; @@ -42,7 +37,8 @@ public async Task Get(int customerId, string id, int? page = 1, i var orderByField = this.GetOrderBy(orderBy, orderByDescending, out var descending); var entityResult = - await Mediator.Send(new GetCollection(customerId, id, Request.Headers.IfNoneMatch.AsETagValues(), page.Value, + await Mediator.Send(new GetCollection(customerId, id, Request.Headers.IfNoneMatch.AsETagValues(), + page.Value, pageSize.Value, orderByField, descending)); @@ -67,61 +63,23 @@ await Mediator.Send(new GetCollection(customerId, id, Request.Headers.IfNoneMatc [Authorize] [HttpPost("collections")] - public async Task Post(int customerId, [FromServices] PresentationValidator validator) + public async Task Post(int customerId) { - var deserializeValidationResult = await DeserializeAndValidate(validator, null, null); - if (deserializeValidationResult.HasError) return deserializeValidationResult.Error; - - return await HandleUpsert(new CreateCollection(customerId, - deserializeValidationResult.ConvertedIIIF, deserializeValidationResult.RawRequestBody)); + return await HandleUpsert(new CollectionWriteRequest(customerId, HttpMethod.Post, string.Empty, + await Request.GetRawRequestBodyAsync(), false, Request.HasShowExtraHeader(), + Request.Headers.IfMatch)); } [Authorize] [HttpPut("collections/{id}")] - public async Task Put(int customerId, string id, - [FromServices] RootCollectionValidator rootValidator, - [FromServices] PresentationValidator presentationValidator) + public async Task Put(int customerId, string id) { - var deserializeValidationResult = await DeserializeAndValidate(presentationValidator, id, rootValidator); - if (deserializeValidationResult.HasError) return deserializeValidationResult.Error; - - return await HandleUpsert(new UpsertCollection(customerId, id, - deserializeValidationResult.ConvertedIIIF, Request.Headers.IfMatch, - deserializeValidationResult.RawRequestBody), invalidatesEtag:Request.Headers.IfMatch); + return await HandleUpsert(new CollectionWriteRequest(customerId, HttpMethod.Put, + id, await Request.GetRawRequestBodyAsync(), false, + Request.HasShowExtraHeader(), Request.Headers.IfMatch + )); } - - private async Task> DeserializeAndValidate( - PresentationValidator presentationValidator, string? id, RootCollectionValidator? rootValidator) - { - if (!Request.HasShowExtraHeader()) - { - return DeserializeValidationResult.Failure(this.Forbidden()); - } - - var rawRequestBody = await Request.GetRawRequestBodyAsync(); - - var deserializedCollection = - await rawRequestBody.TryDeserializePresentation(logger); - if (deserializedCollection.Error) - { - return DeserializeValidationResult.Failure(PresentationUnableToSerialize()); - } - - var validation = id != null && KnownCollections.IsRoot(id) - ? rootValidator!.Validate(deserializedCollection.ConvertedIIIF) - : presentationValidator.Validate(deserializedCollection.ConvertedIIIF); - - if (!validation.IsValid) - { - return DeserializeValidationResult.Failure(this.ValidationFailed(validation)); - } - - return DeserializeValidationResult.Success(deserializedCollection.ConvertedIIIF, - rawRequestBody); - } - - [Authorize] [HttpDelete("collections/{id}")] public async Task Delete(int customerId, string id) @@ -130,12 +88,4 @@ public async Task Delete(int customerId, string id) return await HandleDelete(new DeleteCollection(customerId, id)); } - - /// - /// Creates an that produces a response with 400 status code. - /// - /// The created for the response. - private ObjectResult PresentationUnableToSerialize() => - this.PresentationProblem("Could not deserialize collection", null, (int)HttpStatusCode.BadRequest, - "Deserialization Error", this.GetErrorType(ModifyCollectionType.CannotDeserialize)); } diff --git a/src/IIIFPresentation/API/Features/Storage/Requests/CollectionWriteRequest.cs b/src/IIIFPresentation/API/Features/Storage/Requests/CollectionWriteRequest.cs new file mode 100644 index 00000000..a5f226a9 --- /dev/null +++ b/src/IIIFPresentation/API/Features/Storage/Requests/CollectionWriteRequest.cs @@ -0,0 +1,519 @@ +using System.Data; +using System.Diagnostics; +using API.Converters; +using API.Features.Common.Helpers; +using API.Features.Storage.Helpers; +using API.Features.Storage.Models; +using API.Features.Storage.Validators; +using API.Helpers; +using API.Infrastructure.Helpers; +using API.Infrastructure.Http; +using API.Infrastructure.IdGenerator; +using API.Infrastructure.Requests; +using API.Settings; +using AWS.Helpers; +using Core; +using Core.Auth; +using Core.Exceptions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Models; +using Models.API.Collection; +using Models.API.General; +using Models.Database.Collections; +using Models.Database.General; +using Repository; +using Repository.Helpers; +using Repository.Paths; +using DBCollection = Models.Database.Collections.Collection; +using Result = + API.Infrastructure.Requests.ModifyEntityResult; + +namespace API.Features.Storage.Requests; + +/// +/// Common encapsulation of data available in a controller action, including the caller's intent +/// +/// Caller's customer id - assumed verified by authentication +/// HTTP method used by caller, which implies intent (create at parent vs. upsert resource at location) +/// Entire path AFTER /{customerId} - might be flat, providing resource type and id, might be hierarchical showing parent and possibly the intended/desired slug +/// Raw string data - will be parsed, validated etc. +/// Could be deduced from , but this value is known by controller, so to simplify code it is provided explicitly +/// Whether the request included the "show extra properties" header +///// Controller which created and sent this request. Used to create a response. +public class CollectionWriteRequest( + int customerId, + HttpMethod requestMethod, + string requestPath, + string rawRequestBody, + bool isHierarchical, + bool isShowExtras, + string? eTag +) : IRequest +{ + public int CustomerId { get; set; } = customerId; + public HttpMethod RequestMethod { get; set; } = requestMethod; + public string RequestPath { get; set; } = requestPath; + public string RawRequestBody { get; set; } = rawRequestBody; + public bool IsHierarchical { get; set; } = isHierarchical; + public bool IsShowExtras { get; set; } = isShowExtras; + public string? ETag { get; set; } = eTag; +} + +public class CollectionWriteService( + PresentationContext dbContext, + ILogger logger, + IIIIFS3Service iiifS3, + IPathGenerator pathGenerator, + IParentSlugParser parentSlugParser, + IdentityManager identityManager, + RootCollectionValidator rootValidator, + PresentationValidator presentationValidator, + IOptions options) +: IRequestHandler + +{ + private readonly ApiSettings settings = options.Value; + + private const int DefaultCurrentPage = 1; + + public async Task Handle( + CollectionWriteRequest request, CancellationToken cancellationToken = default) + { + // This method should encapsulate all the rules, checks and actions in all scenarios where + // a Collection (storage or not) is created or updated. + + // 1. Non-hierarchical requests must include the extra prop header + // source: pre-existing CollectionController logic + if (request is { IsHierarchical: false, IsShowExtras: false }) + return Result.Failure($"This request requires '{CustomHttpHeaders.ShowExtras}' header", + ModifyCollectionType.ExtraHeaderRequired, WriteResult.Forbidden); + + // 2. Deserialize raw body as PresentationCollection + var deserializedCollection = + await request.RawRequestBody.TryDeserializePresentation(logger); + if (deserializedCollection.Error) + return Result.Failure("Could not deserialize collection.", ModifyCollectionType.CannotDeserialize, + WriteResult.BadRequest); + + var collection = deserializedCollection.ConvertedIIIF; + Debug.Assert(collection != null, "collection != null"); // by validation above + + // 3. Find out if we have externally supplied `id`. + // This can happen for flat PUT requests, and will be the last path segment + string? suppliedId = null; + if (!request.IsHierarchical && request.RequestMethod == HttpMethod.Put) + { + suppliedId = request.RequestPath.Split('/').Last(); + } + + // 4. Execute validator + // source: pre-existing CollectionController logic + // Note: only doing root validation, as the `PresentationValidator` is too narrow + // instead of that the various validations are done in this method + + if (suppliedId != null && KnownCollections.IsRoot(suppliedId)) + { + var validation = await rootValidator.ValidateAsync(deserializedCollection.ConvertedIIIF, cancellationToken); + if (!validation.IsValid) + { + var message = string.Join(". ", validation.Errors.Select(s => s.ErrorMessage).Distinct()); + return Result.Failure(message, ModifyCollectionType.ValidationFailed, WriteResult.FailedValidation); + } + } + + // 5. If not storage collection (i.e. "IIIF Collection"), ensure it's convertible + // source: pre-existing logic in UpsertCollection/CreateCollection requests + TryConvertIIIFResult? iiifCollection = null; + + var isStorageCollection = collection.Behavior.IsStorageCollection(); + if (!isStorageCollection) + { + iiifCollection = request.RawRequestBody.ConvertCollectionToIIIF(logger); + if (iiifCollection.Error) return ErrorHelper.CannotValidateIIIF(); + } + + // 6. If required, load a possibly-already-existing DB record of the collection + // This is valid for PUT requests only + // source: pre-existing logic in UpsertCollection + DBCollection? databaseCollection = null; + if (request.RequestMethod == HttpMethod.Put) + { + // 6.1 Ensure we have id of the collection. For flat PUTs this has been already loaded above (from path) + var retrievalId = suppliedId; + + // For hierarchical calls we have to first inspect the hierarchy using the full hierarchical path + if (request.IsHierarchical) + { + // RequestPath here is e.g. /grandparentStorage/parentStorage/collectionBeingUpserted + var checkHierarchy = + await dbContext.RetrieveHierarchy(request.CustomerId, request.RequestPath, cancellationToken); + retrievalId = + checkHierarchy + ?.CollectionId; // if hierarchy returned record AND it's a collection, then CollectionId is not null + } + + if (retrievalId != null) + { + databaseCollection = + await dbContext.RetrieveCollectionWithParentAsync(request.CustomerId, retrievalId, true, + cancellationToken); + } + } + + // 7. Determine the operation (create/update) + // source: pre-existing logic in UpsertCollection + var result = databaseCollection == null ? WriteResult.Created : WriteResult.Updated; + + // 8. ETag check + // For creation, ETag is forbidden. + // For update, ETag is mandatory AND it MUST match + if (databaseCollection == null) + { + if (!string.IsNullOrEmpty(request.ETag)) + return ErrorHelper.EtagNotRequired(); + } + else + { + if (!EtagComparer.IsMatch(databaseCollection.Etag, request.ETag)) + return ErrorHelper.EtagNonMatching(); + } + + // 9. Determine the collection id + // It can be the one we already have (in the retrieved db record) or we need to mint new one. + // source: pre-existing logic in CreateCollection + string collectionId; + + try + { + // Assumption: for a PUT into existing resource, db collection id is not null. + // If it's a flat PUT to a desired "location", we have a "desired id". + // All other cases are creation without id and call for a new id to be minted + collectionId = databaseCollection?.Id ?? + suppliedId ?? + await identityManager.GenerateUniqueId(request.CustomerId, cancellationToken); + } + catch (ConstraintException ex) + { + logger.LogError(ex, "An exception occured while generating a unique id"); + return ErrorHelper.CannotGenerateUniqueId(); + } + + // 10. Check for collection type conversion + // If this is an update, we want to inform the user that conversion of collection type is not allowed + // source: pre-existing logic in UpsertCollection + if (databaseCollection != null && isStorageCollection != databaseCollection.IsStorageCollection) + { + logger.LogError( + "Customer {CustomerId} attempted to convert collection {CollectionId} to {CollectionType}", + request.CustomerId, databaseCollection.Id, isStorageCollection ? "storage" : "iiif"); + return ErrorHelper.CannotChangeCollectionType(isStorageCollection); + } + + // 11. Determine and validate the parent collection and the created/updated resource slug + // This section is quite "varied", as this information can be passed in many different ways + // based on both the URL, method and body JSON + DBCollection? parent = null; + string? resourceSlug; + + // Special case: updating root collection + var isRootUpdate = + request.RequestMethod == HttpMethod.Put // root is created automatically so it can only be updated + && databaseCollection != null // it's guaranteed to already exist, so this will be true + && KnownCollections.IsRoot(databaseCollection.Id); + + // For root update we don't need parent, and we have slug already + if (isRootUpdate) + { + resourceSlug = databaseCollection!.Hierarchy!.Single().Slug; + } + else + { + // 11.1. Hierarchical + if (request.IsHierarchical) + { + // Info/assumptions: + // a. Parent provided hierarchical (grandparent/parent/desired-slug) [PUT] + // b. Parent provided hierarchical (grandparent/parent) [POST] + // c. Slug can be provided in body as id (grandparent/parent/desired-slug) + // d. Can be Presentation Collection (with slug and or parent in body) + // e. Body-supplied parent can be hierarchical OR flat + // f. Primarily the parent AND slug are taken from the PUT url + // g. Body id can contain desired hierarchical path, just like url + // h. If mismatch between body and url: bad request + + string parentSlug; + + // In hierarchical call, RequestPath is slug (a) [PUT] + if (request.RequestMethod == HttpMethod.Put) + { + // PUT + var splitSlug = request.RequestPath.Split('/'); + var resourceSlugFromUrl = splitSlug[^1]; + + // 11.1.1. Check: slug mismatch + if (collection.Slug is { Length: > 0 } bodySlug + && !string.Equals(resourceSlugFromUrl, bodySlug)) + { + return ErrorHelper.MismatchedId(); + } + + // if it wasn't set, do it here as "normalization" - this simplifies code later + collection.Slug ??= resourceSlugFromUrl; + parentSlug = string.Join("/", splitSlug.Take(..^1)); + } + else + { + // POST + parentSlug = request.RequestPath; + + string? resourceSlugFromId = null; + if (collection.Id is { Length: > 0 }) + { + // First, let's set the `PublicId` to the id + // this will let us use the ParentSlugParser, and we'll be minting an id anyway + collection.PublicId = collection.Id; + + var parentSlugResult = + await parentSlugParser.Parse(collection, request.CustomerId, null, cancellationToken); + + if (parentSlugResult.IsError) + return parentSlugResult.Errors; + + parent = parentSlugResult.ParsedParentSlug.Parent; + resourceSlugFromId = parentSlugResult.ParsedParentSlug.Slug; + + collection.Slug ??= resourceSlugFromId; + } + + // Check: if slug provided in `id` AND in `slug`, they must match: + if (resourceSlugFromId != null) + { + // as we did ??=, this will fail only if there is indeed mismatch + if (!string.Equals(collection.Slug, resourceSlugFromId)) + return ErrorHelper.MismatchedId(); + } + } + + var parentCollection = + await dbContext.RetrieveHierarchy(request.CustomerId, parentSlug, cancellationToken); + + var parentValidationError = + ParentValidator.ValidateParentCollection(parentCollection?.Collection); + + if (parentValidationError != null) + return parentValidationError; + + // by the above validation + Debug.Assert(parentCollection != null, $"{nameof(parentCollection)} != null"); + + // 11.1.2. Check: parent mismatch + // -with `parent` variable? [this is clanky, but it's even worse otherwise] + if (parent != null) + { + if (!string.Equals(parent.Id, parentCollection.Collection?.Id)) + return ErrorHelper.MismatchedParent(); + } + + // -with `parent` property? + if (collection.Parent is { Length: > 0 }) + { + var parseResult = + await parentSlugParser.Parse(collection, request.CustomerId, null, cancellationToken); + + if (parseResult.IsError) + return parseResult.Errors; + + if (parseResult.ParsedParentSlug?.Parent is { } parsedParent + && !string.Equals(parsedParent.Id, parentCollection.Collection?.Id)) + { + return ErrorHelper.MismatchedParent(); + } + } + + // 11.1.3: Set variables for use below + parent = parentCollection.Collection; + resourceSlug = collection.Slug; + } + else + { + // 11.2. Flat + var parsedParentSlugResult = await parentSlugParser.Parse(collection, request.CustomerId, + collectionId, cancellationToken); + if (parsedParentSlugResult.IsError) + return parsedParentSlugResult.Errors; + + // By above error check + Debug.Assert(parsedParentSlugResult.ParsedParentSlug.Parent != null, + "parsedParentSlugResult.ParsedParentSlug.Parent != null"); + + parent = parsedParentSlugResult.ParsedParentSlug.Parent; + resourceSlug = parsedParentSlugResult.ParsedParentSlug.Slug; + } + } + + // By above + if(!isRootUpdate) + { + Debug.Assert(parent != null, $"{nameof(parent)} != null"); + } + Debug.Assert(resourceSlug != null, $"{nameof(resourceSlug)} != null"); + + // finally check the slug against prohibited list + // source: from PresentationValidator + if(SpecConstants.ProhibitedSlugs.Contains(resourceSlug)) + return ErrorHelper.ProhibitedSlug(resourceSlug); + + + // 12. Create/update the db collection object + // source: pre-existing logic in UpsertCollection + if (databaseCollection == null) + { + var createdDate = DateTime.UtcNow; + + databaseCollection = new DBCollection + { + Id = collectionId, + Created = createdDate, + CreatedBy = Authorizer.GetUser(), + CustomerId = request.CustomerId, + Hierarchy = + [ + new Hierarchy + { + Type = isStorageCollection + ? ResourceType.StorageCollection + : ResourceType.IIIFCollection, + Slug = resourceSlug, + Canonical = true, + ItemsOrder = collection.ItemsOrder, + Parent = parent.Id + } + ] + }; + + SetCommonProperties(databaseCollection, collection, createdDate); + + await dbContext.AddAsync(databaseCollection, cancellationToken); + } + else + { + var existingHierarchy = databaseCollection.Hierarchy!.Single(c => c.Canonical); + + databaseCollection.Modified = DateTime.UtcNow; + databaseCollection.ModifiedBy = Authorizer.GetUser(); + SetCommonProperties(databaseCollection, collection); + + // 'root' collection hierarchy can't change + if (!databaseCollection.IsRoot()) + { + existingHierarchy.Parent = parent.Id; + existingHierarchy.ItemsOrder = collection.ItemsOrder; + existingHierarchy.Slug = resourceSlug; + existingHierarchy.Type = + isStorageCollection ? ResourceType.StorageCollection : ResourceType.IIIFCollection; + } + } + + // Above should ensure: + Debug.Assert(databaseCollection != null, $"{nameof(databaseCollection)} != null"); + + // 13. Save changes + // source: pre-existing logic in UpsertCollection + await using var transaction = + await dbContext.Database.BeginTransactionAsync(cancellationToken); + + var saveErrors = + await dbContext.TrySaveCollection(request.CustomerId, logger, + cancellationToken); + + if (saveErrors != null) + { + return saveErrors; + } + + var hierarchy = databaseCollection.Hierarchy!.Single(); + if (hierarchy.Parent != null) + { + try + { + hierarchy.FullPath = + await CollectionRetrieval.RetrieveFullPathForCollection(databaseCollection, dbContext, + cancellationToken); + } + catch (PresentationException) + { + return Result.Failure( + "New slug exceeds 1000 records. This could mean an item no longer belongs to the root collection.", + ModifyCollectionType.PossibleCircularReference, WriteResult.BadRequest); + } + } + + await transaction.CommitAsync(cancellationToken); + + var items = dbContext + .RetrieveCollectionItems(request.CustomerId, databaseCollection.Id) + .Take(settings.PageSize); + + var total = await dbContext.GetTotalItemCountForCollection(databaseCollection, items.Count(), + settings.PageSize, 1, cancellationToken); + + foreach (var item in items) + { + // We know the fullPath of parent collection so we can use that as the base for child items + item.FullPath = pathGenerator.GenerateFullPath(item, hierarchy); + } + + await UploadToS3IfRequiredAsync(databaseCollection, iiifCollection?.ConvertedIIIF, isStorageCollection, + cancellationToken); + + + // If we want just plain IIIF output, we'll clean (i.e. rewrite the standard props) and return as-is. + if (!request.IsShowExtras) + { + // Note: skipping "IsHierarchical" check because it has to be + collection.Id = pathGenerator.GenerateHierarchicalId(hierarchy); + return Result.Success(PresentationIIIFCleaner.OnlyIIIFProperties(collection), WriteResult.Created, databaseCollection.Etag); + } + + var enrichedPresentationCollection = collection.EnrichPresentationCollection(databaseCollection, + settings.PageSize, DefaultCurrentPage, total, await items.ToListAsync(cancellationToken: cancellationToken), + parent, pathGenerator); + + if (request.IsHierarchical) + { + enrichedPresentationCollection.Id = pathGenerator.GenerateHierarchicalId(hierarchy); + } + + return Result.Success(enrichedPresentationCollection, result, etag: databaseCollection.Etag); + + } + + /// + /// Set properties that are common to both insert and update operations + /// + private static void SetCommonProperties( + DBCollection databaseCollection, + PresentationCollection incomingCollection, + DateTime? specificModifiedDate = null) + { + databaseCollection.Modified = specificModifiedDate ?? DateTime.UtcNow; + databaseCollection.IsPublic = incomingCollection.Behavior.IsPublic(); + databaseCollection.IsStorageCollection = incomingCollection.Behavior.IsStorageCollection(); + databaseCollection.Label = incomingCollection.Label; + databaseCollection.Thumbnail = incomingCollection.GetThumbnail(); + databaseCollection.Tags = incomingCollection.Tags; + } + + private async Task UploadToS3IfRequiredAsync(Collection collection, IIIF.Presentation.V3.Collection? iiifCollection, + bool isStorageCollection, CancellationToken cancellationToken = default) + { + if (!isStorageCollection) + { + await iiifS3.SaveIIIFToS3(iiifCollection!, collection, pathGenerator.GenerateFlatCollectionId(collection), + false, cancellationToken); + } + } +} diff --git a/src/IIIFPresentation/API/Features/Storage/Requests/CreateCollection.cs b/src/IIIFPresentation/API/Features/Storage/Requests/CreateCollection.cs deleted file mode 100644 index f5a6ea96..00000000 --- a/src/IIIFPresentation/API/Features/Storage/Requests/CreateCollection.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Data; -using API.Converters; -using API.Features.Storage.Helpers; -using API.Features.Storage.Models; -using API.Helpers; -using API.Infrastructure.IdGenerator; -using API.Infrastructure.Requests; -using API.Settings; -using AWS.Helpers; -using Core; -using Core.Auth; -using MediatR; -using Microsoft.Extensions.Options; -using Models.API.Collection; -using Models.API.General; -using Models.Database.General; -using Repository; -using Repository.Helpers; -using Repository.Paths; -using Collection = Models.Database.Collections.Collection; - -namespace API.Features.Storage.Requests; - -/// -/// Create a new Collection (storage or iiif) in DB and upload provided JSON to S3 if iiif-collection -/// -public class CreateCollection(int customerId, PresentationCollection collection, string rawRequestBody) - : IRequest> -{ - public int CustomerId { get; } = customerId; - - public PresentationCollection Collection { get; } = collection; - - public string RawRequestBody { get; } = rawRequestBody; -} - -public class CreateCollectionHandler( - PresentationContext dbContext, - ILogger logger, - IIIIFS3Service iiifS3, - IdentityManager identityManager, - IPathGenerator pathGenerator, - IParentSlugParser parentSlugParser, - IOptions options) - : IRequestHandler> -{ - private readonly ApiSettings settings = options.Value; - - private const int CurrentPage = 1; - - public async Task> Handle(CreateCollection request, CancellationToken cancellationToken) - { - var isStorageCollection = request.Collection.Behavior.IsStorageCollection(); - TryConvertIIIFResult? iiifCollection = null; - if (!isStorageCollection) - { - iiifCollection = request.RawRequestBody.ConvertCollectionToIIIF(logger); - if (iiifCollection.Error) return ErrorHelper.CannotValidateIIIF(); - } - - var parsedParentSlugResult = - await parentSlugParser.Parse(request.Collection, request.CustomerId, null, cancellationToken); - if (parsedParentSlugResult.IsError) return parsedParentSlugResult.Errors; - var parsedParentSlug = parsedParentSlugResult.ParsedParentSlug; - - string id; - - try - { - id = await identityManager.GenerateUniqueId(request.CustomerId, cancellationToken); - } - catch (ConstraintException ex) - { - logger.LogError(ex, "An exception occured while generating a unique id"); - return ErrorHelper.CannotGenerateUniqueId(); - } - - var dateCreated = DateTime.UtcNow; - var collection = new Collection - { - Id = id, - Created = dateCreated, - Modified = dateCreated, - CreatedBy = Authorizer.GetUser(), - CustomerId = request.CustomerId, - Tags = request.Collection.Tags, - IsPublic = request.Collection.Behavior.IsPublic(), - IsStorageCollection = isStorageCollection, - Label = request.Collection.Label, - Thumbnail = request.Collection.GetThumbnail(), - Hierarchy = - [ - new Hierarchy - { - Type = isStorageCollection - ? ResourceType.StorageCollection - : ResourceType.IIIFCollection, - Slug = parsedParentSlug!.Slug!, - Canonical = true, - ItemsOrder = request.Collection.ItemsOrder, - Parent = parsedParentSlug.Parent!.Id - } - ] - }; - - dbContext.Collections.Add(collection); - - var saveErrors = - await dbContext.TrySaveCollection(request.CustomerId, logger, - cancellationToken); - - if (saveErrors != null) - { - return saveErrors; - } - - await UploadToS3IfRequiredAsync(collection, iiifCollection?.ConvertedIIIF, isStorageCollection, - cancellationToken); - - collection.Hierarchy.GetCanonical().FullPath = - await CollectionRetrieval.RetrieveFullPathForCollection(collection, dbContext, cancellationToken); - - var enrichedPresentationCollection = request.Collection.EnrichPresentationCollection(collection, - settings.PageSize, CurrentPage, 0, [], parsedParentSlug.Parent, pathGenerator); // there can be no items attached to this, as it's just been created - - return ModifyEntityResult.Success( - enrichedPresentationCollection, - WriteResult.Created, - collection.Etag); - } - - private async Task UploadToS3IfRequiredAsync(Collection collection, IIIF.Presentation.V3.Collection? iiifCollection, - bool isStorageCollection, CancellationToken cancellationToken = default) - { - if (!isStorageCollection) - { - await iiifS3.SaveIIIFToS3(iiifCollection!, collection, pathGenerator.GenerateFlatCollectionId(collection), - false, cancellationToken); - } - } -} diff --git a/src/IIIFPresentation/API/Features/Storage/Requests/PostHierarchicalCollection.cs b/src/IIIFPresentation/API/Features/Storage/Requests/PostHierarchicalCollection.cs deleted file mode 100644 index ab29e135..00000000 --- a/src/IIIFPresentation/API/Features/Storage/Requests/PostHierarchicalCollection.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System.Data; -using API.Features.Common.Helpers; -using API.Features.Storage.Helpers; -using API.Infrastructure.IdGenerator; -using API.Infrastructure.Requests; -using AWS.Helpers; -using Core; -using Core.Auth; -using Core.IIIF; -using IIIF.Presentation.V3; -using IIIF.Presentation.V3.Content; -using MediatR; -using Models.API.General; -using Models.Database.General; -using Repository; -using Repository.Helpers; -using Repository.Paths; -using DatabaseCollection = Models.Database.Collections; - -namespace API.Features.Storage.Requests; - -public class PostHierarchicalCollection( - int customerId, - string slug, - string rawRequestBody) : IRequest> -{ - public int CustomerId { get; } = customerId; - - public string Slug { get; } = slug; - - public string RawRequestBody { get; } = rawRequestBody; -} - -public class PostHierarchicalCollectionHandler( - PresentationContext dbContext, - ILogger logger, - IdentityManager identityManager, - IIIIFS3Service iiifS3, - IPathGenerator pathGenerator) - : IRequestHandler> -{ - - public async Task> Handle(PostHierarchicalCollection request, - CancellationToken cancellationToken) - { - var convertResult = request.RawRequestBody.ConvertCollectionToIIIF(logger); - if (convertResult.Error) return ErrorHelper.CannotValidateIIIF(); - var collectionFromBody = convertResult.ConvertedIIIF!; - - var splitSlug = request.Slug.Split('/'); - - var parentSlug = string.Join("/", splitSlug.Take(..^1)); - var parentCollection = - await dbContext.RetrieveHierarchy(request.CustomerId, parentSlug, cancellationToken); - - var parentValidationError = - ParentValidator.ValidateParentCollection(parentCollection?.Collection); - if (parentValidationError != null) return parentValidationError; - - var id = await GenerateUniqueId(request, cancellationToken); - if (id == null) return ErrorHelper.CannotGenerateUniqueId(); - - var collection = CreateDatabaseCollection(request, collectionFromBody, id, parentCollection, splitSlug); - dbContext.Collections.Add(collection); - - var saveErrors = - await dbContext.TrySaveCollection(request.CustomerId, logger, - cancellationToken); - - if (saveErrors != null) - { - return saveErrors; - } - - await iiifS3.SaveIIIFToS3(collectionFromBody, collection, pathGenerator.GenerateFlatCollectionId(collection), - false, cancellationToken); - - var hierarchy = collection.Hierarchy.GetCanonical(); - - if (hierarchy.Parent != null) - { - hierarchy.FullPath = - await CollectionRetrieval.RetrieveFullPathForCollection(collection, dbContext, cancellationToken); - } - - collectionFromBody.Id = pathGenerator.GenerateHierarchicalId(hierarchy); - return ModifyEntityResult.Success(collectionFromBody, WriteResult.Created, collection.Etag); - } - - private static DatabaseCollection.Collection CreateDatabaseCollection(PostHierarchicalCollection request, Collection collectionFromBody, string id, - Hierarchy parentHierarchy, string[] splitSlug) - { - var thumbnails = collectionFromBody.Thumbnail?.OfType().ToList(); - - var dateCreated = DateTime.UtcNow; - var collection = new DatabaseCollection.Collection - { - Id = id, - Created = dateCreated, - Modified = dateCreated, - CreatedBy = Authorizer.GetUser(), - CustomerId = request.CustomerId, - IsPublic = collectionFromBody.Behavior != null && collectionFromBody.Behavior.IsPublic(), - IsStorageCollection = false, - Label = collectionFromBody.Label, - Thumbnail = thumbnails?.GetThumbnailPath(), - Hierarchy = [ - new Hierarchy - { - CollectionId = id, - Type = ResourceType.IIIFCollection, - Slug = splitSlug.Last(), - CustomerId = request.CustomerId, - Canonical = true, - ItemsOrder = 0, - Parent = parentHierarchy.CollectionId - } - ] - }; - - return collection; - } - - private async Task GenerateUniqueId(PostHierarchicalCollection request, CancellationToken cancellationToken) - { - try - { - return await identityManager.GenerateUniqueId(request.CustomerId, cancellationToken); - } - catch (ConstraintException ex) - { - logger.LogError(ex, "An exception occured while generating a unique id"); - return null; - } - } -} diff --git a/src/IIIFPresentation/API/Features/Storage/Requests/UpsertCollection.cs b/src/IIIFPresentation/API/Features/Storage/Requests/UpsertCollection.cs deleted file mode 100644 index 0afc8621..00000000 --- a/src/IIIFPresentation/API/Features/Storage/Requests/UpsertCollection.cs +++ /dev/null @@ -1,216 +0,0 @@ -using API.Converters; -using API.Features.Common.Helpers; -using API.Features.Storage.Helpers; -using API.Features.Storage.Models; -using API.Helpers; -using API.Infrastructure.Helpers; -using API.Infrastructure.Requests; -using API.Settings; -using AWS.Helpers; -using Core; -using Core.Auth; -using Core.Exceptions; -using Core.Helpers; -using MediatR; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using Models.API.Collection; -using Models.API.General; -using Models.Database.Collections; -using Models.Database.General; -using Repository; -using Repository.Helpers; -using Repository.Paths; - -namespace API.Features.Storage.Requests; - -public class UpsertCollection(int customerId, string collectionId, PresentationCollection collection, string? eTag, - string rawRequestBody) - : IRequest> -{ - public int CustomerId { get; } = customerId; - - public string CollectionId { get; } = collectionId; - - public PresentationCollection Collection { get; } = collection; - - public string? ETag { get; } = eTag; - - public string RawRequestBody { get; } = rawRequestBody; -} - -public class UpsertCollectionHandler( - PresentationContext dbContext, - ILogger logger, - IIIIFS3Service iiifS3, - IPathGenerator pathGenerator, - IParentSlugParser parentSlugParser, - IOptions options) - : IRequestHandler> -{ - private readonly ApiSettings settings = options.Value; - - private const int DefaultCurrentPage = 1; - - public async Task> Handle(UpsertCollection request, - CancellationToken cancellationToken) - { - var isStorageCollection = request.Collection.Behavior.IsStorageCollection(); - TryConvertIIIFResult? iiifCollection = null; - if (!isStorageCollection) - { - iiifCollection = request.RawRequestBody.ConvertCollectionToIIIF(logger); - if (iiifCollection.Error) return ErrorHelper.CannotValidateIIIF(); - } - var databaseCollection = - await dbContext.RetrieveCollectionWithParentAsync(request.CustomerId, request.CollectionId, true, cancellationToken); - - var parsedParentSlugResult = await parentSlugParser.Parse(request.Collection, request.CustomerId, - request.CollectionId, cancellationToken); - if (parsedParentSlugResult.IsError) return parsedParentSlugResult.Errors; - var parsedParentSlug = parsedParentSlugResult.ParsedParentSlug; - - var parentCollection = parsedParentSlug.Parent; - - if (databaseCollection == null) - { - // No existing collection = create - if (!string.IsNullOrEmpty(request.ETag)) return ErrorHelper.EtagNotRequired(); - - var createdDate = DateTime.UtcNow; - - databaseCollection = new Collection - { - Id = request.CollectionId, - Created = createdDate, - CreatedBy = Authorizer.GetUser(), - CustomerId = request.CustomerId, - Hierarchy = - [ - new Hierarchy - { - Type = isStorageCollection - ? ResourceType.StorageCollection - : ResourceType.IIIFCollection, - Slug = parsedParentSlug.Slug, - Canonical = true, - ItemsOrder = request.Collection.ItemsOrder, - Parent = parsedParentSlug.Parent.Id - } - ] - }; - - SetCommonProperties(databaseCollection, request.Collection, createdDate); - - await dbContext.AddAsync(databaseCollection, cancellationToken); - } - else - { - if (!EtagComparer.IsMatch(databaseCollection.Etag, request.ETag)) - return ErrorHelper.EtagNonMatching(); - - if (isStorageCollection != databaseCollection.IsStorageCollection) - { - logger.LogError( - "Customer {CustomerId} attempted to convert collection {CollectionId} to {CollectionType}", - request.CustomerId, request.CollectionId, isStorageCollection ? "storage" : "iiif"); - return ErrorHelper.CannotChangeCollectionType(isStorageCollection); - } - - var existingHierarchy = databaseCollection.Hierarchy!.Single(c => c.Canonical); - - databaseCollection.Modified = DateTime.UtcNow; - databaseCollection.ModifiedBy = Authorizer.GetUser(); - SetCommonProperties(databaseCollection, request.Collection); - - // 'root' collection hierarchy can't change - if (!databaseCollection.IsRoot()) - { - existingHierarchy.Parent = parsedParentSlug.Parent.Id; - existingHierarchy.ItemsOrder = request.Collection.ItemsOrder; - existingHierarchy.Slug = request.Collection.Slug ?? string.Empty; - existingHierarchy.Type = - isStorageCollection ? ResourceType.StorageCollection : ResourceType.IIIFCollection; - } - } - - await using var transaction = - await dbContext.Database.BeginTransactionAsync(cancellationToken); - - var saveErrors = - await dbContext.TrySaveCollection(request.CustomerId, logger, - cancellationToken); - - if (saveErrors != null) - { - return saveErrors; - } - - var hierarchy = databaseCollection.Hierarchy.Single(); - if (hierarchy.Parent != null) - { - try - { - hierarchy.FullPath = - await CollectionRetrieval.RetrieveFullPathForCollection(databaseCollection, dbContext, - cancellationToken); - } - catch (PresentationException) - { - return ModifyEntityResult.Failure( - "New slug exceeds 1000 records. This could mean an item no longer belongs to the root collection.", - ModifyCollectionType.PossibleCircularReference, WriteResult.BadRequest); - } - } - - await transaction.CommitAsync(cancellationToken); - - var items = dbContext - .RetrieveCollectionItems(request.CustomerId, databaseCollection.Id) - .Take(settings.PageSize); - - var total = await dbContext.GetTotalItemCountForCollection(databaseCollection, items.Count(), - settings.PageSize, 1, cancellationToken); - - foreach (var item in items) - { - // We know the fullPath of parent collection so we can use that as the base for child items - item.FullPath = pathGenerator.GenerateFullPath(item, hierarchy); - } - - await UploadToS3IfRequiredAsync(databaseCollection, iiifCollection?.ConvertedIIIF, isStorageCollection, - cancellationToken); - - var enrichedPresentationCollection = request.Collection.EnrichPresentationCollection(databaseCollection, - settings.PageSize, DefaultCurrentPage, total, await items.ToListAsync(cancellationToken: cancellationToken), - parentCollection, pathGenerator); - - return ModifyEntityResult.Success(enrichedPresentationCollection, etag: databaseCollection.Etag); - } - - /// - /// Set properties that are common to both insert and update operations - /// - private static void SetCommonProperties( - Collection databaseCollection, - PresentationCollection incomingCollection, - DateTime? specificModifiedDate = null) - { - databaseCollection.Modified = specificModifiedDate ?? DateTime.UtcNow; - databaseCollection.IsPublic = incomingCollection.Behavior.IsPublic(); - databaseCollection.IsStorageCollection = incomingCollection.Behavior.IsStorageCollection(); - databaseCollection.Label = incomingCollection.Label; - databaseCollection.Thumbnail = incomingCollection.GetThumbnail(); - databaseCollection.Tags = incomingCollection.Tags; - } - - private async Task UploadToS3IfRequiredAsync(Collection collection, IIIF.Presentation.V3.Collection? iiifCollection, - bool isStorageCollection, CancellationToken cancellationToken = default) - { - if (!isStorageCollection) - { - await iiifS3.SaveIIIFToS3(iiifCollection!, collection, pathGenerator.GenerateFlatCollectionId(collection), - false, cancellationToken); - } - } -} diff --git a/src/IIIFPresentation/API/Features/Storage/StorageController.cs b/src/IIIFPresentation/API/Features/Storage/StorageController.cs index f4f258f4..3cc2eeb2 100644 --- a/src/IIIFPresentation/API/Features/Storage/StorageController.cs +++ b/src/IIIFPresentation/API/Features/Storage/StorageController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using API.Auth; using API.Converters; @@ -21,7 +22,7 @@ namespace API.Features.Storage; -[Route("/{customerId:int}")] +[Route("/{customerId:int}/{*slug}")] [ApiController] public class StorageController( IAuthenticator authenticator, @@ -33,7 +34,7 @@ public class StorageController( ILogger logger) : PresentationController(options.Value, mediator, eTagCache, logger) { - [HttpGet("{*slug}")] + [HttpGet("")] [VaryHeader] public async Task GetHierarchical(int customerId, string slug = "") { @@ -95,11 +96,71 @@ public async Task GetHierarchical(int customerId, string slug = " } [Authorize] - [HttpPost("{*slug}")] - public async Task PostHierarchicalCollection(int customerId, string slug) + [HttpPost("")] + public async Task PostHierarchical(int customerId, string? slug) { + slug ??= string.Empty; + // X-IIIF-CS-Show-Extras is not required here, the body should be vanilla json var rawRequestBody = await Request.GetRawRequestBodyAsync(); - return await HandleUpsert(new PostHierarchicalCollection(customerId, slug, rawRequestBody)); + + // This will load string value of `type` property on the top level of the JSON, if present + var type = FastJsonPropertyRead.FindAtLevel(rawRequestBody, "type"); + + return type switch + { + nameof(IIIF.Presentation.V3.Manifest) => await HandleUpsert( + new DispatchManifestRequest(customerId, HttpMethod.Post, slug, rawRequestBody, true, + Request.HasShowExtraHeader(), Request.HasCreateSpaceHeader(), Request.Headers.IfMatch)), + nameof(IIIF.Presentation.V3.Collection) => await HandleUpsert( + new CollectionWriteRequest(customerId, HttpMethod.Post, slug, rawRequestBody, + true, Request.HasShowExtraHeader(), Request.Headers.IfMatch)), + _ => this.PresentationProblem("Unsupported resource type", statusCode: 400) + }; + } + + [Authorize] + [HttpPut("")] + public async Task PutHierarchical(int customerId, string slug) + { + var rawRequestBody = await Request.GetRawRequestBodyAsync(); + + // This will load string value of `type` property on the top level of the JSON, if present + var type = FastJsonPropertyRead.FindAtLevel(rawRequestBody, "type"); + + return type switch + { + nameof(IIIF.Presentation.V3.Manifest) => await HandleUpsert( + new DispatchManifestRequest(customerId, HttpMethod.Put, slug, rawRequestBody, true, + Request.HasShowExtraHeader(), Request.HasCreateSpaceHeader(), Request.Headers.IfMatch)), + nameof(IIIF.Presentation.V3.Collection) => await HandleUpsert( + new CollectionWriteRequest(customerId, HttpMethod.Put, slug, rawRequestBody, + true, Request.HasShowExtraHeader(), Request.Headers.IfMatch)), + _ => this.PresentationProblem("Unsupported resource type", statusCode: 400) + }; + } + + [Authorize] + [HttpDelete("")] + public async Task DeleteHierarchical(int customerId, string slug) + { + var hierarchy = await dbContext.RetrieveHierarchy(customerId, slug); + if (hierarchy == null) return this.PresentationNotFound(); + + switch (hierarchy.Type) + { + case ResourceType.IIIFManifest: + Debug.Assert(hierarchy.ManifestId != null, "hierarchy.ManifestId != null"); + return await HandleDelete(new DeleteManifest(customerId, hierarchy.ManifestId)); + + case ResourceType.IIIFCollection: + case ResourceType.StorageCollection: + Debug.Assert(hierarchy.CollectionId != null, "hierarchy.CollectionId != null"); + return await HandleDelete(new DeleteCollection(customerId, hierarchy.CollectionId)); + + default: + return this.PresentationProblem("Cannot fulfill this resource type", null, + (int)HttpStatusCode.InternalServerError, "Cannot fulfill this resource type"); + } } } diff --git a/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs b/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs index 31a7899c..4ae324cb 100644 --- a/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs +++ b/src/IIIFPresentation/API/Infrastructure/ControllerBaseX.cs @@ -83,7 +83,10 @@ public static IActionResult ModifyResultToHttpResult(this ControllerBa $"{errorTitle}: Conflict", controller.GetErrorType(entityResult.ErrorType)), WriteResult.FailedValidation => controller.PresentationProblem(entityResult.Error, instance, (int)HttpStatusCode.BadRequest, - $"{errorTitle}: Validation failed"), + $"{errorTitle}: Validation failed", controller.GetErrorType(ModifyCollectionType.ValidationFailed)), + WriteResult.Forbidden => controller.PresentationProblem(entityResult.Error, instance, + (int)HttpStatusCode.Forbidden, + $"{errorTitle}: Forbidden"), WriteResult.StorageLimitExceeded => controller.PresentationProblem(entityResult.Error, instance, (int)HttpStatusCode.InsufficientStorage, $"{errorTitle}: Storage limit exceeded"), diff --git a/src/IIIFPresentation/Core/WriteResult.cs b/src/IIIFPresentation/Core/WriteResult.cs index c3f7dd85..25804aa5 100644 --- a/src/IIIFPresentation/Core/WriteResult.cs +++ b/src/IIIFPresentation/Core/WriteResult.cs @@ -58,5 +58,10 @@ public enum WriteResult /// /// Request failed a precondition /// - PreConditionFailed + PreConditionFailed, + + /// + /// Request tried to perform operation that is not allowed + /// + Forbidden } diff --git a/src/IIIFPresentation/Models/API/General/ModifyCollectionType.cs b/src/IIIFPresentation/Models/API/General/ModifyCollectionType.cs index fec86965..4e6cf816 100644 --- a/src/IIIFPresentation/Models/API/General/ModifyCollectionType.cs +++ b/src/IIIFPresentation/Models/API/General/ModifyCollectionType.cs @@ -26,5 +26,7 @@ public enum ModifyCollectionType PublicIdIncorrect = 22, PaintableAssetError = 23, AssetsDoNotMatch = 25, + MissingSlug = 26, + ExtraHeaderRequired = 27, Unknown = 1000 } diff --git a/src/IIIFPresentation/Repository/Helpers/CollectionRetrieval.cs b/src/IIIFPresentation/Repository/Helpers/CollectionRetrieval.cs index 0e2df508..c4f9e6d6 100644 --- a/src/IIIFPresentation/Repository/Helpers/CollectionRetrieval.cs +++ b/src/IIIFPresentation/Repository/Helpers/CollectionRetrieval.cs @@ -76,6 +76,10 @@ ORDER BY generation_number DESC public static async Task RetrieveHierarchy(this PresentationContext dbContext, int customerId, string slug, CancellationToken cancellationToken = default) { + // If the slug is/ends with `/` it will cause issues with the query + if (slug.EndsWith('/')) + slug = slug[..^1]; + // no need to include the batch as this is only hit for the root collection if (slug.Equals(string.Empty)) { diff --git a/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextX.cs b/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextX.cs index 593e1b03..b14618a2 100644 --- a/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextX.cs +++ b/src/IIIFPresentation/Test.Helpers/Integration/PresentationContextX.cs @@ -16,4 +16,16 @@ public static class PresentationContextX EntityEntry c => db.GetETag(c.Entity), _ => null }; + + public static Guid? GetETagById(this PresentationContext db, int customerId, string id) => + db.Hierarchy.AsNoTracking() + .Include(x => x.Collection) + .Include(x => x.Manifest) + .FirstOrDefault(h => (h.CollectionId == id || h.ManifestId == id) && h.CustomerId == customerId) + switch + { + { Manifest: { } m } => m.Etag, + { Collection: { } c } => c.Etag, + _ => null + }; }