diff --git a/src/protagonist/API.Tests/Features/Assets/ApiAssetRepositoryTests.cs b/src/protagonist/API.Tests/Features/Assets/ApiAssetRepositoryTests.cs index c8d88b487..3d4e88a16 100644 --- a/src/protagonist/API.Tests/Features/Assets/ApiAssetRepositoryTests.cs +++ b/src/protagonist/API.Tests/Features/Assets/ApiAssetRepositoryTests.cs @@ -9,6 +9,7 @@ using DLCS.Core.Caching; using DLCS.Core.Types; using DLCS.Model.Assets; +using DLCS.Model.Policies; using DLCS.Repository; using DLCS.Repository.Assets; using DLCS.Repository.Entities; @@ -49,15 +50,14 @@ public ApiAssetRepositoryTests(DlcsDatabaseFixture dbFixture) var entityCounterRepo = new EntityCounterRepository(dbContext); - var assetRepository = new AssetRepository( - dbContext, + var assetRepositoryCachingHelper = new AssetCachingHelper( new MockCachingService(), - entityCounterRepo, Options.Create(new CacheSettings()), - new NullLogger() + new NullLogger() ); - sut = new ApiAssetRepository(dbContext, assetRepository, entityCounterRepo); + sut = new ApiAssetRepository(dbContext, entityCounterRepo, assetRepositoryCachingHelper, + new NullLogger()); dbFixture.CleanUp(); } @@ -376,17 +376,17 @@ public async Task DeleteAsset_ReturnsImageDeliveryChannels_FromDeletedAsset() new() { Channel = AssetDeliveryChannels.Image, - DeliveryChannelPolicyId = 1 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ImageDefault }, new() { Channel = AssetDeliveryChannels.Thumbnails, - DeliveryChannelPolicyId = 3 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ThumbsDefault }, new() { Channel = AssetDeliveryChannels.File, - DeliveryChannelPolicyId = 4 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.FileNone } }); await contextForTests.SaveChangesAsync(); diff --git a/src/protagonist/API.Tests/Features/DeliveryChannels/DefaultDeliveryChannelRepositoryTests.cs b/src/protagonist/API.Tests/Features/DeliveryChannels/DefaultDeliveryChannelRepositoryTests.cs index 69dddecef..4f3903d51 100644 --- a/src/protagonist/API.Tests/Features/DeliveryChannels/DefaultDeliveryChannelRepositoryTests.cs +++ b/src/protagonist/API.Tests/Features/DeliveryChannels/DefaultDeliveryChannelRepositoryTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using API.Features.DeliveryChannels; +using API.Features.DeliveryChannels.DataAccess; using API.Tests.Integration.Infrastructure; using DLCS.Core.Caching; using DLCS.Model.DeliveryChannels; @@ -23,8 +24,7 @@ public class DefaultDeliveryChannelRepositoryTests public DefaultDeliveryChannelRepositoryTests(DlcsDatabaseFixture dbFixture) { dbContext = dbFixture.DbContext; - sut = new DefaultDeliveryChannelRepository(new MockCachingService(), new NullLogger(), - Options.Create(new CacheSettings()), dbFixture.DbContext); + sut = new DefaultDeliveryChannelRepository(new MockCachingService(), new NullLogger(), Options.Create(new CacheSettings()), dbFixture.DbContext); dbFixture.CleanUp(); @@ -52,7 +52,7 @@ public DefaultDeliveryChannelRepositoryTests(DlcsDatabaseFixture dbFixture) { Space = 0, Customer = 2, - DeliveryChannelPolicyId = 1, + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ImageDefault, MediaType = "image/*" }); @@ -60,10 +60,10 @@ public DefaultDeliveryChannelRepositoryTests(DlcsDatabaseFixture dbFixture) } [Fact] - public void MatchedDeliveryChannels_ReturnsAllDeliveryChannelPolicies_WhenCalled() + public async Task MatchedDeliveryChannels_ReturnsAllDeliveryChannelPolicies_WhenCalled() { // Arrange and Act - var matches = sut.MatchedDeliveryChannels("image/tiff", 1, 2); + var matches = await sut.MatchedDeliveryChannels("image/tiff", 1, 2); // Assert matches.Count.Should().Be(1); @@ -71,32 +71,32 @@ public void MatchedDeliveryChannels_ReturnsAllDeliveryChannelPolicies_WhenCalled } [Fact] - public void MatchedDeliveryChannels_ShouldNotMatchAnything_WhenCalledWithInvalidMediaType() + public async Task MatchedDeliveryChannels_ShouldNotMatchAnything_WhenCalledWithInvalidMediaType() { // Arrange and Act - var matches = sut.MatchedDeliveryChannels("notValid/tiff", 1, 2); + var matches = await sut.MatchedDeliveryChannels("notValid/tiff", 1, 2); // Assert matches.Count.Should().Be(0); } [Fact] - public void MatchDeliveryChannelPolicyForChannel_MatchesDeliveryChannel_WhenMatchAvailable() + public async Task MatchDeliveryChannelPolicyForChannel_MatchesDeliveryChannel_WhenMatchAvailable() { // Arrange and Act - var matches = sut.MatchDeliveryChannelPolicyForChannel("image/tiff", 1, 2, "iiif-img"); + var matches = await sut.MatchDeliveryChannelPolicyForChannel("image/tiff", 1, 2, "iiif-img"); // Assert matches.Should().NotBeNull(); } [Fact] - public void MatchDeliveryChannelPolicyForChannel_ThrowsException_WhenNotMatched() + public async Task MatchDeliveryChannelPolicyForChannel_ThrowsException_WhenNotMatched() { // Arrange and Act - Action action = () => sut.MatchDeliveryChannelPolicyForChannel("notMatched/tiff", 1, 2, "iiif-img"); + Func action = () => sut.MatchDeliveryChannelPolicyForChannel("notMatched/tiff", 1, 2, "iiif-img"); // Assert - action.Should().ThrowExactly(); + await action.Should().ThrowExactlyAsync(); } } \ No newline at end of file diff --git a/src/protagonist/API.Tests/Features/DeliveryChannels/DeliveryChannelPoliciesTests.cs b/src/protagonist/API.Tests/Features/DeliveryChannels/DeliveryChannelPolicyRepositoryTests.cs similarity index 67% rename from src/protagonist/API.Tests/Features/DeliveryChannels/DeliveryChannelPoliciesTests.cs rename to src/protagonist/API.Tests/Features/DeliveryChannels/DeliveryChannelPolicyRepositoryTests.cs index ba76d8f1c..c762984a0 100644 --- a/src/protagonist/API.Tests/Features/DeliveryChannels/DeliveryChannelPoliciesTests.cs +++ b/src/protagonist/API.Tests/Features/DeliveryChannels/DeliveryChannelPolicyRepositoryTests.cs @@ -1,5 +1,6 @@ using System; using API.Features.DeliveryChannels; +using API.Features.DeliveryChannels.DataAccess; using API.Tests.Integration.Infrastructure; using DLCS.Core.Caching; using DLCS.Model.Policies; @@ -13,15 +14,15 @@ namespace API.Tests.Features.DeliveryChannels; [Trait("Category", "Database")] [Collection(CollectionDefinitions.DatabaseCollection.CollectionName)] -public class DeliveryChannelPoliciesTests +public class DeliveryChannelPolicyRepositoryTests { private readonly DlcsContext dbContext; private readonly DeliveryChannelPolicyRepository sut; - public DeliveryChannelPoliciesTests(DlcsDatabaseFixture dbFixture) + public DeliveryChannelPolicyRepositoryTests(DlcsDatabaseFixture dbFixture) { dbContext = dbFixture.DbContext; - sut = new DeliveryChannelPolicyRepository(new MockCachingService() ,new NullLogger(), Options.Create(new CacheSettings()), dbFixture.DbContext); + sut = new DeliveryChannelPolicyRepository(new MockCachingService(), new NullLogger(), Options.Create(new CacheSettings()), dbFixture.DbContext); dbFixture.CleanUp(); @@ -44,10 +45,10 @@ public DeliveryChannelPoliciesTests(DlcsDatabaseFixture dbFixture) [InlineData("space-specific-image")] [InlineData("channel/space-specific-image")] [InlineData("https://dlcs.api/customers/2/deliveryChannelPolicies/iiif-img/space-specific-image")] - public void RetrieveDeliveryChannelPolicy_RetrievesACustomerSpecificPolicy(string policy) + public async Task RetrieveDeliveryChannelPolicy_RetrievesACustomerSpecificPolicy(string policy) { // Arrange and Act - var deliveryChannelPolicy = sut.RetrieveDeliveryChannelPolicy(2, "iiif-img", policy); + var deliveryChannelPolicy = await sut.RetrieveDeliveryChannelPolicy(2, "iiif-img", policy); // Assert deliveryChannelPolicy.Channel.Should().Be("iiif-img"); @@ -55,10 +56,10 @@ public void RetrieveDeliveryChannelPolicy_RetrievesACustomerSpecificPolicy(strin } [Fact] - public void RetrieveDeliveryChannelPolicy_RetrievesADefaultPolicy() + public async Task RetrieveDeliveryChannelPolicy_RetrievesADefaultPolicy() { // Arrange and Act - var policy = sut.RetrieveDeliveryChannelPolicy(2, "iiif-img", "default"); + var policy = await sut.RetrieveDeliveryChannelPolicy(2, "iiif-img", "default"); // Assert policy.Channel.Should().Be("iiif-img"); @@ -66,13 +67,13 @@ public void RetrieveDeliveryChannelPolicy_RetrievesADefaultPolicy() } [Fact] - public void RetrieveDeliveryChannelPolicy_RetrieveNonExistentPolicy() + public async Task RetrieveDeliveryChannelPolicy_RetrieveNonExistentPolicy() { // Arrange and Act - Action action = () => sut.RetrieveDeliveryChannelPolicy(2, "notAChannel", "notAPolicy"); + Func action = () => sut.RetrieveDeliveryChannelPolicy(2, "notAChannel", "notAPolicy"); // Assert - action.Should() - .Throw(); + await action.Should() + .ThrowAsync(); } } \ No newline at end of file diff --git a/src/protagonist/API.Tests/Features/Images/Ingest/AssetProcessorTest.cs b/src/protagonist/API.Tests/Features/Images/Ingest/AssetProcessorTest.cs index 67acef7c8..26e8e376a 100644 --- a/src/protagonist/API.Tests/Features/Images/Ingest/AssetProcessorTest.cs +++ b/src/protagonist/API.Tests/Features/Images/Ingest/AssetProcessorTest.cs @@ -1,16 +1,15 @@ using System; using System.Threading; -using System.Threading.Tasks; using API.Features.Assets; using API.Features.Image; using API.Features.Image.Ingest; using API.Settings; using DLCS.Core.Types; -using DLCS.HydraModel; using DLCS.Model.Assets; using DLCS.Model.DeliveryChannels; using DLCS.Model.Storage; using FakeItEasy; +using Microsoft.Extensions.Logging.Abstractions; using Test.Helpers.Settings; using CustomerStorage = DLCS.Model.Storage.CustomerStorage; using StoragePolicy = DLCS.Model.Storage.StoragePolicy; @@ -32,17 +31,20 @@ public AssetProcessorTest() assetRepository = A.Fake(); defaultDeliveryChannelRepository = A.Fake(); deliveryChannelPolicyRepository = A.Fake(); + + var deliveryChannelProcessor = new DeliveryChannelProcessor(defaultDeliveryChannelRepository, deliveryChannelPolicyRepository, + new NullLogger()); var optionsMonitor = OptionsHelpers.GetOptionsMonitor(apiSettings); - sut = new AssetProcessor(assetRepository, storageRepository, defaultDeliveryChannelRepository, deliveryChannelPolicyRepository, optionsMonitor); + sut = new AssetProcessor(assetRepository, storageRepository, deliveryChannelProcessor, optionsMonitor); } [Fact] public async Task Process_ChecksForMaximumNumberOfImages_Exceeded() { // Arrange - A.CallTo(() => assetRepository.GetAsset(A._, A._)).Returns(null); + A.CallTo(() => assetRepository.GetAsset(A._, A._, A._)).Returns(null); A.CallTo(() => storageRepository.GetStorageMetrics(A._, A._)) .Returns(new AssetStorageMetric @@ -65,7 +67,7 @@ public async Task Process_ChecksForMaximumNumberOfImages_Exceeded() public async Task Process_ChecksForTotalImageSize_Exceeded() { // Arrange - A.CallTo(() => assetRepository.GetAsset(A._, A._)).Returns(null); + A.CallTo(() => assetRepository.GetAsset(A._, A._, A._)).Returns(null); A.CallTo(() => storageRepository.GetStorageMetrics(A._, A._)) .Returns(new AssetStorageMetric { @@ -87,7 +89,7 @@ public async Task Process_ChecksForTotalImageSize_Exceeded() public async Task Process_RetrievesNoneDeliveryChannelPolicy_WhenCalledWithNoneDeliveryChannel() { // Arrange - A.CallTo(() => assetRepository.GetAsset(A._, A._)).Returns(null); + A.CallTo(() => assetRepository.GetAsset(A._, A._, A._)).Returns(null); A.CallTo(() => storageRepository.GetStorageMetrics(A._, A._)) .Returns(new AssetStorageMetric @@ -123,7 +125,7 @@ public async Task Process_RetrievesNoneDeliveryChannelPolicy_WhenCalledWithNoneD public async Task Process_RetrievesDeliveryChannelPolicy_WhenCalledWithDeliveryChannels() { // Arrange - A.CallTo(() => assetRepository.GetAsset(A._, A._)).Returns(null); + A.CallTo(() => assetRepository.GetAsset(A._, A._, A._)).Returns(null); A.CallTo(() => storageRepository.GetStorageMetrics(A._, A._)) .Returns(new AssetStorageMetric @@ -167,7 +169,7 @@ public async Task Process_RetrievesDeliveryChannelPolicy_WhenCalledWithDeliveryC public async Task Process_FailsToProcessImage_WhenDeliveryPolicyNotMatched() { // Arrange - A.CallTo(() => assetRepository.GetAsset(A._, A._)).Returns(null); + A.CallTo(() => assetRepository.GetAsset(A._, A._, A._)).Returns(null); A.CallTo(() => storageRepository.GetStorageMetrics(A._, A._)) .Returns(new AssetStorageMetric @@ -202,7 +204,7 @@ public async Task Process_FailsToProcessImage_WhenDeliveryPolicyNotMatched() public async Task Process_ProcessesImage_WhenDeliveryPolicyMatchedFromChannel() { // Arrange - A.CallTo(() => assetRepository.GetAsset(A._, A._)).Returns(null); + A.CallTo(() => assetRepository.GetAsset(A._, A._, A._)).Returns(null); A.CallTo(() => storageRepository.GetStorageMetrics(A._, A._)) .Returns(new AssetStorageMetric @@ -235,7 +237,7 @@ public async Task Process_ProcessesImage_WhenDeliveryPolicyMatchedFromChannel() public async Task Process_FailsToProcessesImage_WhenDeliveryPolicyNotMatchedFromChannel() { // Arrange - A.CallTo(() => assetRepository.GetAsset(A._, A._)).Returns(null); + A.CallTo(() => assetRepository.GetAsset(A._, A._, A._)).Returns(null); A.CallTo(() => storageRepository.GetStorageMetrics(A._, A._)) .Returns(new AssetStorageMetric diff --git a/src/protagonist/API.Tests/Features/Images/Validation/HydraImageValidatorTests.cs b/src/protagonist/API.Tests/Features/Images/Validation/HydraImageValidatorTests.cs index 13aa73f6a..ffc9658d4 100644 --- a/src/protagonist/API.Tests/Features/Images/Validation/HydraImageValidatorTests.cs +++ b/src/protagonist/API.Tests/Features/Images/Validation/HydraImageValidatorTests.cs @@ -22,17 +22,17 @@ public HydraImageValidatorTests() [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void MediaType_NullOrEmpty(string mediaType) + public void MediaType_NullOrEmpty_OnCreate(string mediaType) { - var model = new DLCS.HydraModel.Image { MediaType = mediaType }; - var result = sut.TestValidate(model); + var model = new Image { MediaType = mediaType }; + var result = sut.TestValidate(model, options => options.IncludeRuleSets("default", "create")); result.ShouldHaveValidationErrorFor(a => a.MediaType); } [Fact] public void Batch_Provided() { - var model = new DLCS.HydraModel.Image { Batch = "10" }; + var model = new Image { Batch = "10" }; var result = sut.TestValidate(model); result.ShouldHaveValidationErrorFor(a => a.Batch); } @@ -40,7 +40,7 @@ public void Batch_Provided() [Fact] public void Width_Provided() { - var model = new DLCS.HydraModel.Image { Width = 10 }; + var model = new Image { Width = 10 }; var result = sut.TestValidate(model); result .ShouldHaveValidationErrorFor(a => a.Width) @@ -53,7 +53,7 @@ public void Width_Provided() [InlineData("audio/mp4", "file")] public void Width_Provided_NotFileOnly_OrAudio(string mediaType, string dc) { - var model = new DLCS.HydraModel.Image + var model = new Image { Width = 10, WcDeliveryChannels = dc.Split(","), MediaType = mediaType }; @@ -69,7 +69,7 @@ public void Width_Provided_NotFileOnly_OrAudio(string mediaType, string dc) [InlineData("application/pdf")] public void Width_Allowed_IfFileOnly_AndVideoOrImage(string mediaType) { - var model = new DLCS.HydraModel.Image + var model = new Image { MediaType = mediaType, WcDeliveryChannels = new[] { "file" }, Width = 10 }; @@ -80,7 +80,7 @@ public void Width_Allowed_IfFileOnly_AndVideoOrImage(string mediaType) [Fact] public void Height_Provided() { - var model = new DLCS.HydraModel.Image { Height = 10 }; + var model = new Image { Height = 10 }; var result = sut.TestValidate(model); result .ShouldHaveValidationErrorFor(a => a.Height) @@ -93,7 +93,7 @@ public void Height_Provided() [InlineData("audio/mp4", "file")] public void Height_Provided_NotFileOnly_OrAudio(string mediaType, string dc) { - var model = new DLCS.HydraModel.Image + var model = new Image { Height = 10, WcDeliveryChannels = dc.Split(","), MediaType = mediaType }; @@ -109,7 +109,7 @@ public void Height_Provided_NotFileOnly_OrAudio(string mediaType, string dc) [InlineData("application/pdf")] public void Height_Allowed_IfFileOnly_AndVideoOrImage(string mediaType) { - var model = new DLCS.HydraModel.Image + var model = new Image { MediaType = mediaType, WcDeliveryChannels = new[] { "file" }, Height = 10 }; @@ -120,7 +120,7 @@ public void Height_Allowed_IfFileOnly_AndVideoOrImage(string mediaType) [Fact] public void Duration_Provided() { - var model = new DLCS.HydraModel.Image { Duration = 10 }; + var model = new Image { Duration = 10 }; var result = sut.TestValidate(model); result .ShouldHaveValidationErrorFor(a => a.Duration) @@ -133,7 +133,7 @@ public void Duration_Provided() [InlineData("audio/mp4", "file,iiif-av")] public void Duration_Provided_NotFileOnly_OrImage(string mediaType, string dc) { - var model = new DLCS.HydraModel.Image + var model = new Image { Duration = 10, WcDeliveryChannels = dc.Split(","), MediaType = mediaType }; @@ -149,7 +149,7 @@ public void Duration_Provided_NotFileOnly_OrImage(string mediaType, string dc) [InlineData("application/pdf")] public void Duration_Allowed_IfFileOnly_AndVideoOrAudio(string mediaType) { - var model = new DLCS.HydraModel.Image + var model = new Image { MediaType = mediaType, WcDeliveryChannels = new[] { "file" }, Duration = 10 }; @@ -160,7 +160,7 @@ public void Duration_Allowed_IfFileOnly_AndVideoOrAudio(string mediaType) [Fact] public void Finished_Provided() { - var model = new DLCS.HydraModel.Image { Finished = DateTime.Today }; + var model = new Image { Finished = DateTime.Today }; var result = sut.TestValidate(model); result.ShouldHaveValidationErrorFor(a => a.Finished); } @@ -168,7 +168,7 @@ public void Finished_Provided() [Fact] public void Created_Provided() { - var model = new DLCS.HydraModel.Image { Created = DateTime.Today }; + var model = new Image { Created = DateTime.Today }; var result = sut.TestValidate(model); result.ShouldHaveValidationErrorFor(a => a.Created); } @@ -179,7 +179,7 @@ public void Created_Provided() [InlineData("iiif-av")] public void UseOriginalPolicy_NotImage(string dc) { - var model = new DLCS.HydraModel.Image + var model = new Image { WcDeliveryChannels = dc.Split(","), MediaType = "image/jpeg", @@ -196,7 +196,7 @@ public void UseOriginalPolicy_NotImage(string dc) [InlineData("file,iiif-img")] public void UseOriginalPolicy_Image(string dc) { - var model = new DLCS.HydraModel.Image + var model = new Image { WcDeliveryChannels = dc.Split(","), MediaType = "image/jpeg", @@ -207,9 +207,9 @@ public void UseOriginalPolicy_Image(string dc) } [Fact] - public void DeliveryChannel_CanBeEmpty() + public void WcDeliveryChannel_CanBeEmpty() { - var model = new DLCS.HydraModel.Image(); + var model = new Image(); var result = sut.TestValidate(model); result.ShouldNotHaveValidationErrorFor(a => a.WcDeliveryChannels); } @@ -219,17 +219,17 @@ public void DeliveryChannel_CanBeEmpty() [InlineData("iiif-av")] [InlineData("iiif-img")] [InlineData("file,iiif-av,iiif-img")] - public void DeliveryChannel_CanContainKnownValues(string knownValues) + public void WcDeliveryChannel_CanContainKnownValues(string knownValues) { - var model = new DLCS.HydraModel.Image { WcDeliveryChannels = knownValues.Split(',') }; + var model = new Image { WcDeliveryChannels = knownValues.Split(',') }; var result = sut.TestValidate(model); result.ShouldNotHaveValidationErrorFor(a => a.WcDeliveryChannels); } [Fact] - public void DeliveryChannel_UnknownValue() + public void WcDeliveryChannel_UnknownValue() { - var model = new DLCS.HydraModel.Image { WcDeliveryChannels = new[] { "foo" } }; + var model = new Image { WcDeliveryChannels = new[] { "foo" } }; var result = sut.TestValidate(model); result.ShouldHaveValidationErrorFor(a => a.WcDeliveryChannels); } @@ -239,7 +239,7 @@ public void WcDeliveryChannel_ValidationError_WhenDeliveryChannelsDisabled() { var apiSettings = new ApiSettings(); var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); - var model = new DLCS.HydraModel.Image { WcDeliveryChannels = new[] { "iiif-img" } }; + var model = new Image { WcDeliveryChannels = new[] { "iiif-img" } }; var result = imageValidator.TestValidate(model); result.ShouldHaveValidationErrorFor(a => a.WcDeliveryChannels); } @@ -249,17 +249,37 @@ public void WcDeliveryChannel_NoValidationError_WhenDeliveryChannelsDisabled() { var apiSettings = new ApiSettings(); var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); - var model = new DLCS.HydraModel.Image(); + var model = new Image(); var result = imageValidator.TestValidate(model); result.ShouldNotHaveValidationErrorFor(a => a.WcDeliveryChannels); } + [Fact] + public void DeliveryChannel_ValidationError_DeliveryChannelMissingChannel() + { + var apiSettings = new ApiSettings(); + var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); + var model = new Image { DeliveryChannels = new[] + { + new DeliveryChannel() + { + Policy = "none" + }, + new DeliveryChannel() + { + Channel = "file" + } + } }; + var result = imageValidator.TestValidate(model); + result.ShouldHaveValidationErrorFor(a => a.DeliveryChannels); + } + [Fact] public void DeliveryChannel_ValidationError_WhenNoneAndMoreDeliveryChannels() { var apiSettings = new ApiSettings(); var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); - var model = new DLCS.HydraModel.Image { DeliveryChannels = new[] + var model = new Image { DeliveryChannels = new[] { new DeliveryChannel() { @@ -279,7 +299,7 @@ public void DeliveryChannel_NoValidationError_WhenDeliveryChannelsWithNoNone() { var apiSettings = new ApiSettings(); var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); - var model = new DLCS.HydraModel.Image { DeliveryChannels = new[] + var model = new Image { DeliveryChannels = new[] { new DeliveryChannel() { @@ -295,11 +315,11 @@ public void DeliveryChannel_NoValidationError_WhenDeliveryChannelsWithNoNone() } [Fact] - public void DeliveryChannel_ValidationError_WhenOnlyNone() + public void DeliveryChannel_NoValidationError_WhenOnlyNone() { var apiSettings = new ApiSettings(); var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); - var model = new DLCS.HydraModel.Image { DeliveryChannels = new[] + var model = new Image { DeliveryChannels = new[] { new DeliveryChannel() { @@ -327,7 +347,7 @@ public void DeliveryChannel_NoValidationError_WhenChannelValidForMediaType(strin { var apiSettings = new ApiSettings(); var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); - var model = new DLCS.HydraModel.Image { + var model = new Image { MediaType = mediaType, DeliveryChannels = new[] { @@ -351,7 +371,7 @@ public void DeliveryChannel_ValidationError_WhenWrongChannelForMediaType(string { var apiSettings = new ApiSettings(); var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); - var model = new DLCS.HydraModel.Image { + var model = new Image { MediaType = mediaType, DeliveryChannels = new[] { @@ -363,4 +383,18 @@ public void DeliveryChannel_ValidationError_WhenWrongChannelForMediaType(string var result = imageValidator.TestValidate(model); result.ShouldHaveValidationErrorFor(a => a.DeliveryChannels); } + + [Fact] + public void DeliveryChannel_ValidationError_WhenEmpty_OnPatch() + { + var apiSettings = new ApiSettings(); + var imageValidator = new HydraImageValidator(Options.Create(apiSettings)); + var model = new Image + { + DeliveryChannels = Array.Empty() + }; + var result = imageValidator.TestValidate(model, options => + options.IncludeRuleSets("default", "patch")); + result.ShouldHaveValidationErrorFor(a => a.DeliveryChannels); + } } \ No newline at end of file diff --git a/src/protagonist/API.Tests/Features/Images/Validation/ImageBatchPatchValidatorTests.cs b/src/protagonist/API.Tests/Features/Images/Validation/ImageBatchPatchValidatorTests.cs index 3708fcecb..f6f0d3bed 100644 --- a/src/protagonist/API.Tests/Features/Images/Validation/ImageBatchPatchValidatorTests.cs +++ b/src/protagonist/API.Tests/Features/Images/Validation/ImageBatchPatchValidatorTests.cs @@ -133,7 +133,7 @@ public void Member_MaxUnauthorised_Provided() } [Fact] - public void Member_DeliveryChannels_Provided() + public void Member_WcDeliveryChannels_Provided() { var model = new HydraCollection { Members = new[] { @@ -153,4 +153,22 @@ public void Member_ThumbnailPolicy_Provided() var result = sut.TestValidate(model); result.ShouldHaveValidationErrorFor("Members[0].ThumbnailPolicy"); } + + [Fact] + public void Member_DeliveryChannels_Provided() + { + var model = new HydraCollection { Members = new[] + { + new Image { DeliveryChannels = new [] + { + new DeliveryChannel() + { + Channel = "iiif-img", + Policy = "default" + } + }} + } }; + var result = sut.TestValidate(model); + result.ShouldHaveValidationErrorFor("Members[0].DeliveryChannels"); + } } \ No newline at end of file diff --git a/src/protagonist/API.Tests/Integration/ModifyAssetTests.cs b/src/protagonist/API.Tests/Integration/ModifyAssetTests.cs index 133b7a6cb..856f08880 100644 --- a/src/protagonist/API.Tests/Integration/ModifyAssetTests.cs +++ b/src/protagonist/API.Tests/Integration/ModifyAssetTests.cs @@ -15,6 +15,7 @@ using DLCS.HydraModel; using DLCS.Model.Assets; using DLCS.Model.Messaging; +using DLCS.Model.Policies; using DLCS.Repository; using DLCS.Repository.Entities; using DLCS.Repository.Messaging; @@ -79,11 +80,11 @@ public async Task Put_NewImageAsset_Creates_Asset() var assetId = new AssetId(customerAndSpace.customer, customerAndSpace.space, nameof(Put_NewImageAsset_Creates_Asset)); var hydraImageBody = $@"{{ - ""@type"": ""Image"", - ""origin"": ""https://example.org/{assetId.Asset}.tiff"", - ""family"": ""I"", - ""mediaType"": ""image/tiff"" -}}"; + ""@type"": ""Image"", + ""origin"": ""https://example.org/{assetId.Asset}.tiff"", + ""family"": ""I"", + ""mediaType"": ""image/tiff"" + }}"; A.CallTo(() => EngineClient.SynchronousIngest( A.That.Matches(r => r.Id == assetId), @@ -132,7 +133,7 @@ public async Task Put_NewImageAsset_Creates_Asset_WithCustomDefaultDeliveryChann await dbContext.SaveChangesAsync(); - var assetId = new AssetId(customerAndSpace.customer, customerAndSpace.space, nameof(Put_NewImageAsset_Creates_Asset)); + var assetId = new AssetId(customerAndSpace.customer, customerAndSpace.space, nameof(Put_NewImageAsset_Creates_Asset_WithCustomDefaultDeliveryChannel)); var hydraImageBody = $@"{{ ""@type"": ""Image"", ""origin"": ""https://example.org/{assetId.Asset}.tiff"", @@ -161,12 +162,33 @@ public async Task Put_NewImageAsset_Creates_Asset_WithCustomDefaultDeliveryChann asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "thumbs"); } + [Fact] + public async Task Put_NewImageAsset_Returns400_IfNoDeliveryChannelDefaults() + { + // Arrange + var assetId = new AssetId(99, 1, nameof(Put_NewImageAsset_Returns400_IfNoDeliveryChannelDefaults)); + var hydraImageBody = @"{ + ""@type"": ""Image"", + ""origin"": ""https://example.org/test"", + ""family"": ""I"", + ""mediaType"": ""image/tiff"", + ""deliveryChannels"": [{ ""channel"":""file"" } ] +}"; + + // Act + var content = new StringContent(hydraImageBody, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer().PutAsync(assetId.ToApiResourcePath(), content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest, "there is no default handler for image/tiff for 'file' channel"); + } + [Fact] public async Task Put_NewImageAsset_Creates_AssetWithSpecifiedDeliveryChannels() { var customerAndSpace = await CreateCustomerAndSpace(); - var assetId = new AssetId(customerAndSpace.customer, customerAndSpace.space, nameof(Put_NewImageAsset_Creates_Asset)); + var assetId = new AssetId(customerAndSpace.customer, customerAndSpace.space, nameof(Put_NewImageAsset_Creates_AssetWithSpecifiedDeliveryChannels)); var hydraImageBody = $@"{{ ""@type"": ""Image"", ""origin"": ""https://example.org/{assetId.Asset}.tiff"", @@ -193,8 +215,6 @@ public async Task Put_NewImageAsset_Creates_AssetWithSpecifiedDeliveryChannels() var content = new StringContent(hydraImageBody, Encoding.UTF8, "application/json"); var response = await httpClient.AsCustomer(customerAndSpace.customer).PutAsync(assetId.ToApiResourcePath(), content); - var stuff = await response.Content.ReadAsStringAsync(); - // assert response.StatusCode.Should().Be(HttpStatusCode.Created); response.Headers.Location.PathAndQuery.Should().Be(assetId.ToApiResourcePath()); @@ -258,7 +278,7 @@ public async Task Put_NewImageAsset_Creates_Asset_WhileIgnoringCustomDefaultDeli asset.MaxUnauthorised.Should().Be(-1); asset.ImageDeliveryChannels.Count.Should().Be(2); asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "iiif-img" && - x.DeliveryChannelPolicyId == 1); + x.DeliveryChannelPolicyId == KnownDeliveryChannelPolicies.ImageDefault); asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "thumbs"); } @@ -375,9 +395,9 @@ public async Task Put_NewImageAsset_BadRequest_WhenCalledWithInvalidId() } [Fact] - public async Task Put_NewImageAsset_FailsToCreateAsset_whenMediaTypeAndFamilyNotSet() + public async Task Put_NewImageAsset_FailsToCreateAsset_WhenMediaTypeAndFamilyNotSet() { - var assetId = new AssetId(99, 1, nameof(Put_NewImageAsset_FailsToCreateAsset_whenMediaTypeAndFamilyNotSet)); + var assetId = new AssetId(99, 1, nameof(Put_NewImageAsset_FailsToCreateAsset_WhenMediaTypeAndFamilyNotSet)); var hydraImageBody = $@"{{ ""@type"": ""Image"", ""origin"": ""https://example.org/{assetId.Asset}.tiff"" @@ -397,11 +417,11 @@ public async Task Put_NewImageAsset_FailsToCreateAsset_whenMediaTypeAndFamilyNot } [Fact] - public async Task Put_NewImageAsset_CreatesAsset_whenMediaTypeAndFamilyNotSetWithLegacyEnabled() + public async Task Put_NewImageAsset_CreatesAsset_WhenMediaTypeAndFamilyNotSetWithLegacyEnabled() { const int customer = 325665; const int space = 2; - var assetId = new AssetId(customer, space, nameof(Put_NewImageAsset_CreatesAsset_whenMediaTypeAndFamilyNotSetWithLegacyEnabled)); + var assetId = new AssetId(customer, space, nameof(Put_NewImageAsset_CreatesAsset_WhenMediaTypeAndFamilyNotSetWithLegacyEnabled)); await dbContext.Customers.AddTestCustomer(customer); await dbContext.Spaces.AddTestSpace(customer, space); await dbContext.DefaultDeliveryChannels.AddTestDefaultDeliveryChannels(customer); @@ -430,16 +450,16 @@ public async Task Put_NewImageAsset_CreatesAsset_whenMediaTypeAndFamilyNotSetWit asset.Family.Should().Be(AssetFamily.Image); asset.ImageDeliveryChannels.Count.Should().Be(2); asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "iiif-img" && - x.DeliveryChannelPolicyId == 1); + x.DeliveryChannelPolicyId == KnownDeliveryChannelPolicies.ImageDefault); asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "thumbs"); } [Fact] - public async Task Put_NewImageAsset_CreatesAsset_whenInferringOfMediaTypeNotPossibleWithLegacyEnabled() + public async Task Put_NewImageAsset_CreatesAsset_WhenInferringOfMediaTypeNotPossibleWithLegacyEnabled() { const int customer = 325665; const int space = 2; - var assetId = new AssetId(customer, space, nameof(Put_NewImageAsset_CreatesAsset_whenMediaTypeAndFamilyNotSetWithLegacyEnabled)); + var assetId = new AssetId(customer, space, nameof(Put_NewImageAsset_CreatesAsset_WhenInferringOfMediaTypeNotPossibleWithLegacyEnabled)); await dbContext.Customers.AddTestCustomer(customer); await dbContext.Spaces.AddTestSpace(customer, space); await dbContext.DefaultDeliveryChannels.AddTestDefaultDeliveryChannels(customer); @@ -468,8 +488,9 @@ public async Task Put_NewImageAsset_CreatesAsset_whenInferringOfMediaTypeNotPoss asset.Family.Should().Be(AssetFamily.Image); asset.ImageDeliveryChannels.Count.Should().Be(2); asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "iiif-img" && - x.DeliveryChannelPolicyId == 1); - asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "thumbs"); + x.DeliveryChannelPolicyId == KnownDeliveryChannelPolicies.ImageDefault); + asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "thumbs" && + x.DeliveryChannelPolicyId == KnownDeliveryChannelPolicies.ThumbsDefault); } [Theory] @@ -692,7 +713,7 @@ public async Task Put_NewAudioAsset_Creates_Asset() asset.MaxUnauthorised.Should().Be(-1); asset.ImageDeliveryChannels.Count.Should().Be(1); asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "iiif-av" && - x.DeliveryChannelPolicyId == 5); + x.DeliveryChannelPolicyId == KnownDeliveryChannelPolicies.AvDefaultAudio); } [Fact] @@ -726,7 +747,7 @@ public async Task Put_NewVideoAsset_Creates_Asset() asset.MaxUnauthorised.Should().Be(-1); asset.ImageDeliveryChannels.Count.Should().Be(1); asset.ImageDeliveryChannels.Should().ContainSingle(x => x.Channel == "iiif-av" && - x.DeliveryChannelPolicyId == 6); + x.DeliveryChannelPolicyId == KnownDeliveryChannelPolicies.AvDefaultVideo); } [Fact] @@ -839,12 +860,17 @@ public async Task Put_Existing_Asset_ClearsError_AndMarksAsIngesting() await dbContext.SaveChangesAsync(); var hydraImageBody = $@"{{ - ""@type"": ""Image"", - ""origin"": ""https://example.org/{assetId.Asset}.tiff"", - ""family"": ""I"", - ""mediaType"": ""image/tiff"" -}}"; - + ""@type"": ""Image"", + ""origin"": ""https://example.org/{assetId.Asset}.tiff"", + ""family"": ""I"", + ""mediaType"": ""image/tiff"", + ""deliveryChannels"": [ + {{ + ""channel"": ""iiif-img"", + ""policy"": ""default"" + }}] + }}"; + A.CallTo(() => EngineClient.SynchronousIngest( A.That.Matches(r => r.Id == assetId), @@ -864,7 +890,112 @@ public async Task Put_Existing_Asset_ClearsError_AndMarksAsIngesting() } [Fact] - public async Task Put_Asset_Returns_InsufficientStorage_if_Policy_Exceeded() + public async Task Put_Existing_Asset_Returns400_IfDeliveryChannelsNull() + { + var assetId = new AssetId(99, 1, nameof(Put_Existing_Asset_Returns400_IfDeliveryChannelsNull)); + await dbContext.Images.AddTestAsset(assetId); + await dbContext.SaveChangesAsync(); + + var hydraImageBody = $@"{{ + ""@type"": ""Image"", + ""origin"": ""https://example.org/{assetId.Asset}.tiff"", + ""family"": ""I"", + ""mediaType"": ""image/tiff"" + }}"; + + // act + var content = new StringContent(hydraImageBody, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(99).PutAsync(assetId.ToApiResourcePath(), content); + + // assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Put_Existing_Asset_Returns400_IfDeliveryChannelsEmpty() + { + var assetId = new AssetId(99, 1, nameof(Put_Existing_Asset_Returns400_IfDeliveryChannelsEmpty)); + await dbContext.Images.AddTestAsset(assetId); + await dbContext.SaveChangesAsync(); + + var hydraImageBody = $@"{{ + ""@type"": ""Image"", + ""origin"": ""https://example.org/{assetId.Asset}.tiff"", + ""family"": ""I"", + ""mediaType"": ""image/tiff"", + ""deliveryChannels"": [] + }}"; + + // act + var content = new StringContent(hydraImageBody, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(99).PutAsync(assetId.ToApiResourcePath(), content); + + // assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Put_Existing_Asset_AllowsUpdatingDeliveryChannel() + { + // Arrange + var assetId = new AssetId(99, 1, $"{nameof(Put_Existing_Asset_AllowsUpdatingDeliveryChannel)}"); + + await dbContext.Images.AddTestAsset(assetId, imageDeliveryChannels: new List + { + new() + { + Channel = AssetDeliveryChannels.Image, + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ImageDefault + }, + new() + { + Channel = AssetDeliveryChannels.Thumbnails, + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ThumbsDefault + } + }); + await dbContext.SaveChangesAsync(); + + // change iiif-img to 'use-original', remove thumbs, add file + var hydraImageBody = $@"{{ + ""@type"": ""Image"", + ""origin"": ""https://example.org/{assetId.Asset}"", + ""mediaType"": ""image/tiff"", + ""deliveryChannels"": [ + {{ + ""channel"":""iiif-img"", + ""policy"":""use-original"" + }}, + {{ + ""channel"":""file"", + ""policy"":""none"" + }}] + }}"; + + A.CallTo(() => + EngineClient.SynchronousIngest( + A.That.Matches(r => r.Id == assetId), + A._)) + .Returns(HttpStatusCode.OK); + + // Act + var content = new StringContent(hydraImageBody, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(99).PutAsync(assetId.ToApiResourcePath(), content); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var asset = dbContext.Images.Include(i => i.ImageDeliveryChannels).Single(x => x.Id == assetId); + asset.Id.Should().Be(assetId); + asset.ImageDeliveryChannels + .Should().HaveCount(2).And.Subject + .Should().Satisfy( + i => i.Channel == AssetDeliveryChannels.Image && + i.DeliveryChannelPolicyId == KnownDeliveryChannelPolicies.ImageUseOriginal, + i => i.Channel == AssetDeliveryChannels.File); + } + + [Fact] + public async Task Put_Asset_Returns_InsufficientStorage_If_Policy_Exceeded() { // This will break other tests so we need a different customer // This customer has maxed out their limit of 2! @@ -894,7 +1025,7 @@ await dbContext.StoragePolicies.AddAsync(new DLCS.Model.Storage.StoragePolicy() }); await dbContext.SaveChangesAsync(); - var assetId = new AssetId(customer, 1, nameof(Put_Asset_Returns_InsufficientStorage_if_Policy_Exceeded)); + var assetId = new AssetId(customer, 1, nameof(Put_Asset_Returns_InsufficientStorage_If_Policy_Exceeded)); var hydraImageBody = $@"{{ ""@type"": ""Image"", ""origin"": ""https://example.org/{assetId.Asset}.tiff"", @@ -1078,6 +1209,24 @@ public async Task Patch_Asset_Returns_Notfound_if_Asset_Missing() response.StatusCode.Should().Be(HttpStatusCode.NotFound); } + [Fact] + public async Task Patch_Asset_Returns_BadRequest_if_DeliveryChannels_Empty() + { + // arrange + var assetId = new AssetId(99, 1, nameof(Patch_Asset_Change_ImageOptimisationPolicy_Allowed)); + var hydraImageBody = $@"{{ + ""@type"": ""Image"", + ""deliveryChannels"": [] + }}"; + + // act + var content = new StringContent(hydraImageBody, Encoding.UTF8, "application/json"); + var response = await httpClient.AsCustomer(99).PatchAsync(assetId.ToApiResourcePath(), content); + + // assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + [Fact] public async Task Patch_Images_Updates_Multiple_Images() { @@ -1463,17 +1612,17 @@ await dbContext.Images.AddTestAsset(assetId, imageDeliveryChannels: new List -/// Asset repository, extends base with custom, API-specific methods. +/// API specific asset repository /// public class ApiAssetRepository : IApiAssetRepository { - private readonly IAssetRepository assetRepository; private readonly IEntityCounterRepository entityCounterRepository; + private readonly AssetCachingHelper assetCachingHelper; private readonly DlcsContext dlcsContext; + private readonly ILogger logger; public ApiAssetRepository( DlcsContext dlcsContext, - IAssetRepository assetRepository, - IEntityCounterRepository entityCounterRepository) + IEntityCounterRepository entityCounterRepository, + AssetCachingHelper assetCachingHelper, + ILogger logger) { this.dlcsContext = dlcsContext; - this.assetRepository = assetRepository; this.entityCounterRepository = entityCounterRepository; + this.assetCachingHelper = assetCachingHelper; + this.logger = logger; } - public Task GetAsset(AssetId id) => assetRepository.GetAsset(id); + /// + public async Task GetAsset(AssetId assetId, bool forUpdate = false, bool noCache = false) + { + // Only use change-tracking if this will be used for an update operation + IQueryable images = forUpdate ? dlcsContext.Images : dlcsContext.Images.AsNoTracking(); - public Task GetAsset(AssetId id, bool noCache) => assetRepository.GetAsset(id, noCache); + Task LoadAssetFromDb(AssetId id) => + images + .Include(i => i.ImageDeliveryChannels) + .ThenInclude(i => i.DeliveryChannelPolicy) + .SingleOrDefaultAsync(i => i.Id == id); - public Task GetImageLocation(AssetId assetId) => assetRepository.GetImageLocation(assetId); - - public Task> DeleteAsset(AssetId assetId) => assetRepository.DeleteAsset(assetId); - - /// - /// Save changes to Asset, incrementing EntityCounters if required. - /// - /// - /// An Asset that is ready to be inserted/updated in the DB, that - /// has usually come from an incoming Hydra object. - /// It can also have been obtained from the database by another repository class. - /// - /// True if this is an update, false if insert - /// - public async Task Save(Asset asset, bool isUpdate, CancellationToken cancellationToken) + if (noCache) assetCachingHelper.RemoveAssetFromCache(assetId); + + // Only go via cache if this is a read-only operation + var asset = forUpdate + ? await LoadAssetFromDb(assetId) + : await assetCachingHelper.GetCachedAsset(assetId, LoadAssetFromDb); + return asset; + } + + /// + public async Task> DeleteAsset(AssetId assetId) { - if (dlcsContext.Images.Local.All(trackedAsset => trackedAsset.Id != asset.Id)) + try { - if (isUpdate) + var asset = await dlcsContext.Images + .Include(a => a.ImageDeliveryChannels) + .SingleOrDefaultAsync(i => i.Id == assetId); + if (asset == null) + { + logger.LogDebug("Attempt to delete non-existent asset {AssetId}", assetId); + return new DeleteEntityResult(DeleteResult.NotFound); + } + + // Delete Asset + dlcsContext.Images.Remove(asset); + + // And related ImageLocation + var imageLocation = await dlcsContext.ImageLocations.FindAsync(assetId); + + if (imageLocation != null) + { + dlcsContext.ImageLocations.Remove(imageLocation); + } + + var customer = assetId.Customer; + var space = assetId.Space; + + var imageStorage = + await dlcsContext.ImageStorages.FindAsync(assetId, customer, space); + if (imageStorage != null) { - dlcsContext.Images.Attach(asset); - dlcsContext.Entry(asset).State = EntityState.Modified; + // And related ImageStorage record + dlcsContext.Remove(imageStorage); } else { - await dlcsContext.Images.AddAsync(asset, cancellationToken); - await entityCounterRepository.Increment(asset.Customer, KnownEntityCounters.SpaceImages, asset.Space.ToString()); - await entityCounterRepository.Increment(0, KnownEntityCounters.CustomerImages, asset.Customer.ToString()); + logger.LogInformation("No ImageStorage record found when deleting asset {AssetId}", assetId); + } + + void ReduceCustomerStorage(CustomerStorage customerStorage) + { + // And reduce CustomerStorage record + customerStorage.NumberOfStoredImages -= 1; + customerStorage.TotalSizeOfThumbnails -= imageStorage?.ThumbnailSize ?? 0; + customerStorage.TotalSizeOfStoredImages -= imageStorage?.Size ?? 0; } - } - await dlcsContext.SaveChangesAsync(cancellationToken); + // Reduce CustomerStorage for space + var customerSpaceStorage = await dlcsContext.CustomerStorages.FindAsync(customer, space); + if (customerSpaceStorage != null) ReduceCustomerStorage(customerSpaceStorage); - if (assetRepository is AssetRepositoryCachingBase cachingBase) + // Reduce CustomerStorage for overall customer + var customerStorage = await dlcsContext.CustomerStorages.FindAsync(customer, 0); + if (customerStorage != null) ReduceCustomerStorage(customerStorage); + + var rowCount = await dlcsContext.SaveChangesAsync(); + if (rowCount == 0) + { + return new DeleteEntityResult(DeleteResult.NotFound); + } + + await entityCounterRepository.Decrement(customer, KnownEntityCounters.SpaceImages, space.ToString()); + await entityCounterRepository.Decrement(0, KnownEntityCounters.CustomerImages, customer.ToString()); + assetCachingHelper.RemoveAssetFromCache(assetId); + return new DeleteEntityResult(DeleteResult.Deleted, asset); + } + catch (Exception ex) { - cachingBase.FlushCache(asset.Id); + logger.LogError(ex, "Error deleting asset {AssetId}", assetId); + return new DeleteEntityResult(DeleteResult.Error); + } + } + + /// + public async Task Save(Asset asset, bool isUpdate, CancellationToken cancellationToken) + { + if (!isUpdate) // if this is a creation, add Asset to dbContext + increment entity counters + { + await dlcsContext.Images.AddAsync(asset, cancellationToken); + await entityCounterRepository.Increment(asset.Customer, KnownEntityCounters.SpaceImages, + asset.Space.ToString()); + await entityCounterRepository.Increment(0, KnownEntityCounters.CustomerImages, asset.Customer.ToString()); } + await dlcsContext.SaveChangesAsync(cancellationToken); + assetCachingHelper.RemoveAssetFromCache(asset.Id); return asset; } } \ No newline at end of file diff --git a/src/protagonist/API/Features/Assets/IApiAssetRepository.cs b/src/protagonist/API/Features/Assets/IApiAssetRepository.cs index 8b188668b..d08d511c9 100644 --- a/src/protagonist/API/Features/Assets/IApiAssetRepository.cs +++ b/src/protagonist/API/Features/Assets/IApiAssetRepository.cs @@ -1,11 +1,37 @@ +using DLCS.Core.Types; +using DLCS.Model; using DLCS.Model.Assets; namespace API.Features.Assets; /// -/// Extends basic to include some API specific methods +/// Asset repository containing required operations for API use /// -public interface IApiAssetRepository : IAssetRepository +public interface IApiAssetRepository { + /// + /// Get specified asset and associated ImageDeliveryChannels from database + /// + /// Id of Asset to load + /// Whether this is to be updated, will use change-tracking if so + /// If true the object will not be loaded from cache + /// if found, or null + public Task GetAsset(AssetId assetId, bool forUpdate = false, bool noCache = false); + + /// + /// Delete asset and associated records from database + /// + /// Id of Asset to delete + /// indicating success or failure + public Task> DeleteAsset(AssetId assetId); + + /// + /// Save changes to database. This assumes provided asset is in change tracking for underlying context. Will handle + /// incrementing EntityCounters if this is a new asset. + /// + /// Asset to be saved, needs to be in change tracking for context + /// If true this is an update, else it is create + /// Current cancellation token + /// Returns asset after saving public Task Save(Asset asset, bool isUpdate, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/protagonist/API/Features/DeliveryChannels/AvChannelPolicyOptionsRepository.cs b/src/protagonist/API/Features/DeliveryChannels/DataAccess/AvChannelPolicyOptionsRepository.cs similarity index 93% rename from src/protagonist/API/Features/DeliveryChannels/AvChannelPolicyOptionsRepository.cs rename to src/protagonist/API/Features/DeliveryChannels/DataAccess/AvChannelPolicyOptionsRepository.cs index 3ec273045..93ffa81d6 100644 --- a/src/protagonist/API/Features/DeliveryChannels/AvChannelPolicyOptionsRepository.cs +++ b/src/protagonist/API/Features/DeliveryChannels/DataAccess/AvChannelPolicyOptionsRepository.cs @@ -5,7 +5,7 @@ using LazyCache; using Microsoft.Extensions.Options; -namespace API.Features.DeliveryChannels; +namespace API.Features.DeliveryChannels.DataAccess; public class AvChannelPolicyOptionsRepository : IAvChannelPolicyOptionsRepository { @@ -13,7 +13,8 @@ public class AvChannelPolicyOptionsRepository : IAvChannelPolicyOptionsRepositor private readonly CacheSettings cacheSettings; private readonly IEngineClient engineClient; - public AvChannelPolicyOptionsRepository(IAppCache appCache, IOptions cacheOptions, IEngineClient engineClient) + public AvChannelPolicyOptionsRepository(IAppCache appCache, IOptions cacheOptions, + IEngineClient engineClient) { this.appCache = appCache; cacheSettings = cacheOptions.Value; diff --git a/src/protagonist/API/Features/DeliveryChannels/DefaultDeliveryChannelRepository.cs b/src/protagonist/API/Features/DeliveryChannels/DataAccess/DefaultDeliveryChannelRepository.cs similarity index 69% rename from src/protagonist/API/Features/DeliveryChannels/DefaultDeliveryChannelRepository.cs rename to src/protagonist/API/Features/DeliveryChannels/DataAccess/DefaultDeliveryChannelRepository.cs index f3f093e8a..b4024808b 100644 --- a/src/protagonist/API/Features/DeliveryChannels/DefaultDeliveryChannelRepository.cs +++ b/src/protagonist/API/Features/DeliveryChannels/DataAccess/DefaultDeliveryChannelRepository.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace API.Features.DeliveryChannels; +namespace API.Features.DeliveryChannels.DataAccess; public class DefaultDeliveryChannelRepository : IDefaultDeliveryChannelRepository { @@ -18,10 +18,9 @@ public class DefaultDeliveryChannelRepository : IDefaultDeliveryChannelRepositor private readonly ILogger logger; private readonly DlcsContext dlcsContext; - public DefaultDeliveryChannelRepository( - IAppCache appCache, + public DefaultDeliveryChannelRepository(IAppCache appCache, ILogger logger, - IOptions cacheOptions, + IOptions cacheOptions, DlcsContext dlcsContext) { this.appCache = appCache; @@ -30,11 +29,11 @@ public class DefaultDeliveryChannelRepository : IDefaultDeliveryChannelRepositor this.dlcsContext = dlcsContext; } - public List MatchedDeliveryChannels(string mediaType, int space, int customerId) + public async Task> MatchedDeliveryChannels(string mediaType, int space, int customerId) { var completedMatch = new List(); - var orderedDefaultDeliveryChannels = OrderedDefaultDeliveryChannels(space, customerId); + var orderedDefaultDeliveryChannels = await OrderedDefaultDeliveryChannels(space, customerId); foreach (var defaultDeliveryChannel in orderedDefaultDeliveryChannels) { @@ -52,13 +51,13 @@ public List MatchedDeliveryChannels(string mediaType, int return completedMatch; } - public DeliveryChannelPolicy MatchDeliveryChannelPolicyForChannel( + public async Task MatchDeliveryChannelPolicyForChannel( string mediaType, int space, int customerId, string? channel) { - var orderedDefaultDeliveryChannels = OrderedDefaultDeliveryChannels(space, customerId, channel); + var orderedDefaultDeliveryChannels = await OrderedDefaultDeliveryChannels(space, customerId, channel); foreach (var defaultDeliveryChannel in orderedDefaultDeliveryChannels) { @@ -71,18 +70,19 @@ public List MatchedDeliveryChannels(string mediaType, int throw new InvalidOperationException($"Failed to match media type {mediaType} to channel {channel}"); } - private List GetDefaultDeliveryChannelsForCustomer(int customerId, int space) + private async Task> GetDefaultDeliveryChannelsForCustomer(int customerId, int space) { var key = $"defaultDeliveryChannels:{customerId}"; - - var defaultDeliveryChannels = appCache.GetOrAdd(key, () => + + var defaultDeliveryChannels = await appCache.GetOrAddAsync(key, async () => { logger.LogDebug("Refreshing {CacheKey} from database", key); - var defaultDeliveryChannels = dlcsContext.DefaultDeliveryChannels + var defaultDeliveryChannels = await dlcsContext.DefaultDeliveryChannels .AsNoTracking() .Include(d => d.DeliveryChannelPolicy) - .Where(d => d.Customer == customerId).ToList(); + .Where(d => d.Customer == customerId) + .ToListAsync(); return defaultDeliveryChannels; }, cacheSettings.GetMemoryCacheOptions(CacheDuration.Long)); @@ -90,9 +90,9 @@ private List GetDefaultDeliveryChannelsForCustomer(int c return defaultDeliveryChannels.Where(d => d.Space == space || d.Space == 0).ToList(); } - private List OrderedDefaultDeliveryChannels(int space, int customerId, string? channel = null) + private async Task> OrderedDefaultDeliveryChannels(int space, int customerId, string? channel = null) { - var defaultDeliveryChannels = GetDefaultDeliveryChannelsForCustomer(customerId, space) + var defaultDeliveryChannels = (await GetDefaultDeliveryChannelsForCustomer(customerId, space)) .Where(d => channel == null || d.DeliveryChannelPolicy.Channel == channel); return defaultDeliveryChannels.OrderByDescending(v => v.Space) diff --git a/src/protagonist/API/Features/DeliveryChannels/DeliveryChannelPolicyRepository.cs b/src/protagonist/API/Features/DeliveryChannels/DataAccess/DeliveryChannelPolicyRepository.cs similarity index 74% rename from src/protagonist/API/Features/DeliveryChannels/DeliveryChannelPolicyRepository.cs rename to src/protagonist/API/Features/DeliveryChannels/DataAccess/DeliveryChannelPolicyRepository.cs index b4c3aa4ba..f96c053e7 100644 --- a/src/protagonist/API/Features/DeliveryChannels/DeliveryChannelPolicyRepository.cs +++ b/src/protagonist/API/Features/DeliveryChannels/DataAccess/DeliveryChannelPolicyRepository.cs @@ -1,4 +1,3 @@ -using API.Features.DeliveryChannels.Helpers; using DLCS.Core.Caching; using DLCS.Model.DeliveryChannels; using DLCS.Model.Policies; @@ -8,7 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace API.Features.DeliveryChannels; +namespace API.Features.DeliveryChannels.DataAccess; public class DeliveryChannelPolicyRepository : IDeliveryChannelPolicyRepository { @@ -18,8 +17,7 @@ public class DeliveryChannelPolicyRepository : IDeliveryChannelPolicyRepository private readonly DlcsContext dlcsContext; private const int AdminCustomer = 1; - public DeliveryChannelPolicyRepository( - IAppCache appCache, + public DeliveryChannelPolicyRepository(IAppCache appCache, ILogger logger, IOptions cacheOptions, DlcsContext dlcsContext) @@ -30,17 +28,18 @@ public class DeliveryChannelPolicyRepository : IDeliveryChannelPolicyRepository this.dlcsContext = dlcsContext; } - public DeliveryChannelPolicy RetrieveDeliveryChannelPolicy(int customerId, string channel, string policy) + public async Task RetrieveDeliveryChannelPolicy(int customerId, string channel, string policy) { var key = $"deliveryChannelPolicies:{customerId}"; - var deliveryChannelPolicies = appCache.GetOrAdd(key, () => + var deliveryChannelPolicies = await appCache.GetOrAddAsync(key, async () => { logger.LogDebug("Refreshing {CacheKey} from database", key); - var defaultDeliveryChannels = dlcsContext.DeliveryChannelPolicies + var defaultDeliveryChannels = await dlcsContext.DeliveryChannelPolicies .AsNoTracking() - .Where(d => d.Customer == customerId || d.Customer == AdminCustomer).ToList(); + .Where(d => d.Customer == customerId || d.Customer == AdminCustomer) + .ToListAsync(); return defaultDeliveryChannels; }, cacheSettings.GetMemoryCacheOptions(CacheDuration.Long)); @@ -50,9 +49,9 @@ public DeliveryChannelPolicy RetrieveDeliveryChannelPolicy(int customerId, strin p.System == false && p.Channel == channel && p.Name == policy - .Split('/', StringSplitOptions.None).Last()) || + .Split('/').Last()) || (p.Customer == AdminCustomer && - p.System == true && + p.System && p.Channel == channel && p.Name == policy)); } diff --git a/src/protagonist/API/Features/Image/AssetBeforeProcessing.cs b/src/protagonist/API/Features/Image/AssetBeforeProcessing.cs index b3f45680d..6df058193 100644 --- a/src/protagonist/API/Features/Image/AssetBeforeProcessing.cs +++ b/src/protagonist/API/Features/Image/AssetBeforeProcessing.cs @@ -1,16 +1,24 @@ +using DLCS.Model.Assets; + namespace API.Features.Image; public class AssetBeforeProcessing { - public AssetBeforeProcessing(DLCS.Model.Assets.Asset asset, DeliveryChannelsBeforeProcessing[] deliveryChannelsBeforeProcessing) + public AssetBeforeProcessing(Asset asset, DeliveryChannelsBeforeProcessing[] deliveryChannelsBeforeProcessing) { Asset = asset; DeliveryChannelsBeforeProcessing = deliveryChannelsBeforeProcessing; } - - public DLCS.Model.Assets.Asset Asset { get; init; } - public DeliveryChannelsBeforeProcessing[] DeliveryChannelsBeforeProcessing { get; init; } + public Asset Asset { get; } + + public DeliveryChannelsBeforeProcessing[] DeliveryChannelsBeforeProcessing { get; } } -public record DeliveryChannelsBeforeProcessing(string? Channel, string? Policy); \ No newline at end of file +/// +/// Represents DeliveryChannel information as provided in API request - channel and policy only prior to database +/// identifiers etc +/// +/// Channel (e.g. 'iiif-img', 'file' etc) +/// Name of policy (e.g. 'default', 'video-mp4-480p') +public record DeliveryChannelsBeforeProcessing(string Channel, string? Policy); \ No newline at end of file diff --git a/src/protagonist/API/Features/Image/ImageController.cs b/src/protagonist/API/Features/Image/ImageController.cs index cf5604f28..d55c0dd11 100644 --- a/src/protagonist/API/Features/Image/ImageController.cs +++ b/src/protagonist/API/Features/Image/ImageController.cs @@ -10,6 +10,7 @@ using DLCS.HydraModel; using Hydra.Model; using MediatR; +using FluentValidation; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -105,7 +106,9 @@ public async Task GetImage(int customerId, int spaceId, string im hydraAsset.ModelId = imageId; } - var validationResult = await validator.ValidateAsync(hydraAsset, cancellationToken); + var validationResult = await validator.ValidateAsync(hydraAsset, + strategy => strategy.IncludeRuleSets("default", "create"), cancellationToken); + if (!validationResult.IsValid) { return this.ValidationFailed(validationResult); @@ -150,6 +153,7 @@ public async Task GetImage(int customerId, int spaceId, string im [FromRoute] int spaceId, [FromRoute] string imageId, [FromBody] DLCS.HydraModel.Image hydraAsset, + [FromServices] HydraImageValidator validator, CancellationToken cancellationToken) { if (!apiSettings.DeliveryChannelsEnabled && !hydraAsset.WcDeliveryChannels.IsNullOrEmpty()) @@ -158,6 +162,13 @@ public async Task GetImage(int customerId, int spaceId, string im return this.HydraProblem("Delivery channels are disabled", assetId.ToString(), 400, "Bad Request"); } + var validationResult = await validator.ValidateAsync(hydraAsset, + strategy => strategy.IncludeRuleSets("default", "patch"), cancellationToken); + if (!validationResult.IsValid) + { + return this.ValidationFailed(validationResult); + } + return await PutOrPatchAsset(customerId, spaceId, imageId, hydraAsset, cancellationToken); } diff --git a/src/protagonist/API/Features/Image/Ingest/AssetProcessor.cs b/src/protagonist/API/Features/Image/Ingest/AssetProcessor.cs index b92bd1d79..e832730e1 100644 --- a/src/protagonist/API/Features/Image/Ingest/AssetProcessor.cs +++ b/src/protagonist/API/Features/Image/Ingest/AssetProcessor.cs @@ -1,13 +1,10 @@ -using System.Collections.Generic; +using API.Exceptions; using API.Features.Assets; using API.Infrastructure.Requests; using API.Settings; using DLCS.Core; using DLCS.Core.Collections; -using DLCS.Core.Strings; using DLCS.Model.Assets; -using DLCS.Model.DeliveryChannels; -using DLCS.Model.Policies; using DLCS.Model.Storage; using Microsoft.Extensions.Options; @@ -21,22 +18,18 @@ public class AssetProcessor { private readonly IApiAssetRepository assetRepository; private readonly IStorageRepository storageRepository; - private readonly IDefaultDeliveryChannelRepository defaultDeliveryChannelRepository; - private readonly IDeliveryChannelPolicyRepository deliveryChannelPolicyRepository; + private readonly DeliveryChannelProcessor deliveryChannelProcessor; private readonly ApiSettings settings; - private const string None = "none"; public AssetProcessor( IApiAssetRepository assetRepository, IStorageRepository storageRepository, - IDefaultDeliveryChannelRepository defaultDeliveryChannelRepository, - IDeliveryChannelPolicyRepository deliveryChannelPolicyRepository, + DeliveryChannelProcessor deliveryChannelProcessor, IOptionsMonitor apiSettings) { this.assetRepository = assetRepository; this.storageRepository = storageRepository; - this.defaultDeliveryChannelRepository = defaultDeliveryChannelRepository; - this.deliveryChannelPolicyRepository = deliveryChannelPolicyRepository; + this.deliveryChannelProcessor = deliveryChannelProcessor; settings = apiSettings.CurrentValue; } @@ -59,7 +52,8 @@ public class AssetProcessor Asset? existingAsset; try { - existingAsset = await assetRepository.GetAsset(assetBeforeProcessing.Asset.Id, noCache: true); + existingAsset = await assetRepository.GetAsset(assetBeforeProcessing.Asset.Id, true); + if (existingAsset == null) { if (mustExist) @@ -73,7 +67,8 @@ public class AssetProcessor }; } - var counts = await storageRepository.GetStorageMetrics(assetBeforeProcessing.Asset.Customer, cancellationToken); + var counts = + await storageRepository.GetStorageMetrics(assetBeforeProcessing.Asset.Customer, cancellationToken); if (!counts.CanStoreAsset()) { return new ProcessAssetResult @@ -84,8 +79,8 @@ public class AssetProcessor ) }; } - - if (!counts.CanStoreAssetSize(0,0)) + + if (!counts.CanStoreAssetSize(0, 0)) { return new ProcessAssetResult { @@ -98,9 +93,20 @@ public class AssetProcessor counts.CustomerStorage.NumberOfStoredImages++; } + else if (assetBeforeProcessing.DeliveryChannelsBeforeProcessing.IsNullOrEmpty() && alwaysReingest) + { + return new ProcessAssetResult + { + Result = ModifyEntityResult.Failure( + "Delivery channels are required when updating an existing Asset via PUT", + WriteResult.BadRequest + ) + }; + } var assetPreparationResult = - AssetPreparer.PrepareAssetForUpsert(existingAsset, assetBeforeProcessing.Asset, false, isBatchUpdate, settings.RestrictedAssetIdCharacters); + AssetPreparer.PrepareAssetForUpsert(existingAsset, assetBeforeProcessing.Asset, false, isBatchUpdate, + settings.RestrictedAssetIdCharacters); if (!assetPreparationResult.Success) { @@ -110,31 +116,15 @@ public class AssetProcessor WriteResult.FailedValidation) }; } - - var updatedAsset = assetPreparationResult.UpdatedAsset!; + + var updatedAsset = assetPreparationResult.UpdatedAsset!; // this is from Database var requiresEngineNotification = assetPreparationResult.RequiresReingest || alwaysReingest; - if (existingAsset == null) + var deliveryChannelChanged = await deliveryChannelProcessor.ProcessImageDeliveryChannels(existingAsset, + updatedAsset, assetBeforeProcessing.DeliveryChannelsBeforeProcessing); + if (deliveryChannelChanged) { - try - { - var deliveryChannelChanged = - SetImageDeliveryChannels(updatedAsset, assetBeforeProcessing.DeliveryChannelsBeforeProcessing); - if (deliveryChannelChanged) - { - requiresEngineNotification = true; - } - } - catch (InvalidOperationException) - { - return new ProcessAssetResult - { - Result = ModifyEntityResult.Failure( - "Failed to match delivery channel policy", - WriteResult.Error - ) - }; - } + requiresEngineNotification = true; } if (requiresEngineNotification) @@ -152,7 +142,7 @@ public class AssetProcessor } var assetAfterSave = await assetRepository.Save(updatedAsset, existingAsset != null, cancellationToken); - + return new ProcessAssetResult { ExistingAsset = existingAsset, @@ -161,77 +151,21 @@ public class AssetProcessor existingAsset == null ? WriteResult.Created : WriteResult.Updated) }; } - catch (Exception e) + catch (APIException apiEx) { + var resultStatus = (apiEx.StatusCode ?? 500) == 400 ? WriteResult.BadRequest : WriteResult.Error; return new ProcessAssetResult { - Result = ModifyEntityResult.Failure(e.Message, WriteResult.Error) + Result = ModifyEntityResult.Failure(apiEx.Message, resultStatus) }; } - } - - private bool SetImageDeliveryChannels(Asset updatedAsset, IList deliveryChannelsBeforeProcessing) - { - updatedAsset.ImageDeliveryChannels = new List(); - // Creation, set image delivery channels to default values for media type, if not already set - if (deliveryChannelsBeforeProcessing.IsNullOrEmpty()) - { - var matchedDeliveryChannels = - defaultDeliveryChannelRepository.MatchedDeliveryChannels(updatedAsset.MediaType!, updatedAsset.Space, updatedAsset.Customer); - - foreach (var deliveryChannel in matchedDeliveryChannels) - { - updatedAsset.ImageDeliveryChannels.Add(new ImageDeliveryChannel() - { - ImageId = updatedAsset.Id, - DeliveryChannelPolicyId = deliveryChannel.Id, - Channel = deliveryChannel.Channel - }); - } - return true; - } - - if (deliveryChannelsBeforeProcessing.Count(d => d.Channel == AssetDeliveryChannels.None) == 1) - { - var deliveryChannelPolicy = deliveryChannelPolicyRepository.RetrieveDeliveryChannelPolicy(updatedAsset.Customer, - AssetDeliveryChannels.None, None); - - updatedAsset.ImageDeliveryChannels.Add(new ImageDeliveryChannel() - { - ImageId = updatedAsset.Id, - DeliveryChannelPolicyId = deliveryChannelPolicy.Id, - Channel = AssetDeliveryChannels.None - }); - - return false; - } - - foreach (var deliveryChannel in deliveryChannelsBeforeProcessing) + catch (Exception e) { - DeliveryChannelPolicy deliveryChannelPolicy; - - if (deliveryChannel.Policy.IsNullOrEmpty()) - { - deliveryChannelPolicy = defaultDeliveryChannelRepository.MatchDeliveryChannelPolicyForChannel( - updatedAsset.MediaType!, updatedAsset.Space, updatedAsset.Customer, deliveryChannel.Channel); - } - else - { - deliveryChannelPolicy = deliveryChannelPolicyRepository.RetrieveDeliveryChannelPolicy( - updatedAsset.Customer, - deliveryChannel.Channel!, - deliveryChannel.Policy); - } - - updatedAsset.ImageDeliveryChannels.Add(new ImageDeliveryChannel() + return new ProcessAssetResult { - ImageId = updatedAsset.Id, - DeliveryChannelPolicyId = deliveryChannelPolicy.Id, - Channel = deliveryChannel.Channel - }); + Result = ModifyEntityResult.Failure(e.Message, WriteResult.Error) + }; } - - return true; } } diff --git a/src/protagonist/API/Features/Image/Ingest/DeliveryChannelProcessor.cs b/src/protagonist/API/Features/Image/Ingest/DeliveryChannelProcessor.cs new file mode 100644 index 000000000..8fcc01b5e --- /dev/null +++ b/src/protagonist/API/Features/Image/Ingest/DeliveryChannelProcessor.cs @@ -0,0 +1,209 @@ +using System.Collections.Generic; +using API.Exceptions; +using DLCS.Core.Collections; +using DLCS.Model.Assets; +using DLCS.Model.DeliveryChannels; +using DLCS.Model.Policies; +using Microsoft.Extensions.Logging; + +namespace API.Features.Image.Ingest; + +public class DeliveryChannelProcessor +{ + private readonly IDefaultDeliveryChannelRepository defaultDeliveryChannelRepository; + private readonly IDeliveryChannelPolicyRepository deliveryChannelPolicyRepository; + private readonly ILogger logger; + private const string FileNonePolicy = "none"; + + public DeliveryChannelProcessor(IDefaultDeliveryChannelRepository defaultDeliveryChannelRepository, + IDeliveryChannelPolicyRepository deliveryChannelPolicyRepository, ILogger logger) + { + this.defaultDeliveryChannelRepository = defaultDeliveryChannelRepository; + this.deliveryChannelPolicyRepository = deliveryChannelPolicyRepository; + this.logger = logger; + } + + /// + /// Update updatedAsset.ImageDeliveryChannels, adding/removing/updating as required to match channels specified in + /// deliveryChannelsBeforeProcessing + /// + /// Existing asset, if found (will only be present for updates) + /// + /// Asset that is existing asset (if update) or default asset (if create) with changes applied. + /// + /// List of deliveryChannels submitted in body + /// Boolean indicating whether asset requires processing Engine + public async Task ProcessImageDeliveryChannels(Asset? existingAsset, Asset updatedAsset, + DeliveryChannelsBeforeProcessing[] deliveryChannelsBeforeProcessing) + { + if (existingAsset == null || + DeliveryChannelsRequireReprocessing(existingAsset, deliveryChannelsBeforeProcessing)) + { + try + { + var deliveryChannelChanged = await SetImageDeliveryChannels(updatedAsset, + deliveryChannelsBeforeProcessing, existingAsset != null); + return deliveryChannelChanged; + } + catch (InvalidOperationException) + { + throw new APIException("Failed to match delivery channel policy") + { + StatusCode = 400 + }; + } + } + + return false; + } + + private bool DeliveryChannelsRequireReprocessing(Asset originalAsset, DeliveryChannelsBeforeProcessing[] deliveryChannelsBeforeProcessing) + { + if (originalAsset.ImageDeliveryChannels.Count != deliveryChannelsBeforeProcessing.Length) return true; + + foreach (var deliveryChannel in deliveryChannelsBeforeProcessing) + { + if (!originalAsset.ImageDeliveryChannels.Any(c => + c.Channel == deliveryChannel.Channel && + c.DeliveryChannelPolicy.Name == deliveryChannel.Policy)) + { + return true; + } + } + + return false; + } + + private async Task SetImageDeliveryChannels(Asset asset, DeliveryChannelsBeforeProcessing[] deliveryChannelsBeforeProcessing, bool isUpdate) + { + var assetId = asset.Id; + + if (!isUpdate) + { + logger.LogTrace("Asset {AssetId} is new, resetting ImageDeliveryChannels", assetId); + asset.ImageDeliveryChannels = new List(); + + // Only valid for creation - set image delivery channels to default values for media type + if (deliveryChannelsBeforeProcessing.IsNullOrEmpty()) + { + logger.LogDebug("Asset {AssetId} is new, no deliveryChannels specified. Assigning defaults for mediaType", + assetId); + await AddDeliveryChannelsForMediaType(asset); + return true; + } + } + + // If 'none' specified then it's the only valid option + if (deliveryChannelsBeforeProcessing.Count(d => d.Channel == AssetDeliveryChannels.None) == 1) + { + AddExplicitNoneChannel(asset); + return true; + } + + // Iterate through DeliveryChannels specified in payload and make necessary update/delete/insert + var changeMade = false; + var handledChannels = new List(); + var assetImageDeliveryChannels = asset.ImageDeliveryChannels; + foreach (var deliveryChannel in deliveryChannelsBeforeProcessing) + { + handledChannels.Add(deliveryChannel.Channel); + var deliveryChannelPolicy = await GetDeliveryChannelPolicy(asset, deliveryChannel); + var currentChannel = assetImageDeliveryChannels.SingleOrDefault(idc => idc.Channel == deliveryChannel.Channel); + + // No current ImageDeliveryChannel for channel so this is an addition + if (currentChannel == null) + { + logger.LogTrace("Adding new deliveryChannel {DeliveryChannel}, Policy {PolicyName} to Asset {AssetId}", + deliveryChannel.Channel, deliveryChannelPolicy.Name, assetId); + + assetImageDeliveryChannels.Add(new ImageDeliveryChannel + { + ImageId = assetId, + DeliveryChannelPolicyId = deliveryChannelPolicy.Id, + Channel = deliveryChannel.Channel + }); + changeMade = true; + } + else + { + // There is already a IDC for this thing - has the policy changed? + if (currentChannel.DeliveryChannelPolicyId != deliveryChannelPolicy.Id) + { + logger.LogTrace( + "Asset {AssetId} already has deliveryChannel {DeliveryChannel}, but policy changed from {OldPolicyName} to Asset {NewPolicyName}", + assetId, deliveryChannel.Channel, currentChannel.DeliveryChannelPolicy.Name, + deliveryChannelPolicy.Name); + currentChannel.DeliveryChannelPolicy = deliveryChannelPolicy; + changeMade = true; + } + } + } + + if (isUpdate) + { + // Remove any that are no longer part of the payload + foreach (var deletedChannel in assetImageDeliveryChannels.Where(idc => + !handledChannels.Contains(idc.Channel))) + { + logger.LogTrace("Removing deliveryChannel {DeliveryChannel}, from Asset {AssetId}", + deletedChannel.Channel, assetId); + assetImageDeliveryChannels.Remove(deletedChannel); + changeMade = true; + } + } + + return changeMade; + } + + private async Task GetDeliveryChannelPolicy(Asset asset, DeliveryChannelsBeforeProcessing deliveryChannel) + { + DeliveryChannelPolicy deliveryChannelPolicy; + if (deliveryChannel.Policy.IsNullOrEmpty()) + { + deliveryChannelPolicy = await defaultDeliveryChannelRepository.MatchDeliveryChannelPolicyForChannel( + asset.MediaType!, asset.Space, asset.Customer, deliveryChannel.Channel); + } + else + { + deliveryChannelPolicy = await deliveryChannelPolicyRepository.RetrieveDeliveryChannelPolicy( + asset.Customer, + deliveryChannel.Channel, + deliveryChannel.Policy); + } + + return deliveryChannelPolicy; + } + + private void AddExplicitNoneChannel(Asset asset) + { + logger.LogTrace("assigning 'none' channel for asset {AssetId}", asset.Id); + var deliveryChannelPolicy = deliveryChannelPolicyRepository.RetrieveDeliveryChannelPolicy(asset.Customer, + AssetDeliveryChannels.None, FileNonePolicy); + + // "none" channel can only exist on it's own so remove any others that may be there already prior to adding + asset.ImageDeliveryChannels.Clear(); + asset.ImageDeliveryChannels.Add(new ImageDeliveryChannel + { + ImageId = asset.Id, + DeliveryChannelPolicyId = deliveryChannelPolicy.Id, + Channel = AssetDeliveryChannels.None + }); + } + + private async Task AddDeliveryChannelsForMediaType(Asset asset) + { + var matchedDeliveryChannels = + await defaultDeliveryChannelRepository.MatchedDeliveryChannels(asset.MediaType!, asset.Space, + asset.Customer); + + foreach (var deliveryChannel in matchedDeliveryChannels) + { + asset.ImageDeliveryChannels.Add(new ImageDeliveryChannel + { + ImageId = asset.Id, + DeliveryChannelPolicyId = deliveryChannel.Id, + Channel = deliveryChannel.Channel + }); + } + } +} \ No newline at end of file diff --git a/src/protagonist/API/Features/Image/Requests/DeleteAsset.cs b/src/protagonist/API/Features/Image/Requests/DeleteAsset.cs index fc25c9b92..8983c96f6 100644 --- a/src/protagonist/API/Features/Image/Requests/DeleteAsset.cs +++ b/src/protagonist/API/Features/Image/Requests/DeleteAsset.cs @@ -1,4 +1,5 @@ -using API.Infrastructure.Messaging; +using API.Features.Assets; +using API.Infrastructure.Messaging; using DLCS.Core; using DLCS.Core.Types; using DLCS.Model; @@ -27,12 +28,12 @@ public DeleteAsset(int customer, int space, string imageId, ImageCacheType delet public class DeleteAssetHandler : IRequestHandler { private readonly IAssetNotificationSender assetNotificationSender; - private readonly IAssetRepository assetRepository; + private readonly IApiAssetRepository assetRepository; private readonly ILogger logger; public DeleteAssetHandler( IAssetNotificationSender assetNotificationSender, - IAssetRepository assetRepository, + IApiAssetRepository assetRepository, ILogger logger) { this.assetNotificationSender = assetNotificationSender; diff --git a/src/protagonist/API/Features/Image/Requests/GetAssetMetadata.cs b/src/protagonist/API/Features/Image/Requests/GetAssetMetadata.cs index 4cdaa3fbf..2857db842 100644 --- a/src/protagonist/API/Features/Image/Requests/GetAssetMetadata.cs +++ b/src/protagonist/API/Features/Image/Requests/GetAssetMetadata.cs @@ -1,4 +1,5 @@ using API.Exceptions; +using API.Features.Assets; using API.Infrastructure.Requests; using DLCS.AWS.ElasticTranscoder; using DLCS.AWS.ElasticTranscoder.Models.Job; @@ -26,11 +27,11 @@ public GetAssetMetadata(int customerId, int spaceId, string assetId) public class GetAssetMetadataHandler : IRequestHandler> { - private readonly IAssetRepository assetRepository; + private readonly IApiAssetRepository assetRepository; private readonly IElasticTranscoderWrapper elasticTranscoderWrapper; public GetAssetMetadataHandler( - IAssetRepository assetRepository, + IApiAssetRepository assetRepository, IElasticTranscoderWrapper elasticTranscoderWrapper) { this.assetRepository = assetRepository; diff --git a/src/protagonist/API/Features/Image/Requests/GetImage.cs b/src/protagonist/API/Features/Image/Requests/GetImage.cs index 0eae9293e..82ec28b59 100644 --- a/src/protagonist/API/Features/Image/Requests/GetImage.cs +++ b/src/protagonist/API/Features/Image/Requests/GetImage.cs @@ -1,3 +1,4 @@ +using API.Features.Assets; using DLCS.Core.Types; using DLCS.Model.Assets; using MediatR; @@ -17,11 +18,11 @@ public GetImage(AssetId assetId) public AssetId AssetId { get; } } -public class GetImageHandler : IRequestHandler +public class GetImageHandler : IRequestHandler { - private readonly IAssetRepository assetRepository; + private readonly IApiAssetRepository assetRepository; - public GetImageHandler(IAssetRepository assetRepository) + public GetImageHandler(IApiAssetRepository assetRepository) { this.assetRepository = assetRepository; } diff --git a/src/protagonist/API/Features/Image/Validation/HydraImageValidator.cs b/src/protagonist/API/Features/Image/Validation/HydraImageValidator.cs index fdb926fdb..5d7c57446 100644 --- a/src/protagonist/API/Features/Image/Validation/HydraImageValidator.cs +++ b/src/protagonist/API/Features/Image/Validation/HydraImageValidator.cs @@ -15,9 +15,19 @@ public class HydraImageValidator : AbstractValidator { public HydraImageValidator(IOptions apiSettings) { - // Required fields - RuleFor(a => a.MediaType).NotEmpty().WithMessage("Media type must be specified"); - + RuleSet("patch", () => + { + RuleFor(p => p.DeliveryChannels) + .Must(a => a!.Any()) + .When(a => a.DeliveryChannels != null) + .WithMessage("'deliveryChannels' cannot be an empty array when updating an existing asset via PATCH"); + }); + + RuleSet("create", () => + { + RuleFor(a => a.MediaType).NotEmpty().WithMessage("Media type must be specified"); + }); + When(a => !a.WcDeliveryChannels.IsNullOrEmpty(), DeliveryChannelDependantValidation) .Otherwise(() => { @@ -27,7 +37,7 @@ public HydraImageValidator(IOptions apiSettings) }); When(a => !a.DeliveryChannels.IsNullOrEmpty(), ImageDeliveryChannelDependantValidation); - + // System edited fields RuleFor(a => a.Batch).Empty().WithMessage("Should not include batch"); RuleFor(a => a.Finished).Empty().WithMessage("Should not include finished"); @@ -52,20 +62,20 @@ private void ImageDeliveryChannelDependantValidation() RuleFor(a => a.DeliveryChannels) .Must(d => d.All(d => d.Channel != AssetDeliveryChannels.None)) .When(a => a.DeliveryChannels!.Length > 1) - .WithMessage("If \"none\" is the specified channel, then no other delivery channels are allowed"); + .WithMessage("If 'none' is the specified channel, then no other delivery channels are allowed"); RuleForEach(a => a.DeliveryChannels) .Must(c => !string.IsNullOrEmpty(c.Channel)) - .WithMessage("\"channel\" must be specified when supplying delivery channels to an asset"); + .WithMessage("'channel' must be specified when supplying delivery channels to an asset"); RuleForEach(a => a.DeliveryChannels) - .Must((a, c) => AssetDeliveryChannels.IsChannelValidForMediaType(c.Channel!, a.MediaType!)) + .Must((a, c) => AssetDeliveryChannels.IsChannelValidForMediaType(c.Channel, a.MediaType!)) .When(a => !string.IsNullOrEmpty(a.MediaType)) - .WithMessage((a,c) => $"\"{c.Channel}\" is not a valid delivery channel for asset of type \"{a.MediaType}\""); + .WithMessage((a,c) => $"'{c.Channel}' is not a valid delivery channel for asset of type \"{a.MediaType}\""); RuleForEach(a => a.DeliveryChannels) .Must((a, c) => a.DeliveryChannels!.Count(dc => dc.Channel == c.Channel) <= 1) - .WithMessage("\"deliveryChannels\" cannot contain duplicate channels."); + .WithMessage("'deliveryChannels' cannot contain duplicate channels."); } // Validation rules that depend on DeliveryChannel being populated diff --git a/src/protagonist/API/Features/Image/Validation/ImageBatchPatchValidator.cs b/src/protagonist/API/Features/Image/Validation/ImageBatchPatchValidator.cs index 46eda18eb..7646b7e41 100644 --- a/src/protagonist/API/Features/Image/Validation/ImageBatchPatchValidator.cs +++ b/src/protagonist/API/Features/Image/Validation/ImageBatchPatchValidator.cs @@ -36,6 +36,7 @@ public ImageBatchPatchValidator(IOptions apiSettings) members.RuleFor(a => a.ImageOptimisationPolicy).Empty().WithMessage("Image optimisation policies cannot be set in a bulk patching operation"); members.RuleFor(a => a.MaxUnauthorised).Empty().WithMessage("MaxUnauthorised cannot be set in a bulk patching operation"); members.RuleFor(a => a.WcDeliveryChannels).Empty().WithMessage("Delivery channels cannot be set in a bulk patching operation"); + members.RuleFor(a => a.DeliveryChannels).Empty().WithMessage("Delivery channels cannot be set in a bulk patching operation"); members.RuleFor(a => a.ThumbnailPolicy).Empty().WithMessage("Thumbnail policy cannot be set in a bulk patching operation"); }); } diff --git a/src/protagonist/API/Features/Queues/Validation/QueuePostValidator.cs b/src/protagonist/API/Features/Queues/Validation/QueuePostValidator.cs index 51a6043be..89f170599 100644 --- a/src/protagonist/API/Features/Queues/Validation/QueuePostValidator.cs +++ b/src/protagonist/API/Features/Queues/Validation/QueuePostValidator.cs @@ -30,7 +30,8 @@ public QueuePostValidator(IOptions apiSettings) .Must(m => (m?.Length ?? 0) <= maxBatch) .WithMessage($"Maximum assets in single batch is {maxBatch}"); - RuleForEach(c => c.Members).SetValidator(new HydraImageValidator(apiSettings)); + RuleForEach(c => c.Members).SetValidator(new HydraImageValidator(apiSettings), + "default", "create"); // In addition to above validation, batched updates must have ModelId + Space as this can't be taken from // path diff --git a/src/protagonist/API/Infrastructure/ServiceCollectionX.cs b/src/protagonist/API/Infrastructure/ServiceCollectionX.cs index 96a381642..2c4a556ae 100644 --- a/src/protagonist/API/Infrastructure/ServiceCollectionX.cs +++ b/src/protagonist/API/Infrastructure/ServiceCollectionX.cs @@ -3,6 +3,7 @@ using API.Features.Assets; using API.Features.Customer; using API.Features.DeliveryChannels; +using API.Features.DeliveryChannels.DataAccess; using DLCS.AWS.Configuration; using DLCS.AWS.ElasticTranscoder; using DLCS.AWS.S3; @@ -89,11 +90,8 @@ public static IServiceCollection ConfigureMediatR(this IServiceCollection servic public static IServiceCollection AddDataAccess(this IServiceCollection services, IConfiguration configuration) => services .AddDlcsContext(configuration) - .AddScoped() - .AddScoped(provider => - ActivatorUtilities.CreateInstance( - provider, - provider.GetRequiredService())) + .AddSingleton() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/src/protagonist/API/Startup.cs b/src/protagonist/API/Startup.cs index 5e9d9d246..67ee296cd 100644 --- a/src/protagonist/API/Startup.cs +++ b/src/protagonist/API/Startup.cs @@ -74,6 +74,7 @@ public void ConfigureServices(IServiceCollection services) .AddScoped() .AddSingleton() .AddScoped() + .AddScoped() .AddTransient() .AddScoped() .AddValidatorsFromAssemblyContaining() diff --git a/src/protagonist/DLCS.HydraModel/DeliveryChannel.cs b/src/protagonist/DLCS.HydraModel/DeliveryChannel.cs index d2d5d762a..1c2390dda 100644 --- a/src/protagonist/DLCS.HydraModel/DeliveryChannel.cs +++ b/src/protagonist/DLCS.HydraModel/DeliveryChannel.cs @@ -10,11 +10,11 @@ namespace DLCS.HydraModel; public class DeliveryChannel : DlcsResource { public override string? Context => null; - + [RdfProperty(Description = "The name of the DLCS delivery channel this is based on.", Range = Names.XmlSchema.String, ReadOnly = false, WriteOnly = false)] [JsonProperty(Order = 11, PropertyName = "channel")] - public string? Channel { get; set; } + public string Channel { get; set; } = null!; [HydraLink(Description = "The policy assigned to this delivery channel.", Range = "vocab:deliveryChannelPolicy", ReadOnly = false, WriteOnly = false)] diff --git a/src/protagonist/DLCS.Model/Assets/IAssetRepository.cs b/src/protagonist/DLCS.Model/Assets/IAssetRepository.cs index 0a6fa7993..02956c2e9 100644 --- a/src/protagonist/DLCS.Model/Assets/IAssetRepository.cs +++ b/src/protagonist/DLCS.Model/Assets/IAssetRepository.cs @@ -5,11 +5,7 @@ namespace DLCS.Model.Assets; public interface IAssetRepository { - public Task GetAsset(AssetId id); - - public Task GetAsset(AssetId id, bool noCache); + public Task GetAsset(AssetId assetId); public Task GetImageLocation(AssetId assetId); - - public Task> DeleteAsset(AssetId assetId); } \ No newline at end of file diff --git a/src/protagonist/DLCS.Model/DeliveryChannels/IDefaultDeliveryChannelRepository.cs b/src/protagonist/DLCS.Model/DeliveryChannels/IDefaultDeliveryChannelRepository.cs index 00c6e9f31..a797f3c0d 100644 --- a/src/protagonist/DLCS.Model/DeliveryChannels/IDefaultDeliveryChannelRepository.cs +++ b/src/protagonist/DLCS.Model/DeliveryChannels/IDefaultDeliveryChannelRepository.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using DLCS.Model.Policies; namespace DLCS.Model.DeliveryChannels; @@ -12,7 +13,7 @@ public interface IDefaultDeliveryChannelRepository /// The space to check against /// The customer id /// A list of matched delivery channel policies - public List MatchedDeliveryChannels(string mediaType, int space, int customerId); + public Task> MatchedDeliveryChannels(string mediaType, int space, int customerId); /// /// Retrieves a delivery channel policy for a specific channel @@ -21,6 +22,7 @@ public interface IDefaultDeliveryChannelRepository /// The space to check against /// The customer id /// The channel the policy belongs to - /// A matched deliovery channel policy, or null when no matches - DeliveryChannelPolicy MatchDeliveryChannelPolicyForChannel(string mediaType, int space, int customerId, string? channel); + /// A matched delivery channel policy, or null when no matches + public Task MatchDeliveryChannelPolicyForChannel(string mediaType, int space, int customerId, + string? channel); } \ No newline at end of file diff --git a/src/protagonist/DLCS.Model/DeliveryChannels/IDeliveryChannelPolicyRepository.cs b/src/protagonist/DLCS.Model/DeliveryChannels/IDeliveryChannelPolicyRepository.cs index 903dfe8c0..4c3281812 100644 --- a/src/protagonist/DLCS.Model/DeliveryChannels/IDeliveryChannelPolicyRepository.cs +++ b/src/protagonist/DLCS.Model/DeliveryChannels/IDeliveryChannelPolicyRepository.cs @@ -1,4 +1,5 @@ -using DLCS.Model.Policies; +using System.Threading.Tasks; +using DLCS.Model.Policies; namespace DLCS.Model.DeliveryChannels; @@ -11,5 +12,5 @@ public interface IDeliveryChannelPolicyRepository /// The channel to retrieve the policy for /// The policy name, or url to retrieve the policy for /// A delivery channel policy - public DeliveryChannelPolicy RetrieveDeliveryChannelPolicy(int customer, string channel, string policy); + public Task RetrieveDeliveryChannelPolicy(int customer, string channel, string policy); } \ No newline at end of file diff --git a/src/protagonist/DLCS.Model/Policies/DeliveryChannelPolicy.cs b/src/protagonist/DLCS.Model/Policies/DeliveryChannelPolicy.cs index ba001492b..b2be7a437 100644 --- a/src/protagonist/DLCS.Model/Policies/DeliveryChannelPolicy.cs +++ b/src/protagonist/DLCS.Model/Policies/DeliveryChannelPolicy.cs @@ -58,5 +58,43 @@ public class DeliveryChannelPolicy /// /// List of delivery channels attached to the image /// - public virtual List ImageDeliveryChannels { get; set; } + public List ImageDeliveryChannels { get; set; } +} + +public static class KnownDeliveryChannelPolicies +{ + /// + /// DeliveryChannelPolicyId for "iiif-img" channel, "default" policy + /// + public const int ImageDefault = 1; + + /// + /// DeliveryChannelPolicyId for "iiif-img" channel, "use-original" policy + /// + public const int ImageUseOriginal = 2; + + /// + /// DeliveryChannelPolicyId for "thumbs" channel, "default" policy + /// + public const int ThumbsDefault = 3; + + /// + /// DeliveryChannelPolicyId for "file" channel, "none" policy + /// + public const int FileNone = 4; + + /// + /// DeliveryChannelPolicyId for "iiif-av" channel, "default-audio" policy + /// + public const int AvDefaultAudio = 5; + + /// + /// DeliveryChannelPolicyId for "iiif-av" channel, "default-video" policy + /// + public const int AvDefaultVideo = 6; + + /// + /// DeliveryChannelPolicyId for "none" channel, "none" policy + /// + public const int None = 7; } \ No newline at end of file diff --git a/src/protagonist/DLCS.Repository/Assets/AssetCachingHelper.cs b/src/protagonist/DLCS.Repository/Assets/AssetCachingHelper.cs new file mode 100644 index 000000000..6738072a0 --- /dev/null +++ b/src/protagonist/DLCS.Repository/Assets/AssetCachingHelper.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using DLCS.Core.Caching; +using DLCS.Core.Types; +using DLCS.Model.Assets; +using LazyCache; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DLCS.Repository.Assets; + +/// +/// Helper for working with cached assets +/// +public class AssetCachingHelper +{ + private readonly IAppCache appCache; + private readonly ILogger logger; + private readonly CacheSettings cacheSettings; + private static readonly Asset NullAsset = new() { Id = AssetId.Null }; + + public AssetCachingHelper(IAppCache appCache, IOptions cacheOptions, + ILogger logger) + { + this.appCache = appCache; + this.logger = logger; + cacheSettings = cacheOptions.Value; + } + + /// + /// Purge specified asset from cache + /// + public void RemoveAssetFromCache(AssetId assetId) => appCache.Remove(GetCacheKey(assetId)); + + /// + /// Use provided assetLoader function to load asset from underlying data source. Will cache null values for a short + /// duration. + /// + public async Task GetCachedAsset(AssetId assetId, Func> assetLoader, + CacheDuration cacheDuration = CacheDuration.Default) + { + var key = GetCacheKey(assetId); + + var asset = await appCache.GetOrAddAsync(key, async entry => + { + logger.LogDebug("Refreshing assetCache from database {Asset}", assetId); + var dbAsset = await assetLoader(assetId); + if (dbAsset == null) + { + entry.AbsoluteExpirationRelativeToNow = + TimeSpan.FromSeconds(cacheSettings.GetTtl(CacheDuration.Short)); + return NullAsset; + } + + return dbAsset; + }, cacheSettings.GetMemoryCacheOptions(cacheDuration)); + + return asset.Id == NullAsset.Id ? null : asset; + } + + private string GetCacheKey(AssetId assetId) => $"asset:{assetId}"; +} \ No newline at end of file diff --git a/src/protagonist/DLCS.Repository/Assets/AssetRepository.cs b/src/protagonist/DLCS.Repository/Assets/AssetRepository.cs deleted file mode 100644 index 34884eb80..000000000 --- a/src/protagonist/DLCS.Repository/Assets/AssetRepository.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System; -using System.Threading.Tasks; -using DLCS.Core; -using DLCS.Core.Caching; -using DLCS.Core.Types; -using DLCS.Model; -using DLCS.Model.Assets; -using DLCS.Model.Storage; -using DLCS.Repository.Entities; -using LazyCache; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace DLCS.Repository.Assets; - -/// -/// Implementation of using EFCore for data access. -/// -public class AssetRepository : AssetRepositoryCachingBase -{ - private readonly DlcsContext dlcsContext; - private readonly IEntityCounterRepository entityCounterRepository; - - public AssetRepository(DlcsContext dlcsContext, - IAppCache appCache, - IEntityCounterRepository entityCounterRepository, - IOptions cacheOptions, - ILogger logger) : base(appCache, cacheOptions, logger) - { - this.dlcsContext = dlcsContext; - this.entityCounterRepository = entityCounterRepository; - } - - public override async Task GetImageLocation(AssetId assetId) - => await dlcsContext.ImageLocations.FindAsync(assetId.ToString()); - - protected override async Task> DeleteAssetFromDatabase(AssetId assetId) - { - try - { - var asset = await dlcsContext.Images - .Include(a => a.ImageDeliveryChannels) - .SingleOrDefaultAsync(i => i.Id == assetId); - if (asset == null) - { - Logger.LogDebug("Attempt to delete non-existent asset {AssetId}", assetId); - return new DeleteEntityResult(DeleteResult.NotFound); - } - - // Delete Asset - dlcsContext.Images.Remove(asset); - - // And related ImageLocation - var imageLocation = await dlcsContext.ImageLocations.FindAsync(assetId); - - if (imageLocation != null) - { - dlcsContext.ImageLocations.Remove(imageLocation); - } - - var customer = assetId.Customer; - var space = assetId.Space; - - var imageStorage = - await dlcsContext.ImageStorages.FindAsync(assetId, customer, space); - if (imageStorage != null) - { - // And related ImageStorage record - dlcsContext.Remove(imageStorage); - } - else - { - Logger.LogInformation("No ImageStorage record found when deleting asset {AssetId}", assetId); - } - - void ReduceCustomerStorage(CustomerStorage customerStorage) - { - // And reduce CustomerStorage record - customerStorage.NumberOfStoredImages -= 1; - customerStorage.TotalSizeOfThumbnails -= imageStorage?.ThumbnailSize ?? 0; - customerStorage.TotalSizeOfStoredImages -= imageStorage?.Size ?? 0; - } - - // Reduce CustomerStorage for space - var customerSpaceStorage = await dlcsContext.CustomerStorages.FindAsync(customer, space); - if (customerSpaceStorage != null) ReduceCustomerStorage(customerSpaceStorage); - - // Reduce CustomerStorage for overall customer - var customerStorage = await dlcsContext.CustomerStorages.FindAsync(customer, 0); - if (customerStorage != null) ReduceCustomerStorage(customerStorage); - - var rowCount = await dlcsContext.SaveChangesAsync(); - if (rowCount == 0) - { - return new DeleteEntityResult(DeleteResult.NotFound); - } - - await entityCounterRepository.Decrement(customer, KnownEntityCounters.SpaceImages, space.ToString()); - await entityCounterRepository.Decrement(0, KnownEntityCounters.CustomerImages, customer.ToString()); - return new DeleteEntityResult(DeleteResult.Deleted, asset); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error deleting asset {AssetId}", assetId); - return new DeleteEntityResult(DeleteResult.Error); - } - } - - protected override async Task GetAssetFromDatabase(AssetId assetId) => - await dlcsContext.Images.AsNoTracking() - .Include(i => i.ImageDeliveryChannels) - .ThenInclude(i => i.DeliveryChannelPolicy) - .SingleOrDefaultAsync(i => i.Id == assetId); -} \ No newline at end of file diff --git a/src/protagonist/DLCS.Repository/Assets/AssetRepositoryCachingBase.cs b/src/protagonist/DLCS.Repository/Assets/AssetRepositoryCachingBase.cs deleted file mode 100644 index 7f0344914..000000000 --- a/src/protagonist/DLCS.Repository/Assets/AssetRepositoryCachingBase.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Threading.Tasks; -using DLCS.Core.Caching; -using DLCS.Core.Types; -using DLCS.Model; -using DLCS.Model.Assets; -using LazyCache; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace DLCS.Repository.Assets; - -/// -/// Base AssetRepository that manages caching/clearing/deleting items from underlying cache. -/// -public abstract class AssetRepositoryCachingBase : IAssetRepository -{ - protected readonly IAppCache AppCache; - protected readonly ILogger Logger; - protected readonly CacheSettings CacheSettings; - private static readonly Asset NullAsset = new() { Id = AssetId.Null }; - - public AssetRepositoryCachingBase(IAppCache appCache, IOptions cacheOptions, ILogger logger) - { - this.AppCache = appCache; - this.Logger = logger; - CacheSettings = cacheOptions.Value; - } - - public Task GetAsset(AssetId id) => GetAssetInternal(id); - - public Task GetAsset(AssetId id, bool noCache) => GetAssetInternal(id, noCache); - - public abstract Task GetImageLocation(AssetId assetId); - - public Task> DeleteAsset(AssetId assetId) - { - AppCache.Remove(GetCacheKey(assetId)); - - return DeleteAssetFromDatabase(assetId); - } - - public void FlushCache(AssetId assetId) => AppCache.Remove(GetCacheKey(assetId)); - - /// - /// Delete asset from database - /// - protected abstract Task> DeleteAssetFromDatabase(AssetId assetId); - - /// - /// Find asset in DB and materialise to object - /// - protected abstract Task GetAssetFromDatabase(AssetId assetId); - - private string GetCacheKey(AssetId assetId) => $"asset:{assetId}"; - - private async Task GetAssetInternal(AssetId assetId, bool noCache = false) - { - var key = GetCacheKey(assetId); - - if (noCache) - { - AppCache.Remove(key); - } - - var asset = await AppCache.GetOrAddAsync(key, async entry => - { - Logger.LogDebug("Refreshing assetCache from database {Asset}", assetId); - var dbAsset = await GetAssetFromDatabase(assetId); - if (dbAsset == null) - { - entry.AbsoluteExpirationRelativeToNow = - TimeSpan.FromSeconds(CacheSettings.GetTtl(CacheDuration.Short)); - return NullAsset; - } - - return dbAsset; - }, CacheSettings.GetMemoryCacheOptions()); - - return asset.Id == NullAsset.Id ? null : asset; - } -} \ No newline at end of file diff --git a/src/protagonist/DLCS.Repository/Assets/DapperAssetRepository.cs b/src/protagonist/DLCS.Repository/Assets/DapperAssetRepository.cs index 82382ce1e..5a510844e 100644 --- a/src/protagonist/DLCS.Repository/Assets/DapperAssetRepository.cs +++ b/src/protagonist/DLCS.Repository/Assets/DapperAssetRepository.cs @@ -1,41 +1,38 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using DLCS.Core.Caching; using DLCS.Core.Types; -using DLCS.Model; using DLCS.Model.Assets; -using LazyCache; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; namespace DLCS.Repository.Assets; /// /// Implementation of using Dapper for data access. /// -public class DapperAssetRepository : AssetRepositoryCachingBase, IDapperConfigRepository +public class DapperAssetRepository : IAssetRepository, IDapperConfigRepository { public IConfiguration Configuration { get; } + private readonly AssetCachingHelper assetCachingHelper; public DapperAssetRepository( IConfiguration configuration, - IAppCache appCache, - IOptions cacheOptions, - ILogger logger) : base(appCache, cacheOptions, logger) + AssetCachingHelper assetCachingHelper) { Configuration = configuration; + this.assetCachingHelper = assetCachingHelper; } - public override async Task GetImageLocation(AssetId assetId) + public async Task GetImageLocation(AssetId assetId) => await this.QuerySingleOrDefaultAsync(ImageLocationSql, new {Id = assetId.ToString()}); - - protected override Task> DeleteAssetFromDatabase(AssetId assetId) - => throw new NotImplementedException("Deleting assets via Dapper is not supported"); - - protected override async Task GetAssetFromDatabase(AssetId assetId) + + public async Task GetAsset(AssetId assetId) + { + var asset = await assetCachingHelper.GetCachedAsset(assetId, GetAssetInternal); + return asset; + } + + private async Task GetAssetInternal(AssetId assetId) { var id = assetId.ToString(); IEnumerable rawAsset = await this.QueryAsync(AssetSql, new { Id = id }); diff --git a/src/protagonist/DLCS.Repository/Migrations/20240201171503_Populating delivery channel tables with defaults.cs b/src/protagonist/DLCS.Repository/Migrations/20240201171503_Populating delivery channel tables with defaults.cs index 1e43ec85d..3238d4521 100644 --- a/src/protagonist/DLCS.Repository/Migrations/20240201171503_Populating delivery channel tables with defaults.cs +++ b/src/protagonist/DLCS.Repository/Migrations/20240201171503_Populating delivery channel tables with defaults.cs @@ -1,5 +1,6 @@ using System; using Microsoft.EntityFrameworkCore.Migrations; +using Ids = DLCS.Model.Policies.KnownDeliveryChannelPolicies; #nullable disable @@ -14,13 +15,13 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Id", "Channel", "Created", "Customer", "DisplayName", "Modified", "Name", "PolicyData", "System" }, values: new object[,] { - { 1, "iiif-img", DateTime.UtcNow, 1, "A default image policy", DateTime.UtcNow, "default", null, true }, - { 2, "iiif-img", DateTime.UtcNow, 1, "Use original at Image Server", DateTime.UtcNow, "use-original", null, true }, - { 3, "thumbs", DateTime.UtcNow, 1, "A default thumbs policy", DateTime.UtcNow, "default", "[\"!1024,1024\", \"!400,400\", \"!200,200\", \"!100,100\"]", false }, - { 4, "file", DateTime.UtcNow, 1, "No transformations", DateTime.UtcNow, "none", null, true }, - { 5, "iiif-av", DateTime.UtcNow, 1, "A default audio policy", DateTime.UtcNow, "default-audio", "[\"audio-mp3-128\"]", false }, - { 6, "iiif-av", DateTime.UtcNow, 1, "A default video policy", DateTime.UtcNow, "default-video", "[\"video-mp4-720p\"]", false }, - { 7, "none", DateTime.UtcNow, 1, "Empty channel", DateTime.UtcNow, "none", null, true } + { Ids.ImageDefault, "iiif-img", DateTime.UtcNow, 1, "A default image policy", DateTime.UtcNow, "default", null, true }, + { Ids.ImageUseOriginal, "iiif-img", DateTime.UtcNow, 1, "Use original at Image Server", DateTime.UtcNow, "use-original", null, true }, + { Ids.ThumbsDefault, "thumbs", DateTime.UtcNow, 1, "A default thumbs policy", DateTime.UtcNow, "default", "[\"!1024,1024\", \"!400,400\", \"!200,200\", \"!100,100\"]", false }, + { Ids.FileNone, "file", DateTime.UtcNow, 1, "No transformations", DateTime.UtcNow, "none", null, true }, + { Ids.AvDefaultAudio, "iiif-av", DateTime.UtcNow, 1, "A default audio policy", DateTime.UtcNow, "default-audio", "[\"audio-mp3-128\"]", false }, + { Ids.AvDefaultVideo, "iiif-av", DateTime.UtcNow, 1, "A default video policy", DateTime.UtcNow, "default-video", "[\"video-mp4-720p\"]", false }, + { Ids.None, "none", DateTime.UtcNow, 1, "Empty channel", DateTime.UtcNow, "none", null, true } }); migrationBuilder.InsertData( @@ -28,11 +29,11 @@ protected override void Up(MigrationBuilder migrationBuilder) columns: new[] { "Id", "Customer", "DeliveryChannelPolicyId", "MediaType", "Space" }, values: new object[,] { - { Guid.NewGuid(), 1, 4, "application/*", 0 }, - { Guid.NewGuid(), 1, 1, "image/*", 0 }, - { Guid.NewGuid(), 1, 6, "video/*", 0 }, - { Guid.NewGuid(), 1, 5, "audio/*", 0 }, - { Guid.NewGuid(), 1, 3, "image/*", 0 } + { Guid.NewGuid(), 1, Ids.FileNone, "application/*", 0 }, + { Guid.NewGuid(), 1, Ids.ImageDefault, "image/*", 0 }, + { Guid.NewGuid(), 1, Ids.AvDefaultVideo, "video/*", 0 }, + { Guid.NewGuid(), 1, Ids.AvDefaultAudio, "audio/*", 0 }, + { Guid.NewGuid(), 1, Ids.ThumbsDefault, "image/*", 0 } }); } diff --git a/src/protagonist/Engine.Tests/Ingest/IngestExecutorTests.cs b/src/protagonist/Engine.Tests/Ingest/IngestExecutorTests.cs index 41b8ce6ea..9651b05db 100644 --- a/src/protagonist/Engine.Tests/Ingest/IngestExecutorTests.cs +++ b/src/protagonist/Engine.Tests/Ingest/IngestExecutorTests.cs @@ -1,5 +1,6 @@ using DLCS.Model.Assets; using DLCS.Model.Customers; +using DLCS.Model.Policies; using DLCS.Model.Storage; using Engine.Data; using Engine.Ingest; @@ -204,6 +205,28 @@ public async Task IngestAsset_FirstWorkerFail_DoesNotCallFurtherWorkers(IngestRe result.Status.Should().Be(status); secondWorker.Called.Should().BeFalse(); } + + [Fact] + public async Task IngestAsset_SkipsProcessing_IfAssetHasNoneDeliveryChannel() + { + // Arrange + var asset = new Asset() + { + ImageDeliveryChannels = new[] + { + new ImageDeliveryChannel() + { + Channel = AssetDeliveryChannels.None + } + } + }; + + // Act + var result = await sut.IngestAsset(asset, customerOriginStrategy); + + // Assert + result.Status.Should().Be(IngestResultStatus.Success); + } } public class FakeWorker : IAssetIngesterWorker diff --git a/src/protagonist/Engine.Tests/Integration/ImageIngestTests.cs b/src/protagonist/Engine.Tests/Integration/ImageIngestTests.cs index 2124750c6..c30942252 100644 --- a/src/protagonist/Engine.Tests/Integration/ImageIngestTests.cs +++ b/src/protagonist/Engine.Tests/Integration/ImageIngestTests.cs @@ -6,6 +6,7 @@ using DLCS.Core.Types; using DLCS.Model.Assets; using DLCS.Model.Messaging; +using DLCS.Model.Policies; using DLCS.Repository; using DLCS.Repository.Strategy; using DLCS.Repository.Strategy.Utils; @@ -38,7 +39,7 @@ public class ImageIngestTests : IClassFixture> new ImageDeliveryChannel() { Channel = AssetDeliveryChannels.Image, - DeliveryChannelPolicyId = 1 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ImageDefault } }; diff --git a/src/protagonist/Engine.Tests/Integration/TimebasedIngestTests.cs b/src/protagonist/Engine.Tests/Integration/TimebasedIngestTests.cs index 4a7c5e5bd..3bc402209 100644 --- a/src/protagonist/Engine.Tests/Integration/TimebasedIngestTests.cs +++ b/src/protagonist/Engine.Tests/Integration/TimebasedIngestTests.cs @@ -8,6 +8,7 @@ using DLCS.Core.Types; using DLCS.Model.Assets; using DLCS.Model.Messaging; +using DLCS.Model.Policies; using DLCS.Repository; using DLCS.Repository.Strategy.Utils; using Engine.Tests.Integration.Infrastructure; @@ -33,6 +34,14 @@ public class TimebasedIngestTests : IClassFixture private static readonly TestBucketWriter BucketWriter = new(); private static readonly IElasticTranscoderWrapper ElasticTranscoderWrapper = A.Fake(); private readonly ApiStub apiStub; + private readonly List timebasedDeliveryChannels = new() + { + new ImageDeliveryChannel + { + Channel = AssetDeliveryChannels.Timebased, + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.AvDefaultVideo + } + }; public TimebasedIngestTests(ProtagonistAppFactory appFactory, EngineFixture engineFixture) { @@ -209,7 +218,7 @@ public async Task IngestAsset_SetsFileSizeCorrectly_IfAlsoAvailableForFileChanne new() { Channel = AssetDeliveryChannels.File, - DeliveryChannelPolicyId = 3 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.FileNone } }; diff --git a/src/protagonist/Engine/Ingest/IngestExecutor.cs b/src/protagonist/Engine/Ingest/IngestExecutor.cs index 73fa77c3e..0be21b132 100644 --- a/src/protagonist/Engine/Ingest/IngestExecutor.cs +++ b/src/protagonist/Engine/Ingest/IngestExecutor.cs @@ -34,9 +34,26 @@ public class IngestExecutor public async Task IngestAsset(Asset asset, CustomerOriginStrategy customerOriginStrategy, CancellationToken cancellationToken = default) { - var workers = workerBuilder.GetWorkers(asset); - var context = new IngestionContext(asset); + + // If the asset has the `none` delivery channel specified, skip processing and mark the ingest as being complete + if (asset.HasSingleDeliveryChannel(AssetDeliveryChannels.None)) + { + var imageStorage = new ImageStorage + { + Id = asset.Id, + Customer = asset.Customer, + Space = asset.Space, + Size = 0, + LastChecked = DateTime.UtcNow, + ThumbnailSize = 0, + }; + await assetRepository.UpdateIngestedAsset(context.Asset, null, imageStorage, + true, cancellationToken); + return new IngestResult(asset.Id, IngestResultStatus.Success); + } + + var workers = workerBuilder.GetWorkers(asset); var overallStatus = IngestResultStatus.Unknown; if (!assetIngestorSizeCheck.CustomerHasNoStorageCheck(asset.Customer)) diff --git a/src/protagonist/Orchestrator.Tests/Integration/FileHandlingTests.cs b/src/protagonist/Orchestrator.Tests/Integration/FileHandlingTests.cs index d576bfd9b..ad4e86133 100644 --- a/src/protagonist/Orchestrator.Tests/Integration/FileHandlingTests.cs +++ b/src/protagonist/Orchestrator.Tests/Integration/FileHandlingTests.cs @@ -9,6 +9,7 @@ using DLCS.Model.Assets; using DLCS.Model.Auth; using DLCS.Model.Customers; +using DLCS.Model.Policies; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -28,14 +29,13 @@ public class FileHandlingTests : IClassFixture> { private readonly DlcsDatabaseFixture dbFixture; private readonly HttpClient httpClient; - private readonly IAmazonS3 amazonS3; private readonly string stubAddress; private readonly List deliveryChannelsForFile = new() { new ImageDeliveryChannel() { Channel = AssetDeliveryChannels.File, - DeliveryChannelPolicyId = 4 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ImageDefault } }; @@ -46,8 +46,6 @@ public class FileHandlingTests : IClassFixture> public FileHandlingTests(ProtagonistAppFactory factory, OrchestratorFixture orchestratorFixture) { - amazonS3 = orchestratorFixture.LocalStackFixture.AWSS3ClientFactory(); - dbFixture = orchestratorFixture.DbFixture; stubAddress = orchestratorFixture.ApiStub.Address; httpClient = factory diff --git a/src/protagonist/Orchestrator.Tests/Integration/ImageHandlingTests.cs b/src/protagonist/Orchestrator.Tests/Integration/ImageHandlingTests.cs index 66b6c0a73..360bf499f 100644 --- a/src/protagonist/Orchestrator.Tests/Integration/ImageHandlingTests.cs +++ b/src/protagonist/Orchestrator.Tests/Integration/ImageHandlingTests.cs @@ -11,6 +11,7 @@ using DLCS.Core.Types; using DLCS.Model.Assets; using DLCS.Model.Auth.Entities; +using DLCS.Model.Policies; using IIIF; using IIIF.ImageApi; using IIIF.ImageApi.V2; @@ -49,7 +50,7 @@ public class ImageHandlingTests : IClassFixture> new ImageDeliveryChannel() { Channel = AssetDeliveryChannels.Image, - DeliveryChannelPolicyId = 1 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ImageDefault } }; @@ -1608,7 +1609,7 @@ await dbFixture.DbContext.Images.AddTestAsset(id, imageDeliveryChannels: new Lis new() { Channel = AssetDeliveryChannels.File, - DeliveryChannelPolicyId = 3 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.FileNone } }); await dbFixture.DbContext.SaveChangesAsync(); diff --git a/src/protagonist/Orchestrator.Tests/Integration/RefreshInfoJsonHandlingTests.cs b/src/protagonist/Orchestrator.Tests/Integration/RefreshInfoJsonHandlingTests.cs index 4baba88d5..5c68cbeba 100644 --- a/src/protagonist/Orchestrator.Tests/Integration/RefreshInfoJsonHandlingTests.cs +++ b/src/protagonist/Orchestrator.Tests/Integration/RefreshInfoJsonHandlingTests.cs @@ -6,6 +6,7 @@ using Amazon.S3.Model; using DLCS.Core.Types; using DLCS.Model.Assets; +using DLCS.Model.Policies; using IIIF.ImageApi.V3; using IIIF.Serialisation; using Microsoft.AspNetCore.Mvc.Testing; @@ -66,7 +67,7 @@ public async Task GetInfoJson_Refreshed_IfAlreadyInS3_ButOutOfDate() new() { Channel = AssetDeliveryChannels.Image, - DeliveryChannelPolicyId = 1 + DeliveryChannelPolicyId = KnownDeliveryChannelPolicies.ImageDefault } }); await dbFixture.DbContext.SaveChangesAsync(); diff --git a/src/protagonist/Orchestrator.Tests/Integration/TimebasedHandlingTests.cs b/src/protagonist/Orchestrator.Tests/Integration/TimebasedHandlingTests.cs index cebc94a06..13b9fc530 100644 --- a/src/protagonist/Orchestrator.Tests/Integration/TimebasedHandlingTests.cs +++ b/src/protagonist/Orchestrator.Tests/Integration/TimebasedHandlingTests.cs @@ -6,6 +6,7 @@ using DLCS.Core.Collections; using DLCS.Core.Types; using DLCS.Model.Assets; +using DLCS.Model.Policies; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; using Orchestrator.Tests.Integration.Infrastructure; @@ -28,7 +29,7 @@ public class TimebasedHandlingTests : IClassFixture services .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddScoped() diff --git a/src/protagonist/Thumbs/Startup.cs b/src/protagonist/Thumbs/Startup.cs index 7c64fb9e5..b8b90f0a1 100644 --- a/src/protagonist/Thumbs/Startup.cs +++ b/src/protagonist/Thumbs/Startup.cs @@ -54,6 +54,7 @@ public void ConfigureServices(IServiceCollection services) .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddTransient();