From 87183221582ab419dc8fe6f354fb8362cd77d26a Mon Sep 17 00:00:00 2001 From: Joseph Shook Date: Fri, 19 Jan 2024 14:00:25 -0800 Subject: [PATCH 1/2] Ignore Header parameters so that charset=utf-8 will not fail to match on a valid Formatter The existing behavior can be seen by running the Hl7.DemoFileSystemFhirServer.AspNetCore project as is. Requesting a fhir resource with an "Accept" header of "application/json+fhir; charset=utf-8" I would expect the results to be json but they are returned as xml. This is because the base type of TextInputFormatter matches on the header parameters also. This PR overrides CanWriteResult to ignore the header parameter. This results in the Json Formatter being selected for output formatting. It may be appropriate to check encoding if it exists and ensure it is not something other than utf-8. Also this code would allow a path forward for other misc header parameters like fhirVersion. --- .../FhirMediaTypeFormatter.cs | 67 +++++++++++++++++++ src/Hl7.Fhir.WebApi.AspNetCore/Utility.cs | 63 +++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/src/Hl7.Fhir.WebApi.AspNetCore/FhirMediaTypeFormatter.cs b/src/Hl7.Fhir.WebApi.AspNetCore/FhirMediaTypeFormatter.cs index 22dd2a0c..ae656029 100644 --- a/src/Hl7.Fhir.WebApi.AspNetCore/FhirMediaTypeFormatter.cs +++ b/src/Hl7.Fhir.WebApi.AspNetCore/FhirMediaTypeFormatter.cs @@ -16,6 +16,8 @@ using System.Linq; using Hl7.Fhir.Serialization; using Hl7.Fhir.Utility; +using Microsoft.Extensions.Primitives; +using System.Resources; namespace Hl7.Fhir.WebApi { @@ -120,6 +122,71 @@ protected override bool CanWriteType(Type type) return false; } + /// + public override bool CanWriteResult(OutputFormatterCanWriteContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (SupportedMediaTypes.Count == 0) + { + var message = $"No media types found in '{GetType().FullName}.{nameof(SupportedMediaTypes)}'. Add at least one media type to the list of supported media types."; + + throw new InvalidOperationException(message); + } + + if (!CanWriteType(context.ObjectType)) + { + return false; + } + + if (!context.ContentType.HasValue) + { + // If the desired content type is set to null, then the current formatter can write anything + // it wants. + context.ContentType = new StringSegment(SupportedMediaTypes[0]); + return true; + } + else + { + var parsedContentType = new MediaType(context.ContentType); + for (var i = 0; i < SupportedMediaTypes.Count; i++) + { + var supportedMediaType = new MediaType(SupportedMediaTypes[i]); + if (supportedMediaType.HasWildcard) + { + // For supported media types that are wildcard patterns, confirm that the requested + // media type satisfies the wildcard pattern (e.g., if "text/entity+json;v=2" requested + // and formatter supports "text/*+json"). + // We only do this when comparing against server-defined content types (e.g., those + // from [Produces] or Response.ContentType), otherwise we'd potentially be reflecting + // back arbitrary Accept header values. + if (context.ContentTypeIsServerDefined + && parsedContentType.IsSubsetOf(supportedMediaType)) + { + return true; + } + } + else + { + // For supported media types that are not wildcard patterns, confirm that this formatter + // supports a more specific media type than requested e.g. OK if "text/*" requested and + // formatter supports "text/plain". + // contentType is typically what we got in an Accept header. + if (supportedMediaType.IsSubsetOfIgnoreParameters(parsedContentType)) + { + context.ContentType = new StringSegment(SupportedMediaTypes[i]); + return true; + } + } + } + } + + return false; + } + const string x_correlation_id = "X-Correlation-Id"; public override void WriteResponseHeaders(OutputFormatterWriteContext context) { diff --git a/src/Hl7.Fhir.WebApi.AspNetCore/Utility.cs b/src/Hl7.Fhir.WebApi.AspNetCore/Utility.cs index 57b6d561..735a5f17 100644 --- a/src/Hl7.Fhir.WebApi.AspNetCore/Utility.cs +++ b/src/Hl7.Fhir.WebApi.AspNetCore/Utility.cs @@ -17,6 +17,7 @@ using System.Net; using System.Net.Http; using System.Text; +using Microsoft.AspNetCore.Mvc.Formatters; namespace Hl7.Fhir.WebApi { @@ -185,5 +186,67 @@ public static string ToFhirDateTime(this System.DateTime? me) } #endregion + #region << Static Helpers for Header Subset comparison that ignores Header parameters >> + public static bool IsSubsetOfIgnoreParameters(this MediaType supported, MediaType set) + { + return supported.MatchesType(set) && + supported.MatchesSubtype(set); + } + + private static bool MatchesType(this MediaType supported, MediaType set) + { + return set.MatchesAllTypes || + set.Type.Equals(supported.Type, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesSubtype(this MediaType supported, MediaType set) + { + if (set.MatchesAllSubTypes) + { + return true; + } + + if (set.SubTypeSuffix.HasValue) + { + if (supported.SubTypeSuffix.HasValue) + { + // Both the set and the media type being checked have suffixes, so both parts must match. + return supported.MatchesSubtypeWithoutSuffix(set) && supported.MatchesSubtypeSuffix(set); + } + else + { + // The set has a suffix, but the media type being checked doesn't. We never consider this to match. + return false; + } + } + else + { + // If this subtype or suffix matches the subtype of the set, + // it is considered a subtype. + // Ex: application/json > application/val+json + return supported.MatchesEitherSubtypeOrSuffix(set); + } + } + + private static bool MatchesSubtypeWithoutSuffix(this MediaType supported, MediaType set) + { + return set.MatchesAllSubTypesWithoutSuffix || + set.SubTypeWithoutSuffix.Equals(supported.SubTypeWithoutSuffix, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesSubtypeSuffix(this MediaType supported, MediaType set) + { + // We don't have support for wildcards on suffixes alone (e.g., "application/entity+*") + // because there's no clear use case for it. + return set.SubTypeSuffix.Equals(supported.SubTypeSuffix, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesEitherSubtypeOrSuffix(this MediaType supported, MediaType set) + { + return set.SubType.Equals(supported.SubType, StringComparison.OrdinalIgnoreCase) || + set.SubType.Equals(supported.SubTypeSuffix, StringComparison.OrdinalIgnoreCase); + } + + #endregion } } From b4075e4b95bd6c0487947c423736e8151c61c1eb Mon Sep 17 00:00:00 2001 From: Joseph Shook Date: Fri, 19 Jan 2024 15:24:50 -0800 Subject: [PATCH 2/2] Adding a test --- src/Test.WebApi.AspNetCore/BasicTests.cs | 75 ++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/Test.WebApi.AspNetCore/BasicTests.cs b/src/Test.WebApi.AspNetCore/BasicTests.cs index 7c22731d..fde79ca6 100644 --- a/src/Test.WebApi.AspNetCore/BasicTests.cs +++ b/src/Test.WebApi.AspNetCore/BasicTests.cs @@ -6,6 +6,7 @@ using Hl7.Fhir.Validation; using Hl7.Fhir.WebApi; using Microsoft.EntityFrameworkCore; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Collections.Generic; @@ -14,6 +15,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Text.Json; using System.Threading; using Task = System.Threading.Tasks.Task; @@ -951,6 +953,79 @@ public async System.Threading.Tasks.Task ReadBinary() } + [TestMethod] + public async Task RequestAcceptJsonWithHeaderParameter() + { + var app = new UnitTestFhirServerApplication(); + var httpClient = app.CreateClient(); + var acceptHeader = "application/json+fhir"; + var acceptHeaderWithEncoding = "application/json+fhir; charset=utf-8"; + var acceptHeaderWithEncodingAndVersion = "application/json+fhir; carset=utf-8; fhirVersion=4.0"; + var badAcceptHeader = "application/notjson+fhir"; + var acceptXmlHeader = "application/xml+fhir"; + + httpClient.DefaultRequestHeaders.Add("Accept", acceptHeader); + var raw = await httpClient.GetStringAsync($"{app.Server.BaseAddress}Patient"); + + try + { + await new FhirJsonParser().ParseAsync(raw); + } + catch (Exception ex) + { + Assert.Fail("Expected Json formatted bundle: " + ex.Message); + } + + httpClient.DefaultRequestHeaders.Remove("Accept"); + httpClient.DefaultRequestHeaders.Add("Accept", acceptHeaderWithEncoding); + raw = await httpClient.GetStringAsync($"{app.Server.BaseAddress}Patient"); + try + { + await new FhirJsonParser().ParseAsync(raw); + } + catch (Exception ex) + { + Assert.Fail("Expected Json formatted bundle: " + ex.Message); + } + + httpClient.DefaultRequestHeaders.Remove("Accept"); + httpClient.DefaultRequestHeaders.Add("Accept", acceptHeaderWithEncodingAndVersion); + raw = await httpClient.GetStringAsync($"{app.Server.BaseAddress}Patient"); + try + { + await new FhirJsonParser().ParseAsync(raw); + } + catch (Exception ex) + { + Assert.Fail("Expected Json formatted bundle: " + ex.Message); + } + + httpClient.DefaultRequestHeaders.Remove("Accept"); + httpClient.DefaultRequestHeaders.Add("Accept", badAcceptHeader); + raw = await httpClient.GetStringAsync($"{app.Server.BaseAddress}Patient"); + try + { + await new FhirXmlParser().ParseAsync(raw); + } + catch (Exception ex) + { + Assert.Fail("Expected Xml formatted bundle: " + ex.Message); + } + + httpClient.DefaultRequestHeaders.Remove("Accept"); + httpClient.DefaultRequestHeaders.Add("Accept", acceptXmlHeader); + raw = await httpClient.GetStringAsync($"{app.Server.BaseAddress}Patient"); + try + { + await new FhirXmlParser().ParseAsync(raw); + } + catch (Exception ex) + { + Assert.Fail("Expected Xml formatted bundle: " + ex.Message); + } + + } + private void ClientFhir_OnBeforeRequest(object sender, HttpRequestMessage msg) { System.Diagnostics.Trace.WriteLine("---------------------------------------------------");