Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

custom json/xml patch requests #2753

Merged
merged 6 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 79 additions & 37 deletions src/Hl7.Fhir.Base/Rest/BaseFhirClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,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 +449,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 +806,54 @@ 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;
}

private async Task<TResource?> extractResourceFromHttpResponse<TResource>(IEnumerable<HttpStatusCode> expect, HttpResponseMessage responseMessage, HttpRequestMessage? msg = null, Bundle.EntryComponent? entryComponent = null) where TResource : Resource
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved
{
// 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 +876,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!))
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved
&& Settings.ReturnPreference == ReturnPreference.Representation
&& LastResult.Location is { } fetchLocation
&& new ResourceIdentity(fetchLocation).IsRestResourceIdentity(); // Check that it isn't an operation too
Expand All @@ -833,43 +900,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)
Kasdejong marked this conversation as resolved.
Show resolved Hide resolved
};

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 +951,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