Skip to content

Commit

Permalink
Merge pull request #2753 from FirelyTeam/feature/custom-patch-request
Browse files Browse the repository at this point in the history
custom json/xml patch requests
  • Loading branch information
mmsmits committed Apr 3, 2024
2 parents 69efc6f + 9c41030 commit 3267f75
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 44 deletions.
119 changes: 82 additions & 37 deletions src/Hl7.Fhir.Base/Rest/BaseFhirClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Hl7.Fhir.Utility;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System.Net.Http;
Expand Down Expand Up @@ -438,7 +439,6 @@ public virtual async Task ConditionalDeleteMultipleAsync(SearchParams condition,
{
if (id == null) throw Error.ArgumentNull(nameof(id));


var tx = new TransactionBuilder(Endpoint);
var resourceType = typeNameOrDie<TResource>();

Expand All @@ -450,6 +450,26 @@ public virtual async Task ConditionalDeleteMultipleAsync(SearchParams condition,
return executeAsync<TResource>(tx.ToBundle(), new[] { HttpStatusCode.Created, HttpStatusCode.OK }, ct);
}

public virtual Task<TResource?> PatchAsync<TResource>(string id, string patchDocument, ResourceFormat format, CancellationToken? ct = null) where TResource : Resource
{
if (id == null) throw Error.ArgumentNull(nameof(id));

var resourceType = typeNameOrDie<TResource>();
var url = new RestUrl(Endpoint).AddPath(resourceType, id);

var request = new HttpRequestMessage(new("PATCH"), url.Uri).WithFormatParameter(format);

request.Content = new StringContent(patchDocument);
request.Content.Headers.ContentType = new MediaTypeHeaderValue(format switch
{
ResourceFormat.Json => "application/json-patch+json",
ResourceFormat.Xml => "application/xml-patch+xml",
_ => throw Error.Argument(nameof(format), "Unsupported format")
});

return executeAsync<TResource>(request, new[] { HttpStatusCode.Created, HttpStatusCode.OK }, ct);
}

/// <summary>
/// Conditionally patch a resource on a FHIR Endpoint
/// </summary>
Expand Down Expand Up @@ -787,6 +807,56 @@ public async Task DeleteHistoryVersionAsync(string location, CancellationToken?

using var responseMessage = await Requester.ExecuteAsync(requestMessage, cancellation).ConfigureAwait(false);

return await extractResourceFromHttpResponse<TResource>(expect, responseMessage, entryComponent: request);
}

private async Task<TResource?> executeAsync<TResource>(HttpRequestMessage request, IEnumerable<HttpStatusCode> expect, CancellationToken? ct) where TResource : Resource
{
var cancellation = ct ?? CancellationToken.None;

cancellation.ThrowIfCancellationRequested();

using var responseMessage = await Requester.ExecuteAsync(request, cancellation).ConfigureAwait(false);

return await extractResourceFromHttpResponse<TResource>(expect, responseMessage, request);
}

#endregion

#region Utilities

// Create our own and add decompression strategy in default handler.
private static HttpClientHandler makeDefaultHandler() =>
new()
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};

private static Uri getValidatedEndpoint(Uri endpoint)
{
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));

endpoint = new Uri(endpoint.OriginalString.EnsureEndsWith("/"));

if (!endpoint.IsAbsoluteUri) throw new ArgumentException("Endpoint must be absolute", nameof(endpoint));

return endpoint;
}
private static ResourceIdentity verifyResourceIdentity(Uri location, bool needId, bool needVid)
{
var result = new ResourceIdentity(location);

if (result.ResourceType == null) throw Error.Argument(nameof(location), "Must be a FHIR REST url containing the resource type in its path");
if (needId && result.Id == null) throw Error.Argument(nameof(location), "Must be a FHIR REST url containing the logical id in its path");
if (needVid && !result.HasVersion) throw Error.Argument(nameof(location), "Must be a FHIR REST url containing the version id in its path");

return result;
}

// either msg or entryComponent should be set
private async Task<TResource?> extractResourceFromHttpResponse<TResource>(IEnumerable<HttpStatusCode> expect, HttpResponseMessage responseMessage, HttpRequestMessage? msg = null, Bundle.EntryComponent? entryComponent = null) where TResource : Resource
{
if (msg is null && entryComponent is null) throw new ArgumentException("Either msg or entryComponent should be set");
// Validate the response and throw the appropriate exceptions. Also, if we have *not* verified the FHIR version
// of the server, add a suggestion about this in the (legacy) parsing exception.
var suggestedVersionOnParseError = !Settings.VerifyFhirVersion ? fhirVersion : null;
Expand All @@ -809,7 +879,7 @@ await ValidateResponse(responseMessage, expect, getSerializationEngine(), sugges
// the full body of the altered resource.
var noRealBody = LastBodyAsResource is null || (LastBodyAsResource is OperationOutcome && string.IsNullOrEmpty(LastBodyAsResource.Id));
var shouldFetchFullRepresentation = noRealBody
&& isPostOrPutOrPatch(request)
&& (msg is not null ? isPostOrPutOrPatch(msg.Method) : isPostOrPutOrPatch(entryComponent!))
&& Settings.ReturnPreference == ReturnPreference.Representation
&& LastResult.Location is { } fetchLocation
&& new ResourceIdentity(fetchLocation).IsRestResourceIdentity(); // Check that it isn't an operation too
Expand All @@ -833,43 +903,14 @@ await ValidateResponse(responseMessage, expect, getSerializationEngine(), sugges
null => null,

// Unexpected response type in the body, throw.
_ => throw new FhirOperationException(unexpectedBodyType(request.Request), responseMessage.StatusCode)
_ => throw new FhirOperationException(entryComponent is not null ? unexpectedBodyTypeForBundle(entryComponent.Request) : unexpectedBodyTypeForMessage(msg!), responseMessage.StatusCode)
};

static string unexpectedBodyType(Bundle.RequestComponent rc) => $"Operation {rc.Method} on {rc.Url} " +

static string unexpectedBodyTypeForBundle(Bundle.RequestComponent rc) => $"Operation {rc.Method} on {rc.Url} " +
$"expected a body of type {typeof(TResource).Name} but a {typeof(TResource).Name} was returned.";

static string unexpectedBodyTypeForMessage(HttpRequestMessage msg) => $"Operation {msg.Method} on {msg.RequestUri} " +
$"expected a body of type {typeof(TResource).Name} but a {typeof(TResource).Name} was returned.";
}

#endregion

#region Utilities

// Create our own and add decompression strategy in default handler.
private static HttpClientHandler makeDefaultHandler() =>
new()
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};

private static Uri getValidatedEndpoint(Uri endpoint)
{
if (endpoint == null) throw new ArgumentNullException(nameof(endpoint));

endpoint = new Uri(endpoint.OriginalString.EnsureEndsWith("/"));

if (!endpoint.IsAbsoluteUri) throw new ArgumentException("Endpoint must be absolute", nameof(endpoint));

return endpoint;
}
private static ResourceIdentity verifyResourceIdentity(Uri location, bool needId, bool needVid)
{
var result = new ResourceIdentity(location);

if (result.ResourceType == null) throw Error.Argument(nameof(location), "Must be a FHIR REST url containing the resource type in its path");
if (needId && result.Id == null) throw Error.Argument(nameof(location), "Must be a FHIR REST url containing the logical id in its path");
if (needVid && !result.HasVersion) throw Error.Argument(nameof(location), "Must be a FHIR REST url containing the version id in its path");

return result;
}

/// <summary>
Expand Down Expand Up @@ -913,9 +954,13 @@ internal static async Task<ResponseData> ValidateResponse(HttpResponseMessage re

private static bool isPostOrPutOrPatch(Bundle.EntryComponent interaction) =>
interaction.Request.Method is Bundle.HTTPVerb.POST or Bundle.HTTPVerb.PUT or Bundle.HTTPVerb.PATCH;

private static bool isPostOrPutOrPatch(HttpMethod method) =>
method == HttpMethod.Post || method == HttpMethod.Put || method == new HttpMethod("PATCH");

private bool _versionChecked = false;


private IFhirSerializationEngine getSerializationEngine()
{
return Settings.SerializationEngine ?? FhirSerializationEngineFactory.Legacy.FromParserSettings(Inspector, Settings.ParserSettings ?? new());
Expand Down
2 changes: 1 addition & 1 deletion src/Hl7.Fhir.Base/Rest/TransactionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ public TransactionBuilder ConditionalPatch(string resourceType, SearchParams con

return this;
}

#endregion

#region Delete
Expand Down
3 changes: 0 additions & 3 deletions src/Hl7.Fhir.Base/Rest/TransactionBuilder_obsolete.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@

#nullable enable

using Hl7.Fhir.Introspection;
using Hl7.Fhir.Model;
using Hl7.Fhir.Serialization;
using Hl7.Fhir.Utility;
using System;

namespace Hl7.Fhir.Rest;
Expand Down
48 changes: 47 additions & 1 deletion src/Hl7.Fhir.Support.Poco.Tests/Rest/FhirClientMockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -470,14 +470,60 @@ async Task<HttpResponseMessage> blocker(CancellationToken ct)

// Start the task and wait until it is "blocking"
var blockingTask = client.OperationAsync(new Uri("http://example.com/fhir/$ping"), ct: cts.Token);
while (!isBlocking) ;
while (!isBlocking);

// now cancel it.
cts.Cancel();

var act = async () => await blockingTask;
await act.Should().ThrowAsync<OperationCanceledException>();
}

[TestMethod]
public async Task TestCustomJsonPatch()
{
var body = """
[
{
"path": "/name/0/id",
"op": "test",
"value": "12804999"
},
{
"path": "/name/0/given",
"op": "replace",
"value": [
"Beulah",
"Z"
]
}
]
"""; // A JSON Patch operation\

var handlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
handlerMock
.Protected()
// Setup the PROTECTED method to mock
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>()
).ReturnsAsync(new HttpResponseMessage()).Verifiable();

var client = new BaseFhirClient(new ("http://example.com/fhir/"), handlerMock.Object, TESTINSPECTOR, new FhirClientSettings { ExplicitFhirVersion = TESTVERSION, VerifyFhirVersion = false });

_ = await client.PatchAsync<TestPatient>("1", body, ResourceFormat.Json);

handlerMock.Protected().Verify(
"SendAsync",
Times.Once(),
ItExpr.Is<HttpRequestMessage>(req =>
req.Content.Headers.ContentType.ToString() == "application/json-patch+json" &&
req.Content.ReadAsStringAsync().Result == body &&
req.RequestUri.ToString().Contains("_format=json")
),
ItExpr.IsAny<CancellationToken>());
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,6 @@ public void TestConditionalDeleteWithIfmatch()
[TestMethod]
public void TestDeleteHistory()
{
var p = new TestPatient();
var tx = new TransactionBuilder("http://myserver.org/fhir")
.DeleteHistory("Patient", "7");
var b = tx.ToBundle();
Expand All @@ -166,7 +165,6 @@ public void TestDeleteHistory()
[TestMethod]
public void TestDeleteHistoryVersion()
{
var p = new TestPatient();
var tx = new TransactionBuilder("http://myserver.org/fhir")
.DeleteHistoryVersion("Patient", "7", "1");
var b = tx.ToBundle();
Expand Down

0 comments on commit 3267f75

Please sign in to comment.