Skip to content

Commit

Permalink
Merge pull request #1553 from json-api-dotnet/relaxed-operations-medi…
Browse files Browse the repository at this point in the history
…a-type

Allow relaxed Content-Type for atomic operations
  • Loading branch information
bkoelman authored May 25, 2024
2 parents a8ff4ec + 94d7332 commit 32cc3b5
Show file tree
Hide file tree
Showing 15 changed files with 288 additions and 24 deletions.
1 change: 1 addition & 0 deletions src/JsonApiDotNetCore/Middleware/HeaderConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ public static class HeaderConstants
{
public const string MediaType = "application/vnd.api+json";
public const string AtomicOperationsMediaType = $"{MediaType}; ext=\"https://jsonapi.org/ext/atomic\"";
public const string RelaxedAtomicOperationsMediaType = $"{MediaType}; ext=atomic-operations";
}
45 changes: 31 additions & 14 deletions src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,20 @@ namespace JsonApiDotNetCore.Middleware;
[PublicAPI]
public sealed class JsonApiMiddleware
{
private static readonly MediaTypeHeaderValue MediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType);
private static readonly MediaTypeHeaderValue AtomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType);
private static readonly string[] NonOperationsContentTypes = [HeaderConstants.MediaType];
private static readonly MediaTypeHeaderValue[] NonOperationsMediaTypes = [MediaTypeHeaderValue.Parse(HeaderConstants.MediaType)];

private static readonly string[] OperationsContentTypes =
[
HeaderConstants.AtomicOperationsMediaType,
HeaderConstants.RelaxedAtomicOperationsMediaType
];

private static readonly MediaTypeHeaderValue[] OperationsMediaTypes =
[
MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType),
MediaTypeHeaderValue.Parse(HeaderConstants.RelaxedAtomicOperationsMediaType)
];

private readonly RequestDelegate? _next;

Expand Down Expand Up @@ -56,8 +68,8 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin

if (primaryResourceType != null)
{
if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerWriteOptions) ||
!await ValidateAcceptHeaderAsync(MediaType, httpContext, options.SerializerWriteOptions))
if (!await ValidateContentTypeHeaderAsync(NonOperationsContentTypes, httpContext, options.SerializerWriteOptions) ||
!await ValidateAcceptHeaderAsync(NonOperationsMediaTypes, httpContext, options.SerializerWriteOptions))
{
return;
}
Expand All @@ -68,8 +80,8 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
}
else if (IsRouteForOperations(routeValues))
{
if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions) ||
!await ValidateAcceptHeaderAsync(AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions))
if (!await ValidateContentTypeHeaderAsync(OperationsContentTypes, httpContext, options.SerializerWriteOptions) ||
!await ValidateAcceptHeaderAsync(OperationsMediaTypes, httpContext, options.SerializerWriteOptions))
{
return;
}
Expand Down Expand Up @@ -126,16 +138,19 @@ private async Task<bool> ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso
: null;
}

private static async Task<bool> ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerOptions serializerOptions)
private static async Task<bool> ValidateContentTypeHeaderAsync(ICollection<string> allowedContentTypes, HttpContext httpContext,
JsonSerializerOptions serializerOptions)
{
string? contentType = httpContext.Request.ContentType;

if (contentType != null && contentType != allowedContentType)
if (contentType != null && !allowedContentTypes.Contains(contentType, StringComparer.OrdinalIgnoreCase))
{
string allowedValues = string.Join(" or ", allowedContentTypes.Select(value => $"'{value}'"));

await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.UnsupportedMediaType)
{
Title = "The specified Content-Type header value is not supported.",
Detail = $"Please specify '{allowedContentType}' instead of '{contentType}' for the Content-Type header value.",
Detail = $"Please specify {allowedValues} instead of '{contentType}' for the Content-Type header value.",
Source = new ErrorSource
{
Header = "Content-Type"
Expand All @@ -148,7 +163,7 @@ private static async Task<bool> ValidateContentTypeHeaderAsync(string allowedCon
return true;
}

private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext,
private static async Task<bool> ValidateAcceptHeaderAsync(ICollection<MediaTypeHeaderValue> allowedMediaTypes, HttpContext httpContext,
JsonSerializerOptions serializerOptions)
{
string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept");
Expand All @@ -164,15 +179,15 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a
{
if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue))
{
headerValue.Quality = null;

if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*")
{
seenCompatibleMediaType = true;
break;
}

if (allowedMediaTypeValue.Equals(headerValue))
headerValue.Quality = null;

if (allowedMediaTypes.Contains(headerValue))
{
seenCompatibleMediaType = true;
break;
Expand All @@ -182,10 +197,12 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a

if (!seenCompatibleMediaType)
{
string allowedValues = string.Join(" or ", allowedMediaTypes.Select(value => $"'{value}'"));

await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.NotAcceptable)
{
Title = "The specified Accept header value does not contain any supported media types.",
Detail = $"Please include '{allowedMediaTypeValue}' in the Accept header values.",
Detail = $"Please include {allowedValues} in the Accept header values.",
Source = new ErrorSource
{
Header = "Accept"
Expand Down
49 changes: 46 additions & 3 deletions src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ namespace JsonApiDotNetCore.Serialization.Response;
/// <inheritdoc cref="IJsonApiWriter" />
public sealed class JsonApiWriter : IJsonApiWriter
{
private static readonly MediaTypeHeaderValue OperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType);
private static readonly MediaTypeHeaderValue RelaxedOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.RelaxedAtomicOperationsMediaType);

private static readonly MediaTypeHeaderValue[] AllowedOperationsMediaTypes =
[
OperationsMediaType,
RelaxedOperationsMediaType
];

private readonly IJsonApiRequest _request;
private readonly IJsonApiOptions _options;
private readonly IResponseModelAdapter _responseModelAdapter;
Expand Down Expand Up @@ -70,7 +79,8 @@ public async Task WriteAsync(object? model, HttpContext httpContext)
return $"Sending {httpContext.Response.StatusCode} response for {method} request at '{url}' with body: <<{responseBody}>>";
});

await SendResponseBodyAsync(httpContext.Response, responseBody);
string responseContentType = GetResponseContentType(httpContext.Request);
await SendResponseBodyAsync(httpContext.Response, responseBody, responseContentType);
}

private static bool CanWriteBody(HttpStatusCode statusCode)
Expand Down Expand Up @@ -167,11 +177,44 @@ private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders
return false;
}

private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody)
private string GetResponseContentType(HttpRequest httpRequest)
{
if (_request.Kind != EndpointKind.AtomicOperations)
{
return HeaderConstants.MediaType;
}

MediaTypeHeaderValue? bestMatch = null;

foreach (MediaTypeHeaderValue headerValue in httpRequest.GetTypedHeaders().Accept)
{
double quality = headerValue.Quality ?? 1.0;
headerValue.Quality = null;

if (AllowedOperationsMediaTypes.Contains(headerValue))
{
if (bestMatch == null || bestMatch.Quality < quality)
{
headerValue.Quality = quality;
bestMatch = headerValue;
}
}
}

if (bestMatch == null)
{
return httpRequest.ContentType ?? HeaderConstants.AtomicOperationsMediaType;
}

bestMatch.Quality = null;
return RelaxedOperationsMediaType.Equals(bestMatch) ? HeaderConstants.RelaxedAtomicOperationsMediaType : HeaderConstants.AtomicOperationsMediaType;
}

private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody, string contentType)
{
if (!string.IsNullOrEmpty(responseBody))
{
httpResponse.ContentType = _request.Kind == EndpointKind.AtomicOperations ? HeaderConstants.AtomicOperationsMediaType : HeaderConstants.MediaType;
httpResponse.ContentType = contentType;

using IDisposable _ = CodeTimingSessionManager.Current.Measure("Send response body");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public AtomicLoggingTests(IntegrationTestContext<TestableStartup<OperationsDbCon
}

[Fact]
public async Task Logs_at_error_level_on_unhandled_exception()
public async Task Logs_unhandled_exception_at_Error_level()
{
// Arrange
var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>();
Expand Down Expand Up @@ -88,7 +88,7 @@ public async Task Logs_at_error_level_on_unhandled_exception()
}

[Fact]
public async Task Logs_at_info_level_on_invalid_request_body()
public async Task Logs_invalid_request_body_error_at_Information_level()
{
// Arrange
var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Net;
using FluentAssertions;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Serialization.Objects;
using TestBuildingBlocks;
using Xunit;
Expand Down Expand Up @@ -29,6 +30,9 @@ public async Task Cannot_process_for_missing_request_body()
// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);

httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType);

responseDocument.Errors.ShouldHaveCount(1);

ErrorObject error = responseDocument.Errors[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public AtomicTraceLoggingTests(IntegrationTestContext<TestableStartup<Operations
}

[Fact]
public async Task Logs_execution_flow_at_trace_level_on_operations_request()
public async Task Logs_execution_flow_at_Trace_level_on_operations_request()
{
// Arrange
var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ public async Task Permits_global_wildcard_in_Accept_headers()

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
}

[Fact]
Expand All @@ -102,6 +105,9 @@ public async Task Permits_application_wildcard_in_Accept_headers()

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
}

[Fact]
Expand All @@ -124,10 +130,59 @@ public async Task Permits_JsonApi_without_parameters_in_Accept_headers()

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
}

[Fact]
public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint()
public async Task Prefers_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint()
{
// Arrange
var requestBody = new
{
atomic__operations = new[]
{
new
{
op = "add",
data = new
{
type = "policies",
attributes = new
{
name = "some"
}
}
}
}
};

const string route = "/operations";
const string contentType = HeaderConstants.RelaxedAtomicOperationsMediaType;

Action<HttpRequestHeaders> setRequestHeaders = headers =>
{
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=atomic-operations; q=0.2"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.8"));
};

// Act
(HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<Document>(route, requestBody, contentType, setRequestHeaders);

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType);
}

[Fact]
public async Task Prefers_JsonApi_with_relaxed_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint()
{
// Arrange
var requestBody = new
Expand Down Expand Up @@ -158,14 +213,18 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};ext=\"https://jsonapi.org/ext/atomic\"; q=0.2"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.2"));
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=atomic-operations; q=0.8"));
};

// Act
(HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<Document>(route, requestBody, contentType, setRequestHeaders);

// Assert
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);

httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.RelaxedAtomicOperationsMediaType);
}

[Fact]
Expand Down Expand Up @@ -236,10 +295,13 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint()

responseDocument.Errors.ShouldHaveCount(1);

const string detail =
$"Please include '{HeaderConstants.AtomicOperationsMediaType}' or '{HeaderConstants.RelaxedAtomicOperationsMediaType}' in the Accept header values.";

ErrorObject error = responseDocument.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable);
error.Title.Should().Be("The specified Accept header value does not contain any supported media types.");
error.Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values.");
error.Detail.Should().Be(detail);
error.Source.ShouldNotBeNull();
error.Source.Header.Should().Be("Accept");
}
Expand Down
Loading

0 comments on commit 32cc3b5

Please sign in to comment.