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

Allow relaxed Content-Type for atomic operations #1553

Merged
merged 1 commit into from
May 25, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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
Loading