diff --git a/src/Api/IInputDataPlugin.cs b/src/Api/IInputDataPlugin.cs new file mode 100644 index 000000000..c2b5b1f9d --- /dev/null +++ b/src/Api/IInputDataPlugin.cs @@ -0,0 +1,32 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Threading.Tasks; +using FellowOakDicom; +using Monai.Deploy.InformaticsGateway.Api.Storage; + +namespace Monai.Deploy.InformaticsGateway.Api +{ + /// + /// IInputDataPlugin enables lightweight data processing over incoming data received from supported data ingestion + /// services. + /// Refer to for additional details. + /// + public interface IInputDataPlugin + { + Task<(DicomFile dicomFile, FileStorageMetadata fileMetadata)> Execute(DicomFile dicomFile, FileStorageMetadata fileMetadata); + } +} diff --git a/src/Api/IInputDataPluginEngine.cs b/src/Api/IInputDataPluginEngine.cs new file mode 100644 index 000000000..b84ccfc65 --- /dev/null +++ b/src/Api/IInputDataPluginEngine.cs @@ -0,0 +1,40 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Collections.Generic; +using System.Threading.Tasks; +using FellowOakDicom; +using Monai.Deploy.InformaticsGateway.Api.Storage; + +namespace Monai.Deploy.InformaticsGateway.Api +{ + /// + /// IInputDataPluginEngine processes incoming data receivied from various supported services through + /// a list of plug-ins based on . + /// Rules: + /// + /// A list of plug-ins can be included with each export request, and each plug-in is executed in the order stored, processing one file at a time, enabling piping of the data before each file is exported. + /// Plugins MUST be lightweight and not hinder the export process + /// Plugins SHALL not accumulate files in memory or storage for bulk processing + /// + /// + public interface IInputDataPluginEngine + { + void Configure(IReadOnlyList pluginAssemblies); + + Task<(DicomFile dicomFile, FileStorageMetadata fileMetadata)> ExecutePlugins(DicomFile dicomFile, FileStorageMetadata fileMetadata); + } +} diff --git a/src/Api/MonaiApplicationEntity.cs b/src/Api/MonaiApplicationEntity.cs index f250d1f9d..68050a8ee 100644 --- a/src/Api/MonaiApplicationEntity.cs +++ b/src/Api/MonaiApplicationEntity.cs @@ -72,6 +72,8 @@ public class MonaiApplicationEntity : MongoDBEntityBase /// public List Workflows { get; set; } = default!; + public List PluginAssemblies { get; set; } = default!; + /// /// Optional field to specify SOP Class UIDs to ignore. /// and are mutually exclusive. @@ -128,6 +130,8 @@ public void SetDefaultValues() IgnoredSopClasses ??= new List(); AllowedSopClasses ??= new List(); + + PluginAssemblies ??= new List(); } public override string ToString() diff --git a/src/Database/EntityFramework/Configuration/MonaiApplicationEntityConfiguration.cs b/src/Database/EntityFramework/Configuration/MonaiApplicationEntityConfiguration.cs index ecd8edc7b..264ce54c2 100644 --- a/src/Database/EntityFramework/Configuration/MonaiApplicationEntityConfiguration.cs +++ b/src/Database/EntityFramework/Configuration/MonaiApplicationEntityConfiguration.cs @@ -1,5 +1,5 @@ /* - * Copyright 2021-2022 MONAI Consortium + * Copyright 2021-2023 MONAI Consortium * Copyright 2021 NVIDIA Corporation * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -25,6 +25,7 @@ namespace Monai.Deploy.InformaticsGateway.Database.EntityFramework.Configuration { #pragma warning disable CS8604, CS8603 + internal class MonaiApplicationEntityConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) @@ -51,6 +52,11 @@ public void Configure(EntityTypeBuilder builder) v => JsonSerializer.Serialize(v, jsonSerializerSettings), v => JsonSerializer.Deserialize>(v, jsonSerializerSettings)) .Metadata.SetValueComparer(valueComparer); + builder.Property(j => j.PluginAssemblies) + .HasConversion( + v => JsonSerializer.Serialize(v, jsonSerializerSettings), + v => JsonSerializer.Deserialize>(v, jsonSerializerSettings)) + .Metadata.SetValueComparer(valueComparer); builder.Property(j => j.IgnoredSopClasses) .HasConversion( v => JsonSerializer.Serialize(v, jsonSerializerSettings), @@ -67,5 +73,6 @@ public void Configure(EntityTypeBuilder builder) builder.Ignore(p => p.Id); } } + #pragma warning restore CS8604, CS8603 } diff --git a/src/Database/EntityFramework/Migrations/20230802003305_R4_0.4.0.Designer.cs b/src/Database/EntityFramework/Migrations/20230802003305_R4_0.4.0.Designer.cs new file mode 100644 index 000000000..96573a26a --- /dev/null +++ b/src/Database/EntityFramework/Migrations/20230802003305_R4_0.4.0.Designer.cs @@ -0,0 +1,326 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Monai.Deploy.InformaticsGateway.Database.EntityFramework; + +#nullable disable + +namespace Monai.Deploy.InformaticsGateway.Database.Migrations +{ + [DbContext(typeof(InformaticsGatewayContext))] + [Migration("20230802003305_R4_0.4.0")] + partial class R4_040 + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.15"); + + modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.DestinationApplicationEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("AeTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.Property("DateTimeCreated") + .HasColumnType("TEXT"); + + b.Property("DateTimeUpdated") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.HasIndex(new[] { "Name" }, "idx_destination_name") + .IsUnique(); + + b.HasIndex(new[] { "Name", "AeTitle", "HostIp", "Port" }, "idx_source_all") + .IsUnique(); + + b.ToTable("DestinationApplicationEntities"); + }); + + modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.DicomAssociationInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CalledAeTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CallingAeTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateTimeCreated") + .HasColumnType("TEXT"); + + b.Property("DateTimeDisconnected") + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Errors") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("RemoteHost") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RemotePort") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("DicomAssociationHistories"); + }); + + modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.MonaiApplicationEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnOrder(0); + + b.Property("AeTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AllowedSopClasses") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.Property("DateTimeCreated") + .HasColumnType("TEXT"); + + b.Property("DateTimeUpdated") + .HasColumnType("TEXT"); + + b.Property("Grouping") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IgnoredSopClasses") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PluginAssemblies") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timeout") + .HasColumnType("INTEGER"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT"); + + b.Property("Workflows") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.HasIndex(new[] { "Name" }, "idx_monaiae_name") + .IsUnique(); + + b.ToTable("MonaiApplicationEntities"); + }); + + modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.Rest.InferenceRequest", b => + { + b.Property("InferenceRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.Property("DateTimeCreated") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InputMetadata") + .HasColumnType("TEXT"); + + b.Property("InputResources") + .HasColumnType("TEXT"); + + b.Property("OutputResources") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TransactionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TryCount") + .HasColumnType("INTEGER"); + + b.HasKey("InferenceRequestId"); + + b.HasIndex(new[] { "InferenceRequestId" }, "idx_inferencerequest_inferencerequestid") + .IsUnique(); + + b.HasIndex(new[] { "State" }, "idx_inferencerequest_state"); + + b.HasIndex(new[] { "TransactionId" }, "idx_inferencerequest_transactionid") + .IsUnique(); + + b.ToTable("InferenceRequests"); + }); + + modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.SourceApplicationEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("AeTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasColumnType("TEXT"); + + b.Property("DateTimeCreated") + .HasColumnType("TEXT"); + + b.Property("DateTimeUpdated") + .HasColumnType("TEXT"); + + b.Property("HostIp") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedBy") + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.HasIndex(new[] { "Name", "AeTitle", "HostIp" }, "idx_source_all") + .IsUnique() + .HasDatabaseName("idx_source_all1"); + + b.HasIndex(new[] { "Name" }, "idx_source_name") + .IsUnique(); + + b.ToTable("SourceApplicationEntities"); + }); + + modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Api.Storage.Payload", b => + { + b.Property("PayloadId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateTimeCreated") + .HasColumnType("TEXT"); + + b.Property("Files") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MachineName") + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("Timeout") + .HasColumnType("INTEGER"); + + b.HasKey("PayloadId"); + + b.HasIndex(new[] { "CorrelationId", "PayloadId" }, "idx_payload_ids") + .IsUnique(); + + b.HasIndex(new[] { "State" }, "idx_payload_state"); + + b.ToTable("Payloads"); + }); + + modelBuilder.Entity("Monai.Deploy.InformaticsGateway.Database.Api.StorageMetadataWrapper", b => + { + b.Property("CorrelationId") + .HasColumnType("TEXT"); + + b.Property("Identity") + .HasColumnType("TEXT"); + + b.Property("DateTimeCreated") + .HasColumnType("TEXT"); + + b.Property("IsUploaded") + .HasColumnType("INTEGER"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("CorrelationId", "Identity"); + + b.HasIndex(new[] { "CorrelationId" }, "idx_storagemetadata_correlation"); + + b.HasIndex(new[] { "CorrelationId", "Identity" }, "idx_storagemetadata_ids"); + + b.HasIndex(new[] { "IsUploaded" }, "idx_storagemetadata_uploaded"); + + b.ToTable("StorageMetadataWrapperEntities"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Database/EntityFramework/Migrations/20230802003305_R4_0.4.0.cs b/src/Database/EntityFramework/Migrations/20230802003305_R4_0.4.0.cs new file mode 100644 index 000000000..5b0483c71 --- /dev/null +++ b/src/Database/EntityFramework/Migrations/20230802003305_R4_0.4.0.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Monai.Deploy.InformaticsGateway.Database.Migrations +{ + public partial class R4_040 : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PluginAssemblies", + table: "MonaiApplicationEntities", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PluginAssemblies", + table: "MonaiApplicationEntities"); + } + } +} diff --git a/src/Database/EntityFramework/Migrations/InformaticsGatewayContextModelSnapshot.cs b/src/Database/EntityFramework/Migrations/InformaticsGatewayContextModelSnapshot.cs index cc545bd79..b6b55d4ac 100644 --- a/src/Database/EntityFramework/Migrations/InformaticsGatewayContextModelSnapshot.cs +++ b/src/Database/EntityFramework/Migrations/InformaticsGatewayContextModelSnapshot.cs @@ -133,6 +133,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); + b.Property("PluginAssemblies") + .IsRequired() + .HasColumnType("TEXT"); + b.Property("Timeout") .HasColumnType("INTEGER"); diff --git a/src/Database/EntityFramework/Test/MonaiApplicationEntityRepositoryTest.cs b/src/Database/EntityFramework/Test/MonaiApplicationEntityRepositoryTest.cs index 372484845..d25eca352 100644 --- a/src/Database/EntityFramework/Test/MonaiApplicationEntityRepositoryTest.cs +++ b/src/Database/EntityFramework/Test/MonaiApplicationEntityRepositoryTest.cs @@ -1,5 +1,5 @@ /* - * Copyright 2022 MONAI Consortium + * Copyright 2022-2023 MONAI Consortium * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -70,7 +70,8 @@ public async Task GivenAMonaiApplicationEntity_WhenAddingToDatabase_ExpectItToBe AllowedSopClasses = new List { "1", "2", "3" }, Workflows = new List { "W1", "W2" }, Grouping = "G", - IgnoredSopClasses = new List { "4", "5" } + IgnoredSopClasses = new List { "4", "5" }, + PluginAssemblies = new List { "AssemblyA", "AssemblyB", "AssemblyC" }, }; var store = new MonaiApplicationEntityRepository(_serviceScopeFactory.Object, _logger.Object, _options); @@ -132,7 +133,7 @@ public async Task GivenAMonaiApplicationEntity_WhenRemoveIsCalled_ExpectItToDele } [Fact] - public async Task GivenDestinationApplicationEntitiesInTheDatabase_WhenToListIsCalled_ExpectAllEntitiesToBeReturned() + public async Task GivenMonaiApplicationEntitiesInTheDatabase_WhenToListIsCalled_ExpectAllEntitiesToBeReturned() { var store = new MonaiApplicationEntityRepository(_serviceScopeFactory.Object, _logger.Object, _options); diff --git a/src/InformaticsGateway/Common/PlugingLoadingException.cs b/src/InformaticsGateway/Common/PlugingLoadingException.cs new file mode 100644 index 000000000..dea15d6c1 --- /dev/null +++ b/src/InformaticsGateway/Common/PlugingLoadingException.cs @@ -0,0 +1,31 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; + +namespace Monai.Deploy.InformaticsGateway.Common +{ + public class PlugingLoadingException : Exception + { + public PlugingLoadingException(string message) : base(message) + { + } + + public PlugingLoadingException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/InformaticsGateway/Common/SR.cs b/src/InformaticsGateway/Common/SR.cs new file mode 100644 index 000000000..e23ec6041 --- /dev/null +++ b/src/InformaticsGateway/Common/SR.cs @@ -0,0 +1,27 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.IO; +using System; + +namespace Monai.Deploy.InformaticsGateway.Common +{ + internal static class SR + { + public const string PlugInDirectoryName = "plug-ins"; + public static readonly string PlugInDirectoryPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, SR.PlugInDirectoryName); + } +} diff --git a/src/InformaticsGateway/Common/TypeExtensions.cs b/src/InformaticsGateway/Common/TypeExtensions.cs new file mode 100644 index 000000000..662e394c8 --- /dev/null +++ b/src/InformaticsGateway/Common/TypeExtensions.cs @@ -0,0 +1,77 @@ +/* + * Copyright 2022-2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Ardalis.GuardClauses; +using Microsoft.Extensions.DependencyInjection; + +namespace Monai.Deploy.InformaticsGateway.Common +{ + public static class TypeExtensions + { + public static T CreateInstance(this Type type, IServiceProvider serviceProvider, params object[] parameters) + { + Guard.Against.Null(type, nameof(type)); + Guard.Against.Null(serviceProvider, nameof(serviceProvider)); + + return (T)ActivatorUtilities.CreateInstance(serviceProvider, type, parameters); + } + + public static T CreateInstance(this Type interfaceType, IServiceProvider serviceProvider, string typeString, params object[] parameters) + { + Guard.Against.Null(interfaceType, nameof(interfaceType)); + Guard.Against.Null(serviceProvider, nameof(serviceProvider)); + Guard.Against.NullOrWhiteSpace(typeString, nameof(typeString)); + + var type = interfaceType.GetType(typeString); + var processor = ActivatorUtilities.CreateInstance(serviceProvider, type, parameters); + + return (T)processor; + } + + public static Type GetType(this Type interfaceType, string typeString) + { + Guard.Against.Null(interfaceType, nameof(interfaceType)); + Guard.Against.NullOrWhiteSpace(typeString, nameof(typeString)); + + var type = Type.GetType( + typeString, + (name) => + { + var assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(z => !string.IsNullOrWhiteSpace(z.FullName) && z.FullName.StartsWith(name.FullName)); + + assembly ??= Assembly.LoadFile(Path.Combine(SR.PlugInDirectoryPath, $"{name.Name}.dll")); + + return assembly; + }, + null, + true); + + if (type is not null && + (type.IsSubclassOf(interfaceType) || + (type.BaseType is not null && type.BaseType.IsAssignableTo(interfaceType)) || + (type.GetInterfaces().Contains(interfaceType)))) + { + return type; + } + + throw new NotSupportedException($"{typeString} is not a sub-type of {interfaceType.Name}"); + } + } +} diff --git a/src/InformaticsGateway/Program.cs b/src/InformaticsGateway/Program.cs index d2c37df1d..3c3e04ee4 100644 --- a/src/InformaticsGateway/Program.cs +++ b/src/InformaticsGateway/Program.cs @@ -1,5 +1,5 @@ /* - * Copyright 2021-2022 MONAI Consortium + * Copyright 2021-2023 MONAI Consortium * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Monai.Deploy.InformaticsGateway.Api; using Monai.Deploy.InformaticsGateway.Common; using Monai.Deploy.InformaticsGateway.Configuration; using Monai.Deploy.InformaticsGateway.Database; @@ -111,6 +112,7 @@ internal static IHostBuilder CreateHostBuilder(string[] args) => services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddMonaiDeployStorageService(hostContext.Configuration.GetSection("InformaticsGateway:storage:serviceAssemblyName").Value, Monai.Deploy.Storage.HealthCheckOptions.ServiceHealthCheck); diff --git a/src/InformaticsGateway/Services/Common/InputDataPluginEngine.cs b/src/InformaticsGateway/Services/Common/InputDataPluginEngine.cs new file mode 100644 index 000000000..11b714469 --- /dev/null +++ b/src/InformaticsGateway/Services/Common/InputDataPluginEngine.cs @@ -0,0 +1,85 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FellowOakDicom; +using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Api; +using Monai.Deploy.InformaticsGateway.Api.Storage; +using Monai.Deploy.InformaticsGateway.Common; + +namespace Monai.Deploy.InformaticsGateway.Services.Common +{ + internal class InputDataPluginEngine : IInputDataPluginEngine + { + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private IReadOnlyList _plugsins; + + public InputDataPluginEngine(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Configure(IReadOnlyList pluginAssemblies) + { + _plugsins = LoadPlugins(_serviceProvider, pluginAssemblies); + } + + public async Task<(DicomFile dicomFile, FileStorageMetadata fileMetadata)> ExecutePlugins(DicomFile dicomFile, FileStorageMetadata fileMetadata) + { + if (_plugsins == null) + { + throw new ApplicationException("InputDataPluginEngine not configured, please call Configure() first."); + } + + foreach (var plugin in _plugsins) + { + (dicomFile, fileMetadata) = await plugin.Execute(dicomFile, fileMetadata).ConfigureAwait(false); + } + + return (dicomFile, fileMetadata); + } + + private static IReadOnlyList LoadPlugins(IServiceProvider serviceProvider, IReadOnlyList pluginAssemblies) + { + var exceptions = new List(); + var list = new List(); + foreach (var plugin in pluginAssemblies) + { + try + { + list.Add(typeof(IInputDataPlugin).CreateInstance(serviceProvider, typeString: plugin)); + } + catch (Exception ex) + { + exceptions.Add(new PlugingLoadingException($"Error loading plug-in '{plugin}'.", ex)); + } + } + + if (exceptions.Any()) + { + throw new AggregateException("Error loading plug-in(s).", exceptions); + } + + return list; + } + } +} diff --git a/src/InformaticsGateway/Services/Scp/ApplicationEntityHandler.cs b/src/InformaticsGateway/Services/Scp/ApplicationEntityHandler.cs index b2d9ca5af..a668bdfcc 100644 --- a/src/InformaticsGateway/Services/Scp/ApplicationEntityHandler.cs +++ b/src/InformaticsGateway/Services/Scp/ApplicationEntityHandler.cs @@ -35,7 +35,7 @@ namespace Monai.Deploy.InformaticsGateway.Services.Scp { internal class ApplicationEntityHandler : IDisposable, IApplicationEntityHandler { - private readonly object _lock = new object(); + private readonly object _lock = new(); private readonly ILogger _logger; private readonly IOptions _options; @@ -43,6 +43,7 @@ internal class ApplicationEntityHandler : IDisposable, IApplicationEntityHandler private readonly IPayloadAssembler _payloadAssembler; private readonly IObjectUploadQueue _uploadQueue; private readonly IFileSystem _fileSystem; + private readonly IInputDataPluginEngine _pluginEngine; private MonaiApplicationEntity _configuration; private DicomJsonOptions _dicomJsonOptions; private bool _validateDicomValueOnJsonSerialization; @@ -61,6 +62,7 @@ public ApplicationEntityHandler( _payloadAssembler = _serviceScope.ServiceProvider.GetService() ?? throw new ServiceNotFoundException(nameof(IPayloadAssembler)); _uploadQueue = _serviceScope.ServiceProvider.GetService() ?? throw new ServiceNotFoundException(nameof(IObjectUploadQueue)); _fileSystem = _serviceScope.ServiceProvider.GetService() ?? throw new ServiceNotFoundException(nameof(IFileSystem)); + _pluginEngine = _serviceScope.ServiceProvider.GetService() ?? throw new ServiceNotFoundException(nameof(IInputDataPluginEngine)); } public void Configure(MonaiApplicationEntity monaiApplicationEntity, DicomJsonOptions dicomJsonOptions, bool validateDicomValuesOnJsonSerialization) @@ -79,6 +81,7 @@ public void Configure(MonaiApplicationEntity monaiApplicationEntity, DicomJsonOp _configuration = monaiApplicationEntity; _dicomJsonOptions = dicomJsonOptions; _validateDicomValueOnJsonSerialization = validateDicomValuesOnJsonSerialization; + _pluginEngine.Configure(_configuration.PluginAssemblies); } } @@ -112,11 +115,14 @@ public async Task HandleInstanceAsync(DicomCStoreRequest request, string calledA dicomInfo.SetWorkflows(_configuration.Workflows.ToArray()); } - await dicomInfo.SetDataStreams(request.File, request.File.ToJson(_dicomJsonOptions, _validateDicomValueOnJsonSerialization), _options.Value.Storage.TemporaryDataStorage, _fileSystem, _options.Value.Storage.LocalTemporaryStoragePath).ConfigureAwait(false); + var (dicomFile, fileMetadata) = await _pluginEngine.ExecutePlugins(request.File, dicomInfo).ConfigureAwait(false); + + dicomInfo = fileMetadata as DicomFileStorageMetadata; + await dicomInfo.SetDataStreams(dicomFile, dicomFile.ToJson(_dicomJsonOptions, _validateDicomValueOnJsonSerialization), _options.Value.Storage.TemporaryDataStorage, _fileSystem, _options.Value.Storage.LocalTemporaryStoragePath).ConfigureAwait(false); var dicomTag = FellowOakDicom.DicomTag.Parse(_configuration.Grouping); _logger.QueueInstanceUsingDicomTag(dicomTag); - var key = request.Dataset.GetSingleValue(dicomTag); + var key = dicomFile.Dataset.GetSingleValue(dicomTag); var payloadid = await _payloadAssembler.Queue(key, dicomInfo, _configuration.Timeout).ConfigureAwait(false); dicomInfo.PayloadId = payloadid.ToString(); diff --git a/src/InformaticsGateway/Services/Scu/ScuService.cs b/src/InformaticsGateway/Services/Scu/ScuService.cs index 6ca72f240..a23b4ca2a 100644 --- a/src/InformaticsGateway/Services/Scu/ScuService.cs +++ b/src/InformaticsGateway/Services/Scu/ScuService.cs @@ -56,7 +56,7 @@ public ScuService(IServiceScopeFactory serviceScopeFactory, _workQueue = _scope.ServiceProvider.GetService() ?? throw new ServiceNotFoundException(nameof(IScuQueue)); } - private async Task BackgroundProcessingAsync(CancellationToken cancellationToken) + private Task BackgroundProcessingAsync(CancellationToken cancellationToken) { _logger.ServiceRunning(ServiceName); while (!cancellationToken.IsCancellationRequested) @@ -83,11 +83,12 @@ private async Task BackgroundProcessingAsync(CancellationToken cancellationToken } Status = ServiceStatus.Cancelled; _logger.ServiceCancelled(ServiceName); + return Task.CompletedTask; } private void ProcessThread(ScuWorkRequest request, CancellationToken cancellationToken) { - Task.Run(() => Process(request, cancellationToken)); + Task.Run(() => Process(request, cancellationToken), cancellationToken); } private async Task Process(ScuWorkRequest request, CancellationToken cancellationToken) diff --git a/src/InformaticsGateway/Test/Monai.Deploy.InformaticsGateway.Test.csproj b/src/InformaticsGateway/Test/Monai.Deploy.InformaticsGateway.Test.csproj index abc369e6a..52256569c 100644 --- a/src/InformaticsGateway/Test/Monai.Deploy.InformaticsGateway.Test.csproj +++ b/src/InformaticsGateway/Test/Monai.Deploy.InformaticsGateway.Test.csproj @@ -1,5 +1,5 @@  + + + + + net6.0 + enable + enable + + + + + + + diff --git a/src/InformaticsGateway/Test/Plugins/TestInputDataPlugins.cs b/src/InformaticsGateway/Test/Plugins/TestInputDataPlugins.cs new file mode 100644 index 000000000..0bf5afec2 --- /dev/null +++ b/src/InformaticsGateway/Test/Plugins/TestInputDataPlugins.cs @@ -0,0 +1,45 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using FellowOakDicom; +using Monai.Deploy.InformaticsGateway.Api; +using Monai.Deploy.InformaticsGateway.Api.Storage; + +namespace Monai.Deploy.InformaticsGateway.Test.Plugins +{ + public class TestInputDataPluginAddWorkflow : IInputDataPlugin + { + public static readonly string TestString = "TestInputDataPlugin executed!"; + + public Task<(DicomFile dicomFile, FileStorageMetadata fileMetadata)> Execute(DicomFile dicomFile, FileStorageMetadata fileMetadata) + { + fileMetadata.Workflows.Add(TestString); + return Task.FromResult((dicomFile, fileMetadata)); + } + } + + public class TestInputDataPluginModifyDicomFile : IInputDataPlugin + { + public static readonly DicomTag ExpectedTag = DicomTag.PatientAddress; + public static readonly string ExpectedValue = "Aborted by TestInputDataPluginModifyCorrelationId"; + + public Task<(DicomFile dicomFile, FileStorageMetadata fileMetadata)> Execute(DicomFile dicomFile, FileStorageMetadata fileMetadata) + { + dicomFile.Dataset.Add(ExpectedTag, ExpectedValue); + return Task.FromResult((dicomFile, fileMetadata)); + } + } +} diff --git a/src/InformaticsGateway/Test/Services/Common/InputDataPluginEngineTest.cs b/src/InformaticsGateway/Test/Services/Common/InputDataPluginEngineTest.cs new file mode 100644 index 000000000..d80cce0ee --- /dev/null +++ b/src/InformaticsGateway/Test/Services/Common/InputDataPluginEngineTest.cs @@ -0,0 +1,144 @@ +/* + * Copyright 2023 MONAI Consortium + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FellowOakDicom; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Monai.Deploy.InformaticsGateway.Api.Storage; +using Monai.Deploy.InformaticsGateway.Common; +using Monai.Deploy.InformaticsGateway.Services.Common; +using Monai.Deploy.InformaticsGateway.Test.Plugins; +using Moq; +using Xunit; + +namespace Monai.Deploy.InformaticsGateway.Test.Services.Common +{ + public class InputDataPluginEngineTest + { + private readonly Mock> _logger; + private readonly Mock _serviceScopeFactory; + private readonly Mock _serviceScope; + private readonly ServiceProvider _serviceProvider; + + public InputDataPluginEngineTest() + { + _logger = new Mock>(); + _serviceScopeFactory = new Mock(); + _serviceScope = new Mock(); + + var services = new ServiceCollection(); + services.AddScoped(p => _logger.Object); + + _serviceProvider = services.BuildServiceProvider(); + _serviceScopeFactory.Setup(p => p.CreateScope()).Returns(_serviceScope.Object); + _serviceScope.Setup(p => p.ServiceProvider).Returns(_serviceProvider); + + _logger.Setup(p => p.IsEnabled(It.IsAny())).Returns(true); + } + + [Fact] + public void GivenAnInputDataPluginEngine_WhenInitialized_ExpectParametersToBeValidated() + { + Assert.Throws(() => new InputDataPluginEngine(null, null)); + Assert.Throws(() => new InputDataPluginEngine(_serviceProvider, null)); + + _ = new InputDataPluginEngine(_serviceProvider, _logger.Object); + } + + [Fact] + public void GivenAnInputDataPluginEngine_WhenConfigureIsCalledWithBogusAssemblies_ThrowsException() + { + var pluginEngine = new InputDataPluginEngine(_serviceProvider, _logger.Object); + var assemblies = new List() { "SomeBogusAssemblye" }; + + var exceptions = Assert.Throws(() => pluginEngine.Configure(assemblies)); + + Assert.Single(exceptions.InnerExceptions); + Assert.True(exceptions.InnerException is PlugingLoadingException); + Assert.Contains("Error loading plug-in 'SomeBogusAssemblye'", exceptions.InnerException.Message); + } + + [Fact] + public void GivenAnInputDataPluginEngine_WhenConfigureIsCalledWithAValidAssembly_ExpectNoExceptions() + { + var pluginEngine = new InputDataPluginEngine(_serviceProvider, _logger.Object); + var assemblies = new List() { typeof(TestInputDataPluginAddWorkflow).AssemblyQualifiedName }; + + pluginEngine.Configure(assemblies); + } + + [Fact] + public async Task GivenAnInputDataPluginEngine_WhenExecutePluginsIsCalledWithoutConfigure_ThrowsException() + { + var pluginEngine = new InputDataPluginEngine(_serviceProvider, _logger.Object); + var assemblies = new List() { typeof(TestInputDataPluginAddWorkflow).AssemblyQualifiedName }; + + var dicomFile = GenerateDicomFile(); + var dicomInfo = new DicomFileStorageMetadata( + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString(), + dicomFile.Dataset.GetString(DicomTag.StudyInstanceUID), + dicomFile.Dataset.GetString(DicomTag.SeriesInstanceUID), + dicomFile.Dataset.GetString(DicomTag.SOPInstanceUID)); + + await Assert.ThrowsAsync(async () => await pluginEngine.ExecutePlugins(dicomFile, dicomInfo)); + } + + [Fact] + public async Task GivenAnInputDataPluginEngine_WhenExecutePluginsIsCalled_ExpectDataIsProcessedByPluginAsync() + { + var pluginEngine = new InputDataPluginEngine(_serviceProvider, _logger.Object); + var assemblies = new List() + { + typeof(TestInputDataPluginAddWorkflow).AssemblyQualifiedName, + typeof(TestInputDataPluginModifyDicomFile).AssemblyQualifiedName + }; + + pluginEngine.Configure(assemblies); + + var dicomFile = GenerateDicomFile(); + var dicomInfo = new DicomFileStorageMetadata( + Guid.NewGuid().ToString(), + Guid.NewGuid().ToString(), + dicomFile.Dataset.GetString(DicomTag.StudyInstanceUID), + dicomFile.Dataset.GetString(DicomTag.SeriesInstanceUID), + dicomFile.Dataset.GetString(DicomTag.SOPInstanceUID)); + + var (resultDicomFile, resultDicomInfo) = await pluginEngine.ExecutePlugins(dicomFile, dicomInfo); + + Assert.Equal(resultDicomFile, dicomFile); + Assert.Equal(resultDicomInfo, dicomInfo); + Assert.True(dicomInfo.Workflows.Contains(TestInputDataPluginAddWorkflow.TestString)); + Assert.Equal(TestInputDataPluginModifyDicomFile.ExpectedValue, resultDicomFile.Dataset.GetString(TestInputDataPluginModifyDicomFile.ExpectedTag)); + } + + private static DicomFile GenerateDicomFile() + { + var dataset = new DicomDataset + { + { DicomTag.PatientID, "PID" }, + { DicomTag.StudyInstanceUID, DicomUIDGenerator.GenerateDerivedFromUUID() }, + { DicomTag.SeriesInstanceUID, DicomUIDGenerator.GenerateDerivedFromUUID() }, + { DicomTag.SOPInstanceUID, DicomUIDGenerator.GenerateDerivedFromUUID() }, + { DicomTag.SOPClassUID, DicomUID.SecondaryCaptureImageStorage.UID } + }; + return new DicomFile(dataset); + } + } +} diff --git a/src/InformaticsGateway/Test/Services/Scp/ApplicationEntityHandlerTest.cs b/src/InformaticsGateway/Test/Services/Scp/ApplicationEntityHandlerTest.cs index 96e465187..388037aaf 100644 --- a/src/InformaticsGateway/Test/Services/Scp/ApplicationEntityHandlerTest.cs +++ b/src/InformaticsGateway/Test/Services/Scp/ApplicationEntityHandlerTest.cs @@ -31,6 +31,7 @@ using Monai.Deploy.InformaticsGateway.Services.Connectors; using Monai.Deploy.InformaticsGateway.Services.Scp; using Monai.Deploy.InformaticsGateway.Services.Storage; +using Monai.Deploy.InformaticsGateway.Test.Plugins; using Moq; using xRetry; using Xunit; @@ -43,6 +44,7 @@ public class ApplicationEntityHandlerTest private readonly Mock _serviceScopeFactory; private readonly Mock _serviceScope; + private readonly Mock _inputDataPluginEngine; private readonly Mock _payloadAssembler; private readonly Mock _uploadQueue; private readonly IOptions _options; @@ -54,6 +56,7 @@ public ApplicationEntityHandlerTest() _logger = new Mock>(); _serviceScopeFactory = new Mock(); _serviceScope = new Mock(); + _inputDataPluginEngine = new Mock(); _payloadAssembler = new Mock(); _uploadQueue = new Mock(); @@ -64,6 +67,10 @@ public ApplicationEntityHandlerTest() services.AddScoped(p => _payloadAssembler.Object); services.AddScoped(p => _uploadQueue.Object); services.AddScoped(p => _fileSystem); + services.AddScoped(p => _inputDataPluginEngine.Object); + + _inputDataPluginEngine.Setup(p => p.Configure(It.IsAny>())); + _serviceProvider = services.BuildServiceProvider(); _serviceScopeFactory.Setup(p => p.CreateScope()).Returns(_serviceScope.Object); _serviceScope.Setup(p => p.ServiceProvider).Returns(_serviceProvider); @@ -157,7 +164,8 @@ public async Task GivenACStoreRequest_WhenHandleInstanceAsyncIsCalled_ExpectADic { AeTitle = "TESTAET", Name = "TESTAET", - Workflows = new List() { "AppA", "AppB", Guid.NewGuid().ToString() } + Workflows = new List() { "AppA", "AppB", Guid.NewGuid().ToString() }, + PluginAssemblies = new List() { typeof(TestInputDataPluginAddWorkflow).AssemblyQualifiedName } }; var handler = new ApplicationEntityHandler(_serviceScopeFactory.Object, _logger.Object, _options); @@ -166,11 +174,15 @@ public async Task GivenACStoreRequest_WhenHandleInstanceAsyncIsCalled_ExpectADic var request = GenerateRequest(); var dicomToolkit = new DicomToolkit(); var uids = dicomToolkit.GetStudySeriesSopInstanceUids(request.File); + _inputDataPluginEngine.Setup(p => p.ExecutePlugins(It.IsAny(), It.IsAny())) + .Returns((DicomFile dicomFile, FileStorageMetadata fileMetadata) => Task.FromResult((dicomFile, fileMetadata))); await handler.HandleInstanceAsync(request, aet.AeTitle, "CALLING", Guid.NewGuid(), uids); _uploadQueue.Verify(p => p.Queue(It.IsAny()), Times.Once()); _payloadAssembler.Verify(p => p.Queue(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + _inputDataPluginEngine.Verify(p => p.Configure(It.IsAny>()), Times.Once()); + _inputDataPluginEngine.Verify(p => p.ExecutePlugins(It.IsAny(), It.IsAny()), Times.Once()); } [RetryFact(5, 250)] diff --git a/src/InformaticsGateway/Test/packages.lock.json b/src/InformaticsGateway/Test/packages.lock.json index 4d2dc7524..c01b8d746 100644 --- a/src/InformaticsGateway/Test/packages.lock.json +++ b/src/InformaticsGateway/Test/packages.lock.json @@ -1897,132 +1897,138 @@ "monai.deploy.informaticsgateway": { "type": "Project", "dependencies": { - "Ardalis.GuardClauses": "4.0.1", - "DotNext.Threading": "4.7.4", - "HL7-dotnetcore": "2.35.0", - "Karambolo.Extensions.Logging.File": "3.4.0", - "Microsoft.EntityFrameworkCore": "6.0.15", - "Microsoft.Extensions.DependencyInjection.Abstractions": "6.0.0", - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "6.0.15", - "Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "6.0.15", - "Microsoft.Extensions.Hosting": "6.0.1", - "Microsoft.Extensions.Logging": "6.0.0", - "Microsoft.Extensions.Logging.Console": "6.0.0", - "Microsoft.Extensions.Options": "6.0.0", - "Monai.Deploy.InformaticsGateway.Api": "1.0.0", - "Monai.Deploy.InformaticsGateway.Common": "1.0.0", - "Monai.Deploy.InformaticsGateway.Configuration": "1.0.0", - "Monai.Deploy.InformaticsGateway.Database": "1.0.0", - "Monai.Deploy.InformaticsGateway.Database.Api": "1.0.0", - "Monai.Deploy.InformaticsGateway.Database.EntityFramework": "1.0.0", - "Monai.Deploy.InformaticsGateway.DicomWeb.Client": "1.0.0", - "Monai.Deploy.Messaging.RabbitMQ": "0.1.22", - "Monai.Deploy.Security": "0.1.3", - "Monai.Deploy.Storage": "0.2.16", - "Monai.Deploy.Storage.MinIO": "0.2.16", - "NLog": "5.1.3", - "NLog.Web.AspNetCore": "5.2.3", - "Polly": "7.2.3", - "Swashbuckle.AspNetCore": "6.5.0", - "fo-dicom": "5.0.3", - "fo-dicom.NLog": "5.0.3" + "Ardalis.GuardClauses": "[4.0.1, )", + "DotNext.Threading": "[4.7.4, )", + "HL7-dotnetcore": "[2.35.0, )", + "Karambolo.Extensions.Logging.File": "[3.4.0, )", + "Microsoft.EntityFrameworkCore": "[6.0.15, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[6.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "[6.0.15, )", + "Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "[6.0.15, )", + "Microsoft.Extensions.Hosting": "[6.0.1, )", + "Microsoft.Extensions.Logging": "[6.0.0, )", + "Microsoft.Extensions.Logging.Console": "[6.0.0, )", + "Microsoft.Extensions.Options": "[6.0.0, )", + "Monai.Deploy.InformaticsGateway.Api": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Common": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Configuration": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Database": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Database.Api": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Database.EntityFramework": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.DicomWeb.Client": "[1.0.0, )", + "Monai.Deploy.Messaging.RabbitMQ": "[0.1.22, )", + "Monai.Deploy.Security": "[0.1.3, )", + "Monai.Deploy.Storage": "[0.2.16, )", + "Monai.Deploy.Storage.MinIO": "[0.2.16, )", + "NLog": "[5.1.3, )", + "NLog.Web.AspNetCore": "[5.2.3, )", + "Polly": "[7.2.3, )", + "Swashbuckle.AspNetCore": "[6.5.0, )", + "fo-dicom": "[5.0.3, )", + "fo-dicom.NLog": "[5.0.3, )" } }, "monai.deploy.informaticsgateway.api": { "type": "Project", "dependencies": { - "Macross.Json.Extensions": "3.0.0", - "Microsoft.EntityFrameworkCore.Abstractions": "6.0.15", - "Monai.Deploy.InformaticsGateway.Common": "1.0.0", - "Monai.Deploy.Messaging": "0.1.22", - "Monai.Deploy.Storage": "0.2.16" + "Macross.Json.Extensions": "[3.0.0, )", + "Microsoft.EntityFrameworkCore.Abstractions": "[6.0.15, )", + "Monai.Deploy.InformaticsGateway.Common": "[1.0.0, )", + "Monai.Deploy.Messaging": "[0.1.22, )", + "Monai.Deploy.Storage": "[0.2.16, )" } }, "monai.deploy.informaticsgateway.client.common": { "type": "Project", "dependencies": { - "Ardalis.GuardClauses": "4.0.1", - "System.Text.Json": "6.0.7" + "Ardalis.GuardClauses": "[4.0.1, )", + "System.Text.Json": "[6.0.7, )" } }, "monai.deploy.informaticsgateway.common": { "type": "Project", "dependencies": { - "Ardalis.GuardClauses": "4.0.1", - "System.IO.Abstractions": "17.2.3", - "System.Threading.Tasks.Dataflow": "6.0.0", - "fo-dicom": "5.0.3" + "Ardalis.GuardClauses": "[4.0.1, )", + "System.IO.Abstractions": "[17.2.3, )", + "System.Threading.Tasks.Dataflow": "[6.0.0, )", + "fo-dicom": "[5.0.3, )" } }, "monai.deploy.informaticsgateway.configuration": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "6.0.3", - "Microsoft.Extensions.Options": "6.0.0", - "Monai.Deploy.InformaticsGateway.Api": "1.0.0", - "Monai.Deploy.InformaticsGateway.Common": "1.0.0", - "Monai.Deploy.Messaging": "0.1.22", - "Monai.Deploy.Storage": "0.2.16", - "System.IO.Abstractions": "17.2.3" + "Microsoft.Extensions.Logging.Abstractions": "[6.0.3, )", + "Microsoft.Extensions.Options": "[6.0.0, )", + "Monai.Deploy.InformaticsGateway.Api": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Common": "[1.0.0, )", + "Monai.Deploy.Messaging": "[0.1.22, )", + "Monai.Deploy.Storage": "[0.2.16, )", + "System.IO.Abstractions": "[17.2.3, )" } }, "monai.deploy.informaticsgateway.database": { "type": "Project", "dependencies": { - "AspNetCore.HealthChecks.MongoDb": "6.0.2", - "Microsoft.EntityFrameworkCore": "6.0.15", - "Microsoft.Extensions.Configuration": "6.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "6.0.0", - "Microsoft.Extensions.Configuration.Json": "6.0.0", - "Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "6.0.15", - "Microsoft.Extensions.Options.ConfigurationExtensions": "6.0.0", - "Monai.Deploy.InformaticsGateway.Api": "1.0.0", - "Monai.Deploy.InformaticsGateway.Configuration": "1.0.0", - "Monai.Deploy.InformaticsGateway.Database.Api": "1.0.0", - "Monai.Deploy.InformaticsGateway.Database.EntityFramework": "1.0.0", - "Monai.Deploy.InformaticsGateway.Database.MongoDB": "1.0.0" + "AspNetCore.HealthChecks.MongoDb": "[6.0.2, )", + "Microsoft.EntityFrameworkCore": "[6.0.15, )", + "Microsoft.Extensions.Configuration": "[6.0.1, )", + "Microsoft.Extensions.Configuration.FileExtensions": "[6.0.0, )", + "Microsoft.Extensions.Configuration.Json": "[6.0.0, )", + "Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "[6.0.15, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[6.0.0, )", + "Monai.Deploy.InformaticsGateway.Api": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Configuration": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Database.Api": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Database.EntityFramework": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Database.MongoDB": "[1.0.0, )" } }, "monai.deploy.informaticsgateway.database.api": { "type": "Project", "dependencies": { - "Microsoft.EntityFrameworkCore": "6.0.15", - "Monai.Deploy.InformaticsGateway.Api": "1.0.0", - "Monai.Deploy.InformaticsGateway.Configuration": "1.0.0", - "Polly": "7.2.3" + "Microsoft.EntityFrameworkCore": "[6.0.15, )", + "Monai.Deploy.InformaticsGateway.Api": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Configuration": "[1.0.0, )", + "Polly": "[7.2.3, )" } }, "monai.deploy.informaticsgateway.database.entityframework": { "type": "Project", "dependencies": { - "Microsoft.EntityFrameworkCore": "6.0.15", - "Microsoft.EntityFrameworkCore.Sqlite": "6.0.15", - "Microsoft.Extensions.Configuration": "6.0.1", - "Microsoft.Extensions.Configuration.FileExtensions": "6.0.0", - "Microsoft.Extensions.Configuration.Json": "6.0.0", - "Monai.Deploy.InformaticsGateway.Api": "1.0.0", - "Monai.Deploy.InformaticsGateway.Configuration": "1.0.0", - "Monai.Deploy.InformaticsGateway.Database.Api": "1.0.0" + "Microsoft.EntityFrameworkCore": "[6.0.15, )", + "Microsoft.EntityFrameworkCore.Sqlite": "[6.0.15, )", + "Microsoft.Extensions.Configuration": "[6.0.1, )", + "Microsoft.Extensions.Configuration.FileExtensions": "[6.0.0, )", + "Microsoft.Extensions.Configuration.Json": "[6.0.0, )", + "Monai.Deploy.InformaticsGateway.Api": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Configuration": "[1.0.0, )", + "Monai.Deploy.InformaticsGateway.Database.Api": "[1.0.0, )" } }, "monai.deploy.informaticsgateway.database.mongodb": { "type": "Project", "dependencies": { - "Monai.Deploy.InformaticsGateway.Database.Api": "1.0.0", - "MongoDB.Driver": "2.19.1", - "MongoDB.Driver.Core": "2.19.1" + "Monai.Deploy.InformaticsGateway.Database.Api": "[1.0.0, )", + "MongoDB.Driver": "[2.19.1, )", + "MongoDB.Driver.Core": "[2.19.1, )" } }, "monai.deploy.informaticsgateway.dicomweb.client": { "type": "Project", "dependencies": { - "Ardalis.GuardClauses": "4.0.1", - "Microsoft.AspNet.WebApi.Client": "5.2.9", - "Microsoft.Extensions.Http": "6.0.0", - "Microsoft.Net.Http.Headers": "2.2.8", - "Monai.Deploy.InformaticsGateway.Client.Common": "1.0.0", - "System.Linq.Async": "6.0.1", - "fo-dicom": "5.0.3" + "Ardalis.GuardClauses": "[4.0.1, )", + "Microsoft.AspNet.WebApi.Client": "[5.2.9, )", + "Microsoft.Extensions.Http": "[6.0.0, )", + "Microsoft.Net.Http.Headers": "[2.2.8, )", + "Monai.Deploy.InformaticsGateway.Client.Common": "[1.0.0, )", + "System.Linq.Async": "[6.0.1, )", + "fo-dicom": "[5.0.3, )" + } + }, + "monai.deploy.informaticsgateway.test.plugins": { + "type": "Project", + "dependencies": { + "Monai.Deploy.InformaticsGateway.Api": "[1.0.0, )" } } } diff --git a/src/Monai.Deploy.InformaticsGateway.sln b/src/Monai.Deploy.InformaticsGateway.sln index 1bf686dba..5e7a9e4b3 100644 --- a/src/Monai.Deploy.InformaticsGateway.sln +++ b/src/Monai.Deploy.InformaticsGateway.sln @@ -54,7 +54,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Monai.Deploy.InformaticsGat EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Monai.Deploy.InformaticsGateway.Database.MongoDB", "Database\MongoDB\Monai.Deploy.InformaticsGateway.Database.MongoDB.csproj", "{5ED73EEA-4DFA-426D-82E8-AA24D3CB4C31}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Monai.Deploy.InformaticsGateway.Database.MongoDB.Integration.Test", "Database\MongoDB\Integration.Test\Monai.Deploy.InformaticsGateway.Database.MongoDB.Integration.Test.csproj", "{2F849556-44B6-484A-B612-CB0FA5D29AC6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Monai.Deploy.InformaticsGateway.Database.MongoDB.Integration.Test", "Database\MongoDB\Integration.Test\Monai.Deploy.InformaticsGateway.Database.MongoDB.Integration.Test.csproj", "{2F849556-44B6-484A-B612-CB0FA5D29AC6}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Monai.Deploy.InformaticsGateway.Test.Plugins", "InformaticsGateway\Test\Plugins\Monai.Deploy.InformaticsGateway.Test.Plugins.csproj", "{7E735CE9-CE74-450E-A4E8-060D185DDEF1}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -366,6 +368,18 @@ Global {2F849556-44B6-484A-B612-CB0FA5D29AC6}.Release|x64.Build.0 = Release|Any CPU {2F849556-44B6-484A-B612-CB0FA5D29AC6}.Release|x86.ActiveCfg = Release|Any CPU {2F849556-44B6-484A-B612-CB0FA5D29AC6}.Release|x86.Build.0 = Release|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Debug|x64.Build.0 = Debug|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Debug|x86.Build.0 = Debug|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Release|Any CPU.Build.0 = Release|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Release|x64.ActiveCfg = Release|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Release|x64.Build.0 = Release|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Release|x86.ActiveCfg = Release|Any CPU + {7E735CE9-CE74-450E-A4E8-060D185DDEF1}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -387,6 +401,7 @@ Global {EA930DE2-33C4-447C-9E26-31387652D408} = {B8E99EF7-84EA-4D11-B722-9EE81B89CD86} {5ED73EEA-4DFA-426D-82E8-AA24D3CB4C31} = {290E4C9B-841D-4E2C-91A0-5A69BAB122F3} {2F849556-44B6-484A-B612-CB0FA5D29AC6} = {B8E99EF7-84EA-4D11-B722-9EE81B89CD86} + {7E735CE9-CE74-450E-A4E8-060D185DDEF1} = {B8E99EF7-84EA-4D11-B722-9EE81B89CD86} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E23DC856-D033-49F6-9BC6-9F1D0ECD05CB} diff --git a/tests/Integration.Test/Common/Assertions.cs b/tests/Integration.Test/Common/Assertions.cs index 33787d549..4d9eea3ee 100644 --- a/tests/Integration.Test/Common/Assertions.cs +++ b/tests/Integration.Test/Common/Assertions.cs @@ -1,5 +1,5 @@ /* - * Copyright 2022 MONAI Consortium + * Copyright 2023 MONAI Consortium * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -48,7 +48,7 @@ public Assertions(Configurations configurations, InformaticsGatewayConfiguration _retryPolicy = Policy.Handle().WaitAndRetryAsync(retryCount: 5, sleepDurationProvider: _ => TimeSpan.FromMilliseconds(500)); } - internal async Task ShouldHaveUploadedDicomDataToMinio(IReadOnlyList messages, Dictionary fileHashes) + internal async Task ShouldHaveUploadedDicomDataToMinio(IReadOnlyList messages, Dictionary fileHashes, Action additionalChecks = null) { Guard.Against.Null(messages); Guard.Against.NullOrEmpty(fileHashes); @@ -75,6 +75,12 @@ await _retryPolicy.ExecuteAsync(async () => memoryStream.Position = 0; var dicomFile = DicomFile.Open(memoryStream); dicomValidationKey = dicomFile.GenerateFileName(); + + if (additionalChecks is not null) + { + additionalChecks(dicomFile); + } + fileHashes.Should().ContainKey(dicomValidationKey).WhoseValue.Should().Be(dicomFile.CalculateHash()); }); await minioClient.GetObjectAsync(getObjectArgs); diff --git a/tests/Integration.Test/Drivers/RabbitMqConsumer.cs b/tests/Integration.Test/Drivers/RabbitMqConsumer.cs index e82f53d1a..70b34da43 100644 --- a/tests/Integration.Test/Drivers/RabbitMqConsumer.cs +++ b/tests/Integration.Test/Drivers/RabbitMqConsumer.cs @@ -48,12 +48,13 @@ public RabbitMqConsumer(RabbitMQMessageSubscriberService subscriberService, stri subscriberService.SubscribeAsync( queueName, queueName, - async (eventArgs) => + (eventArgs) => { _outputHelper.WriteLine($"Message received from queue {queueName} for {queueName}."); _messages.Add(eventArgs.Message); subscriberService.Acknowledge(eventArgs.Message); _outputHelper.WriteLine($"{DateTime.UtcNow} - {queueName} message received with correlation ID={eventArgs.Message.CorrelationId}, delivery tag={eventArgs.Message.DeliveryTag}"); + return Task.CompletedTask; }); } diff --git a/tests/Integration.Test/Features/DicomDimseScp.feature b/tests/Integration.Test/Features/DicomDimseScp.feature index 1c4116a16..7741e62ed 100644 --- a/tests/Integration.Test/Features/DicomDimseScp.feature +++ b/tests/Integration.Test/Features/DicomDimseScp.feature @@ -44,7 +44,7 @@ Feature: DICOM DIMSE SCP Services When a C-STORE-RQ is sent to 'Informatics Gateway' with AET '' from 'TEST-RUNNER' Then a successful response should be received And workflow requests sent to message broker - And studies are uploaded to storage service + And studies are uploaded to storage service with data input plugins Examples: | modality | count | aet | timeout | diff --git a/tests/Integration.Test/Monai.Deploy.InformaticsGateway.Integration.Test.csproj b/tests/Integration.Test/Monai.Deploy.InformaticsGateway.Integration.Test.csproj index eea628e76..e7db9ec11 100644 --- a/tests/Integration.Test/Monai.Deploy.InformaticsGateway.Integration.Test.csproj +++ b/tests/Integration.Test/Monai.Deploy.InformaticsGateway.Integration.Test.csproj @@ -1,5 +1,5 @@