diff --git a/src/Spark.Engine.Test/Service/PatchServiceTests.cs b/src/Spark.Engine.Test/Service/PatchServiceTests.cs new file mode 100644 index 000000000..833474c0d --- /dev/null +++ b/src/Spark.Engine.Test/Service/PatchServiceTests.cs @@ -0,0 +1,271 @@ +namespace Spark.Engine.Test.Service +{ + using System.IO; + using System.Linq; + using System.Reflection; + using Engine.Extensions; + using Engine.Service.FhirServiceExtensions; + using Hl7.Fhir.Model; + using Hl7.Fhir.Serialization; + using Xunit; + + public class PatchServiceTests + { + private readonly FhirJsonParser _jsonParser = new FhirJsonParser(); + private readonly PatchService _applier = new PatchService(); + private readonly FhirXmlParser _xmlParser = new FhirXmlParser(); + + [Fact] + public void CanReplaceStatusOnMedicationRequest() + { + var json = File.ReadAllText(Path.Combine("TestData", "R4", "medication-status-replace-patch.json")); + var parameters = _jsonParser.Parse(json); + + var resource = new MedicationRequest {Id = "test"}; + resource = (MedicationRequest) _applier.Apply(resource, parameters); + + Assert.Equal(MedicationRequest.medicationrequestStatus.Completed, resource.Status); + } + + [Fact] + public void CanReplacePerformerTypeOnMedicationRequest() + { + var resource = new MedicationRequest {Id = "test"}; + var json = File.ReadAllText( + Path.Combine("TestData", "R4", "medication-replace-codeable-concept-patch.json")); + var parameters = _jsonParser.Parse(json); + + resource = (MedicationRequest) _applier.Apply(resource, parameters); + + Assert.Equal("abc", resource.PerformerType.Coding[0].System); + Assert.Equal("123", resource.PerformerType.Coding[0].Code); + Assert.Equal("test", resource.PerformerType.Text); + } + + [Fact] + public void CanReplaceSubjectOnMedicationRequest() + { + var resource = new MedicationRequest {Id = "test", Subject = new ResourceReference("abc")}; + var json = File.ReadAllText( + Path.Combine("TestData", "R4", "medication-replace-resource-reference-patch.json")); + var parameters = _jsonParser.Parse(json); + + resource = (MedicationRequest) _applier.Apply(resource, parameters); + + Assert.Equal("abc", resource.Subject.Reference); + } + + [Fact] + public void CanReplaceInstantiatesCanonicalOnMedicationRequest() + { + var resource = new MedicationRequest {Id = "test"}; + var json = File.ReadAllText(Path.Combine("TestData", "R4", "medication-replace-canonical-patch.json")); + var parameters = _jsonParser.Parse(json); + + resource = (MedicationRequest) _applier.Apply(resource, parameters); + + Assert.Equal("abc", resource.InstantiatesCanonical.First()); + } + + [Fact] + public void CanReplaceDosageOnMedicationRequest() + { + var resource = new MedicationRequest {Id = "test"}; + var json = File.ReadAllText( + Path.Combine("TestData", "R4", "medication-replace-dosage-instruction-patch.json")); + var parameters = _jsonParser.Parse(json); + + resource = (MedicationRequest) _applier.Apply(resource, parameters); + + Assert.Equal(1m, resource.DosageInstruction[0].MaxDosePerLifetime.Value); + } + + [Fact] + public void CanApplyPropertyAssignmentPatch() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "property-assignment-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient {Id = "test"}; + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Equal("1930-01-01", resource.BirthDate); + } + + [Fact] + public void WhenApplyingPropertyAssignmentPatchToNonEmptyPropertyThenThrows() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "property-assignment-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient {Id = "test", BirthDate = "1930-01-01"}; + + Assert.Throws(() => _applier.Apply(resource, parameters)); + } + + [Fact] + public void CanApplyCollectionAddPatch() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "collection-add-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient {Id = "test"}; + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Equal("John", resource.Name[0].Given.First()); + Assert.Equal("Doe", resource.Name[0].Family); + } + + [Fact] + public void CanApplyCollectionReplacePatch() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "collection-replace-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient + { + Id = "test", Name = {new HumanName {Given = new[] {"John"}, Family = "Johnson"}} + }; + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Equal("Jane", resource.Name[0].Given.First()); + Assert.Equal("Doe", resource.Name[0].Family); + } + + [Fact] + public void CanFullResourceReplacePatch() + { + var resource = new Patient + { + Id = "test", Name = {new HumanName {Given = new[] {"John"}, Family = "Johnson"}} + }; + + var replacement = new Patient + { + Id = "test", Name = {new HumanName {Given = new[] {"Jane"}, Family = "Doe"}} + }; + + var parameters = replacement.ToPatch(); + + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Equal("Jane", resource.Name[0].Given.First()); + Assert.Equal("Doe", resource.Name[0].Family); + } + + [Fact] + public void CanCreateDiffPatch() + { + var resource = new Patient + { + Id = "test", + Gender = AdministrativeGender.Male, + Name = {new HumanName {Given = new[] {"John"}, Family = "Johnson"}} + }; + + var replacement = new Patient + { + Id = "test", + BirthDateElement = new Hl7.Fhir.Model.Date(2020, 1, 2), + Name = {new HumanName {Given = new[] {"Jane"}, Family = "Doe"}} + }; + + var parameters = replacement.ToPatch(resource); + + Assert.Equal(4, parameters.Parameter.Count); + } + + [Fact] + public void CanApplyCreatedDiffPatch() + { + var resource = new Patient + { + Id = "test", + Gender = AdministrativeGender.Male, + Name = {new HumanName {Given = new[] {"John"}, Family = "Johnson"}} + }; + + var replacement = new Patient + { + Id = "test", + BirthDateElement = new Hl7.Fhir.Model.Date(2020, 1, 2), + Name = {new HumanName {Given = new[] {"Jane"}, Family = "Doe"}} + }; + + var patch = replacement.ToPatch(resource); + _applier.Apply(resource, patch); + + Assert.False(resource.Gender.HasValue); + Assert.Equal(replacement.BirthDate, resource.BirthDate); + Assert.Equal("Jane", resource.Name[0].Given.First()); + Assert.Equal("Doe", resource.Name[0].Family); + } + + [Fact] + public void CanApplyCollectionInsertPatch() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "collection-insert-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient + { + Id = "test", Name = {new HumanName {Given = new[] {"John"}, Family = "Johnson"}} + }; + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Equal("Jane", resource.Name[0].Given.First()); + Assert.Equal("Doe", resource.Name[0].Family); + + Assert.Equal("John", resource.Name[1].Given.First()); + Assert.Equal("Johnson", resource.Name[1].Family); + } + + [Fact] + public void CanApplyCollectionMovePatch() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "collection-move-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient + { + Id = "test", + Name = + { + new HumanName {Given = new[] {"John"}, Family = "Johnson"}, + new HumanName {Given = new[] {"Jane"}, Family = "Doe"} + } + }; + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Equal("Jane", resource.Name[0].Given.First()); + Assert.Equal("Doe", resource.Name[0].Family); + + Assert.Equal("John", resource.Name[1].Given.First()); + Assert.Equal("Johnson", resource.Name[1].Family); + } + + [Fact] + public void CanApplyPropertyReplacementPatch() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "property-replace-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient {Id = "test", BirthDate = "1970-12-24"}; + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Equal("1930-01-01", resource.BirthDate); + } + + [Fact] + public void CanApplyCollectionDeletePatch() + { + var xml = File.ReadAllText(Path.Combine("TestData", "R4", "collection-delete-patch.xml")); + var parameters = _xmlParser.Parse(xml); + + var resource = new Patient {Id = "test", Name = {new HumanName {Text = "John Doe"}}}; + resource = (Patient) _applier.Apply(resource, parameters); + + Assert.Empty(resource.Name); + } + } +} diff --git a/src/Spark.Engine.Test/Spark.Engine.Test.csproj b/src/Spark.Engine.Test/Spark.Engine.Test.csproj index c64fb83da..7f233b370 100644 --- a/src/Spark.Engine.Test/Spark.Engine.Test.csproj +++ b/src/Spark.Engine.Test/Spark.Engine.Test.csproj @@ -51,12 +51,48 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest PreserveNewest + + PreserveNewest + + + PreserveNewest + \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/collection-add-patch.xml b/src/Spark.Engine.Test/TestData/R4/collection-add-patch.xml new file mode 100644 index 000000000..cf621c9a6 --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/collection-add-patch.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/collection-delete-patch.xml b/src/Spark.Engine.Test/TestData/R4/collection-delete-patch.xml new file mode 100644 index 000000000..d0d849b39 --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/collection-delete-patch.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/collection-insert-patch.xml b/src/Spark.Engine.Test/TestData/R4/collection-insert-patch.xml new file mode 100644 index 000000000..2f053ced8 --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/collection-insert-patch.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/collection-move-patch.xml b/src/Spark.Engine.Test/TestData/R4/collection-move-patch.xml new file mode 100644 index 000000000..71e70ede7 --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/collection-move-patch.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/collection-replace-patch.xml b/src/Spark.Engine.Test/TestData/R4/collection-replace-patch.xml new file mode 100644 index 000000000..9c2c949ca --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/collection-replace-patch.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/medication-replace-canonical-patch.json b/src/Spark.Engine.Test/TestData/R4/medication-replace-canonical-patch.json new file mode 100644 index 000000000..d36b03be0 --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/medication-replace-canonical-patch.json @@ -0,0 +1,26 @@ +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "operation", + "part": [ + { + "name": "path", + "valueString": "MedicationRequest" + }, + { + "name": "type", + "valueCode": "add" + }, + { + "name": "name", + "valueString": "instantiatesCanonical" + }, + { + "name": "value", + "valueCanonical": "abc" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/medication-replace-codeable-concept-patch.json b/src/Spark.Engine.Test/TestData/R4/medication-replace-codeable-concept-patch.json new file mode 100644 index 000000000..37d9df55e --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/medication-replace-codeable-concept-patch.json @@ -0,0 +1,51 @@ +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "operation", + "part": [ + { + "name": "path", + "valueString": "MedicationRequest.performerType" + }, + { + "name": "type", + "valueCode": "replace" + }, + { + "name": "value", + "part": [ + { + "valueCodeableConcept": { + "coding": [ + { + "system": "abc", + "code": "123" + } + ], + "text": "test" + } + } + ] + } + ] + }, + { + "name": "operation", + "part": [ + { + "name": "path", + "valueString": "MedicationRequest.id" + }, + { + "name": "type", + "valueCode": "replace" + }, + { + "name": "value", + "valueId": "test" + } + ] + } + ] +} diff --git a/src/Spark.Engine.Test/TestData/R4/medication-replace-dosage-instruction-patch.json b/src/Spark.Engine.Test/TestData/R4/medication-replace-dosage-instruction-patch.json new file mode 100644 index 000000000..3e194e38d --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/medication-replace-dosage-instruction-patch.json @@ -0,0 +1,37 @@ +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "operation", + "part": [ + { + "name": "path", + "valueString": "MedicationRequest" + }, + { + "name": "type", + "valueCode": "add" + }, + { + "name": "name", + "valueString": "dosageInstruction" + }, + { + "name": "value", + "part": [ + { + "valueDosage": { + "maxDosePerLifetime": { + "value": 1, + "unit": "ml", + "system": "http://unitsofmeasure.org", + "code": "ml" + } + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/medication-replace-resource-reference-patch.json b/src/Spark.Engine.Test/TestData/R4/medication-replace-resource-reference-patch.json new file mode 100644 index 000000000..3f00885ce --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/medication-replace-resource-reference-patch.json @@ -0,0 +1,28 @@ +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "operation", + "part": [ + { + "name": "path", + "valueString": "MedicationRequest.subject" + }, + { + "name": "type", + "valueCode": "replace" + }, + { + "name": "value", + "part": [ + { + "valueReference": { + "reference": "abc" + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/medication-status-replace-patch.json b/src/Spark.Engine.Test/TestData/R4/medication-status-replace-patch.json new file mode 100644 index 000000000..fea91cc78 --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/medication-status-replace-patch.json @@ -0,0 +1,22 @@ +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "operation", + "part": [ + { + "name": "path", + "valueString": "MedicationRequest.status" + }, + { + "name": "type", + "valueCode": "replace" + }, + { + "name": "value", + "valueCode": "completed" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/property-assignment-patch.xml b/src/Spark.Engine.Test/TestData/R4/property-assignment-patch.xml new file mode 100644 index 000000000..c36af64fa --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/property-assignment-patch.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Spark.Engine.Test/TestData/R4/property-replace-patch.xml b/src/Spark.Engine.Test/TestData/R4/property-replace-patch.xml new file mode 100644 index 000000000..8bd17ee36 --- /dev/null +++ b/src/Spark.Engine.Test/TestData/R4/property-replace-patch.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Spark.Engine/Extensions/NetCore/IServiceCollectionExtensions.cs b/src/Spark.Engine/Extensions/NetCore/IServiceCollectionExtensions.cs index a70961d7f..bd97ab853 100644 --- a/src/Spark.Engine/Extensions/NetCore/IServiceCollectionExtensions.cs +++ b/src/Spark.Engine/Extensions/NetCore/IServiceCollectionExtensions.cs @@ -54,13 +54,14 @@ public static IMvcCoreBuilder AddFhir(this IServiceCollection services, SparkSet services.TryAddTransient(); // paging services.TryAddTransient(); // storage services.TryAddTransient(); // conformance + services.TryAddTransient(); // patch services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); services.TryAddTransient(); - services.AddTransient((provider) => new IFhirServiceExtension[] + services.AddTransient((provider) => new IFhirServiceExtension[] { provider.GetRequiredService(), provider.GetRequiredService(), @@ -68,6 +69,7 @@ public static IMvcCoreBuilder AddFhir(this IServiceCollection services, SparkSet provider.GetRequiredService(), provider.GetRequiredService(), provider.GetRequiredService(), + provider.GetRequiredService(), }); services.TryAddSingleton((provider) => new FhirJsonParser(settings.ParserSettings)); diff --git a/src/Spark.Engine/Extensions/PatchExtensions.cs b/src/Spark.Engine/Extensions/PatchExtensions.cs new file mode 100644 index 000000000..14b17f6b6 --- /dev/null +++ b/src/Spark.Engine/Extensions/PatchExtensions.cs @@ -0,0 +1,176 @@ +// /* +// * Copyright (c) 2014, Furore (info@furore.com) and contributors +// * See the file CONTRIBUTORS for details. +// * +// * This file is licensed under the BSD 3-Clause license +// * available at https://raw.github.com/furore-fhir/spark/master/LICENSE +// */ + +namespace Spark.Engine.Extensions +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + using Hl7.Fhir.Model; + using Hl7.Fhir.Serialization; + + internal static class PatchExtensions + { + private enum Change + { + Delete, + Replace, + None + } + + public static Parameters ToPatch(this T current, T previous) where T : Resource + { + var operations = typeof(T).GetProperties() + .Where( + p => p.SetMethod != null && typeof(DataType).IsAssignableFrom(p.PropertyType) + || p.PropertyType.IsGenericType + && typeof(List<>).IsAssignableFrom(p.PropertyType.GetGenericTypeDefinition()) + && typeof(DataType).IsAssignableFrom(p.PropertyType.GenericTypeArguments[0])) + .Select(p => CreateChangeTuple(current, previous, p)) + .Where(x => x.Item1 != Change.None) + .SelectMany(x => + { + if (x.Item1 == Change.Delete) + { + return CreateDeleteParameter(x.name); + } + if (x.Item3 is IEnumerable items) + { + return CreateDeleteParameter(x.name) + .Concat(items.OfType().Select(d => CreateSingleAddParameter(x.name, d))); + } + + return new[] { CreateSingleValueParameters(x.name, x.Item3 as DataType) }; + }); + + return new Parameters { Parameter = operations.ToList() }; + } + + public static Parameters ToPatch(this T resource) + where T : Resource + { + var operations = typeof(T).GetProperties() + .Where( + p => typeof(DataType).IsAssignableFrom(p.PropertyType) + || p.PropertyType.IsGenericType + && typeof(List<>).IsAssignableFrom(p.PropertyType.GetGenericTypeDefinition()) + && typeof(DataType).IsAssignableFrom(p.PropertyType.GenericTypeArguments[0])) + .SelectMany( + p => + { + var name = p.Name.Replace("Element", ""); + name = char.ToLowerInvariant(name[0]) + name.Substring(1); + var value = p.GetValue(resource); + if (value is IEnumerable enumerable) + { + return CreateDeleteParameter(name) + .Concat( + enumerable.OfType() + .Select(v => CreateSingleAddParameter(name, v))); + } + + return new[] + { + CreateSingleValueParameters( + name, + (DataType) value) + }; + }); + + return new Parameters { Parameter = operations.ToList() }; + } + + private static (Change, string name, object) CreateChangeTuple(T current, T previous, PropertyInfo p) + where T : Resource + { + var currentValue = p.GetValue(current); + var previousValue = p.GetValue(previous); + + var name = p.Name.Replace("Element", ""); + name = char.ToLowerInvariant(name[0]) + name.Substring(1); + + if (currentValue == null) + { + return previousValue == null ? (Change.None, null, null) : (Change.Delete, name, (object)null); + } + + if (currentValue is IEnumerable currentEnumerable) + { + var currentArray = new HashSet(currentEnumerable.OfType().Select(x => x.ToXml())); + if (previousValue == null || !(previousValue is IEnumerable previousEnumerable)) + { + return (Change.Replace, name, currentValue); + } + + var previousArray = new HashSet(previousEnumerable.OfType().Select(x => x.ToXml())); + return currentArray.SetEquals(previousArray) ? (Change.None, name, null) : (Change.Replace, name, currentValue); + } + + if (previousValue == null || ((DataType)previousValue).ToXml() != ((DataType)currentValue).ToXml()) + { + return (Change.Replace, name, currentValue); + } + + return (Change.None, null, null); + } + + private static Parameters.ParameterComponent CreateSingleValueParameters(string name, DataType value) + where T : Resource + { + var operation = new Parameters.ParameterComponent { Name = "operation" }; + operation.Part.Add( + new Parameters.ParameterComponent { Name = "path", Value = new FhirString(typeof(T).Name + "." + name) }); + if (value != null) + { + operation.Part.Add(new Parameters.ParameterComponent { Name = "type", Value = new Code("replace") }); + var item = value is PrimitiveType + ? new Parameters.ParameterComponent { Name = "value", Value = value } + : new Parameters.ParameterComponent { Name = "value", Part = { new Parameters.ParameterComponent { Value = value } } }; + operation.Part.Add(item); + } + else + { + operation.Part.Add(new Parameters.ParameterComponent { Name = "type", Value = new Code("delete") }); + } + + return operation; + } + + private static Parameters.ParameterComponent CreateSingleAddParameter(string name, DataType value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + var operation = new Parameters.ParameterComponent { Name = "operation" }; + operation.Part.Add( + new Parameters.ParameterComponent { Name = "path", Value = new FhirString(typeof(T).Name) }); + operation.Part.Add(new Parameters.ParameterComponent { Name = "type", Value = new Code("add") }); + operation.Part.Add(new Parameters.ParameterComponent { Name = "name", Value = new FhirString(name) }); + var item = value is PrimitiveType + ? new Parameters.ParameterComponent { Name = "value", Value = value } + : new Parameters.ParameterComponent { Name = "value", Part = { new Parameters.ParameterComponent { Value = value } } }; + operation.Part.Add(item); + + return operation; + } + + private static IEnumerable CreateDeleteParameter(string name) + { + var operation = new Parameters.ParameterComponent { Name = "operation" }; + operation.Part.Add( + new Parameters.ParameterComponent { Name = "path", Value = new FhirString(typeof(T).Name + "." + name) }); + operation.Part.Add(new Parameters.ParameterComponent { Name = "type", Value = new Code("delete") }); + + yield return operation; + } + } +} \ No newline at end of file diff --git a/src/Spark.Engine/Service/AsyncFhirService.cs b/src/Spark.Engine/Service/AsyncFhirService.cs index e3ebd77db..db8c63b30 100644 --- a/src/Spark.Engine/Service/AsyncFhirService.cs +++ b/src/Spark.Engine/Service/AsyncFhirService.cs @@ -17,8 +17,8 @@ namespace Spark.Engine.Service { public class AsyncFhirService : ExtendableWith, IAsyncFhirService, IInteractionHandler { - // CCR: FhirService now implements InteractionHandler that is used by the TransactionService to actually perform the operation. - // This creates a circular reference that is solved by sending the handler on each call. + // CCR: FhirService now implements InteractionHandler that is used by the TransactionService to actually perform the operation. + // This creates a circular reference that is solved by sending the handler on each call. // A future step might be to split that part into a different service (maybe StorageService?) private readonly IFhirResponseFactory _responseFactory; @@ -226,6 +226,31 @@ public async Task UpdateAsync(IKey key, Resource resource) : await PutAsync(key, resource).ConfigureAwait(false); } + public async Task PatchAsync(IKey key, Parameters parameters) + { + if (parameters == null) + { + return new FhirResponse(HttpStatusCode.BadRequest); + } + var resourceStorage = GetFeature(); + var current = await resourceStorage.GetAsync(key.WithoutVersion()).ConfigureAwait(false); + if (current != null && current.IsPresent) + { + var patchService = GetFeature(); + try + { + var resource = patchService.Apply(current.Resource, parameters); + return await PutAsync(Entry.PUT(current.Key.WithoutVersion(), resource)).ConfigureAwait(false); + } + catch + { + return new FhirResponse(HttpStatusCode.BadRequest); + } + } + + return Respond.WithCode(HttpStatusCode.NotFound); + } + public Task ValidateOperationAsync(IKey key, Resource resource) { throw new NotImplementedException(); @@ -320,6 +345,8 @@ public async Task HandleInteractionAsync(Entry interaction) return Respond.WithCode(HttpStatusCode.NoContent); case Bundle.HTTPVerb.GET: return await VersionReadAsync((Key)interaction.Key).ConfigureAwait(false); + case Bundle.HTTPVerb.PATCH: + return await PatchAsync(interaction.Key, interaction.Resource as Parameters).ConfigureAwait(false); default: return Respond.Success; } @@ -372,7 +399,7 @@ internal async Task StoreAsync(Entry entry) { var result = await GetFeature() .AddAsync(entry).ConfigureAwait(false); - await _serviceListener.InformAsync(entry); + await _serviceListener.InformAsync(entry).ConfigureAwait(false); return result; } } diff --git a/src/Spark.Engine/Service/FhirServiceExtensions/IPatchService.cs b/src/Spark.Engine/Service/FhirServiceExtensions/IPatchService.cs new file mode 100644 index 000000000..40158be95 --- /dev/null +++ b/src/Spark.Engine/Service/FhirServiceExtensions/IPatchService.cs @@ -0,0 +1,9 @@ +namespace Spark.Engine.Service.FhirServiceExtensions +{ + using Hl7.Fhir.Model; + + public interface IPatchService : IFhirServiceExtension + { + Resource Apply(Resource resource, Parameters patch); + } +} \ No newline at end of file diff --git a/src/Spark.Engine/Service/FhirServiceExtensions/PatchService.cs b/src/Spark.Engine/Service/FhirServiceExtensions/PatchService.cs new file mode 100644 index 000000000..49ac5dc26 --- /dev/null +++ b/src/Spark.Engine/Service/FhirServiceExtensions/PatchService.cs @@ -0,0 +1,235 @@ +namespace Spark.Engine.Service.FhirServiceExtensions +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Linq.Expressions; + using System.Reflection; + using Hl7.Fhir.Language; + using Hl7.Fhir.Model; + using Hl7.FhirPath; + using Expression = System.Linq.Expressions.Expression; + using fhirExpression = Hl7.FhirPath.Expressions; + + public class PatchService : IPatchService + { + private readonly FhirPathCompiler _compiler; + + public PatchService() + { + _compiler = new FhirPathCompiler(); + } + + public Resource Apply(Resource resource, Parameters patch) + { + foreach (var component in patch.Parameter.Where(x => x.Name == "operation")) + { + var operationType = component.Part.First(x => x.Name == "type").Value.ToString(); + var path = component.Part.First(x => x.Name == "path").Value.ToString(); + var name = component.Part.FirstOrDefault(x => x.Name == "name")?.Value.ToString(); + var value = component.Part.FirstOrDefault(x => x.Name == "value")?.Value ?? component.Part.FirstOrDefault(x => x.Name == "value")?.Part[0].Value; + + var parameterExpression = Expression.Parameter(resource.GetType(), "x"); + var expression = operationType == "add" ? _compiler.Parse($"{path}.{name}") : _compiler.Parse(path); + var result = expression.Accept( + new ResourceVisitor(parameterExpression), + new fhirExpression.SymbolTable()); + var valueExpression = CreateValueExpression(value, result); + switch (operationType) + { + case "add": + result = AddValue(result, valueExpression); + break; + case "insert": + result = InsertValue(result, valueExpression); + break; + case "replace": + result = Expression.Assign(result, valueExpression); + break; + case "delete": + result = DeleteValue(result); + break; + case "move": + var source = int.Parse(component.Part.First(x => x.Name == "source").Value.ToString()!); + var destination = int.Parse(component.Part.First(x => x.Name == "destination").Value.ToString()!); + result = MoveItem(result, source, destination); + break; + } + + var compiled = Expression.Lambda(result!, parameterExpression).Compile(); + compiled.DynamicInvoke(resource); + } + + return resource; + } + + private static Expression CreateValueExpression(DataType value, Expression result) + { + return value switch + { + Code code => result is MemberExpression me + ? (Expression)Expression.MemberInit( + Expression.New(me.Type.GetConstructor(Array.Empty())), + Expression.Bind( + me.Type.GetProperty("ObjectValue"), + Expression.Constant(code.Value))) + : Expression.Constant(value), + _ => Expression.Constant(value) + }; + } + + private static Expression MoveItem(Expression result, int source, int destination) + { + var propertyInfo = GetProperty(result.Type, "Item"); + var variable = Expression.Variable(propertyInfo.PropertyType, "item"); + var block = Expression.Block( + new[] { variable }, + Expression.Assign( + variable, + Expression.MakeIndex(result, propertyInfo, new[] { Expression.Constant(source) })), + Expression.Call(result, GetMethod(result.Type, "RemoveAt"), Expression.Constant(source)), + Expression.Call( + result, + GetMethod(result.Type, "Insert"), + Expression.Constant(Math.Max(0, destination - 1)), + variable)); + return block; + } + + private static Expression InsertValue(Expression result, Expression valueExpression) + { + return result switch + { + IndexExpression indexExpression => Expression.Call( + indexExpression.Object, + GetMethod(indexExpression.Object!.Type, "Insert"), + new[] { indexExpression.Arguments[0], valueExpression }), + _ => result + }; + } + + private static Expression AddValue(Expression result, Expression value) + { + return result switch + { + MemberExpression me when me.Type.IsGenericType + && GetMethod(me.Type, "Add") != null => + Expression.Block( + Expression.IfThen( + Expression.Equal(me, Expression.Default(result.Type)), + Expression.Throw(Expression.New(typeof(InvalidOperationException)))), + Expression.Call(me, GetMethod(me.Type, "Add"), value)), + MemberExpression me => Expression.Block( + Expression.IfThen( + Expression.NotEqual(me, Expression.Default(result.Type)), + Expression.Throw(Expression.New(typeof(InvalidOperationException)))), + Expression.Assign(me, value)), + _ => result + }; + } + + private static Expression DeleteValue(Expression result) + { + return result switch + { + IndexExpression indexExpression => Expression.Call( + indexExpression.Object, + GetMethod(indexExpression.Object!.Type, "RemoveAt"), + indexExpression.Arguments), + MemberExpression me when me.Type.IsGenericType + && typeof(List<>).IsAssignableFrom(me.Type.GetGenericTypeDefinition()) => + Expression.Call(me, GetMethod(me.Type, "Clear")), + MemberExpression me => Expression.Assign(me, Expression.Default(me.Type)), + _ => result + }; + } + + private static MethodInfo GetMethod(Type constantType, string methodName) + { + var propertyInfos = constantType.GetMethods(); + var property = + propertyInfos.FirstOrDefault(p => p.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase)); + + return property; + } + + private static PropertyInfo GetProperty(Type constantType, string propertyName) + { + var propertyInfos = constantType.GetProperties(); + var property = + propertyInfos.FirstOrDefault(p => p.Name.Equals(propertyName + "Element", StringComparison.OrdinalIgnoreCase)) + ?? propertyInfos.FirstOrDefault(x => x.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)); + + return property; + } + + private class ResourceVisitor : fhirExpression.ExpressionVisitor + { + private readonly ParameterExpression _parameter; + + public ResourceVisitor(ParameterExpression parameter) + { + _parameter = parameter; + } + + /// + public override Expression VisitConstant( + fhirExpression.ConstantExpression expression, + fhirExpression.SymbolTable scope) + { + if (expression.ExpressionType == TypeSpecifier.Integer) + { + return Expression.Constant((int)expression.Value); + } + + if (expression.ExpressionType == TypeSpecifier.String) + { + var propertyName = expression.Value.ToString(); + var property = GetProperty(_parameter.Type, propertyName); + return property == null + ? (Expression)_parameter + : Expression.Property(_parameter, property); + } + + return null; + } + + /// + public override Expression VisitFunctionCall( + fhirExpression.FunctionCallExpression expression, + fhirExpression.SymbolTable scope) + { + switch (expression) + { + case fhirExpression.IndexerExpression indexerExpression: + { + var index = indexerExpression.Index.Accept(this, scope); + var property = indexerExpression.Focus.Accept(this, scope); + var itemProperty = GetProperty(property.Type, "Item"); + return Expression.MakeIndex(property, itemProperty, new[] { index }); + } + case fhirExpression.ChildExpression child: + { + return child.Arguments.First().Accept(this, scope); + } + default: + return _parameter; + } + } + + /// + public override Expression VisitNewNodeListInit( + fhirExpression.NewNodeListInitExpression expression, + fhirExpression.SymbolTable scope) + { + return _parameter; + } + + /// + public override Expression VisitVariableRef(fhirExpression.VariableRefExpression expression, fhirExpression.SymbolTable scope) + { + return _parameter; + } + } + } +} diff --git a/src/Spark.Engine/Service/IAsyncFhirService.cs b/src/Spark.Engine/Service/IAsyncFhirService.cs index 6711233e0..a23024e7a 100644 --- a/src/Spark.Engine/Service/IAsyncFhirService.cs +++ b/src/Spark.Engine/Service/IAsyncFhirService.cs @@ -31,6 +31,8 @@ public interface IAsyncFhirService Task TransactionAsync(IList interactions); Task TransactionAsync(Bundle bundle); Task UpdateAsync(IKey key, Resource resource); + Task PatchAsync(IKey key, Parameters patch); + Task ValidateOperationAsync(IKey key, Resource resource); Task VersionReadAsync(IKey key); Task VersionSpecificUpdateAsync(IKey versionedKey, Resource resource); diff --git a/src/Spark.Web/Controllers/FhirController.cs b/src/Spark.Web/Controllers/FhirController.cs index 2c62c9df1..efb602b0d 100644 --- a/src/Spark.Web/Controllers/FhirController.cs +++ b/src/Spark.Web/Controllers/FhirController.cs @@ -51,7 +51,7 @@ public async Task> Update(string type, Resource resou { string versionId = Request.GetTypedHeaders().IfMatch?.FirstOrDefault()?.Tag.Buffer; Key key = Key.Create(type, id, versionId); - if(key.HasResourceId()) + if (key.HasResourceId()) { Request.TransferResourceIdIfRawBinary(resource, id); @@ -82,6 +82,14 @@ public async Task Create(string type, Resource resource) return await _fhirService.CreateAsync(key, resource).ConfigureAwait(false); } + [HttpPatch("{type}/{id}")] + public async Task Patch(string type, string id, Parameters patch) + { + Key key = Key.Create(type, id); + FhirResponse response = await _fhirService.PatchAsync(key, patch).ConfigureAwait(false); + return response; + } + [HttpDelete("{type}/{id}")] public async Task Delete(string type, string id) {