From 94d73326a5a635d89eef8c2cae1f93860ff1204f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 25 May 2024 20:34:28 +0200 Subject: [PATCH] Permit using a relaxed (simpler) variant of the atomic:operations media type in the Content-Type request/response HTTP header, because OpenAPI code generators often choke on the double quotes in the official media type (producing code that doesn't compile). By default, the Content-Type used for responses is the same as the incoming one, although it can be overruled by sending an Accept HTTP header. --- .../Middleware/HeaderConstants.cs | 1 + .../Middleware/JsonApiMiddleware.cs | 45 +++++--- .../Serialization/Response/JsonApiWriter.cs | 49 +++++++- .../Mixed/AtomicLoggingTests.cs | 4 +- .../Mixed/AtomicRequestBodyTests.cs | 4 + .../Mixed/AtomicTraceLoggingTests.cs | 2 +- .../ContentNegotiation/AcceptHeaderTests.cs | 68 ++++++++++- .../ContentTypeHeaderTests.cs | 109 +++++++++++++++++- .../NonJsonApiControllerTests.cs | 6 + .../ReadWrite/Creating/CreateResourceTests.cs | 4 + .../AddToToManyRelationshipTests.cs | 4 + .../RemoveFromToManyRelationshipTests.cs | 4 + .../ReplaceToManyRelationshipTests.cs | 4 + .../UpdateToOneRelationshipTests.cs | 4 + .../Updating/Resources/UpdateResourceTests.cs | 4 + 15 files changed, 288 insertions(+), 24 deletions(-) diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index d290ba80eb..2e223e7fea 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -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"; } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 7cd2573727..4d9896ce8a 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -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; @@ -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; } @@ -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; } @@ -126,16 +138,19 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso : null; } - private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerOptions serializerOptions) + private static async Task ValidateContentTypeHeaderAsync(ICollection 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" @@ -148,7 +163,7 @@ private static async Task ValidateContentTypeHeaderAsync(string allowedCon return true; } - private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext, + private static async Task ValidateAcceptHeaderAsync(ICollection allowedMediaTypes, HttpContext httpContext, JsonSerializerOptions serializerOptions) { string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept"); @@ -164,15 +179,15 @@ private static async Task 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; @@ -182,10 +197,12 @@ private static async Task 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" diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 22de5284a2..9a4e8869e7 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -18,6 +18,15 @@ namespace JsonApiDotNetCore.Serialization.Response; /// 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; @@ -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) @@ -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"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs index 58824a8bf9..412a3f388c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -36,7 +36,7 @@ public AtomicLoggingTests(IntegrationTestContext(); @@ -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(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index ee9d144123..c4416bbac1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -1,5 +1,6 @@ using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; @@ -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]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs index 14e1e4fd61..f3c47ed4b5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -33,7 +33,7 @@ public AtomicTraceLoggingTests(IntegrationTestContext(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index f06df45bb4..27e89d98f8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -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] @@ -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] @@ -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 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(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 @@ -158,7 +213,8 @@ 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 @@ -166,6 +222,9 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.RelaxedAtomicOperationsMediaType); } [Fact] @@ -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"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 793dee05c8..4cabba7843 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -30,6 +30,7 @@ public async Task Returns_JsonApi_ContentType_header() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); } @@ -64,10 +65,47 @@ public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_exten // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); } + [Fact] + public async Task Returns_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension() + { + // 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; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.RelaxedAtomicOperationsMediaType); + } + [Fact] public async Task Denies_unknown_ContentType_header() { @@ -163,6 +201,39 @@ public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_exten httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); } + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension_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; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + } + [Fact] public async Task Denies_JsonApi_ContentType_header_with_profile() { @@ -268,6 +339,41 @@ public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extens error.Source.Header.Should().Be("Content-Type"); } + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension_at_resource_endpoint() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + const string contentType = HeaderConstants.RelaxedAtomicOperationsMediaType; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } + [Fact] public async Task Denies_JsonApi_ContentType_header_with_CharSet() { @@ -373,7 +479,8 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() responseDocument.Errors.ShouldHaveCount(1); - const string detail = $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; + const string detail = + $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' or '{HeaderConstants.RelaxedAtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs index 3c4a6a6bae..bd5029736b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -30,6 +30,7 @@ public async Task Get_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); @@ -58,6 +59,7 @@ public async Task Post_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); @@ -78,6 +80,7 @@ public async Task Post_skips_error_handler() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); @@ -106,6 +109,7 @@ public async Task Put_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); @@ -126,6 +130,7 @@ public async Task Patch_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); @@ -146,6 +151,7 @@ public async Task Delete_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 5f1f894a9b..917f10be85 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; @@ -476,6 +477,9 @@ public async Task Cannot_create_resource_for_missing_request_body() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs index 879a86caa8..acf46913a0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -1,5 +1,6 @@ using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; @@ -196,6 +197,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 4f3e8d3ab3..8df5e10a60 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -308,6 +309,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 431bf89396..48c25db786 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -1,5 +1,6 @@ using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; @@ -225,6 +226,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index b96fcd6fff..b85e268163 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -1,5 +1,6 @@ using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; @@ -261,6 +262,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 6087f09fff..9d328bd9e4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -2,6 +2,7 @@ using System.Reflection; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; @@ -663,6 +664,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0];