From ba0a8172a72bdf3f1b077f9d9a32cb8d12b3fe71 Mon Sep 17 00:00:00 2001 From: whyfate <593301179@qq.com> Date: Thu, 14 Dec 2023 08:43:56 +0000 Subject: [PATCH 1/5] support _revinclude --- .../Search/ReverseIncludeTests.cs | 4 +-- .../Search/Model/ReverseInclude.cs | 2 +- .../SnapshotPaginationProvider.cs | 7 ++-- .../SnapshotPaginationService.cs | 23 ++++++++++-- .../Search/Searcher/MongoSearcher.cs | 35 ++++++++++++------- 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/Spark.Engine.Test/Search/ReverseIncludeTests.cs b/src/Spark.Engine.Test/Search/ReverseIncludeTests.cs index 484245b73..0c134b8e5 100644 --- a/src/Spark.Engine.Test/Search/ReverseIncludeTests.cs +++ b/src/Spark.Engine.Test/Search/ReverseIncludeTests.cs @@ -10,7 +10,7 @@ public class ReverseIncludeTests [TestMethod] public void TestParseValid() { - ReverseInclude sut = ReverseInclude.Parse("Patient.actor"); + ReverseInclude sut = ReverseInclude.Parse("Patient:actor"); Assert.AreEqual("Patient", sut.ResourceType); Assert.AreEqual("actor", sut.SearchPath); @@ -18,7 +18,7 @@ public void TestParseValid() [TestMethod] public void TestParseValidLongerPath() { - ReverseInclude sut = ReverseInclude.Parse("Provenance.target.patient"); + ReverseInclude sut = ReverseInclude.Parse("Provenance:target.patient"); Assert.AreEqual("Provenance", sut.ResourceType); Assert.AreEqual("target.patient", sut.SearchPath); diff --git a/src/Spark.Engine/Search/Model/ReverseInclude.cs b/src/Spark.Engine/Search/Model/ReverseInclude.cs index c54ade7b2..431a5ebae 100644 --- a/src/Spark.Engine/Search/Model/ReverseInclude.cs +++ b/src/Spark.Engine/Search/Model/ReverseInclude.cs @@ -5,7 +5,7 @@ namespace Spark.Engine.Search.Model { public class ReverseInclude { - private static Regex _pattern = new Regex(@"(?[^\.]+)\.(?.*)"); + private static Regex _pattern = new Regex(@"(?[^\.]+):(?.*)"); public string ResourceType { get; set; } public string SearchPath { get; set; } diff --git a/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationProvider.cs b/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationProvider.cs index 429541062..c60f50f78 100644 --- a/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationProvider.cs +++ b/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationProvider.cs @@ -7,6 +7,7 @@ * available at https://raw.githubusercontent.com/FirelyTeam/spark/stu3/master/LICENSE */ +using Spark.Core; using Spark.Engine.Core; using Spark.Engine.Store.Interfaces; using Spark.Service; @@ -15,13 +16,15 @@ namespace Spark.Engine.Service.FhirServiceExtensions { public class SnapshotPaginationProvider : ISnapshotPaginationProvider { + private IFhirIndex _fhirIndex; private IFhirStore _fhirStore; private readonly ITransfer _transfer; private readonly ILocalhost _localhost; private readonly ISnapshotPaginationCalculator _snapshotPaginationCalculator; - public SnapshotPaginationProvider(IFhirStore fhirStore, ITransfer transfer, ILocalhost localhost, ISnapshotPaginationCalculator snapshotPaginationCalculator) + public SnapshotPaginationProvider(IFhirIndex fhirIndex, IFhirStore fhirStore, ITransfer transfer, ILocalhost localhost, ISnapshotPaginationCalculator snapshotPaginationCalculator) { + _fhirIndex = fhirIndex; _fhirStore = fhirStore; _transfer = transfer; _localhost = localhost; @@ -30,7 +33,7 @@ public SnapshotPaginationProvider(IFhirStore fhirStore, ITransfer transfer, ILoc public ISnapshotPagination StartPagination(Snapshot snapshot) { - return new SnapshotPaginationService(_fhirStore, _transfer, _localhost, _snapshotPaginationCalculator, snapshot); + return new SnapshotPaginationService(_fhirIndex, _fhirStore, _transfer, _localhost, _snapshotPaginationCalculator, snapshot); } } } \ No newline at end of file diff --git a/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationService.cs b/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationService.cs index d4d29b617..3fa9311c6 100644 --- a/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationService.cs +++ b/src/Spark.Engine/Service/FhirServiceExtensions/SnapshotPaginationService.cs @@ -21,14 +21,16 @@ namespace Spark.Engine.Service.FhirServiceExtensions { internal class SnapshotPaginationService : ISnapshotPagination { + private IFhirIndex _fhirIndex; private IFhirStore _fhirStore; private readonly ITransfer _transfer; private readonly ILocalhost _localhost; private readonly ISnapshotPaginationCalculator _snapshotPaginationCalculator; private readonly Snapshot _snapshot; - public SnapshotPaginationService(IFhirStore fhirStore, ITransfer transfer, ILocalhost localhost, ISnapshotPaginationCalculator snapshotPaginationCalculator, Snapshot snapshot) + public SnapshotPaginationService(IFhirIndex fhirIndex, IFhirStore fhirStore, ITransfer transfer, ILocalhost localhost, ISnapshotPaginationCalculator snapshotPaginationCalculator, Snapshot snapshot) { + _fhirIndex = fhirIndex; _fhirStore = fhirStore; _transfer = transfer; _localhost = localhost; @@ -71,6 +73,9 @@ private async Task CreateBundleAsync(int? start = null) IList included = await GetIncludesRecursiveForAsync(entries, _snapshot.Includes).ConfigureAwait(false); entries.Append(included); + IList revIncluded = await GetRevIncludeAsync(entries, _snapshot.ReverseIncludes); + entries.Append(revIncluded); + _transfer.Externalize(entries); bundle.Append(entries); BuildLinks(bundle, start); @@ -95,7 +100,7 @@ private async Task> GetIncludesRecursiveForAsync(IList entri private async Task> GetIncludesForAsync(IList entries, IEnumerable includes) { - if (includes == null) return new List(); + if (includes == null || !includes.Any()) return new List(); IEnumerable paths = includes.SelectMany(i => IncludeToPath(i)); IList identifiers = entries.GetResources().GetReferences(paths).Distinct().Select(k => (IKey)Key.ParseOperationPath(k)).ToList(); @@ -105,6 +110,20 @@ private async Task> GetIncludesForAsync(IList entries, IEnum return result; } + + private async Task> GetRevIncludeAsync(IList entries, IEnumerable revIncludes) + { + if (revIncludes == null || !revIncludes.Any()) return new List(); + + var searchResults = await _fhirIndex.GetReverseIncludesAsync(entries.Select(e => e.Key).ToList(), revIncludes.ToList()); + if (!searchResults.Any()) + { + return new List(); + } + + return await _fhirStore.GetAsync(searchResults.Select(k => Key.ParseOperationPath(k))).ConfigureAwait(false); + } + private void BuildLinks(Bundle bundle, int? offset = null) { bundle.SelfLink = offset == null diff --git a/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs b/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs index 98b6a0994..8d614e54d 100644 --- a/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs +++ b/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs @@ -654,27 +654,36 @@ public async Task GetReverseIncludesAsync(IList keys, IList if (keys != null && revIncludes != null) { - var riQueries = new List>(); - foreach (var revInclude in revIncludes) { var ri = SM.ReverseInclude.Parse(revInclude); if (!ri.SearchPath.Contains(".")) //for now, leave out support for chained revIncludes. There aren't that many anyway. { - riQueries.Add( - Builders.Filter.And( - Builders.Filter.Eq(InternalField.RESOURCE, ri.ResourceType) - , Builders.Filter.In(ri.SearchPath, internal_ids))); - } - } + var searchParamter = _fhirModel.FindSearchParameter(ri.ResourceType, ri.SearchPath); + if (searchParamter == null || searchParamter.Type != SearchParamType.Reference) + { + continue; + } - if (riQueries.Count > 0) - { - var revIncludeQuery = Builders.Filter.Or(riQueries); - var resultKeys = await CollectKeysAsync(revIncludeQuery).ConfigureAwait(false); - results = await KeysToSearchResultsAsync(resultKeys).ConfigureAwait(false); + var queries = new List> + { + // I don't know the level design,but must use level filter to hit index. + Builders.Filter.Eq(InternalField.LEVEL, 0), + Builders.Filter.Eq(InternalField.RESOURCE, ri.ResourceType), + Builders.Filter.In(ri.SearchPath, internal_ids) + }; + + // Avoid using Or queries as indexes do not hit + var revIncludeQuery = Builders.Filter.And(queries); + List selfLinks = await CollectSelfLinksAsync(revIncludeQuery, null); + foreach (BsonValue selfLink in selfLinks) + { + results.Add(selfLink.ToString()); + } + } } } + return results; } From cfb4a2e5fc3809aa3092d944197c6b59c7e62bb3 Mon Sep 17 00:00:00 2001 From: whyfate <593301179@qq.com> Date: Sun, 31 Dec 2023 13:36:28 +0000 Subject: [PATCH 2/5] Remove unnecessary code. --- src/Spark.Mongo/Search/Searcher/MongoSearcher.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs b/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs index 8d614e54d..d5cc74444 100644 --- a/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs +++ b/src/Spark.Mongo/Search/Searcher/MongoSearcher.cs @@ -667,8 +667,6 @@ public async Task GetReverseIncludesAsync(IList keys, IList var queries = new List> { - // I don't know the level design,but must use level filter to hit index. - Builders.Filter.Eq(InternalField.LEVEL, 0), Builders.Filter.Eq(InternalField.RESOURCE, ri.ResourceType), Builders.Filter.In(ri.SearchPath, internal_ids) }; From 06278142577339d4e58a7360d63ec0f224f7870b Mon Sep 17 00:00:00 2001 From: haowang <593301179@qq.com> Date: Wed, 25 Jun 2025 14:26:36 +0800 Subject: [PATCH 3/5] support multipartformdata mimetype. --- src/Spark.Engine/Core/FhirMediaType.cs | 3 ++- src/Spark.Engine/Filters/UnsupportedMediaTypeFilter.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Spark.Engine/Core/FhirMediaType.cs b/src/Spark.Engine/Core/FhirMediaType.cs index 7de269544..49d9d5ef2 100644 --- a/src/Spark.Engine/Core/FhirMediaType.cs +++ b/src/Spark.Engine/Core/FhirMediaType.cs @@ -16,10 +16,11 @@ public static class FhirMediaType public static string OctetStreamMimeType = "application/octet-stream"; public static string FormUrlEncodedMimeType = "application/x-www-form-urlencoded"; public static string AnyMimeType = "*/*"; + public static string MultipartFormDataMimeType = "multipart/form-data"; public static IEnumerable JsonMimeTypes => ContentType.JSON_CONTENT_HEADERS; public static IEnumerable XmlMimeTypes => ContentType.XML_CONTENT_HEADERS; public static IEnumerable SupportedMimeTypes => JsonMimeTypes .Concat(XmlMimeTypes) - .Concat(new[] { OctetStreamMimeType, FormUrlEncodedMimeType, AnyMimeType }); + .Concat(new[] { OctetStreamMimeType, FormUrlEncodedMimeType, AnyMimeType, MultipartFormDataMimeType }); } diff --git a/src/Spark.Engine/Filters/UnsupportedMediaTypeFilter.cs b/src/Spark.Engine/Filters/UnsupportedMediaTypeFilter.cs index 2c997e019..09b4b8f75 100644 --- a/src/Spark.Engine/Filters/UnsupportedMediaTypeFilter.cs +++ b/src/Spark.Engine/Filters/UnsupportedMediaTypeFilter.cs @@ -37,7 +37,7 @@ public void OnActionExecuting(ActionExecutingContext context) if (context.HttpContext.Request.ContentType != null) { - if (!FhirMediaType.SupportedMimeTypes.Any(mimeType => context.HttpContext.Request.ContentType.Contains(mimeType))) + if (!FhirMediaType.SupportedMimeTypes.Any(mimeType => context.HttpContext.Request.ContentType.StartsWith(mimeType))) { throw Error.UnsupportedMediaType(); } From 113f39fcfe8e46e47b3df8ab5e8655ddce09b0ab Mon Sep 17 00:00:00 2001 From: haowang <593301179@qq.com> Date: Wed, 23 Jul 2025 19:09:41 +0800 Subject: [PATCH 4/5] fix:read resource with etag. --- ...ionalHeaderFhirResponseInterceptorTests.cs | 75 +++++++++++++++++++ src/Spark.Engine/Extensions/ETag.cs | 4 +- .../Extensions/HttpRequestFhirExtensions.cs | 4 +- .../Extensions/InteractionExtensions.cs | 2 +- ...onditionalHeaderFhirResponseInterceptor.cs | 2 +- 5 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 src/Spark.Engine.Test/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptorTests.cs diff --git a/src/Spark.Engine.Test/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptorTests.cs b/src/Spark.Engine.Test/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptorTests.cs new file mode 100644 index 000000000..221f60133 --- /dev/null +++ b/src/Spark.Engine.Test/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptorTests.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Http; +using Spark.Engine.Core; +using Spark.Engine.Extensions; +using Spark.Engine.FhirResponseFactory; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace Spark.Engine.Test.FhirResponseFactory; + +public class ConditionalHeaderFhirResponseInterceptorTests +{ + [Fact] + public void TestNotModified() + { + var versionId = "1"; + var etag = ETag.Create(versionId); + var lastUpdated = DateTimeOffset.UtcNow.AddMinutes(-5); + var context = new DefaultHttpContext(); + context.Request.Headers["If-None-Match"] = etag; + context.Request.Headers["If-Modified-Since"] = lastUpdated.ToString("R"); + var parameters = new ConditionalHeaderParameters(context.Request); + + Assert.Equal(parameters.IfModifiedSince.Value.ToString("R"), lastUpdated.ToString("R")); + Assert.Contains(parameters.IfNoneMatchTags, i => i == etag); + + var patient = new Hl7.Fhir.Model.Patient + { + Id = "1", + Meta = new Hl7.Fhir.Model.Meta + { + VersionId = versionId, + LastUpdated = lastUpdated + } + }; + + var interceptor = new ConditionalHeaderFhirResponseInterceptor(); + var response = interceptor.GetFhirResponse(Entry.PUT(Key.Create(patient.TypeName, patient.Id, patient.Meta.VersionId), patient), parameters); + Assert.NotNull(response); + Assert.True(response.StatusCode == System.Net.HttpStatusCode.NotModified); + } + + + [Fact] + public void TestModified() + { + var versionId = "1"; + var etag = ETag.Create(versionId); + var lastUpdated = DateTimeOffset.UtcNow.AddMinutes(-5); + var context = new DefaultHttpContext(); + context.Request.Headers["If-None-Match"] = etag; + context.Request.Headers["If-Modified-Since"] = lastUpdated.ToString("R"); + var parameters = new ConditionalHeaderParameters(context.Request); + + Assert.Equal(parameters.IfModifiedSince.Value.ToString("R"), lastUpdated.ToString("R")); + Assert.Contains(parameters.IfNoneMatchTags, i => i == etag); + + var patient = new Hl7.Fhir.Model.Patient + { + Id = "1", + Meta = new Hl7.Fhir.Model.Meta + { + VersionId = "2", + LastUpdated = DateTimeOffset.UtcNow + } + }; + + var interceptor = new ConditionalHeaderFhirResponseInterceptor(); + var response = interceptor.GetFhirResponse(Entry.PUT(Key.Create(patient.TypeName, patient.Id, patient.Meta.VersionId), patient), parameters); + Assert.Null(response); + } +} diff --git a/src/Spark.Engine/Extensions/ETag.cs b/src/Spark.Engine/Extensions/ETag.cs index cd84c5823..2cbfbdc01 100644 --- a/src/Spark.Engine/Extensions/ETag.cs +++ b/src/Spark.Engine/Extensions/ETag.cs @@ -10,9 +10,9 @@ namespace Spark.Engine.Extensions; public static class ETag { - public static EntityTagHeaderValue Create(string value) + public static string Create(string value) { string tag = "\"" + value + "\""; - return new EntityTagHeaderValue(tag, true); + return new EntityTagHeaderValue(tag, true).ToString(); } } diff --git a/src/Spark.Engine/Extensions/HttpRequestFhirExtensions.cs b/src/Spark.Engine/Extensions/HttpRequestFhirExtensions.cs index 1729065b9..4bf5a92e6 100644 --- a/src/Spark.Engine/Extensions/HttpRequestFhirExtensions.cs +++ b/src/Spark.Engine/Extensions/HttpRequestFhirExtensions.cs @@ -165,12 +165,12 @@ internal static void AcquireHeaders(this HttpResponse response, FhirResponse fhi { if (fhirResponse.Key != null) { - response.Headers.Append(HttpHeaderName.ETAG, ETag.Create(fhirResponse.Key.VersionId)?.ToString()); + response.Headers.Append(HttpHeaderName.ETAG, ETag.Create(fhirResponse.Key.VersionId)); Uri location = fhirResponse.Key.ToUri(); response.Headers.Append(HttpHeaderName.LOCATION, location.OriginalString); - if (response.ContentLength > 0) + if (fhirResponse.HasBody) { response.Headers.Append(HttpHeaderName.CONTENT_LOCATION, location.OriginalString); if (fhirResponse.Resource?.Meta?.LastUpdated != null) diff --git a/src/Spark.Engine/Extensions/InteractionExtensions.cs b/src/Spark.Engine/Extensions/InteractionExtensions.cs index ec9d5c016..7b8c599f1 100644 --- a/src/Spark.Engine/Extensions/InteractionExtensions.cs +++ b/src/Spark.Engine/Extensions/InteractionExtensions.cs @@ -79,7 +79,7 @@ public static Bundle.EntryComponent TranslateToSparseEntry(this Entry entry, Fhi { Status = string.Format("{0} {1}", (int) response.StatusCode, response.StatusCode), Location = response.Key?.ToString(), - Etag = response.Key != null ? ETag.Create(response.Key.VersionId).ToString() : null, + Etag = response.Key != null ? ETag.Create(response.Key.VersionId) : null, LastModified = (entry != null && entry.Resource != null && entry.Resource.Meta != null) ? entry.Resource.Meta.LastUpdated diff --git a/src/Spark.Engine/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptor.cs b/src/Spark.Engine/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptor.cs index a1d54a148..8df528db1 100644 --- a/src/Spark.Engine/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptor.cs +++ b/src/Spark.Engine/FhirResponseFactory/ConditionalHeaderFhirResponseInterceptor.cs @@ -29,7 +29,7 @@ public FhirResponse GetFhirResponse(Entry entry, object input) ConditionalHeaderParameters parameters = ConvertInput(input); if (parameters == null) return null; - bool? matchTags = parameters.IfNoneMatchTags.Any() ? parameters.IfNoneMatchTags.Any(t => t == ETag.Create(entry.Key.VersionId).Tag) : (bool?)null; + bool? matchTags = parameters.IfNoneMatchTags.Any() ? parameters.IfNoneMatchTags.Any(t => t == ETag.Create(entry.Key.VersionId)) : (bool?)null; bool? matchModifiedDate = parameters.IfModifiedSince.HasValue ? parameters.IfModifiedSince.Value < entry.Resource.Meta.LastUpdated : (bool?) null; From e6848f5ecda17283f6699a0d6d3cd0d4adf470b5 Mon Sep 17 00:00:00 2001 From: haowang <593301179@qq.com> Date: Tue, 12 Aug 2025 11:41:49 +0800 Subject: [PATCH 5/5] fix:update read etag. --- src/Spark.Web/Controllers/FhirController.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Spark.Web/Controllers/FhirController.cs b/src/Spark.Web/Controllers/FhirController.cs index a7d8d98f9..f110bd7e0 100644 --- a/src/Spark.Web/Controllers/FhirController.cs +++ b/src/Spark.Web/Controllers/FhirController.cs @@ -55,7 +55,10 @@ public async Task VRead(string type, string id, string vid) [HttpPut("{type}/{id?}")] public async Task> Update(string type, Resource resource, string id = null) { - string versionId = Request.GetTypedHeaders().IfMatch?.FirstOrDefault()?.Tag.Buffer; + string versionId = null; + var ifMatch = Request.GetTypedHeaders().IfMatch.FirstOrDefault(); + if (ifMatch is { Tag.Value: not null }) versionId = ifMatch.Tag.Value.Trim('"'); + Key key = Key.Create(type, id, versionId); if (key.HasResourceId()) { @@ -77,12 +80,14 @@ public async Task Create(string type, Resource resource) if (Request.Headers.ContainsKey(FhirHttpHeaders.IfNoneExist)) { - NameValueCollection searchQueryString = HttpUtility.ParseQueryString(Request.GetTypedHeaders().IfNoneExist()); + NameValueCollection searchQueryString = + HttpUtility.ParseQueryString(Request.GetTypedHeaders().IfNoneExist()); IEnumerable> searchValues = searchQueryString.Keys.Cast() .Select(k => new Tuple(k, searchQueryString[k])); - return await _fhirService.ConditionalCreateAsync(key, resource, SearchParams.FromUriParamList(searchValues)).ConfigureAwait(false); + return await _fhirService.ConditionalCreateAsync(key, resource, SearchParams.FromUriParamList(searchValues)) + .ConfigureAwait(false); } return await _fhirService.CreateAsync(key, resource).ConfigureAwait(false); @@ -243,4 +248,4 @@ public async Task Document(string id) Key key = Key.Create("Composition", id); return await _fhirService.DocumentAsync(key).ConfigureAwait(false); } -} \ No newline at end of file +}