From 2a3c0a3a3dc6f8113692485eab33c50ed240572a Mon Sep 17 00:00:00 2001 From: Will Sugarman Date: Tue, 30 Nov 2021 16:41:49 -0800 Subject: [PATCH] Misc Extended Query Tag Fixes (#1213) - Ensure user has read access for fetching operation status - Ignore DICOM instances that have been deleted between fetching the batch and performing the re-index - Add non-root user to docker image for functions - Update enum serialization to strictly use names - Update swagger doc to include new APIs and use enum names instead of numbers --- docker/functions/Dockerfile | 3 + .../ExtendedQueryTagControllerTests.cs | 4 +- .../BodyModelStateValidatorAttributeTests.cs | 2 +- .../BodyModelStateValidatorAttribute.cs | 2 +- .../Features/Swagger/ReflectionTypeFilter.cs | 99 + .../DicomServerServiceCollectionExtensions.cs | 29 +- .../ExtendedQueryTagEntryExtensionsTests.cs | 4 +- ...AddExtendedQueryTagEntryValidationTests.cs | 27 +- .../ExtendedQueryTagEntryValidatorTests.cs | 2 +- .../Operations/OperationStatusHandlerTests.cs | 31 +- .../StrictStringEnumConverterFactoryTests.cs | 142 + .../StrictStringEnumConverterTests.cs | 119 + .../DicomCoreResource.Designer.cs | 18 + .../DicomCoreResource.resx | 8 + .../AddExtendedQueryTagEntry.cs | 13 +- .../Features/Indexing/IInstanceReindexer.cs | 10 +- .../Features/Indexing/InstanceReindexer.cs | 33 +- .../Operations/OperationStatusHandler.cs | 17 +- .../StrictStringEnumConverter.cs | 59 + .../StrictStringEnumConverterFactory.cs | 35 + .../ReindexDurableFunctionTests.Activity.cs | 2 +- .../SqlExtendedQueryTagStoreV2.cs | 2 +- .../Extensions/DicomTagExtensions.cs | 2 +- swagger/v1-prerelease/swagger.yaml | 3149 +++++++++++++++-- .../Persistence/ExtendedQueryTagStoreTests.cs | 4 +- .../Rest/ExtendedQueryTagTests.cs | 2 +- 26 files changed, 3470 insertions(+), 348 deletions(-) create mode 100644 src/Microsoft.Health.Dicom.Api/Features/Swagger/ReflectionTypeFilter.cs create mode 100644 src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterFactoryTests.cs create mode 100644 src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterTests.cs create mode 100644 src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverter.cs create mode 100644 src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverterFactory.cs diff --git a/docker/functions/Dockerfile b/docker/functions/Dockerfile index 29b3081d3d..bd2769a5c6 100644 --- a/docker/functions/Dockerfile +++ b/docker/functions/Dockerfile @@ -8,6 +8,9 @@ FROM mcr.microsoft.com/azure-functions/dotnet:4.0.1.16816@sha256:2c18c4ca6ad982257e9023161329c38be6d9d494bc48a6aae15ef43cc8f7ea5d AS az-func-runtime ENV AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ AzureWebJobsScriptRoot=/home/site/wwwroot +RUN groupadd nonroot && useradd -r -M -s /sbin/nologin -g nonroot -c nonroot nonroot +RUN chown -R nonroot:nonroot /azure-functions-host +USER nonroot # Copy the DICOM Server repository and build the Azure Functions project FROM mcr.microsoft.com/dotnet/sdk:6.0.100-alpine3.14@sha256:1d9d66775f0d67cb68af9c7a083d22b576ea96f3f7a6893660d48f536b73e59f AS build diff --git a/src/Microsoft.Health.Dicom.Api.UnitTests/Controllers/ExtendedQueryTagControllerTests.cs b/src/Microsoft.Health.Dicom.Api.UnitTests/Controllers/ExtendedQueryTagControllerTests.cs index c97c318534..9eeaaf7d61 100644 --- a/src/Microsoft.Health.Dicom.Api.UnitTests/Controllers/ExtendedQueryTagControllerTests.cs +++ b/src/Microsoft.Health.Dicom.Api.UnitTests/Controllers/ExtendedQueryTagControllerTests.cs @@ -127,13 +127,13 @@ public async Task GivenOperationId_WhenAddingTags_ReturnIdWithHeader() { new AddExtendedQueryTagEntry { - Level = QueryTagLevel.Instance.ToString(), + Level = QueryTagLevel.Instance, Path = "00101001", VR = DicomVRCode.PN, }, new AddExtendedQueryTagEntry { - Level = QueryTagLevel.Instance.ToString(), + Level = QueryTagLevel.Instance, Path = "11330001", PrivateCreator = "Microsoft", VR = DicomVRCode.SS, diff --git a/src/Microsoft.Health.Dicom.Api.UnitTests/Features/Filters/BodyModelStateValidatorAttributeTests.cs b/src/Microsoft.Health.Dicom.Api.UnitTests/Features/Filters/BodyModelStateValidatorAttributeTests.cs index 571e72c6c5..c77cd2b8b3 100644 --- a/src/Microsoft.Health.Dicom.Api.UnitTests/Features/Filters/BodyModelStateValidatorAttributeTests.cs +++ b/src/Microsoft.Health.Dicom.Api.UnitTests/Features/Filters/BodyModelStateValidatorAttributeTests.cs @@ -42,7 +42,7 @@ public void GivenModelError_WhenOnActionExecution_ThenShouldThrowInvalidRequestB _context.ModelState.AddModelError(key2, error2); _context.ModelState.AddModelError(key3, error3); var exp = Assert.Throws(() => _filter.OnActionExecuting(_context)); - Assert.Equal($"The field '{key2}' in request body is invalid: {error1}", exp.Message); + Assert.Equal($"The field '{key3}' in request body is invalid: {error3}", exp.Message); } private static ActionExecutingContext CreateContext() diff --git a/src/Microsoft.Health.Dicom.Api/Features/Filters/BodyModelStateValidatorAttribute.cs b/src/Microsoft.Health.Dicom.Api/Features/Filters/BodyModelStateValidatorAttribute.cs index 7941d305a3..12f5ac1564 100644 --- a/src/Microsoft.Health.Dicom.Api/Features/Filters/BodyModelStateValidatorAttribute.cs +++ b/src/Microsoft.Health.Dicom.Api/Features/Filters/BodyModelStateValidatorAttribute.cs @@ -17,7 +17,7 @@ public override void OnActionExecuting(ActionExecutingContext context) EnsureArg.IsNotNull(context, nameof(context)); if (!context.ModelState.IsValid) { - var error = context.ModelState.First(x => x.Value.ValidationState == AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid); + var error = context.ModelState.Last(x => x.Value.ValidationState == AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid); throw new InvalidRequestBodyException(error.Key, error.Value.Errors.First().ErrorMessage); } } diff --git a/src/Microsoft.Health.Dicom.Api/Features/Swagger/ReflectionTypeFilter.cs b/src/Microsoft.Health.Dicom.Api/Features/Swagger/ReflectionTypeFilter.cs new file mode 100644 index 0000000000..2544a1cfda --- /dev/null +++ b/src/Microsoft.Health.Dicom.Api/Features/Swagger/ReflectionTypeFilter.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Microsoft.Health.Dicom.Api.Features.Swagger +{ + // The ReflectionTypeFilter is used to remove types added mistakenly by the Swashbuckle library. + // Swashbuckle mistakenly assumes the DICOM server will serialize Type properties present on the fo-dicom types, + // so this filter removes erroneously added properties in addition to their recursively added data models. + internal class ReflectionTypeFilter : IDocumentFilter + { + // Get all built-in generic collections (and IEnumerable) + private static readonly HashSet CollectionTypes = typeof(List<>).Assembly + .ExportedTypes + .Where(x => x.Namespace == "System.Collections.Generic") + .Where(x => x == typeof(IEnumerable<>) || x.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>))) + .ToHashSet(); + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + HashSet reflectionTypes = GetExposedTypes(typeof(Type)); + + // Resolve the enumerable up-front so that the dictionary can be mutated below + List> schemas = context.SchemaRepository.Schemas.ToList(); + + // Remove all properties, and schemas themselves, whose types are from System.Reflection + foreach (KeyValuePair entry in schemas) + { + if (reflectionTypes.Contains(entry.Key)) + { + context.SchemaRepository.Schemas.Remove(entry.Key); + } + else if (entry.Value.Type == "object" && entry.Value.Properties?.Count > 0) + { + entry.Value.Properties = entry.Value.Properties + .Where(x => x.Value.Reference == null || !reflectionTypes.Contains(x.Value.Reference.Id)) + .ToDictionary(x => x.Key, x => x.Value); + } + } + } + + private static HashSet GetExposedTypes(Type t) + { + var exposedTypes = new HashSet(); + UpdateExposedTypes(t, exposedTypes); + + // The OpenApiSchema type only has the type names + return exposedTypes.Select(x => x.Name).ToHashSet(StringComparer.Ordinal); + } + + private static void UpdateExposedTypes(Type t, HashSet exposed) + { + // Get all public instance properties present in the Type that may be discovered via reflection by Swashbuckle + foreach (Type propertyType in t.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(x => GetExposedType(x.PropertyType))) + { + if (!IsBuiltInType(propertyType) && exposed.Add(propertyType)) + { + UpdateExposedTypes(propertyType, exposed); + } + } + } + + private static bool IsBuiltInType(Type t) + => (t.IsPrimitive && t != typeof(IntPtr) && t != typeof(UIntPtr)) + || t == typeof(string) + || t == typeof(TimeSpan) + || t == typeof(DateTime) + || t == typeof(DateTimeOffset) + || t == typeof(Guid); + + private static Type GetExposedType(Type t) + { + // If we're serializing a collection to JSON, it will be represented as a JSON array. + // So the type we're really concerned with is the element type contained in the collection. + if (t.IsArray) + { + t = t.GetElementType(); + } + else if (t.IsGenericType && CollectionTypes.Contains(t.GetGenericTypeDefinition())) + { + Type enumerableType = t.GetGenericTypeDefinition() != typeof(IEnumerable<>) + ? t.GetInterfaces().Single(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + : t; + + t = enumerableType.GetGenericArguments()[0]; + } + + return t; + } + } +} diff --git a/src/Microsoft.Health.Dicom.Api/Registration/DicomServerServiceCollectionExtensions.cs b/src/Microsoft.Health.Dicom.Api/Registration/DicomServerServiceCollectionExtensions.cs index 5d3181ccff..2af4fbab40 100644 --- a/src/Microsoft.Health.Dicom.Api/Registration/DicomServerServiceCollectionExtensions.cs +++ b/src/Microsoft.Health.Dicom.Api/Registration/DicomServerServiceCollectionExtensions.cs @@ -5,7 +5,6 @@ using System; using System.Reflection; -using System.Text.Json.Serialization; using EnsureThat; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; @@ -32,6 +31,7 @@ using Microsoft.Health.Dicom.Core.Features.Context; using Microsoft.Health.Dicom.Core.Features.Routing; using Microsoft.Health.Dicom.Core.Registration; +using Microsoft.Health.Dicom.Core.Serialization; using Microsoft.Health.Extensions.DependencyInjection; using Microsoft.IO; using Swashbuckle.AspNetCore.SwaggerGen; @@ -109,7 +109,7 @@ public static IDicomServerBuilder AddDicomServer( options.RespectBrowserAcceptHeader = true; options.OutputFormatters.Insert(0, new DicomJsonOutputFormatter()); }) - .AddJsonSerializerOptions(o => o.Converters.Add(new JsonStringEnumConverter())); + .AddJsonSerializerOptions(o => o.Converters.Add(new StrictStringEnumConverterFactory())); services.AddApiVersioning(c => { @@ -128,10 +128,13 @@ public static IDicomServerBuilder AddDicomServer( }); services.AddTransient, ConfigureSwaggerOptions>(); - services.AddSwaggerGen(options => options.OperationFilter()); - services.AddSwaggerGen(options => options.OperationFilter()); - services.AddSwaggerGen(options => options.OperationFilter()); - services.AddSwaggerGenNewtonsoftSupport(); + services.AddSwaggerGen(options => + { + options.OperationFilter(); + options.OperationFilter(); + options.OperationFilter(); + options.DocumentFilter(); + }); services.AddSingleton(); @@ -197,13 +200,13 @@ public Action Configure(Action next) }); //Disabling swagger ui until accesability team gets back to us - /*app.UseSwaggerUI(options => - { - foreach (ApiVersionDescription description in provider.ApiVersionDescriptions) - { - options.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.yaml", description.GroupName.ToUpperInvariant()); - } - });*/ + //app.UseSwaggerUI(options => + //{ + // foreach (ApiVersionDescription description in provider.ApiVersionDescriptions) + // { + // options.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.yaml", description.GroupName.ToUpperInvariant()); + // } + //}); next(app); }; diff --git a/src/Microsoft.Health.Dicom.Core.UnitTests/Extensions/ExtendedQueryTagEntryExtensionsTests.cs b/src/Microsoft.Health.Dicom.Core.UnitTests/Extensions/ExtendedQueryTagEntryExtensionsTests.cs index 699045617e..a7f80c0bfd 100644 --- a/src/Microsoft.Health.Dicom.Core.UnitTests/Extensions/ExtendedQueryTagEntryExtensionsTests.cs +++ b/src/Microsoft.Health.Dicom.Core.UnitTests/Extensions/ExtendedQueryTagEntryExtensionsTests.cs @@ -31,7 +31,7 @@ public void GivenValidExtendedQueryTagEntry_WhenNormalizing_ThenShouldReturnSame public void GivenPrivateTagWithNonEmptyPrivateCreator_WhenNormalizing_ThenPrivateCreatorShouldBeNull(string privateCreator) { DicomTag tag1 = new DicomTag(0x0405, 0x1001); - AddExtendedQueryTagEntry normalized = new AddExtendedQueryTagEntry() { Level = QueryTagLevel.Instance.ToString(), Path = tag1.GetPath(), PrivateCreator = privateCreator, VR = DicomVRCode.CS }.Normalize(); + AddExtendedQueryTagEntry normalized = new AddExtendedQueryTagEntry() { Level = QueryTagLevel.Instance, Path = tag1.GetPath(), PrivateCreator = privateCreator, VR = DicomVRCode.CS }.Normalize(); Assert.Null(normalized.PrivateCreator); } @@ -117,7 +117,7 @@ private static GetExtendedQueryTagEntry CreateExtendedQueryTagEntry(string path, private static AddExtendedQueryTagEntry CreateExtendedQueryTagEntry(string path, string vr, string privateCreator, QueryTagLevel level = QueryTagLevel.Instance) { - return new AddExtendedQueryTagEntry { Path = path, VR = vr, PrivateCreator = privateCreator, Level = level.ToString() }; + return new AddExtendedQueryTagEntry { Path = path, VR = vr, PrivateCreator = privateCreator, Level = level }; } } } diff --git a/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/AddExtendedQueryTagEntryValidationTests.cs b/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/AddExtendedQueryTagEntryValidationTests.cs index d69cd8c4a7..3b11fac229 100644 --- a/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/AddExtendedQueryTagEntryValidationTests.cs +++ b/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/AddExtendedQueryTagEntryValidationTests.cs @@ -16,7 +16,7 @@ public class AddExtendedQueryTagEntryValidationTests [Fact] public void GivenValidAddExtendedQueryTagEntry_WhenValidating_ShouldSucced() { - AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "00101001", Level = "Study" }; + AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "00101001", Level = QueryTagLevel.Study }; var validationContext = new ValidationContext(addExtendedQueryTagEntry); IEnumerable results = addExtendedQueryTagEntry.Validate(validationContext); Assert.Empty(results); @@ -28,51 +28,44 @@ public void GivenValidAddExtendedQueryTagEntry_WhenValidating_ShouldSucced() [InlineData(" ")] public void GivenInvalidPath_WhenValidating_ResultShouldHaveExceptions(string pathValue) { - AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = pathValue, Level = "Study" }; + AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = pathValue, Level = QueryTagLevel.Study }; var validationContext = new ValidationContext(addExtendedQueryTagEntry); IEnumerable results = addExtendedQueryTagEntry.Validate(validationContext); Assert.Single(results); Assert.Equal("The Dicom Tag Property Path must be specified and must not be null, empty or whitespace.", results.First().ErrorMessage); } - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void GivenEmptyNullOrWhitespaceLevel_WhenValidating_ResultShouldHaveExceptions(string levelValue) + [Fact] + public void GivenEmptyNullOrWhitespaceLevel_WhenValidating_ResultShouldHaveExceptions() { - AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "00101001", Level = levelValue }; + AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "00101001", Level = null }; var validationContext = new ValidationContext(addExtendedQueryTagEntry); IEnumerable results = addExtendedQueryTagEntry.Validate(validationContext); Assert.Collection( results, - item => Assert.Equal("The Dicom Tag Property Level must be specified and must not be null, empty or whitespace.", item.ErrorMessage), - item => Assert.Equal(string.Format("Input Dicom Tag Level '{0}' is invalid. It must have value 'Study', 'Series' or 'Instance'.", levelValue), item.ErrorMessage) - ); + item => Assert.Equal("The Dicom Tag Property Level must be specified and must not be null, empty or whitespace.", item.ErrorMessage)); } [Fact] public void GivenInvalidLevel_WhenValidating_ResultShouldHaveExceptions() { - AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "00101001", Level = "Studys" }; + AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "00101001", Level = (QueryTagLevel)47 }; var validationContext = new ValidationContext(addExtendedQueryTagEntry); IEnumerable results = addExtendedQueryTagEntry.Validate(validationContext); Assert.Single(results); - Assert.Equal("Input Dicom Tag Level 'Studys' is invalid. It must have value 'Study', 'Series' or 'Instance'.", results.First().ErrorMessage); + Assert.Equal("Input Dicom Tag Level '47' is invalid. It must have value 'Study', 'Series' or 'Instance'.", results.First().ErrorMessage); } [Fact] public void GivenMultipleValidationErrors_WhenValidating_ResultShouldHaveExceptions() { - AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "", Level = " " }; + AddExtendedQueryTagEntry addExtendedQueryTagEntry = new AddExtendedQueryTagEntry() { Path = "", Level = null }; var validationContext = new ValidationContext(addExtendedQueryTagEntry); IEnumerable results = addExtendedQueryTagEntry.Validate(validationContext); Assert.Collection( results, item => Assert.Equal("The Dicom Tag Property Path must be specified and must not be null, empty or whitespace.", item.ErrorMessage), - item => Assert.Equal("The Dicom Tag Property Level must be specified and must not be null, empty or whitespace.", item.ErrorMessage), - item => Assert.Equal("Input Dicom Tag Level ' ' is invalid. It must have value 'Study', 'Series' or 'Instance'.", item.ErrorMessage) - ); + item => Assert.Equal("The Dicom Tag Property Level must be specified and must not be null, empty or whitespace.", item.ErrorMessage)); } } } diff --git a/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/ExtendedQueryTagEntryValidatorTests.cs b/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/ExtendedQueryTagEntryValidatorTests.cs index 1d295246c4..6bb8c37f78 100644 --- a/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/ExtendedQueryTagEntryValidatorTests.cs +++ b/src/Microsoft.Health.Dicom.Core.UnitTests/Features/ExtendedQueryTag/ExtendedQueryTagEntryValidatorTests.cs @@ -193,7 +193,7 @@ public void GivenPrivateIdentificationCodeWithWrongVR_WhenValidating_ThenShouldS private static AddExtendedQueryTagEntry CreateExtendedQueryTagEntry(string path, string vr, string privateCreator = null, QueryTagLevel level = QueryTagLevel.Instance) { - return new AddExtendedQueryTagEntry { Path = path, VR = vr, PrivateCreator = privateCreator, Level = level.ToString() }; + return new AddExtendedQueryTagEntry { Path = path, VR = vr, PrivateCreator = privateCreator, Level = level }; } } } diff --git a/src/Microsoft.Health.Dicom.Core.UnitTests/Features/Operations/OperationStatusHandlerTests.cs b/src/Microsoft.Health.Dicom.Core.UnitTests/Features/Operations/OperationStatusHandlerTests.cs index 6f5c17f879..d6ac2c08f5 100644 --- a/src/Microsoft.Health.Dicom.Core.UnitTests/Features/Operations/OperationStatusHandlerTests.cs +++ b/src/Microsoft.Health.Dicom.Core.UnitTests/Features/Operations/OperationStatusHandlerTests.cs @@ -6,7 +6,10 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Dicom.Core.Exceptions; using Microsoft.Health.Dicom.Core.Features.Operations; +using Microsoft.Health.Dicom.Core.Features.Security; using Microsoft.Health.Dicom.Core.Messages.Operations; using Microsoft.Health.Dicom.Core.Models.Operations; using NSubstitute; @@ -19,15 +22,33 @@ public class OperationStatusHandlerTests [Fact] public void GivenNullArgument_WhenConstructing_ThenThrowArgumentNullException() { - Assert.Throws(() => new OperationStatusHandler(null)); + Assert.Throws(() => new OperationStatusHandler(null, Substitute.For())); + Assert.Throws(() => new OperationStatusHandler(Substitute.For>(), null)); + } + + [Fact] + public async Task GivenInvalidAuthorization_WhenHandlingRequest_ThenThrowUnauthorizedDicomActionException() + { + using var source = new CancellationTokenSource(); + IAuthorizationService auth = Substitute.For>(); + IDicomOperationsClient client = Substitute.For(); + var handler = new OperationStatusHandler(auth, client); + + auth.CheckAccess(DataActions.Read, source.Token).Returns(DataActions.None); + + await Assert.ThrowsAsync(() => handler.Handle(new OperationStatusRequest(Guid.NewGuid()), source.Token)); + + await auth.Received(1).CheckAccess(DataActions.Read, source.Token); + await client.DidNotReceiveWithAnyArgs().GetStatusAsync(default, default); } [Fact] public async Task GivenValidRequest_WhenHandlingRequest_ThenReturnResponse() { using var source = new CancellationTokenSource(); + IAuthorizationService auth = Substitute.For>(); IDicomOperationsClient client = Substitute.For(); - var handler = new OperationStatusHandler(client); + var handler = new OperationStatusHandler(auth, client); Guid id = Guid.NewGuid(); var expected = new OperationStatus @@ -41,11 +62,13 @@ public async Task GivenValidRequest_WhenHandlingRequest_ThenReturnResponse() Type = OperationType.Reindex, }; - client.GetStatusAsync(Arg.Is(id), Arg.Is(source.Token)).Returns(expected); + auth.CheckAccess(DataActions.Read, source.Token).Returns(DataActions.Read); + client.GetStatusAsync(id, source.Token).Returns(expected); Assert.Same(expected, (await handler.Handle(new OperationStatusRequest(id), source.Token)).OperationStatus); - await client.Received(1).GetStatusAsync(Arg.Is(id), Arg.Is(source.Token)); + await auth.Received(1).CheckAccess(DataActions.Read, source.Token); + await client.Received(1).GetStatusAsync(id, source.Token); } } } diff --git a/src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterFactoryTests.cs b/src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterFactoryTests.cs new file mode 100644 index 0000000000..2691628020 --- /dev/null +++ b/src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterFactoryTests.cs @@ -0,0 +1,142 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Microsoft.Health.Dicom.Core.Features.ExtendedQueryTag; +using Microsoft.Health.Dicom.Core.Serialization; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.Health.Dicom.Core.UnitTests.Serialization +{ + public class StrictStringEnumConverterFactoryTests + { + private readonly JsonSerializerOptions _defaultOptions; + + public StrictStringEnumConverterFactoryTests() + { + _defaultOptions = new JsonSerializerOptions(); + _defaultOptions.Converters.Add(new StrictStringEnumConverterFactory()); + } + + [Fact] + public void GivenInvalidTypes_WhenCheckingCanConvert_ThenReturnFalse() + { + var factory = new StrictStringEnumConverterFactory(); + Assert.False(factory.CanConvert(typeof(int))); + Assert.False(factory.CanConvert(typeof(string))); + Assert.False(factory.CanConvert(typeof(object))); + } + + [Fact] + public void GivenValidTypes_WhenCheckingCanConvert_ThenReturnTrue() + { + var factory = new StrictStringEnumConverterFactory(); + Assert.True(factory.CanConvert(typeof(SeekOrigin))); + Assert.False(factory.CanConvert(typeof(BindingFlags?))); + } + + [Theory] + [InlineData("\"1\"", false)] + [InlineData("2", false)] + [InlineData("[ 1, 2, 3 ]", false)] + [InlineData("\"1\"", true)] + [InlineData("2", true)] + [InlineData("[ 1, 2, 3 ]", true)] + public void GivenInvalidJson_WhenReadingJson_ThenThrowJsonException(string jsonValue, bool nullable) + { + var jsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes($"{{ \"Level\": {jsonValue} }}")); + + try + { + if (nullable) + { + JsonSerializer.Deserialize(ref jsonReader, _defaultOptions); + } + else + { + JsonSerializer.Deserialize(ref jsonReader, _defaultOptions); + } + + throw new ThrowsException(typeof(JsonException)); + } + catch (Exception e) + { + if (e.GetType() != typeof(JsonException)) + { + throw new ThrowsException(typeof(JsonException), e); + } + } + } + + [Theory] + [InlineData("INSTANCE", QueryTagLevel.Instance, false)] + [InlineData("series", QueryTagLevel.Series, false)] + [InlineData("StUdY", QueryTagLevel.Study, false)] + [InlineData("INSTANCE", QueryTagLevel.Instance, true)] + [InlineData("series", QueryTagLevel.Series, true)] + [InlineData("StUdY", QueryTagLevel.Study, true)] + public void GivenValidJson_WhenReadingJson_ThenReturnParsedValue(string name, QueryTagLevel expected, bool nullable) + { + var jsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes($"{{ \"Level\": \"{name}\" }}")); + + QueryTagLevel actual = nullable + ? JsonSerializer.Deserialize(ref jsonReader, _defaultOptions).Level.GetValueOrDefault() + : JsonSerializer.Deserialize(ref jsonReader, _defaultOptions).Level; + Assert.Equal(expected, actual); + } + + [Fact] + public void GivenNullValue_WhenReadingNullableJson_ThenReturnNull() + { + var jsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes($"{{ \"Level\": null }}")); + + NullableExample actual = JsonSerializer.Deserialize(ref jsonReader, _defaultOptions); + Assert.Null(actual.Level); + } + + [Theory] + [InlineData(QueryTagLevel.Instance, "\"Instance\"", false)] + [InlineData(QueryTagLevel.Series, "\"Series\"", false)] + [InlineData(QueryTagLevel.Study, "\"Study\"", false)] + [InlineData(QueryTagLevel.Instance, "\"Instance\"", true)] + [InlineData(QueryTagLevel.Series, "\"Series\"", true)] + [InlineData(QueryTagLevel.Study, "\"Study\"", true)] + public void GivenEnumValue_WhenWritingJson_ThenWriteName(QueryTagLevel value, string expected, bool nullable) + { + string actual = nullable + ? JsonSerializer.Serialize(new NullableExample { Level = value }, _defaultOptions) + : JsonSerializer.Serialize(new Example { Level = value }, _defaultOptions); + + Assert.Equal($"{{\"Level\":{expected}}}", actual); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GivenUndefinedEnumValue_WhenWritingJson_ThenThrowJsonException(bool nullable) + { + object obj = nullable + ? new NullableExample { Level = (QueryTagLevel)12 } + : new Example { Level = (QueryTagLevel)12 }; + + Assert.Throws(() => JsonSerializer.Serialize(obj, _defaultOptions)); + } + + private sealed class Example + { + public QueryTagLevel Level { get; set; } + } + + private sealed class NullableExample + { + public QueryTagLevel? Level { get; set; } + } + } +} diff --git a/src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterTests.cs b/src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterTests.cs new file mode 100644 index 0000000000..1670220d8e --- /dev/null +++ b/src/Microsoft.Health.Dicom.Core.UnitTests/Serialization/StrictStringEnumConverterTests.cs @@ -0,0 +1,119 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.IO; +using System.Text; +using System.Text.Json; +using Microsoft.Health.Dicom.Core.Features.ExtendedQueryTag; +using Microsoft.Health.Dicom.Core.Serialization; +using Xunit; +using Xunit.Sdk; + +namespace Microsoft.Health.Dicom.Core.UnitTests.Serialization +{ + public class StrictStringEnumConverterTests + { + private static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions(); + + [Fact] + public void GivenStrictStringEnumConverter_WhenCheckingNullHandling_ThenReturnFalse() + { + Assert.False(new StrictStringEnumConverter().HandleNull); + } + + [Theory] + [InlineData("null")] + [InlineData("42")] + [InlineData("{ \"foo\": \"bar\" }")] + [InlineData("[ 1, 2, 3 ]")] + [InlineData("\"\"")] + [InlineData("\"bar\"")] + [InlineData("\"0123456789abcdef0123456789abcde\"")] + public void GivenInvalidToken_WhenReadingJson_ThenThrowJsonReaderException(string json) + { + var jsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + + Assert.True(jsonReader.Read()); + try + { + new JsonGuidConverter("N").Read(ref jsonReader, typeof(Guid), DefaultOptions); + throw new ThrowsException(typeof(JsonException)); + } + catch (Exception e) + { + if (e.GetType() != typeof(JsonException)) + { + throw new ThrowsException(typeof(JsonException), e); + } + } + } + + [Theory] + [InlineData("1")] + [InlineData("\"studys\"")] + [InlineData("\"innstance\"")] + [InlineData("42")] + public void GivenInvalidEnumName_WhenReadingJson_ThenThrowJsonReaderException(string json) + { + var jsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json)); + + Assert.True(jsonReader.Read()); + try + { + new StrictStringEnumConverter().Read(ref jsonReader, typeof(QueryTagLevel), DefaultOptions); + throw new ThrowsException(typeof(JsonException)); + } + catch (Exception e) + { + if (e.GetType() != typeof(JsonException)) + { + throw new ThrowsException(typeof(JsonException), e); + } + } + } + + [Theory] + [InlineData("INSTANCE", QueryTagLevel.Instance)] + [InlineData("series", QueryTagLevel.Series)] + [InlineData("StUdY", QueryTagLevel.Study)] + public void GivenStringToken_WhenReadingJson_ThenReturnEnum(string name, QueryTagLevel expected) + { + var jsonReader = new Utf8JsonReader(Encoding.UTF8.GetBytes("\"" + name + "\"")); + + Assert.True(jsonReader.Read()); + QueryTagLevel actual = new StrictStringEnumConverter().Read(ref jsonReader, typeof(QueryTagLevel), DefaultOptions); + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(QueryTagLevel.Instance, "\"Instance\"")] + [InlineData(QueryTagLevel.Series, "\"Series\"")] + [InlineData(QueryTagLevel.Study, "\"Study\"")] + public void GivenEnumValue_WhenWritingJson_ThenWriteName(QueryTagLevel value, string expected) + { + using var buffer = new MemoryStream(); + var jsonWriter = new Utf8JsonWriter(buffer); + + new StrictStringEnumConverter().Write(jsonWriter, value, DefaultOptions); + + jsonWriter.Flush(); + buffer.Seek(0, SeekOrigin.Begin); + + using var reader = new StreamReader(buffer, Encoding.UTF8); + Assert.Equal(expected, reader.ReadToEnd()); + } + + [Fact] + public void GivenUndefinedEnumValue_WhenWritingJson_ThenThrowJsonException() + { + using var buffer = new MemoryStream(); + var jsonWriter = new Utf8JsonWriter(buffer); + + Assert.Throws( + () => new StrictStringEnumConverter().Write(jsonWriter, (QueryTagLevel)12, DefaultOptions)); + } + } +} diff --git a/src/Microsoft.Health.Dicom.Core/DicomCoreResource.Designer.cs b/src/Microsoft.Health.Dicom.Core/DicomCoreResource.Designer.cs index 4c0bfa987f..d69a524a80 100644 --- a/src/Microsoft.Health.Dicom.Core/DicomCoreResource.Designer.cs +++ b/src/Microsoft.Health.Dicom.Core/DicomCoreResource.Designer.cs @@ -912,6 +912,24 @@ internal static string StudyNotFound { } } + /// + /// Looks up a localized string similar to Expected token type '{0}' but read '{1}' instead.. + /// + internal static string UnexpectedJsonToken { + get { + return ResourceManager.GetString("UnexpectedJsonToken", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Expected value '{0}' to be one of the following values: [{1}]. + /// + internal static string UnexpectedValue { + get { + return ResourceManager.GetString("UnexpectedValue", resourceCulture); + } + } + /// /// Looks up a localized string similar to Invalid QIDO-RS query. Unknown query parameter '{0}'. If the parameter is an attribute keyword, check the casing as they are case-sensitive. The conformance statement has a list of supported query parameters, attributes and the levels.. /// diff --git a/src/Microsoft.Health.Dicom.Core/DicomCoreResource.resx b/src/Microsoft.Health.Dicom.Core/DicomCoreResource.resx index ccc88faaac..3f2b551148 100644 --- a/src/Microsoft.Health.Dicom.Core/DicomCoreResource.resx +++ b/src/Microsoft.Health.Dicom.Core/DicomCoreResource.resx @@ -471,4 +471,12 @@ For details on valid range queries, please refer to Search Matching section in C Implicit VR is not allowed. + + Expected token type '{0}' but read '{1}' instead. + {0} Expected token type. {1} Actual token type. + + + Expected value '{0}' to be one of the following values: [{1}] + {0} Actual value. {1} Comma-separated list of expected values. + \ No newline at end of file diff --git a/src/Microsoft.Health.Dicom.Core/Features/ExtendedQueryTag/AddExtendedQueryTagEntry.cs b/src/Microsoft.Health.Dicom.Core/Features/ExtendedQueryTag/AddExtendedQueryTagEntry.cs index 19dc026c98..303ef7bc2e 100644 --- a/src/Microsoft.Health.Dicom.Core/Features/ExtendedQueryTag/AddExtendedQueryTagEntry.cs +++ b/src/Microsoft.Health.Dicom.Core/Features/ExtendedQueryTag/AddExtendedQueryTagEntry.cs @@ -17,26 +17,25 @@ public class AddExtendedQueryTagEntry : ExtendedQueryTagEntry, IValidatableObjec /// /// Level of this tag. Could be Study, Series or Instance. /// - public string Level { get; set; } + public QueryTagLevel? Level { get; set; } public IEnumerable Validate(ValidationContext validationContext) { string property; if (string.IsNullOrWhiteSpace(Path)) { - property = "Path"; + property = nameof(Path); yield return new ValidationResult(string.Format(DicomCoreResource.AddExtendedQueryTagEntryPropertyNotSpecified, property), new[] { property }); } - if (string.IsNullOrWhiteSpace(Level)) + if (!Level.HasValue) { - property = "Level"; + property = nameof(Level); yield return new ValidationResult(string.Format(DicomCoreResource.AddExtendedQueryTagEntryPropertyNotSpecified, property), new[] { property }); } - - if (!Enum.TryParse(typeof(QueryTagLevel), Level, true, out object _)) + else if (!Enum.IsDefined(Level.GetValueOrDefault())) { - property = "Level"; + property = nameof(Level); yield return new ValidationResult(string.Format(DicomCoreResource.InvalidDicomTagLevel, Level), new[] { property }); } } diff --git a/src/Microsoft.Health.Dicom.Core/Features/Indexing/IInstanceReindexer.cs b/src/Microsoft.Health.Dicom.Core/Features/Indexing/IInstanceReindexer.cs index ae61be55a6..8e1c975c91 100644 --- a/src/Microsoft.Health.Dicom.Core/Features/Indexing/IInstanceReindexer.cs +++ b/src/Microsoft.Health.Dicom.Core/Features/Indexing/IInstanceReindexer.cs @@ -17,12 +17,16 @@ namespace Microsoft.Health.Dicom.Core.Features.Indexing public interface IInstanceReindexer { /// - /// Reindex DICOM instance of watermark on extended query tags. + /// Asynchronously reindexes the DICOM instance with the + /// for the set of . /// /// Extended query tag store entries. /// The versioned instance id. /// The cancellation token. - /// The task. - Task ReindexInstanceAsync(IReadOnlyCollection entries, VersionedInstanceIdentifier versionedInstanceId, CancellationToken cancellationToken = default); + /// + /// A representing the asynchronous operation. The value of the + /// indicates whether the reindexing was successful. + /// + Task ReindexInstanceAsync(IReadOnlyCollection entries, VersionedInstanceIdentifier versionedInstanceId, CancellationToken cancellationToken = default); } } diff --git a/src/Microsoft.Health.Dicom.Core/Features/Indexing/InstanceReindexer.cs b/src/Microsoft.Health.Dicom.Core/Features/Indexing/InstanceReindexer.cs index 873eafc251..324cbc0906 100644 --- a/src/Microsoft.Health.Dicom.Core/Features/Indexing/InstanceReindexer.cs +++ b/src/Microsoft.Health.Dicom.Core/Features/Indexing/InstanceReindexer.cs @@ -9,6 +9,8 @@ using System.Threading.Tasks; using Dicom; using EnsureThat; +using Microsoft.Extensions.Logging; +using Microsoft.Health.Dicom.Core.Exceptions; using Microsoft.Health.Dicom.Core.Features.Common; using Microsoft.Health.Dicom.Core.Features.ExtendedQueryTag; using Microsoft.Health.Dicom.Core.Features.Model; @@ -24,24 +26,47 @@ public class InstanceReindexer : IInstanceReindexer private readonly IMetadataStore _metadataStore; private readonly IIndexDataStore _indexDataStore; private readonly IReindexDatasetValidator _dicomDatasetReindexValidator; + private readonly ILogger _logger; - public InstanceReindexer(IMetadataStore metadataStore, IIndexDataStore indexDataStore, IReindexDatasetValidator dicomDatasetReindexValidator) + public InstanceReindexer( + IMetadataStore metadataStore, + IIndexDataStore indexDataStore, + IReindexDatasetValidator dicomDatasetReindexValidator, + ILogger logger) { _metadataStore = EnsureArg.IsNotNull(metadataStore, nameof(metadataStore)); _indexDataStore = EnsureArg.IsNotNull(indexDataStore, nameof(indexDataStore)); _dicomDatasetReindexValidator = EnsureArg.IsNotNull(dicomDatasetReindexValidator, nameof(dicomDatasetReindexValidator)); + _logger = EnsureArg.IsNotNull(logger, nameof(logger)); } - public async Task ReindexInstanceAsync(IReadOnlyCollection entries, VersionedInstanceIdentifier versionedInstanceId, CancellationToken cancellationToken) + public async Task ReindexInstanceAsync( + IReadOnlyCollection entries, + VersionedInstanceIdentifier versionedInstanceId, + CancellationToken cancellationToken) { EnsureArg.IsNotNull(entries, nameof(entries)); EnsureArg.IsNotNull(versionedInstanceId, nameof(versionedInstanceId)); - DicomDataset dataset = await _metadataStore.GetInstanceMetadataAsync(versionedInstanceId, cancellationToken); + DicomDataset dataset; + try + { + dataset = await _metadataStore.GetInstanceMetadataAsync(versionedInstanceId, cancellationToken); + } + catch (ItemNotFoundException) + { + _logger.LogWarning("Could not find metadata for instance with {Identifier}", versionedInstanceId); + return false; + } // Only reindex on valid query tags - var validQueryTags = await _dicomDatasetReindexValidator.ValidateAsync(dataset, versionedInstanceId.Version, entries.Select(x => new QueryTag(x)).ToList(), cancellationToken); + IReadOnlyCollection validQueryTags = await _dicomDatasetReindexValidator.ValidateAsync( + dataset, + versionedInstanceId.Version, + entries.Select(x => new QueryTag(x)).ToList(), + cancellationToken); await _indexDataStore.ReindexInstanceAsync(dataset, versionedInstanceId.Version, validQueryTags, cancellationToken); + return true; } } } diff --git a/src/Microsoft.Health.Dicom.Core/Features/Operations/OperationStatusHandler.cs b/src/Microsoft.Health.Dicom.Core/Features/Operations/OperationStatusHandler.cs index 38ab956e7d..a6a42f6ecc 100644 --- a/src/Microsoft.Health.Dicom.Core/Features/Operations/OperationStatusHandler.cs +++ b/src/Microsoft.Health.Dicom.Core/Features/Operations/OperationStatusHandler.cs @@ -8,6 +8,10 @@ using System.Threading.Tasks; using EnsureThat; using MediatR; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Dicom.Core.Exceptions; +using Microsoft.Health.Dicom.Core.Features.Common; +using Microsoft.Health.Dicom.Core.Features.Security; using Microsoft.Health.Dicom.Core.Messages.Operations; using Microsoft.Health.Dicom.Core.Models.Operations; @@ -17,16 +21,18 @@ namespace Microsoft.Health.Dicom.Core.Features.Operations /// Represents a handler that encapsulates /// to process instances of . /// - public class OperationStatusHandler : IRequestHandler + public class OperationStatusHandler : BaseHandler, IRequestHandler { private readonly IDicomOperationsClient _client; /// /// Initializes a new instance of the class. /// + /// A service for determining if a user is authorized. /// A client for interacting with DICOM operations. /// is . - public OperationStatusHandler(IDicomOperationsClient client) + public OperationStatusHandler(IAuthorizationService authorizationService, IDicomOperationsClient client) + : base(authorizationService) => _client = EnsureArg.IsNotNull(client, nameof(client)); /// @@ -46,8 +52,13 @@ public OperationStatusHandler(IDicomOperationsClient client) /// The was canceled. public async Task Handle(OperationStatusRequest request, CancellationToken cancellationToken) { - // TODO: Check for data action EnsureArg.IsNotNull(request, nameof(request)); + + if (await AuthorizationService.CheckAccess(DataActions.Read, cancellationToken) != DataActions.Read) + { + throw new UnauthorizedDicomActionException(DataActions.Read); + } + OperationStatus status = await _client.GetStatusAsync(request.OperationId, cancellationToken); return status != null ? new OperationStatusResponse(status) : null; } diff --git a/src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverter.cs b/src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverter.cs new file mode 100644 index 0000000000..3ce88751e6 --- /dev/null +++ b/src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverter.cs @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Health.Dicom.Core.Serialization +{ + // Note: This class ignore the JsonNamingPolicy as it is not currently in-use for DICOM. + internal sealed class StrictStringEnumConverter : JsonConverter + where T : struct, Enum + { + private static readonly ImmutableDictionary Values = ImmutableDictionary.CreateRange( + StringComparer.OrdinalIgnoreCase, + Enum.GetValues().Select(x => KeyValuePair.Create(Enum.GetName(x), x))); + + public override bool CanConvert(Type typeToConvert) + => typeToConvert == typeof(T); + + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException( + string.Format(CultureInfo.CurrentCulture, DicomCoreResource.UnexpectedJsonToken, JsonTokenType.String, reader.TokenType)); + } + + string name = reader.GetString(); + if (!Values.TryGetValue(name, out T value)) + { + throw new JsonException( + string.Format(CultureInfo.CurrentCulture, DicomCoreResource.UnexpectedValue, name, GetOrderedNames())); + } + + return value; + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + if (!Enum.IsDefined(value)) + { + throw new JsonException( + string.Format(CultureInfo.CurrentCulture, DicomCoreResource.UnexpectedValue, value, GetOrderedNames())); + } + + writer.WriteStringValue(Enum.GetName(value)); + } + + private static string GetOrderedNames() + => string.Join(", ", Values.Keys.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).Select(x => $"'{x}'")); + } +} diff --git a/src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverterFactory.cs b/src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverterFactory.cs new file mode 100644 index 0000000000..2e5d559094 --- /dev/null +++ b/src/Microsoft.Health.Dicom.Core/Serialization/StrictStringEnumConverterFactory.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using EnsureThat; + +namespace Microsoft.Health.Dicom.Core.Serialization +{ + /// + /// Represents a for enumeration types where values are + /// strictly represented by their names as JSON string tokens. + /// + public sealed class StrictStringEnumConverterFactory : JsonConverterFactory + { + /// + /// Determines whether the JSON converter can operate on the given type. + /// + /// The type to serialize and/or deserialize. + /// + /// if the type is compatible with the converter; otherwise . + /// + /// is . + public override bool CanConvert(Type typeToConvert) + => EnsureArg.IsNotNull(typeToConvert).IsEnum; + + /// + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + => Activator.CreateInstance( + typeof(StrictStringEnumConverter<>).MakeGenericType(EnsureArg.IsNotNull(typeToConvert))) as JsonConverter; + } +} diff --git a/src/Microsoft.Health.Dicom.Operations.UnitTests/Indexing/ReindexDurableFunctionTests.Activity.cs b/src/Microsoft.Health.Dicom.Operations.UnitTests/Indexing/ReindexDurableFunctionTests.Activity.cs index 789896ecb8..4bfbbbc61d 100644 --- a/src/Microsoft.Health.Dicom.Operations.UnitTests/Indexing/ReindexDurableFunctionTests.Activity.cs +++ b/src/Microsoft.Health.Dicom.Operations.UnitTests/Indexing/ReindexDurableFunctionTests.Activity.cs @@ -151,7 +151,7 @@ public async Task GivenBatch_WhenReindexing_ThenShouldReindexEachInstance() foreach (VersionedInstanceIdentifier identifier in expected) { - _instanceReindexer.ReindexInstanceAsync(batch.QueryTags, identifier).Returns(Task.CompletedTask); + _instanceReindexer.ReindexInstanceAsync(batch.QueryTags, identifier).Returns(true); } // Call the activity diff --git a/src/Microsoft.Health.Dicom.SqlServer/Features/ExtendedQueryTag/SqlExtendedQueryTagStoreV2.cs b/src/Microsoft.Health.Dicom.SqlServer/Features/ExtendedQueryTag/SqlExtendedQueryTagStoreV2.cs index 458cf022bd..df5c7408d7 100644 --- a/src/Microsoft.Health.Dicom.SqlServer/Features/ExtendedQueryTag/SqlExtendedQueryTagStoreV2.cs +++ b/src/Microsoft.Health.Dicom.SqlServer/Features/ExtendedQueryTag/SqlExtendedQueryTagStoreV2.cs @@ -129,7 +129,7 @@ public override async Task GetExtendedQueryTagAs internal static AddExtendedQueryTagsInputTableTypeV1Row ToAddExtendedQueryTagsInputTableTypeV1Row(AddExtendedQueryTagEntry entry) { - return new AddExtendedQueryTagsInputTableTypeV1Row(entry.Path, entry.VR, entry.PrivateCreator, (byte)((QueryTagLevel)Enum.Parse(typeof(QueryTagLevel), entry.Level, true))); + return new AddExtendedQueryTagsInputTableTypeV1Row(entry.Path, entry.VR, entry.PrivateCreator, (byte)entry.Level); } public override async Task DeleteExtendedQueryTagAsync(string tagPath, string vr, CancellationToken cancellationToken = default) diff --git a/src/Microsoft.Health.Dicom.Tests.Common/Extensions/DicomTagExtensions.cs b/src/Microsoft.Health.Dicom.Tests.Common/Extensions/DicomTagExtensions.cs index 74fc2ffab5..95b6b9b0b3 100644 --- a/src/Microsoft.Health.Dicom.Tests.Common/Extensions/DicomTagExtensions.cs +++ b/src/Microsoft.Health.Dicom.Tests.Common/Extensions/DicomTagExtensions.cs @@ -13,7 +13,7 @@ public static class DicomTagExtensions { public static AddExtendedQueryTagEntry BuildAddExtendedQueryTagEntry(this DicomTag tag, string vr = null, string privateCreator = null, QueryTagLevel level = QueryTagLevel.Series) { - return new AddExtendedQueryTagEntry { Path = tag.GetPath(), VR = vr ?? tag.GetDefaultVR()?.Code, PrivateCreator = privateCreator, Level = level.ToString() }; + return new AddExtendedQueryTagEntry { Path = tag.GetPath(), VR = vr ?? tag.GetDefaultVR()?.Code, PrivateCreator = privateCreator, Level = level }; } public static GetExtendedQueryTagEntry BuildGetExtendedQueryTagEntry(this DicomTag tag, string vr = null, string privateCreator = null, QueryTagLevel level = QueryTagLevel.Series, ExtendedQueryTagStatus status = ExtendedQueryTagStatus.Ready) diff --git a/swagger/v1-prerelease/swagger.yaml b/swagger/v1-prerelease/swagger.yaml index 0b9d987cc5..9cde9924e2 100644 --- a/swagger/v1-prerelease/swagger.yaml +++ b/swagger/v1-prerelease/swagger.yaml @@ -1,9 +1,6 @@ openapi: 3.0.1 info: title: Medical Imaging Server for DICOM - license: - name: MIT License - url: https://github.com/microsoft/dicom-server/blob/main/LICENSE version: 1.0-prerelease paths: /v1-prerelease/changefeed: @@ -134,7 +131,7 @@ paths: application/json: schema: type: string - '/v1-prerelease/studies/{studyInstanceUid}': + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}': delete: tags: - Delete @@ -144,6 +141,11 @@ paths: required: true schema: type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '204': description: Success @@ -162,13 +164,18 @@ paths: get: tags: - Retrieve - operationId: VersionedRetrieveStudy + operationId: VersionedPartitionRetrieveStudy parameters: - name: studyInstanceUid in: path required: true schema: type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -195,12 +202,18 @@ paths: post: tags: - Store + operationId: VersionedPartitionStoreInstancesInStudy parameters: - name: studyInstanceUid in: path required: true schema: type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -246,7 +259,7 @@ paths: application/json: schema: type: string - '/studies/{studyInstanceUid}': + '/partitions/{partitionName}/studies/{studyInstanceUid}': delete: tags: - Delete @@ -256,6 +269,11 @@ paths: required: true schema: type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '204': description: Success @@ -274,13 +292,18 @@ paths: get: tags: - Retrieve - operationId: RetrieveStudy + operationId: PartitionRetrieveStudy parameters: - name: studyInstanceUid in: path required: true schema: type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -307,12 +330,18 @@ paths: post: tags: - Store + operationId: PartitionStoreInstancesInStudy parameters: - name: studyInstanceUid in: path required: true schema: type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -358,7 +387,7 @@ paths: application/json: schema: type: string - '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}': + '/v1-prerelease/studies/{studyInstanceUid}': delete: tags: - Delete @@ -368,11 +397,6 @@ paths: required: true schema: type: string - - name: seriesInstanceUid - in: path - required: true - schema: - type: string responses: '204': description: Success @@ -391,18 +415,13 @@ paths: get: tags: - Retrieve - operationId: VersionedRetrieveSeries + operationId: VersionedRetrieveStudy parameters: - name: studyInstanceUid in: path required: true schema: type: string - - name: seriesInstanceUid - in: path - required: true - schema: - type: string responses: '200': description: Success @@ -426,17 +445,67 @@ paths: application/json: schema: type: string - '/studies/{studyInstanceUid}/series/{seriesInstanceUid}': - delete: + post: tags: - - Delete + - Store + operationId: VersionedStoreInstancesInStudy parameters: - name: studyInstanceUid in: path required: true schema: type: string - - name: seriesInstanceUid + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '202': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '409': + description: Conflict + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '415': + description: Client Error + content: + application/json: + schema: + type: string + '/studies/{studyInstanceUid}': + delete: + tags: + - Delete + parameters: + - name: studyInstanceUid in: path required: true schema: @@ -459,18 +528,13 @@ paths: get: tags: - Retrieve - operationId: RetrieveSeries + operationId: RetrieveStudy parameters: - name: studyInstanceUid in: path required: true schema: type: string - - name: seriesInstanceUid - in: path - required: true - schema: - type: string responses: '200': description: Success @@ -494,7 +558,62 @@ paths: application/json: schema: type: string - '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}': + post: + tags: + - Store + operationId: StoreInstancesInStudy + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '202': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '409': + description: Conflict + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '415': + description: Client Error + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}': delete: tags: - Delete @@ -509,7 +628,7 @@ paths: required: true schema: type: string - - name: sopInstanceUid + - name: partitionName in: path required: true schema: @@ -532,7 +651,7 @@ paths: get: tags: - Retrieve - operationId: VersionedRetrieveInstance + operationId: VersionedPartitionRetrieveSeries parameters: - name: studyInstanceUid in: path @@ -544,7 +663,7 @@ paths: required: true schema: type: string - - name: sopInstanceUid + - name: partitionName in: path required: true schema: @@ -553,7 +672,6 @@ paths: '200': description: Success content: - application/dicom: { } multipart/related: { } '400': description: Bad Request @@ -573,7 +691,7 @@ paths: application/json: schema: type: string - '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}': + '/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}': delete: tags: - Delete @@ -588,7 +706,7 @@ paths: required: true schema: type: string - - name: sopInstanceUid + - name: partitionName in: path required: true schema: @@ -611,7 +729,7 @@ paths: get: tags: - Retrieve - operationId: RetrieveInstance + operationId: PartitionRetrieveSeries parameters: - name: studyInstanceUid in: path @@ -623,7 +741,7 @@ paths: required: true schema: type: string - - name: sopInstanceUid + - name: partitionName in: path required: true schema: @@ -632,7 +750,6 @@ paths: '200': description: Success content: - application/dicom: { } multipart/related: { } '400': description: Bad Request @@ -652,84 +769,85 @@ paths: application/json: schema: type: string - /v1-prerelease/extendedquerytags: - post: - tags: - - ExtendedQueryTag - requestBody: - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/AddExtendedQueryTagEntry' - responses: - '202': - description: Success - content: - application/json: - schema: - $ref: '#/components/schemas/AddExtendedQueryTagResponse' - get: + '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}': + delete: tags: - - ExtendedQueryTag + - Delete + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string responses: - '200': + '204': description: Success - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/GetExtendedQueryTagEntry' '404': description: Not Found content: application/json: schema: type: string - /extendedquerytags: - post: - tags: - - ExtendedQueryTag - requestBody: - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/AddExtendedQueryTagEntry' - responses: - '202': - description: Success + '400': + description: Bad Request content: application/json: schema: - $ref: '#/components/schemas/AddExtendedQueryTagResponse' + type: string get: tags: - - ExtendedQueryTag + - Retrieve + operationId: VersionedRetrieveSeries + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string responses: '200': description: Success + content: + multipart/related: { } + '400': + description: Bad Request content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/GetExtendedQueryTagEntry' + type: string '404': description: Not Found content: application/json: schema: type: string - '/v1-prerelease/extendedquerytags/{tagPath}': - delete: - tags: - - ExtendedQueryTag + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '/studies/{studyInstanceUid}/series/{seriesInstanceUid}': + delete: + tags: + - Delete parameters: - - name: tagPath + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid in: path required: true schema: @@ -737,15 +855,29 @@ paths: responses: '204': description: Success + '404': + description: Not Found content: application/json: schema: - $ref: '#/components/schemas/DeleteExtendedQueryTagResponse' + type: string + '400': + description: Bad Request + content: + application/json: + schema: + type: string get: tags: - - ExtendedQueryTag + - Retrieve + operationId: RetrieveSeries parameters: - - name: tagPath + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid in: path required: true schema: @@ -754,9 +886,7 @@ paths: '200': description: Success content: - application/json: - schema: - $ref: '#/components/schemas/GetExtendedQueryTagEntry' + multipart/related: { } '400': description: Bad Request content: @@ -769,12 +899,33 @@ paths: application/json: schema: type: string - '/extendedquerytags/{tagPath}': + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}': delete: tags: - - ExtendedQueryTag + - Delete parameters: - - name: tagPath + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName in: path required: true schema: @@ -782,15 +933,39 @@ paths: responses: '204': description: Success + '404': + description: Not Found content: application/json: schema: - $ref: '#/components/schemas/DeleteExtendedQueryTagResponse' + type: string + '400': + description: Bad Request + content: + application/json: + schema: + type: string get: tags: - - ExtendedQueryTag + - Retrieve + operationId: VersionedPartitionRetrieveInstance parameters: - - name: tagPath + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName in: path required: true schema: @@ -799,9 +974,8 @@ paths: '200': description: Success content: - application/json: - schema: - $ref: '#/components/schemas/GetExtendedQueryTagEntry' + application/dicom: { } + multipart/related: { } '400': description: Bad Request content: @@ -814,81 +988,2130 @@ paths: application/json: schema: type: string - /v1-prerelease/studies: - get: + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}': + delete: tags: - - Query + - Delete + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: - '200': + '204': description: Success + '404': + description: Not Found content: - application/dicom+json: + application/json: schema: - type: array - items: - type: array - items: - $ref: '#/components/schemas/DicomItem' - '204': - description: Success + type: string '400': description: Bad Request content: application/json: schema: type: string - post: + get: tags: - - Store + - Retrieve + operationId: PartitionRetrieveInstance + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success content: - application/dicom+json: - schema: - type: array - items: - $ref: '#/components/schemas/DicomItem' - '202': - description: Success - content: - application/dicom+json: - schema: - type: array - items: - $ref: '#/components/schemas/DicomItem' - '204': - description: Success + application/dicom: { } + multipart/related: { } '400': description: Bad Request content: application/json: schema: type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string '406': description: Not Acceptable content: application/json: schema: type: string - '409': - description: Conflict + '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}': + delete: + tags: + - Delete + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + responses: + '204': + description: Success + '404': + description: Not Found content: - application/dicom+json: + application/json: schema: - type: array - items: - $ref: '#/components/schemas/DicomItem' - '415': - description: Client Error + type: string + '400': + description: Bad Request content: application/json: schema: type: string - /studies: get: tags: - - Query + - Retrieve + operationId: VersionedRetrieveInstance + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom: { } + multipart/related: { } + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}': + delete: + tags: + - Delete + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + responses: + '204': + description: Success + '404': + description: Not Found + content: + application/json: + schema: + type: string + '400': + description: Bad Request + content: + application/json: + schema: + type: string + get: + tags: + - Retrieve + operationId: RetrieveInstance + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom: { } + multipart/related: { } + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + /v1-prerelease/extendedquerytags: + post: + tags: + - ExtendedQueryTag + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AddExtendedQueryTagEntry' + required: true + responses: + '202': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/AddExtendedQueryTagResponse' + '400': + description: Bad Request + get: + tags: + - ExtendedQueryTag + parameters: + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/GetExtendedQueryTagEntry' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + /extendedquerytags: + post: + tags: + - ExtendedQueryTag + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AddExtendedQueryTagEntry' + required: true + responses: + '202': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/AddExtendedQueryTagResponse' + '400': + description: Bad Request + get: + tags: + - ExtendedQueryTag + parameters: + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/GetExtendedQueryTagEntry' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + '/v1-prerelease/extendedquerytags/{tagPath}': + delete: + tags: + - ExtendedQueryTag + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + responses: + '204': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteExtendedQueryTagResponse' + get: + tags: + - ExtendedQueryTag + operationId: VersionedGetExtendedQueryTag + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetExtendedQueryTagEntry' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + patch: + tags: + - ExtendedQueryTag + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateExtendedQueryTagOptions' + text/json: + schema: + $ref: '#/components/schemas/UpdateExtendedQueryTagOptions' + application/*+json: + schema: + $ref: '#/components/schemas/UpdateExtendedQueryTagOptions' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetExtendedQueryTagEntry' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + '/extendedquerytags/{tagPath}': + delete: + tags: + - ExtendedQueryTag + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + responses: + '204': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteExtendedQueryTagResponse' + get: + tags: + - ExtendedQueryTag + operationId: GetExtendedQueryTag + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetExtendedQueryTagEntry' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + patch: + tags: + - ExtendedQueryTag + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateExtendedQueryTagOptions' + text/json: + schema: + $ref: '#/components/schemas/UpdateExtendedQueryTagOptions' + application/*+json: + schema: + $ref: '#/components/schemas/UpdateExtendedQueryTagOptions' + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetExtendedQueryTagEntry' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + '/v1-prerelease/extendedquerytags/{tagPath}/errors': + get: + tags: + - ExtendedQueryTag + operationId: VersionedGetExtendedQueryTagErrors + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ExtendedQueryTagError' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + '/extendedquerytags/{tagPath}/errors': + get: + tags: + - ExtendedQueryTag + operationId: GetExtendedQueryTagErrors + parameters: + - name: tagPath + in: path + required: true + schema: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ExtendedQueryTagError' + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: string + '/operations/{operationId}': + get: + tags: + - Operations + operationId: OperationStatus + parameters: + - name: operationId + in: path + required: true + schema: + type: string + format: uuid + responses: + '400': + description: Bad Request + '404': + description: Not Found + '202': + description: Success + content: + text/plain: + schema: + $ref: '#/components/schemas/OperationStatus' + application/json: + schema: + $ref: '#/components/schemas/OperationStatus' + text/json: + schema: + $ref: '#/components/schemas/OperationStatus' + '200': + description: Success + content: + text/plain: + schema: + $ref: '#/components/schemas/OperationStatus' + application/json: + schema: + $ref: '#/components/schemas/OperationStatus' + text/json: + schema: + $ref: '#/components/schemas/OperationStatus' + '/v1-prerelease/operations/{operationId}': + get: + tags: + - Operations + operationId: VersionedOperationStatus + parameters: + - name: operationId + in: path + required: true + schema: + type: string + format: uuid + responses: + '400': + description: Bad Request + '404': + description: Not Found + '202': + description: Success + content: + text/plain: + schema: + $ref: '#/components/schemas/OperationStatus' + application/json: + schema: + $ref: '#/components/schemas/OperationStatus' + text/json: + schema: + $ref: '#/components/schemas/OperationStatus' + '200': + description: Success + content: + text/plain: + schema: + $ref: '#/components/schemas/OperationStatus' + application/json: + schema: + $ref: '#/components/schemas/OperationStatus' + text/json: + schema: + $ref: '#/components/schemas/OperationStatus' + /v1-prerelease/partitions: + get: + tags: + - Partition + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PartitionEntry' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + /partitions: + get: + tags: + - Partition + responses: + '200': + description: Success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PartitionEntry' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/studies': + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + post: + tags: + - Store + operationId: VersionedPartitionStoreInstance + parameters: + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '202': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '409': + description: Conflict + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '415': + description: Client Error + content: + application/json: + schema: + type: string + '/partitions/{partitionName}/studies': + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + post: + tags: + - Store + operationId: PartitionStoreInstance + parameters: + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '202': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '409': + description: Conflict + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '415': + description: Client Error + content: + application/json: + schema: + type: string + /v1-prerelease/studies: + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + post: + tags: + - Store + operationId: VersionedStoreInstance + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '202': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '409': + description: Conflict + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '415': + description: Client Error + content: + application/json: + schema: + type: string + /studies: + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + post: + tags: + - Store + operationId: StoreInstance + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '202': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '409': + description: Conflict + content: + application/dicom+json: + schema: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '415': + description: Client Error + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/series': + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/partitions/{partitionName}/series': + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + /v1-prerelease/series: + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + /series: + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/series': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/partitions/{partitionName}/studies/{studyInstanceUid}/series': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/v1-prerelease/studies/{studyInstanceUid}/series': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/studies/{studyInstanceUid}/series': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/instances': + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/partitions/{partitionName}/instances': + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + /v1-prerelease/instances: + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + /instances: + get: + tags: + - Query + parameters: + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/instances': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/partitions/{partitionName}/studies/{studyInstanceUid}/instances': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/v1-prerelease/studies/{studyInstanceUid}/instances': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/studies/{studyInstanceUid}/instances': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string + responses: + '200': + description: Success + content: + application/dicom+json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/DicomItem' + '204': + description: Success + '400': + description: Bad Request + content: + application/json: + schema: + type: string + '/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances': + get: + tags: + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -908,9 +3131,45 @@ paths: application/json: schema: type: string - post: + '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances': + get: tags: - - Store + - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 responses: '200': description: Success @@ -919,15 +3178,9 @@ paths: schema: type: array items: - $ref: '#/components/schemas/DicomItem' - '202': - description: Success - content: - application/dicom+json: - schema: - type: array - items: - $ref: '#/components/schemas/DicomItem' + type: array + items: + $ref: '#/components/schemas/DicomItem' '204': description: Success '400': @@ -936,30 +3189,45 @@ paths: application/json: schema: type: string - '406': - description: Not Acceptable - content: - application/json: - schema: - type: string - '409': - description: Conflict - content: - application/dicom+json: - schema: - type: array - items: - $ref: '#/components/schemas/DicomItem' - '415': - description: Client Error - content: - application/json: - schema: - type: string - /v1-prerelease/series: + '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances': get: tags: - Query + parameters: + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: FuzzyMatching + in: query + schema: + type: boolean + - name: IncludeField + in: query + schema: + type: array + items: + type: string + - name: Offset + in: query + schema: + maximum: 2147483647 + minimum: 0 + type: integer + format: int32 + - name: Limit + in: query + schema: + maximum: 200 + minimum: 1 + type: integer + format: int32 responses: '200': description: Success @@ -979,10 +3247,25 @@ paths: application/json: schema: type: string - /series: + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/metadata': get: tags: - - Query + - Retrieve + parameters: + - name: If-None-Match + in: header + schema: + type: string + - name: studyInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -994,24 +3277,45 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - '/v1-prerelease/studies/{studyInstanceUid}/series': + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '304': + description: Not Modified + '/partitions/{partitionName}/studies/{studyInstanceUid}/metadata': get: tags: - - Query + - Retrieve parameters: + - name: If-None-Match + in: header + schema: + type: string - name: studyInstanceUid in: path required: true schema: type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1023,19 +3327,35 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - '/studies/{studyInstanceUid}/series': + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '304': + description: Not Modified + '/v1-prerelease/studies/{studyInstanceUid}/metadata': get: tags: - - Query + - Retrieve parameters: + - name: If-None-Match + in: header + schema: + type: string - name: studyInstanceUid in: path required: true @@ -1052,41 +3372,40 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - /v1-prerelease/instances: - get: - tags: - - Query - responses: - '200': - description: Success + '404': + description: Not Found content: - application/dicom+json: + application/json: schema: - type: array - items: - type: array - items: - $ref: '#/components/schemas/DicomItem' - '204': - description: Success - '400': - description: Bad Request + type: string + '406': + description: Not Acceptable content: application/json: schema: type: string - /instances: + '304': + description: Not Modified + '/studies/{studyInstanceUid}/metadata': get: tags: - - Query + - Retrieve + parameters: + - name: If-None-Match + in: header + schema: + type: string + - name: studyInstanceUid + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1098,24 +3417,50 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - '/v1-prerelease/studies/{studyInstanceUid}/instances': + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '304': + description: Not Modified + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/metadata': get: tags: - - Query + - Retrieve parameters: + - name: If-None-Match + in: header + schema: + type: string - name: studyInstanceUid in: path required: true schema: type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1127,24 +3472,50 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - '/studies/{studyInstanceUid}/instances': + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '304': + description: Not Modified + '/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/metadata': get: tags: - - Query + - Retrieve parameters: + - name: If-None-Match + in: header + schema: + type: string - name: studyInstanceUid in: path required: true schema: type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1156,19 +3527,35 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances': + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '304': + description: Not Modified + '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/metadata': get: tags: - - Query + - Retrieve parameters: + - name: If-None-Match + in: header + schema: + type: string - name: studyInstanceUid in: path required: true @@ -1190,19 +3577,35 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances': + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '304': + description: Not Modified + '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/metadata': get: tags: - - Query + - Retrieve parameters: + - name: If-None-Match + in: header + schema: + type: string - name: studyInstanceUid in: path required: true @@ -1224,15 +3627,27 @@ paths: type: array items: $ref: '#/components/schemas/DicomItem' - '204': - description: Success '400': description: Bad Request content: application/json: schema: type: string - '/v1-prerelease/studies/{studyInstanceUid}/metadata': + '404': + description: Not Found + content: + application/json: + schema: + type: string + '406': + description: Not Acceptable + content: + application/json: + schema: + type: string + '304': + description: Not Modified + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/metadata': get: tags: - Retrieve @@ -1246,6 +3661,21 @@ paths: required: true schema: type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1277,7 +3707,7 @@ paths: type: string '304': description: Not Modified - '/studies/{studyInstanceUid}/metadata': + '/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/metadata': get: tags: - Retrieve @@ -1291,6 +3721,21 @@ paths: required: true schema: type: string + - name: seriesInstanceUid + in: path + required: true + schema: + type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1322,7 +3767,7 @@ paths: type: string '304': description: Not Modified - '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/metadata': + '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/metadata': get: tags: - Retrieve @@ -1341,6 +3786,11 @@ paths: required: true schema: type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1372,7 +3822,7 @@ paths: type: string '304': description: Not Modified - '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/metadata': + '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/metadata': get: tags: - Retrieve @@ -1391,6 +3841,11 @@ paths: required: true schema: type: string + - name: sopInstanceUid + in: path + required: true + schema: + type: string responses: '200': description: Success @@ -1422,15 +3877,12 @@ paths: type: string '304': description: Not Modified - '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/metadata': + '/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/frames/{frames}': get: tags: - Retrieve + operationId: PartitionRetrieveFrame parameters: - - name: If-None-Match - in: header - schema: - type: string - name: studyInstanceUid in: path required: true @@ -1446,17 +3898,24 @@ paths: required: true schema: type: string + - name: frames + in: path + required: true + schema: + type: array + items: + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success content: - application/dicom+json: - schema: - type: array - items: - type: array - items: - $ref: '#/components/schemas/DicomItem' + multipart/related: { } '400': description: Bad Request content: @@ -1475,17 +3934,12 @@ paths: application/json: schema: type: string - '304': - description: Not Modified - '/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/metadata': + '/v1-prerelease/partitions/{partitionName}/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/frames/{frames}': get: tags: - Retrieve + operationId: VersionedPartitionRetrieveFrame parameters: - - name: If-None-Match - in: header - schema: - type: string - name: studyInstanceUid in: path required: true @@ -1501,17 +3955,24 @@ paths: required: true schema: type: string + - name: frames + in: path + required: true + schema: + type: array + items: + type: integer + format: int32 + - name: partitionName + in: path + required: true + schema: + type: string responses: '200': description: Success content: - application/dicom+json: - schema: - type: array - items: - type: array - items: - $ref: '#/components/schemas/DicomItem' + multipart/related: { } '400': description: Bad Request content: @@ -1530,8 +3991,6 @@ paths: application/json: schema: type: string - '304': - description: Not Modified '/v1-prerelease/studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances/{sopInstanceUid}/frames/{frames}': get: tags: @@ -1692,9 +4151,6 @@ components: AddExtendedQueryTagEntry: type: object properties: - level: - type: string - nullable: true path: type: string nullable: true @@ -1704,22 +4160,29 @@ components: privateCreator: type: string nullable: true + level: + $ref: '#/components/schemas/QueryTagLevel' additionalProperties: false AddExtendedQueryTagResponse: type: object + properties: + operation: + $ref: '#/components/schemas/OperationReference' additionalProperties: false ChangeFeedAction: enum: - - 0 - - 1 - type: integer - format: int32 + - Create + - Delete + type: string ChangeFeedEntry: type: object properties: sequence: type: integer format: int64 + partitionName: + type: string + nullable: true studyInstanceUid: type: string nullable: true @@ -1736,19 +4199,27 @@ components: format: date-time state: $ref: '#/components/schemas/ChangeFeedState' + originalVersion: + type: integer + format: int64 + currentVersion: + type: integer + format: int64 + nullable: true metadata: type: array items: $ref: '#/components/schemas/DicomItem' nullable: true + includeMetadata: + type: boolean additionalProperties: false ChangeFeedState: enum: - - 0 - - 1 - - 2 - type: integer - format: int32 + - Current + - Replaced + - Deleted + type: string DeleteExtendedQueryTagResponse: type: object additionalProperties: false @@ -1883,39 +4354,149 @@ components: type: integer format: int32 readOnly: true - valueType: + additionalProperties: false + ExtendedQueryTagError: + type: object + properties: + partitionName: + type: string + nullable: true + studyInstanceUid: + type: string + nullable: true + seriesInstanceUid: + type: string + nullable: true + sopInstanceUid: + type: string + nullable: true + createdTime: + type: string + format: date-time + errorMessage: type: string nullable: true - readOnly: true + additionalProperties: false + ExtendedQueryTagErrorReference: + type: object + properties: + count: + type: integer + format: int32 + href: + type: string + format: uri + nullable: true additionalProperties: false ExtendedQueryTagStatus: enum: - - 0 - - 1 - - 2 - type: integer - format: int32 + - Adding + - Ready + - Deleting + type: string GetExtendedQueryTagEntry: type: object properties: + path: + type: string + nullable: true + vr: + type: string + nullable: true + privateCreator: + type: string + nullable: true status: $ref: '#/components/schemas/ExtendedQueryTagStatus' level: $ref: '#/components/schemas/QueryTagLevel' - path: + errors: + $ref: '#/components/schemas/ExtendedQueryTagErrorReference' + operation: + $ref: '#/components/schemas/OperationReference' + queryStatus: + $ref: '#/components/schemas/QueryStatus' + additionalProperties: false + OperationReference: + type: object + properties: + id: type: string + format: uuid + href: + type: string + format: uri nullable: true - vr: + additionalProperties: false + OperationRuntimeStatus: + enum: + - Unknown + - NotStarted + - Running + - Completed + - Failed + - Canceled + type: string + OperationStatus: + type: object + properties: + operationId: + type: string + format: uuid + type: + $ref: '#/components/schemas/OperationType' + createdTime: + type: string + format: date-time + lastUpdatedTime: type: string + format: date-time + status: + $ref: '#/components/schemas/OperationRuntimeStatus' + percentComplete: + type: integer + format: int32 + resources: + type: array + items: + type: string + format: uri nullable: true - privateCreator: + additionalProperties: false + OperationType: + enum: + - Unknown + - Reindex + type: string + PartitionEntry: + type: object + properties: + partitionKey: + type: integer + format: int32 + partitionName: type: string nullable: true + createdDate: + type: string + format: date-time additionalProperties: false + QueryStatus: + enum: + - Disabled + - Enabled + type: string QueryTagLevel: enum: - - 0 - - 1 - - 2 - type: integer - format: int32 \ No newline at end of file + - Instance + - Series + - Study + type: string + UpdateExtendedQueryTagOptions: + required: + - queryStatus + type: object + properties: + queryStatus: + $ref: '#/components/schemas/QueryStatus' + additionalProperties: { } \ No newline at end of file diff --git a/test/Microsoft.Health.Dicom.Tests.Integration/Persistence/ExtendedQueryTagStoreTests.cs b/test/Microsoft.Health.Dicom.Tests.Integration/Persistence/ExtendedQueryTagStoreTests.cs index 1536c55bdc..fd6ea95a90 100644 --- a/test/Microsoft.Health.Dicom.Tests.Integration/Persistence/ExtendedQueryTagStoreTests.cs +++ b/test/Microsoft.Health.Dicom.Tests.Integration/Persistence/ExtendedQueryTagStoreTests.cs @@ -350,7 +350,7 @@ public async Task GivenValidTags_WhenGetTagWithPagination_ThenShouldSucceed() for (int i = 0; i < addEntries.Length; i++) { Assert.Equal(addEntries[i].Path, entries[i].Path); - Assert.Equal(addEntries[i].Level, entries[i].Level.ToString()); + Assert.Equal(addEntries[i].Level, entries[i].Level); Assert.Equal(addEntries[i].PrivateCreator, entries[i].PrivateCreator); Assert.Equal(addEntries[i].VR, entries[i].VR); Assert.Equal(ExtendedQueryTagStatus.Ready, entries[i].Status); @@ -414,7 +414,7 @@ private void AssertTag( Assert.Equal(expected.Path, actual.Path); Assert.Equal(expected.PrivateCreator, actual.PrivateCreator); Assert.Equal(expected.VR, actual.VR); - Assert.Equal(expected.Level, actual.Level.ToString()); + Assert.Equal(expected.Level, actual.Level); Assert.Equal(status, actual.Status); // Typically we'll set the status to Adding Assert.Equal(queryStatus, actual.QueryStatus); Assert.Equal(operationId, actual.OperationId); diff --git a/test/Microsoft.Health.Dicom.Web.Tests.E2E/Rest/ExtendedQueryTagTests.cs b/test/Microsoft.Health.Dicom.Web.Tests.E2E/Rest/ExtendedQueryTagTests.cs index 7b0de98f4f..fc8d28b2e3 100644 --- a/test/Microsoft.Health.Dicom.Web.Tests.E2E/Rest/ExtendedQueryTagTests.cs +++ b/test/Microsoft.Health.Dicom.Web.Tests.E2E/Rest/ExtendedQueryTagTests.cs @@ -205,7 +205,7 @@ public async Task GivenInvalidTagLevelInRequestBody_WhenCallingPostAync_ThenShou HttpResponseMessage response = await _client.HttpClient.SendAsync(request, default(CancellationToken)).ConfigureAwait(false); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal("The field '[0].Level' in request body is invalid: Input Dicom Tag Level 'Studys' is invalid. It must have value 'Study', 'Series' or 'Instance'.", response.Content.ReadAsStringAsync().Result); + Assert.Equal("The field '$[0].Level' in request body is invalid: Expected value 'Studys' to be one of the following values: ['Instance', 'Series', 'Study']", response.Content.ReadAsStringAsync().Result); } private async Task CleanupExtendedQueryTag(DicomTag tag)