From 5b0f352ba87e274c4c2ca0adf7bd1b08c3b957be Mon Sep 17 00:00:00 2001 From: "RB Johnson (He/Him)" Date: Thu, 30 Jul 2026 14:20:14 -0700 Subject: [PATCH 1/2] Add search for soft deleted resources --- docs/rest/SoftDeleteSearch.http | 131 ++++++++++++++++++ .../Models/DeletedResourceSearchModel.cs | 46 ++++++ .../Features/Routing/KnownRoutes.cs | 2 + .../Features/Routing/RouteNames.cs | 4 + .../Features/Search/ISearchService.cs | 12 ++ .../Features/Search/SearchService.cs | 57 +++++++- .../Search/SearchDeletedResourcesRequest.cs | 77 ++++++++++ .../Features/Search/QueryBuilderTests.cs | 33 +++++ .../Features/Search/Queries/QueryBuilder.cs | 18 ++- .../Controllers/FhirControllerTests.cs | 57 ++++++++ .../Controllers/FhirController.cs | 47 +++++++ .../SearchDeletedResourcesHandlerTests.cs | 73 ++++++++++ .../Features/Search/SearchServiceTests.cs | 38 +++++ ...ealth.Fhir.Shared.Core.UnitTests.projitems | 1 + .../Extensions/FhirMediatorExtensions.cs | 19 +++ .../Search/SearchDeletedResourcesHandler.cs | 66 +++++++++ ...icrosoft.Health.Fhir.Shared.Core.projitems | 1 + 17 files changed, 675 insertions(+), 7 deletions(-) create mode 100644 docs/rest/SoftDeleteSearch.http create mode 100644 src/Microsoft.Health.Fhir.Api/Models/DeletedResourceSearchModel.cs create mode 100644 src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs create mode 100644 src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs create mode 100644 src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchDeletedResourcesHandlerTests.cs create mode 100644 src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchDeletedResourcesHandler.cs diff --git a/docs/rest/SoftDeleteSearch.http b/docs/rest/SoftDeleteSearch.http new file mode 100644 index 0000000000..3356171870 --- /dev/null +++ b/docs/rest/SoftDeleteSearch.http @@ -0,0 +1,131 @@ +# Soft-deleted resources can be searched at the system or resource-type level. +# The only supported filters are the resource type in the URL and the +# last-updated bounds (_since and _before). + +@hostname = localhost:44348 + +### Get the bearer token, if authentication is enabled +# @name bearer +POST https://{{hostname}}/connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id=globalAdminServicePrincipal +&client_secret=globalAdminServicePrincipal +&scope=fhir-api + +### Create a patient that will be soft deleted +PUT https://{{hostname}}/Patient/soft-delete-search-patient +Content-Type: application/fhir+json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "soft-delete-search-patient", + "active": true, + "name": [ + { + "use": "official", + "family": "Deleted", + "given": [ + "Pat" + ] + } + ] +} + +### Create an observation that will be soft deleted +PUT https://{{hostname}}/Observation/soft-delete-search-observation +Content-Type: application/fhir+json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Observation", + "id": "soft-delete-search-observation", + "status": "final", + "code": { + "coding": [ + { + "system": "http://loinc.org", + "code": "29463-7", + "display": "Body weight" + } + ] + }, + "subject": { + "reference": "Patient/soft-delete-search-patient" + }, + "valueQuantity": { + "value": 72.5, + "unit": "kg", + "system": "http://unitsofmeasure.org", + "code": "kg" + } +} + +### Create an active patient to show that normal resources are excluded +PUT https://{{hostname}}/Patient/soft-delete-search-active-patient +Content-Type: application/fhir+json +Authorization: Bearer {{bearer.response.body.access_token}} + +{ + "resourceType": "Patient", + "id": "soft-delete-search-active-patient", + "active": true, + "name": [ + { + "family": "Active", + "given": [ + "Alex" + ] + } + ] +} + +### Soft delete the sample patient +DELETE https://{{hostname}}/Patient/soft-delete-search-patient +Authorization: Bearer {{bearer.response.body.access_token}} + +### Soft delete the sample observation +DELETE https://{{hostname}}/Observation/soft-delete-search-observation +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search all soft-deleted resource types +# Returns the deleted Patient and Observation, but not the active Patient. +GET https://{{hostname}}/_deleted +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search soft-deleted patients +# The resource type is specified in the URL. This returns only the deleted Patient. +GET https://{{hostname}}/Patient/_deleted +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search soft-deleted observations +GET https://{{hostname}}/Observation/_deleted +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search resources deleted since a last-updated time +# _since is inclusive. Use a timestamp before the DELETE requests above. +GET https://{{hostname}}/_deleted?_since=2000-01-01T00:00:00Z +Authorization: Bearer {{bearer.response.body.access_token}} + +### Search a last-updated time range +# _before is exclusive and cannot be in the future. Replace the example values +# with timestamps that bracket the DELETE requests before running this request. +GET https://{{hostname}}/Patient/_deleted?_since=2026-07-30T20:00:00Z&_before=2026-07-30T22:00:00Z +Authorization: Bearer {{bearer.response.body.access_token}} + +### Sort and page through soft-deleted resources +# Only _lastUpdated is supported for sorting. The default order is descending. +# @name deletedPage +GET https://{{hostname}}/_deleted?_count=1&_sort=-_lastUpdated +Authorization: Bearer {{bearer.response.body.access_token}} + +### Record the next-page URL +@deletedNextPage = {{deletedPage.response.body.link[0].url}} + +### Get the next page +# @name deletedPage +GET {{deletedNextPage}} +Authorization: Bearer {{bearer.response.body.access_token}} + diff --git a/src/Microsoft.Health.Fhir.Api/Models/DeletedResourceSearchModel.cs b/src/Microsoft.Health.Fhir.Api/Models/DeletedResourceSearchModel.cs new file mode 100644 index 0000000000..c001b29a69 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Api/Models/DeletedResourceSearchModel.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.Api.Models; + +/// +/// Query parameters for searching soft-deleted resources. +/// +public class DeletedResourceSearchModel +{ + /// + /// Gets or sets the inclusive lower last-updated bound. + /// + [FromQuery(Name = KnownQueryParameterNames.Since)] + public PartialDateTime Since { get; set; } + + /// + /// Gets or sets the exclusive upper last-updated bound. + /// + [FromQuery(Name = KnownQueryParameterNames.Before)] + public PartialDateTime Before { get; set; } + + /// + /// Gets or sets the page size. + /// + [FromQuery(Name = KnownQueryParameterNames.Count)] + public int? Count { get; set; } + + /// + /// Gets or sets the continuation token. + /// + [FromQuery(Name = KnownQueryParameterNames.ContinuationToken)] + public string ContinuationToken { get; set; } + + /// + /// Gets or sets the last-updated sort order. + /// + [FromQuery(Name = KnownQueryParameterNames.Sort)] + public string Sort { get; set; } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs index fc51a2e60c..525d3d9835 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs @@ -21,9 +21,11 @@ internal class KnownRoutes private const string VidRouteSegment = "{" + KnownActionParameterNames.Vid + "}"; public const string History = "_history"; + public const string Deleted = "_deleted"; public const string Search = "_search"; public const string ResourceType = ResourceTypeRouteSegment; public const string ResourceTypeHistory = ResourceType + "/" + History; + public const string ResourceTypeDeleted = ResourceType + "/" + Deleted; public const string ResourceTypeSearch = ResourceType + "/" + Search; public const string ResourceTypeById = ResourceType + "/" + IdRouteSegment; public const string ResourceTypeByIdHistory = ResourceTypeById + "/" + History; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs index 4f64cba0c3..31f714cc2c 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs @@ -25,6 +25,10 @@ internal static class RouteNames internal const string HistoryTypeId = nameof(HistoryTypeId); + internal const string Deleted = nameof(Deleted); + + internal const string DeletedType = nameof(DeletedType); + internal const string SearchCompartmentByResourceType = nameof(SearchCompartmentByResourceType); internal const string AadSmartOnFhirProxyAuthorize = nameof(AadSmartOnFhirProxyAuthorize); diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs index bfb6e20689..9b5f45daf5 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/ISearchService.cs @@ -81,6 +81,18 @@ Task SearchHistoryAsync( CancellationToken cancellationToken, bool isAsyncOperation = false); + /// + /// Searches current soft-deleted resources. + /// + Task SearchDeletedAsync( + string resourceType, + PartialDateTime since, + PartialDateTime before, + int? count, + string continuationToken, + string sort, + CancellationToken cancellationToken); + /// /// Searches resources by queryParameters and returns the raw resource, /// the current search param values for each resource, diff --git a/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs b/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs index 4104d8f5b7..dad471a83f 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Search/SearchService.cs @@ -95,6 +95,59 @@ public async Task SearchHistoryAsync( string sort, CancellationToken cancellationToken, bool isAsyncOperation = false) + { + return await SearchByVersionTypeAsync( + resourceType, + resourceId, + at, + since, + before, + count, + summary, + continuationToken, + sort, + ResourceVersionType.Latest | ResourceVersionType.History | ResourceVersionType.SoftDeleted, + isAsyncOperation, + cancellationToken); + } + + public async Task SearchDeletedAsync( + string resourceType, + PartialDateTime since, + PartialDateTime before, + int? count, + string continuationToken, + string sort, + CancellationToken cancellationToken) + { + return await SearchByVersionTypeAsync( + resourceType, + resourceId: null, + at: null, + since, + before, + count, + summary: null, + continuationToken, + sort, + ResourceVersionType.SoftDeleted, + isAsyncOperation: false, + cancellationToken); + } + + private async Task SearchByVersionTypeAsync( + string resourceType, + string resourceId, + PartialDateTime at, + PartialDateTime since, + PartialDateTime before, + int? count, + string summary, + string continuationToken, + string sort, + ResourceVersionType resourceVersionTypes, + bool isAsyncOperation, + CancellationToken cancellationToken) { var queryParameters = new List>(); @@ -198,9 +251,7 @@ public async Task SearchHistoryAsync( queryParameters.Add(Tuple.Create(KnownQueryParameterNames.Sort, $"-{KnownQueryParameterNames.LastUpdated}")); } - var historyResourceVersionTypes = ResourceVersionType.Latest | ResourceVersionType.History | ResourceVersionType.SoftDeleted; - - SearchOptions searchOptions = _searchOptionsFactory.Create(resourceType, queryParameters, isAsyncOperation, historyResourceVersionTypes); + SearchOptions searchOptions = _searchOptionsFactory.Create(resourceType, queryParameters, isAsyncOperation, resourceVersionTypes); SearchResult searchResult = await SearchAsync(searchOptions, cancellationToken); diff --git a/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs b/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs new file mode 100644 index 0000000000..bba27e43fe --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Medino; +using Microsoft.Health.Fhir.Core.Features.Conformance; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.Core.Messages.Search +{ + /// + /// A request to search current soft-deleted resources. + /// + public class SearchDeletedResourcesRequest : IRequest, IRequireCapability + { + /// + /// Initializes a new instance of the class. + /// + public SearchDeletedResourcesRequest( + string resourceType, + PartialDateTime since, + PartialDateTime before, + int? count, + string continuationToken, + string sort) + { + ResourceType = resourceType; + Since = since; + Before = before; + Count = count; + ContinuationToken = continuationToken; + Sort = sort; + } + + /// + /// Gets the optional resource type. + /// + public string ResourceType { get; } + + /// + /// Gets the inclusive lower last-updated bound. + /// + public PartialDateTime Since { get; } + + /// + /// Gets the exclusive upper last-updated bound. + /// + public PartialDateTime Before { get; } + + /// + /// Gets the requested page size. + /// + public int? Count { get; } + + /// + /// Gets the continuation token. + /// + public string ContinuationToken { get; } + + /// + /// Gets the last-updated sort order. + /// + public string Sort { get; } + + /// + public IEnumerable RequiredCapabilities() + { + string capability = string.IsNullOrEmpty(ResourceType) + ? "CapabilityStatement.rest.interaction.where(code = 'history-system').exists()" + : $"CapabilityStatement.rest.resource.where(type = '{ResourceType}').interaction.where(code = 'history-type').exists()"; + + yield return new CapabilityQuery(capability); + } + } +} diff --git a/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs b/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs new file mode 100644 index 0000000000..53732c1526 --- /dev/null +++ b/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.CosmosDb.Features.Search.Queries; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using Xunit; + +namespace Microsoft.Health.Fhir.CosmosDb.UnitTests.Features.Search +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class QueryBuilderTests + { + [Fact] + public void GivenSoftDeletedOnlySearch_WhenQueryBuilt_ThenOnlyDeletedResourcesAreSelected() + { + var searchOptions = new SearchOptions + { + ResourceVersionTypes = ResourceVersionType.SoftDeleted, + Sort = [], + }; + + string query = new QueryBuilder().BuildSqlQuerySpec(searchOptions).QueryText; + + Assert.Contains("r.isDeleted =", query); + Assert.DoesNotContain("r.isHistory =", query); + } + } +} diff --git a/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs b/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs index 064f9da5cc..8f649e1e7f 100644 --- a/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs +++ b/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs @@ -79,15 +79,16 @@ public QueryDefinition BuildSqlQuerySpec(SearchOptions searchOptions, QueryBuild searchOptions.Expression.AcceptVisitor(expressionQueryBuilder); } - if (!searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest)) + if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest)) { AppendFilterCondition( "AND", true, (KnownResourceWrapperProperties.IsHistory, true)); } - - if (!searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History)) + else if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History)) { AppendFilterCondition( "AND", @@ -95,7 +96,16 @@ public QueryDefinition BuildSqlQuerySpec(SearchOptions searchOptions, QueryBuild (KnownResourceWrapperProperties.IsHistory, false)); } - if (!searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted)) + if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest)) + { + AppendFilterCondition( + "AND", + true, + (KnownResourceWrapperProperties.IsDeleted, true)); + } + else if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest) && + !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted)) { AppendFilterCondition( "AND", diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs index b771b4321e..a33b1051e5 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs @@ -152,6 +152,8 @@ public void WhenProvidedAFhirController_CheckIfTheSearchEndpointsHaveTheLatencyM TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "SearchCompartmentByResourceType", _targetFhirControllerClass); TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "SystemHistory", _targetFhirControllerClass); TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "TypeHistory", _targetFhirControllerClass); + TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeletedResources", _targetFhirControllerClass); + TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeletedResourcesByType", _targetFhirControllerClass); } [Fact] @@ -591,6 +593,20 @@ await RunHistoryTest( Guid.NewGuid().ToString()); } + [Fact] + public async Task GivenSystemDeletedResourceSearch_WhenProcessingRequest_ThenRequestShouldBeCreatedCorrectly() + { + await RunDeletedResourceSearchTest((model, _) => _fhirController.DeletedResources(model)); + } + + [Fact] + public async Task GivenTypeDeletedResourceSearch_WhenProcessingRequest_ThenRequestShouldBeCreatedCorrectly() + { + await RunDeletedResourceSearchTest( + (model, type) => _fhirController.DeletedResourcesByType(type, model), + KnownResourceTypes.Patient); + } + [Fact] public async Task GivenVReadRequest_WhenProcessingRequest_ThenGetResourceRequestShouldBeCreatedCorrectly() { @@ -1444,6 +1460,47 @@ await _mediator.Received(1).SendAsync( Arg.Any()); } + private async Task RunDeletedResourceSearchTest( + Func> action, + string resourceType = null) + { + var resource = new Bundle + { + Id = Guid.NewGuid().ToString(), + VersionId = Guid.NewGuid().ToString(), + }; + var httpContext = new DefaultHttpContext(); + _fhirController.ControllerContext.HttpContext = httpContext; + _mediator.SendAsync( + Arg.Any(), + Arg.Any()) + .Returns(new SearchResourceHistoryResponse(resource.ToResourceElement())); + SearchDeletedResourcesRequest request = null; + _mediator.When(x => x.SendAsync( + Arg.Any(), + Arg.Any())) + .Do(x => request = x.Arg()); + var model = new DeletedResourceSearchModel + { + Since = PartialDateTime.Parse("2025-01-01"), + Before = PartialDateTime.Parse("2025-02-01"), + Count = 10, + ContinuationToken = "token", + Sort = $"-{KnownQueryParameterNames.LastUpdated}", + }; + + IActionResult response = await action(model, resourceType); + + Assert.IsType(response); + Assert.NotNull(request); + Assert.Equal(resourceType, request.ResourceType); + Assert.Equal(model.Since, request.Since); + Assert.Equal(model.Before, request.Before); + Assert.Equal(model.Count, request.Count); + Assert.Equal(model.ContinuationToken, request.ContinuationToken); + Assert.Equal(model.Sort, request.Sort); + } + private static void TestIfTargetMethodContainsCustomAttribute(Type expectedCustomAttributeType, string methodName, Type targetClassType) { MethodInfo bundleMethodInfo = targetClassType.GetMethod(methodName); diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs index a37b92ab67..381924088a 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs @@ -370,6 +370,53 @@ public async Task TypeHistory( return FhirResult.Create(response); } + /// + /// Returns current soft-deleted resources in the system. + /// + /// Model for last-updated and paging parameters. + [HttpGet] + [Route(KnownRoutes.Deleted, Name = RouteNames.Deleted)] + [AuditEventType(AuditEventSubType.HistorySystem)] + [TypeFilter(typeof(SearchEndpointMetricEmitterAttribute))] + public async Task DeletedResources(DeletedResourceSearchModel searchModel) + { + ResourceElement response = await _mediator.SearchDeletedResourcesAsync( + resourceType: null, + searchModel.Since, + searchModel.Before, + searchModel.Count, + searchModel.ContinuationToken, + searchModel.Sort, + HttpContext.RequestAborted); + + return FhirResult.Create(response); + } + + /// + /// Returns current soft-deleted resources of a specific type. + /// + /// The resource type. + /// Model for last-updated and paging parameters. + [HttpGet] + [Route(KnownRoutes.ResourceTypeDeleted, Name = RouteNames.DeletedType)] + [AuditEventType(AuditEventSubType.HistoryType)] + [TypeFilter(typeof(SearchEndpointMetricEmitterAttribute))] + public async Task DeletedResourcesByType( + string typeParameter, + DeletedResourceSearchModel searchModel) + { + ResourceElement response = await _mediator.SearchDeletedResourcesAsync( + typeParameter, + searchModel.Since, + searchModel.Before, + searchModel.Count, + searchModel.ContinuationToken, + searchModel.Sort, + HttpContext.RequestAborted); + + return FhirResult.Create(response); + } + /// /// Returns the history of a resource /// diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchDeletedResourcesHandlerTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchDeletedResourcesHandlerTests.cs new file mode 100644 index 0000000000..e1f160d3b2 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchDeletedResourcesHandlerTests.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------------------------------- +// 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.Linq; +using System.Threading; +using Hl7.Fhir.Model; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features.Search; +using Microsoft.Health.Fhir.Core.Features.Search.Filters; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Messages.Search; +using Microsoft.Health.Fhir.Tests.Common; +using Microsoft.Health.Test.Utilities; +using NSubstitute; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace Microsoft.Health.Fhir.Core.UnitTests.Features.Search +{ + [Trait(Traits.OwningTeam, OwningTeam.Fhir)] + [Trait(Traits.Category, Categories.Search)] + public class SearchDeletedResourcesHandlerTests + { + [Fact] + public async Task GivenADeletedResourceSearch_WhenHandled_ThenAHistoryBundleIsReturned() + { + ISearchService searchService = Substitute.For(); + IBundleFactory bundleFactory = Substitute.For(); + var handler = new SearchDeletedResourcesHandler( + searchService, + bundleFactory, + DisabledFhirAuthorizationService.Instance, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + var request = new SearchDeletedResourcesRequest("Patient", null, null, null, null, null); + var searchResult = new SearchResult(Enumerable.Empty(), null, null, Array.Empty>()); + var expectedBundle = new Bundle().ToResourceElement(); + + searchService.SearchDeletedAsync("Patient", null, null, null, null, null, CancellationToken.None).Returns(searchResult); + bundleFactory.CreateHistoryBundle(searchResult).Returns(expectedBundle); + + SearchResourceHistoryResponse response = await handler.HandleAsync(request, CancellationToken.None); + + Assert.Same(expectedBundle, response.Bundle); + } + + [Theory] + [InlineData(DataActions.None)] + [InlineData(DataActions.Write)] + [InlineData(DataActions.ReadById)] + public async Task GivenADeletedResourceSearch_WhenUserLacksSearchAccess_ThenAuthorizationFails(DataActions dataActions) + { + ISearchService searchService = Substitute.For(); + IBundleFactory bundleFactory = Substitute.For(); + IAuthorizationService authorizationService = Substitute.For>(); + var handler = new SearchDeletedResourcesHandler( + searchService, + bundleFactory, + authorizationService, + new DataResourceFilter(MissingDataFilterCriteria.Default)); + var request = new SearchDeletedResourcesRequest(null, null, null, null, null, null); + + authorizationService.CheckAccess(DataActions.Read | DataActions.Search, CancellationToken.None).Returns(dataActions); + + await Assert.ThrowsAsync( + () => handler.HandleAsync(request, CancellationToken.None)); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs index 861d49385e..6c4ba27c01 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Features/Search/SearchServiceTests.cs @@ -168,6 +168,44 @@ public async Task GivenAHistorySearch_WhenUsingSummaryCountOrCountZero_ThenSearc Assert.Same(expectedSearchResult, singlePatientSummaryCount); } + [Fact] + public async Task GivenADeletedResourceSearch_WhenSearched_ThenOnlySoftDeletedResourcesAreRequested() + { + const string resourceType = "Observation"; + var since = PartialDateTime.Parse("2025-01-01"); + var before = PartialDateTime.Parse("2025-02-01"); + var expectedSearchOptions = new SearchOptions(); + + _searchOptionsFactory.Create( + resourceType, + Arg.Is>>(parameters => + parameters.Contains(Tuple.Create(SearchParameterNames.LastUpdated, $"ge{since}")) && + parameters.Contains(Tuple.Create(SearchParameterNames.LastUpdated, $"lt{before}")) && + parameters.Contains(Tuple.Create(KnownQueryParameterNames.Count, "25")) && + parameters.Contains(Tuple.Create(KnownQueryParameterNames.ContinuationToken, "token")) && + parameters.Contains(Tuple.Create(KnownQueryParameterNames.Sort, $"-{KnownQueryParameterNames.LastUpdated}"))), + resourceVersionTypes: ResourceVersionType.SoftDeleted) + .Returns(expectedSearchOptions); + + SearchResult expectedSearchResult = SearchResult.Empty(_unsupportedQueryParameters); + _searchService.SearchImplementation = options => + { + Assert.Same(expectedSearchOptions, options); + return expectedSearchResult; + }; + + SearchResult actual = await _searchService.SearchDeletedAsync( + resourceType, + since, + before, + 25, + "token", + $"-{KnownQueryParameterNames.LastUpdated}", + CancellationToken.None); + + Assert.Same(expectedSearchResult, actual); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems index 00d7fb03cc..094e9f9192 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Microsoft.Health.Fhir.Shared.Core.UnitTests.projitems @@ -129,6 +129,7 @@ + diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs b/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs index 8ef08c5f2a..3932864a43 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core/Extensions/FhirMediatorExtensions.cs @@ -141,6 +141,25 @@ public static async Task SearchResourceHistoryAsync(this IMedia return result.Bundle; } + public static async Task SearchDeletedResourcesAsync( + this IMediator mediator, + string resourceType, + PartialDateTime since = null, + PartialDateTime before = null, + int? count = null, + string continuationToken = null, + string sort = null, + CancellationToken cancellationToken = default) + { + EnsureArg.IsNotNull(mediator, nameof(mediator)); + + var result = await mediator.SendAsync( + new SearchDeletedResourcesRequest(resourceType, since, before, count, continuationToken, sort), + cancellationToken); + + return result.Bundle; + } + public static async Task SearchResourceCompartmentAsync(this IMediator mediator, string compartmentType, string compartmentId, string resourceType, IReadOnlyList> queries, CancellationToken cancellationToken = default) { EnsureArg.IsNotNull(mediator, nameof(mediator)); diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchDeletedResourcesHandler.cs b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchDeletedResourcesHandler.cs new file mode 100644 index 0000000000..7a17ea8489 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Shared.Core/Features/Search/SearchDeletedResourcesHandler.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading; +using System.Threading.Tasks; +using EnsureThat; +using Medino; +using Microsoft.Health.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Features.Security; +using Microsoft.Health.Fhir.Core.Features.Security.Authorization; +using Microsoft.Health.Fhir.Core.Messages.Search; +using Microsoft.Health.Fhir.Core.Models; + +namespace Microsoft.Health.Fhir.Core.Features.Search +{ + /// + /// Handles searches for current soft-deleted resources. + /// + public class SearchDeletedResourcesHandler : IRequestHandler + { + private readonly ISearchService _searchService; + private readonly IBundleFactory _bundleFactory; + private readonly IAuthorizationService _authorizationService; + private readonly IDataResourceFilter _dataResourceFilter; + + /// + /// Initializes a new instance of the class. + /// + public SearchDeletedResourcesHandler( + ISearchService searchService, + IBundleFactory bundleFactory, + IAuthorizationService authorizationService, + IDataResourceFilter dataResourceFilter) + { + _searchService = EnsureArg.IsNotNull(searchService, nameof(searchService)); + _bundleFactory = EnsureArg.IsNotNull(bundleFactory, nameof(bundleFactory)); + _authorizationService = EnsureArg.IsNotNull(authorizationService, nameof(authorizationService)); + _dataResourceFilter = EnsureArg.IsNotNull(dataResourceFilter, nameof(dataResourceFilter)); + } + + /// + public async Task HandleAsync(SearchDeletedResourcesRequest request, CancellationToken cancellationToken) + { + EnsureArg.IsNotNull(request, nameof(request)); + + await _authorizationService.CheckSearchAccess(cancellationToken); + + SearchResult searchResult = await _searchService.SearchDeletedAsync( + request.ResourceType, + request.Since, + request.Before, + request.Count, + request.ContinuationToken, + request.Sort, + cancellationToken); + + searchResult = _dataResourceFilter.Filter(searchResult); + + ResourceElement bundle = _bundleFactory.CreateHistoryBundle(searchResult); + return new SearchResourceHistoryResponse(bundle); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems b/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems index 4d87238f6e..76ca3d1599 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems +++ b/src/Microsoft.Health.Fhir.Shared.Core/Microsoft.Health.Fhir.Shared.Core.projitems @@ -90,6 +90,7 @@ + From 137eebee878241c93cfd3432cfc260f7abe5540c Mon Sep 17 00:00:00 2001 From: "RB Johnson (He/Him)" Date: Thu, 30 Jul 2026 14:48:10 -0700 Subject: [PATCH 2/2] Update to be an operation --- docs/rest/SoftDeleteSearch.http | 13 ++-- .../Features/Routing/UrlResolver.cs | 3 + .../OperationDefinition/delete-search.json | 64 +++++++++++++++++++ .../Operations/OperationsConstants.cs | 2 + .../Features/Routing/KnownRoutes.cs | 6 +- .../Features/Routing/RouteNames.cs | 6 +- .../Search/SearchDeletedResourcesRequest.cs | 14 +--- .../Microsoft.Health.Fhir.Core.csproj | 1 + .../Features/Search/QueryBuilderTests.cs | 2 +- .../Features/Search/Queries/QueryBuilder.cs | 3 +- ...rationDefinitionMediatorExtensionsTests.cs | 1 + .../Controllers/FhirControllerTests.cs | 8 +-- .../OperationDefinitionControllerTests.cs | 11 ++++ .../OperationsCapabilityProviderTests.cs | 44 +++++++++++++ .../Features/Routing/UrlResolverTests.cs | 11 ++++ .../Controllers/FhirController.cs | 8 +-- .../OperationDefinitionController.cs | 9 +++ .../OperationsCapabilityProvider.cs | 6 ++ ...rationDefinitionMediatorExtensionsTests.cs | 1 + .../Features/Search/SqlQueryGeneratorTests.cs | 1 + .../QueryGenerators/SqlQueryGenerator.cs | 7 +- 21 files changed, 185 insertions(+), 36 deletions(-) create mode 100644 src/Microsoft.Health.Fhir.Core/Data/OperationDefinition/delete-search.json diff --git a/docs/rest/SoftDeleteSearch.http b/docs/rest/SoftDeleteSearch.http index 3356171870..5745de9885 100644 --- a/docs/rest/SoftDeleteSearch.http +++ b/docs/rest/SoftDeleteSearch.http @@ -92,33 +92,33 @@ Authorization: Bearer {{bearer.response.body.access_token}} ### Search all soft-deleted resource types # Returns the deleted Patient and Observation, but not the active Patient. -GET https://{{hostname}}/_deleted +GET https://{{hostname}}/$delete-search Authorization: Bearer {{bearer.response.body.access_token}} ### Search soft-deleted patients # The resource type is specified in the URL. This returns only the deleted Patient. -GET https://{{hostname}}/Patient/_deleted +GET https://{{hostname}}/Patient/$delete-search Authorization: Bearer {{bearer.response.body.access_token}} ### Search soft-deleted observations -GET https://{{hostname}}/Observation/_deleted +GET https://{{hostname}}/Observation/$delete-search Authorization: Bearer {{bearer.response.body.access_token}} ### Search resources deleted since a last-updated time # _since is inclusive. Use a timestamp before the DELETE requests above. -GET https://{{hostname}}/_deleted?_since=2000-01-01T00:00:00Z +GET https://{{hostname}}/$delete-search?_since=2000-01-01T00:00:00Z Authorization: Bearer {{bearer.response.body.access_token}} ### Search a last-updated time range # _before is exclusive and cannot be in the future. Replace the example values # with timestamps that bracket the DELETE requests before running this request. -GET https://{{hostname}}/Patient/_deleted?_since=2026-07-30T20:00:00Z&_before=2026-07-30T22:00:00Z +GET https://{{hostname}}/Patient/$delete-search?_since=2026-07-30T20:00:00Z&_before=2026-07-30T22:00:00Z Authorization: Bearer {{bearer.response.body.access_token}} ### Sort and page through soft-deleted resources # Only _lastUpdated is supported for sorting. The default order is descending. # @name deletedPage -GET https://{{hostname}}/_deleted?_count=1&_sort=-_lastUpdated +GET https://{{hostname}}/$delete-search?_count=1&_sort=-_lastUpdated Authorization: Bearer {{bearer.response.body.access_token}} ### Record the next-page URL @@ -128,4 +128,3 @@ Authorization: Bearer {{bearer.response.body.access_token}} # @name deletedPage GET {{deletedNextPage}} Authorization: Bearer {{bearer.response.body.access_token}} - diff --git a/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs b/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs index 99ce45fad0..ef4bca722a 100644 --- a/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs +++ b/src/Microsoft.Health.Fhir.Api/Features/Routing/UrlResolver.cs @@ -339,6 +339,9 @@ public Uri ResolveOperationDefinitionUrl(string operationName) case OperationsConstants.BulkDelete: routeName = RouteNames.BulkDeleteDefinition; break; + case OperationsConstants.DeleteSearch: + routeName = RouteNames.DeleteSearchOperationDefinition; + break; case OperationsConstants.BulkUpdate: routeName = RouteNames.BulkUpdateDefinition; break; diff --git a/src/Microsoft.Health.Fhir.Core/Data/OperationDefinition/delete-search.json b/src/Microsoft.Health.Fhir.Core/Data/OperationDefinition/delete-search.json new file mode 100644 index 0000000000..2879b2ddf5 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Data/OperationDefinition/delete-search.json @@ -0,0 +1,64 @@ +{ + "resourceType": "OperationDefinition", + "id": "delete-search", + "url": "[base]/OperationDefinition/delete-search", + "version": "1.0.0", + "name": "Delete Search", + "status": "active", + "kind": "operation", + "description": "Searches current soft-deleted resources. The operation supports system-level and resource-type-level invocation and filters only by the resource last-updated time.", + "code": "delete-search", + "system": true, + "type": true, + "instance": false, + "parameter": [ + { + "name": "_since", + "use": "in", + "min": 0, + "max": "1", + "documentation": "An inclusive lower bound on the soft-deleted resource's last-updated time.", + "type": "instant" + }, + { + "name": "_before", + "use": "in", + "min": 0, + "max": "1", + "documentation": "An exclusive upper bound on the soft-deleted resource's last-updated time.", + "type": "instant" + }, + { + "name": "_count", + "use": "in", + "min": 0, + "max": "1", + "documentation": "The maximum number of soft-deleted resources to return in one page.", + "type": "integer" + }, + { + "name": "_continuationToken", + "use": "in", + "min": 0, + "max": "1", + "documentation": "The continuation token used to retrieve the next page.", + "type": "string" + }, + { + "name": "_sort", + "use": "in", + "min": 0, + "max": "1", + "documentation": "The result order. Only _lastUpdated and -_lastUpdated are supported.", + "type": "string" + }, + { + "name": "return", + "use": "out", + "min": 1, + "max": "1", + "documentation": "A history bundle containing current soft-deleted resources.", + "type": "Bundle" + } + ] +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs index 7f64b99eee..c3657d40ab 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/OperationsConstants.cs @@ -49,6 +49,8 @@ public static class OperationsConstants public const string BulkDeleteSoftDeleted = "bulk-delete-soft-deleted"; + public const string DeleteSearch = "delete-search"; + public const string Includes = "includes"; public const string BulkUpdate = "bulk-update"; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs index 525d3d9835..adea2c3d7f 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/KnownRoutes.cs @@ -21,11 +21,9 @@ internal class KnownRoutes private const string VidRouteSegment = "{" + KnownActionParameterNames.Vid + "}"; public const string History = "_history"; - public const string Deleted = "_deleted"; public const string Search = "_search"; public const string ResourceType = ResourceTypeRouteSegment; public const string ResourceTypeHistory = ResourceType + "/" + History; - public const string ResourceTypeDeleted = ResourceType + "/" + Deleted; public const string ResourceTypeSearch = ResourceType + "/" + Search; public const string ResourceTypeById = ResourceType + "/" + IdRouteSegment; public const string ResourceTypeByIdHistory = ResourceTypeById + "/" + History; @@ -98,6 +96,10 @@ internal class KnownRoutes public const string ResourceTypeBulkDeleteOperationDefinition = OperationDefinition + "/" + OperationsConstants.ResourceTypeBulkDelete; public const string BulkDeleteSoftDeletedOperationDefinition = OperationDefinition + "/" + OperationsConstants.BulkDeleteSoftDeleted; + public const string DeleteSearch = "$delete-search"; + public const string DeleteSearchResourceType = ResourceType + "/" + DeleteSearch; + public const string DeleteSearchOperationDefinition = OperationDefinition + "/" + OperationsConstants.DeleteSearch; + public const string BulkUpdate = "$bulk-update"; public const string BulkUpdateResourceType = ResourceType + "/" + BulkUpdate; public const string BulkUpdateJobLocation = OperationsConstants.Operations + "/" + OperationsConstants.BulkUpdate + "/" + IdRouteSegment; diff --git a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs index 31f714cc2c..9bf5fa9a8c 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Routing/RouteNames.cs @@ -25,9 +25,9 @@ internal static class RouteNames internal const string HistoryTypeId = nameof(HistoryTypeId); - internal const string Deleted = nameof(Deleted); + internal const string DeleteSearch = nameof(DeleteSearch); - internal const string DeletedType = nameof(DeletedType); + internal const string DeleteSearchType = nameof(DeleteSearchType); internal const string SearchCompartmentByResourceType = nameof(SearchCompartmentByResourceType); @@ -85,6 +85,8 @@ internal static class RouteNames internal const string BulkDeleteSoftDeletedDefinition = nameof(BulkDeleteSoftDeletedDefinition); + internal const string DeleteSearchOperationDefinition = nameof(DeleteSearchOperationDefinition); + internal const string Includes = nameof(Includes); internal const string IncludesOperationDefinition = nameof(IncludesOperationDefinition); diff --git a/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs b/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs index bba27e43fe..bf2a96837a 100644 --- a/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs +++ b/src/Microsoft.Health.Fhir.Core/Messages/Search/SearchDeletedResourcesRequest.cs @@ -3,9 +3,7 @@ // Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. // ------------------------------------------------------------------------------------------------- -using System.Collections.Generic; using Medino; -using Microsoft.Health.Fhir.Core.Features.Conformance; using Microsoft.Health.Fhir.Core.Models; namespace Microsoft.Health.Fhir.Core.Messages.Search @@ -13,7 +11,7 @@ namespace Microsoft.Health.Fhir.Core.Messages.Search /// /// A request to search current soft-deleted resources. /// - public class SearchDeletedResourcesRequest : IRequest, IRequireCapability + public class SearchDeletedResourcesRequest : IRequest { /// /// Initializes a new instance of the class. @@ -63,15 +61,5 @@ public SearchDeletedResourcesRequest( /// Gets the last-updated sort order. /// public string Sort { get; } - - /// - public IEnumerable RequiredCapabilities() - { - string capability = string.IsNullOrEmpty(ResourceType) - ? "CapabilityStatement.rest.interaction.where(code = 'history-system').exists()" - : $"CapabilityStatement.rest.resource.where(type = '{ResourceType}').interaction.where(code = 'history-type').exists()"; - - yield return new CapabilityQuery(capability); - } } } diff --git a/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj b/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj index 0f46f641e9..17a537cb44 100644 --- a/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj +++ b/src/Microsoft.Health.Fhir.Core/Microsoft.Health.Fhir.Core.csproj @@ -4,6 +4,7 @@ + diff --git a/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs b/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs index 53732c1526..67ef9176b6 100644 --- a/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs +++ b/src/Microsoft.Health.Fhir.CosmosDb.UnitTests/Features/Search/QueryBuilderTests.cs @@ -27,7 +27,7 @@ public void GivenSoftDeletedOnlySearch_WhenQueryBuilt_ThenOnlyDeletedResourcesAr string query = new QueryBuilder().BuildSqlQuerySpec(searchOptions).QueryText; Assert.Contains("r.isDeleted =", query); - Assert.DoesNotContain("r.isHistory =", query); + Assert.Contains("r.isHistory =", query); } } } diff --git a/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs b/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs index 8f649e1e7f..4658dc339f 100644 --- a/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs +++ b/src/Microsoft.Health.Fhir.CosmosDb/Features/Search/Queries/QueryBuilder.cs @@ -87,7 +87,8 @@ public QueryDefinition BuildSqlQuerySpec(SearchOptions searchOptions, QueryBuild true, (KnownResourceWrapperProperties.IsHistory, true)); } - else if (searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest) && + else if ((searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.Latest) || + searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.SoftDeleted)) && !searchOptions.ResourceVersionTypes.HasFlag(ResourceVersionType.History)) { AppendFilterCondition( diff --git a/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs b/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs index 8b01d3d41b..9642b19881 100644 --- a/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs +++ b/src/Microsoft.Health.Fhir.R4.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs @@ -36,6 +36,7 @@ public OperationDefinitionMediatorExtensionsTests() [InlineData("member-match")] [InlineData("convert-data")] [InlineData("purge-history")] + [InlineData("delete-search")] public async Task GivenVariousOperationNames_WhenGetOperationDefinitionAsync_ThenCorrectRequestIsSent(string operationName) { // Arrange diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs index a33b1051e5..ba79383ede 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/FhirControllerTests.cs @@ -152,8 +152,8 @@ public void WhenProvidedAFhirController_CheckIfTheSearchEndpointsHaveTheLatencyM TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "SearchCompartmentByResourceType", _targetFhirControllerClass); TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "SystemHistory", _targetFhirControllerClass); TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "TypeHistory", _targetFhirControllerClass); - TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeletedResources", _targetFhirControllerClass); - TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeletedResourcesByType", _targetFhirControllerClass); + TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeleteSearch", _targetFhirControllerClass); + TestIfTargetMethodContainsCustomAttribute(expectedCustomAttribute, "DeleteSearchByType", _targetFhirControllerClass); } [Fact] @@ -596,14 +596,14 @@ await RunHistoryTest( [Fact] public async Task GivenSystemDeletedResourceSearch_WhenProcessingRequest_ThenRequestShouldBeCreatedCorrectly() { - await RunDeletedResourceSearchTest((model, _) => _fhirController.DeletedResources(model)); + await RunDeletedResourceSearchTest((model, _) => _fhirController.DeleteSearch(model)); } [Fact] public async Task GivenTypeDeletedResourceSearch_WhenProcessingRequest_ThenRequestShouldBeCreatedCorrectly() { await RunDeletedResourceSearchTest( - (model, type) => _fhirController.DeletedResourcesByType(type, model), + (model, type) => _fhirController.DeleteSearchByType(type, model), KnownResourceTypes.Patient); } diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs index 86a4f80c2d..a0964e296f 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Controllers/OperationDefinitionControllerTests.cs @@ -18,6 +18,7 @@ using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Health.Fhir.Core.Features.Operations; using Microsoft.Health.Fhir.Core.Features.Search; using Microsoft.Health.Fhir.Core.Messages.Operation; using Microsoft.Health.Fhir.Core.Registration; @@ -270,6 +271,16 @@ await _mediator.DidNotReceive().SendAsync( Arg.Any()); } + [Fact] + public async Task GivenConfiguration_WhenDeleteSearchIsEnabled_ThenOperationDefinitionShouldBeReturned() + { + await _controller.DeleteSearchOperationDefinition(); + + await _mediator.Received(1).SendAsync( + Arg.Is(request => request.OperationName == OperationsConstants.DeleteSearch), + Arg.Any()); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs index daab7b90f7..767e1bcb7e 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Operations/OperationsCapabilityProviderTests.cs @@ -407,6 +407,50 @@ public void GivenProvider_WhenAddingDetails_ThenBulkDeleteOperationShouldBeAdded Assert.Equal(OperationDefinitionUrl, restComponent.Operation.First().Definition?.Reference, StringComparer.OrdinalIgnoreCase); } + [Fact] + public async Task GivenAConformanceBuilder_WhenCallingOperationsCapability_ThenDeleteSearchDetailsAreAdded() + { + var provider = new OperationsCapabilityProvider( + _operationsOptions, + _featureOptions, + _coreFeatureOptions, + _implementationGuidesOptions, + _watchdogOptions, + _urlResolver, + _fhirRuntimeConfiguration); + ICapabilityStatementBuilder builder = Substitute.For(); + + await provider.BuildAsync(builder, CancellationToken.None); + + builder.Received(1) + .Apply(Arg.Is>(x => x.Method.Name == nameof(OperationsCapabilityProvider.AddDeleteSearchDetails))); + } + + [Fact] + public void GivenProvider_WhenAddingDetails_ThenDeleteSearchOperationShouldBeAdded() + { + var provider = new OperationsCapabilityProvider( + _operationsOptions, + _featureOptions, + _coreFeatureOptions, + _implementationGuidesOptions, + _watchdogOptions, + _urlResolver, + _fhirRuntimeConfiguration); + var restComponent = new ListedRestComponent + { + Mode = ListedCapabilityStatement.ServerMode, + }; + var capabilityStatement = new ListedCapabilityStatement(); + capabilityStatement.Rest.Add(restComponent); + + provider.AddDeleteSearchDetails(capabilityStatement); + + Assert.Single(restComponent.Operation); + Assert.Equal(OperationsConstants.DeleteSearch, restComponent.Operation.First().Name, StringComparer.OrdinalIgnoreCase); + Assert.Equal(OperationDefinitionUrl, restComponent.Operation.First().Definition?.Reference, StringComparer.OrdinalIgnoreCase); + } + [Theory] [InlineData(true)] [InlineData(false)] diff --git a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs index 64d71750bd..c9cbbdefbf 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api.UnitTests/Features/Routing/UrlResolverTests.cs @@ -311,6 +311,17 @@ public void GivenAReindexOperation_WhenOperationResultUrlIsResolved_ThenCorrectU }); } + [Fact] + public void GivenADeleteSearchOperation_WhenOperationDefinitionUrlIsResolved_ThenCorrectUrlShouldBeReturned() + { + _urlResolver.ResolveOperationDefinitionUrl(OperationsConstants.DeleteSearch); + + Assert.NotNull(_capturedUrlRouteContext); + Assert.Equal(RouteNames.DeleteSearchOperationDefinition, _capturedUrlRouteContext.RouteName); + Assert.Equal(Scheme, _capturedUrlRouteContext.Protocol); + Assert.Equal(Host, _capturedUrlRouteContext.Host); + } + [Fact] public void GivenAnUnknownOperation_WhenOperationResultUrlIsResolved_ThenOperationNotImplementedExceptionShouldBeThrown() { diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs index 381924088a..10f4c4cc0a 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/FhirController.cs @@ -375,10 +375,10 @@ public async Task TypeHistory( /// /// Model for last-updated and paging parameters. [HttpGet] - [Route(KnownRoutes.Deleted, Name = RouteNames.Deleted)] + [Route(KnownRoutes.DeleteSearch, Name = RouteNames.DeleteSearch)] [AuditEventType(AuditEventSubType.HistorySystem)] [TypeFilter(typeof(SearchEndpointMetricEmitterAttribute))] - public async Task DeletedResources(DeletedResourceSearchModel searchModel) + public async Task DeleteSearch(DeletedResourceSearchModel searchModel) { ResourceElement response = await _mediator.SearchDeletedResourcesAsync( resourceType: null, @@ -398,10 +398,10 @@ public async Task DeletedResources(DeletedResourceSearchModel sea /// The resource type. /// Model for last-updated and paging parameters. [HttpGet] - [Route(KnownRoutes.ResourceTypeDeleted, Name = RouteNames.DeletedType)] + [Route(KnownRoutes.DeleteSearchResourceType, Name = RouteNames.DeleteSearchType)] [AuditEventType(AuditEventSubType.HistoryType)] [TypeFilter(typeof(SearchEndpointMetricEmitterAttribute))] - public async Task DeletedResourcesByType( + public async Task DeleteSearchByType( string typeParameter, DeletedResourceSearchModel searchModel) { diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs index 081d6490de..1ca996f2c6 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Controllers/OperationDefinitionController.cs @@ -152,6 +152,14 @@ public async Task BulkDeleteSoftDeletedOperationDefinition() return await GetOperationDefinitionAsync(OperationsConstants.BulkDeleteSoftDeleted); } + [HttpGet] + [Route(KnownRoutes.DeleteSearchOperationDefinition, Name = RouteNames.DeleteSearchOperationDefinition)] + [AllowAnonymous] + public async Task DeleteSearchOperationDefinition() + { + return await GetOperationDefinitionAsync(OperationsConstants.DeleteSearch); + } + [HttpGet] [Route(KnownRoutes.BulkUpdateOperationDefinition, Name = RouteNames.BulkUpdateDefinition)] [AllowAnonymous] @@ -228,6 +236,7 @@ private void CheckIfOperationIsEnabledAndRespond(string operationName) break; case OperationsConstants.MemberMatch: case OperationsConstants.PurgeHistory: + case OperationsConstants.DeleteSearch: operationEnabled = true; break; case OperationsConstants.SearchParameterStatus: diff --git a/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs b/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs index e3c996d239..48bf244156 100644 --- a/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs +++ b/src/Microsoft.Health.Fhir.Shared.Api/Features/Operations/OperationsCapabilityProvider.cs @@ -86,6 +86,7 @@ public Task BuildAsync(ICapabilityStatementBuilder builder, CancellationToken ca builder.Apply(AddMemberMatchDetails); builder.Apply(AddPatientEverythingDetails); + builder.Apply(AddDeleteSearchDetails); if (_operationConfiguration.BulkDelete.Enabled) { @@ -188,6 +189,11 @@ public void AddBulkDeleteDetails(ListedCapabilityStatement capabilityStatement) GetAndAddOperationDefinitionUriToCapabilityStatement(capabilityStatement, OperationsConstants.BulkDelete); } + public void AddDeleteSearchDetails(ListedCapabilityStatement capabilityStatement) + { + GetAndAddOperationDefinitionUriToCapabilityStatement(capabilityStatement, OperationsConstants.DeleteSearch); + } + public void AddBulkUpdateDetails(ListedCapabilityStatement capabilityStatement) { GetAndAddOperationDefinitionUriToCapabilityStatement(capabilityStatement, OperationsConstants.BulkUpdate); diff --git a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs index 8aac757c63..9ff3722891 100644 --- a/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs +++ b/src/Microsoft.Health.Fhir.Shared.Core.UnitTests/Extensions/OperationDefinitionMediatorExtensionsTests.cs @@ -36,6 +36,7 @@ public OperationDefinitionMediatorExtensionsTests() [InlineData("member-match")] [InlineData("convert-data")] [InlineData("purge-history")] + [InlineData("delete-search")] public async Task GivenVariousOperationNames_WhenGetOperationDefinitionAsync_ThenCorrectRequestIsSent(string operationName) { // Arrange diff --git a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs index d6ef87d017..96d4e4ce94 100644 --- a/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs +++ b/src/Microsoft.Health.Fhir.SqlServer.UnitTests/Features/Search/SqlQueryGeneratorTests.cs @@ -95,6 +95,7 @@ public void GivenASearchTypeForSoftDeletedOnly_WhenSqlGenerated_ThenFilterForSof var output = _queryGenerator.VisitSqlRoot(sqlExpression, searchOptions); Assert.Contains("IsDeleted = 1", _strBuilder.ToString()); + Assert.Contains("IsHistory = 0", _strBuilder.ToString()); } [Fact] diff --git a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs index e2ad628365..eef028870b 100644 --- a/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs +++ b/src/Microsoft.Health.Fhir.SqlServer/Features/Search/Expressions/Visitors/QueryGenerators/SqlQueryGenerator.cs @@ -1744,12 +1744,15 @@ private void AppendHistoryClause(in IndentedStringBuilder.DelimitedScope delimit return; } - if (resourceVersionType.HasFlag(ResourceVersionType.Latest) && !resourceVersionType.HasFlag(ResourceVersionType.History)) + if ((resourceVersionType.HasFlag(ResourceVersionType.Latest) || + resourceVersionType.HasFlag(ResourceVersionType.SoftDeleted)) && + !resourceVersionType.HasFlag(ResourceVersionType.History)) { delimited.BeginDelimitedElement(); StringBuilder.Append(VLatest.Resource.IsHistory, tableAlias).Append(" = 0 "); } - else if (resourceVersionType.HasFlag(ResourceVersionType.History) && !resourceVersionType.HasFlag(ResourceVersionType.Latest)) + else if (resourceVersionType.HasFlag(ResourceVersionType.History) && + !resourceVersionType.HasFlag(ResourceVersionType.Latest)) { delimited.BeginDelimitedElement(); StringBuilder.Append(VLatest.Resource.IsHistory, tableAlias).Append(" = 1 ");