Skip to content

Commit 5c3401f

Browse files
committed
Merge branch 'master' into merge-master-into-openapi
2 parents 19ff19f + 32cc3b5 commit 5c3401f

File tree

15 files changed

+289
-25
lines changed

15 files changed

+289
-25
lines changed

src/JsonApiDotNetCore/Middleware/HeaderConstants.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ namespace JsonApiDotNetCore.Middleware;
88
public static class HeaderConstants
99
{
1010
public const string MediaType = "application/vnd.api+json";
11-
public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\"";
11+
public const string AtomicOperationsMediaType = $"{MediaType}; ext=\"https://jsonapi.org/ext/atomic\"";
12+
public const string RelaxedAtomicOperationsMediaType = $"{MediaType}; ext=atomic-operations";
1213
}

src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs

+31-14
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,20 @@ namespace JsonApiDotNetCore.Middleware;
2020
[PublicAPI]
2121
public sealed class JsonApiMiddleware
2222
{
23-
private static readonly MediaTypeHeaderValue MediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType);
24-
private static readonly MediaTypeHeaderValue AtomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType);
23+
private static readonly string[] NonOperationsContentTypes = [HeaderConstants.MediaType];
24+
private static readonly MediaTypeHeaderValue[] NonOperationsMediaTypes = [MediaTypeHeaderValue.Parse(HeaderConstants.MediaType)];
25+
26+
private static readonly string[] OperationsContentTypes =
27+
[
28+
HeaderConstants.AtomicOperationsMediaType,
29+
HeaderConstants.RelaxedAtomicOperationsMediaType
30+
];
31+
32+
private static readonly MediaTypeHeaderValue[] OperationsMediaTypes =
33+
[
34+
MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType),
35+
MediaTypeHeaderValue.Parse(HeaderConstants.RelaxedAtomicOperationsMediaType)
36+
];
2537

2638
private readonly RequestDelegate? _next;
2739

@@ -56,8 +68,8 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
5668

5769
if (primaryResourceType != null)
5870
{
59-
if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerWriteOptions) ||
60-
!await ValidateAcceptHeaderAsync(MediaType, httpContext, options.SerializerWriteOptions))
71+
if (!await ValidateContentTypeHeaderAsync(NonOperationsContentTypes, httpContext, options.SerializerWriteOptions) ||
72+
!await ValidateAcceptHeaderAsync(NonOperationsMediaTypes, httpContext, options.SerializerWriteOptions))
6173
{
6274
return;
6375
}
@@ -68,8 +80,8 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
6880
}
6981
else if (IsRouteForOperations(routeValues))
7082
{
71-
if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions) ||
72-
!await ValidateAcceptHeaderAsync(AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions))
83+
if (!await ValidateContentTypeHeaderAsync(OperationsContentTypes, httpContext, options.SerializerWriteOptions) ||
84+
!await ValidateAcceptHeaderAsync(OperationsMediaTypes, httpContext, options.SerializerWriteOptions))
7385
{
7486
return;
7587
}
@@ -126,16 +138,19 @@ private async Task<bool> ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso
126138
: null;
127139
}
128140

129-
private static async Task<bool> ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerOptions serializerOptions)
141+
private static async Task<bool> ValidateContentTypeHeaderAsync(ICollection<string> allowedContentTypes, HttpContext httpContext,
142+
JsonSerializerOptions serializerOptions)
130143
{
131144
string? contentType = httpContext.Request.ContentType;
132145

133-
if (contentType != null && contentType != allowedContentType)
146+
if (contentType != null && !allowedContentTypes.Contains(contentType, StringComparer.OrdinalIgnoreCase))
134147
{
148+
string allowedValues = string.Join(" or ", allowedContentTypes.Select(value => $"'{value}'"));
149+
135150
await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.UnsupportedMediaType)
136151
{
137152
Title = "The specified Content-Type header value is not supported.",
138-
Detail = $"Please specify '{allowedContentType}' instead of '{contentType}' for the Content-Type header value.",
153+
Detail = $"Please specify {allowedValues} instead of '{contentType}' for the Content-Type header value.",
139154
Source = new ErrorSource
140155
{
141156
Header = "Content-Type"
@@ -148,7 +163,7 @@ private static async Task<bool> ValidateContentTypeHeaderAsync(string allowedCon
148163
return true;
149164
}
150165

151-
private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext,
166+
private static async Task<bool> ValidateAcceptHeaderAsync(ICollection<MediaTypeHeaderValue> allowedMediaTypes, HttpContext httpContext,
152167
JsonSerializerOptions serializerOptions)
153168
{
154169
string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept");
@@ -164,15 +179,15 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a
164179
{
165180
if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue))
166181
{
167-
headerValue.Quality = null;
168-
169182
if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*")
170183
{
171184
seenCompatibleMediaType = true;
172185
break;
173186
}
174187

175-
if (allowedMediaTypeValue.Equals(headerValue))
188+
headerValue.Quality = null;
189+
190+
if (allowedMediaTypes.Contains(headerValue))
176191
{
177192
seenCompatibleMediaType = true;
178193
break;
@@ -182,10 +197,12 @@ private static async Task<bool> ValidateAcceptHeaderAsync(MediaTypeHeaderValue a
182197

183198
if (!seenCompatibleMediaType)
184199
{
200+
string allowedValues = string.Join(" or ", allowedMediaTypes.Select(value => $"'{value}'"));
201+
185202
await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.NotAcceptable)
186203
{
187204
Title = "The specified Accept header value does not contain any supported media types.",
188-
Detail = $"Please include '{allowedMediaTypeValue}' in the Accept header values.",
205+
Detail = $"Please include {allowedValues} in the Accept header values.",
189206
Source = new ErrorSource
190207
{
191208
Header = "Accept"

src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs

+46-3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ namespace JsonApiDotNetCore.Serialization.Response;
1818
/// <inheritdoc cref="IJsonApiWriter" />
1919
public sealed class JsonApiWriter : IJsonApiWriter
2020
{
21+
private static readonly MediaTypeHeaderValue OperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType);
22+
private static readonly MediaTypeHeaderValue RelaxedOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.RelaxedAtomicOperationsMediaType);
23+
24+
private static readonly MediaTypeHeaderValue[] AllowedOperationsMediaTypes =
25+
[
26+
OperationsMediaType,
27+
RelaxedOperationsMediaType
28+
];
29+
2130
private readonly IJsonApiRequest _request;
2231
private readonly IJsonApiOptions _options;
2332
private readonly IResponseModelAdapter _responseModelAdapter;
@@ -70,7 +79,8 @@ public async Task WriteAsync(object? model, HttpContext httpContext)
7079
return $"Sending {httpContext.Response.StatusCode} response for {method} request at '{url}' with body: <<{responseBody}>>";
7180
});
7281

73-
await SendResponseBodyAsync(httpContext.Response, responseBody);
82+
string responseContentType = GetResponseContentType(httpContext.Request);
83+
await SendResponseBodyAsync(httpContext.Response, responseBody, responseContentType);
7484
}
7585

7686
private static bool CanWriteBody(HttpStatusCode statusCode)
@@ -167,11 +177,44 @@ private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders
167177
return false;
168178
}
169179

170-
private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody)
180+
private string GetResponseContentType(HttpRequest httpRequest)
181+
{
182+
if (_request.Kind != EndpointKind.AtomicOperations)
183+
{
184+
return HeaderConstants.MediaType;
185+
}
186+
187+
MediaTypeHeaderValue? bestMatch = null;
188+
189+
foreach (MediaTypeHeaderValue headerValue in httpRequest.GetTypedHeaders().Accept)
190+
{
191+
double quality = headerValue.Quality ?? 1.0;
192+
headerValue.Quality = null;
193+
194+
if (AllowedOperationsMediaTypes.Contains(headerValue))
195+
{
196+
if (bestMatch == null || bestMatch.Quality < quality)
197+
{
198+
headerValue.Quality = quality;
199+
bestMatch = headerValue;
200+
}
201+
}
202+
}
203+
204+
if (bestMatch == null)
205+
{
206+
return httpRequest.ContentType ?? HeaderConstants.AtomicOperationsMediaType;
207+
}
208+
209+
bestMatch.Quality = null;
210+
return RelaxedOperationsMediaType.Equals(bestMatch) ? HeaderConstants.RelaxedAtomicOperationsMediaType : HeaderConstants.AtomicOperationsMediaType;
211+
}
212+
213+
private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody, string contentType)
171214
{
172215
if (!string.IsNullOrEmpty(responseBody))
173216
{
174-
httpResponse.ContentType = _request.Kind == EndpointKind.AtomicOperations ? HeaderConstants.AtomicOperationsMediaType : HeaderConstants.MediaType;
217+
httpResponse.ContentType = contentType;
175218

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

test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public AtomicLoggingTests(IntegrationTestContext<TestableStartup<OperationsDbCon
3636
}
3737

3838
[Fact]
39-
public async Task Logs_at_error_level_on_unhandled_exception()
39+
public async Task Logs_unhandled_exception_at_Error_level()
4040
{
4141
// Arrange
4242
var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>();
@@ -88,7 +88,7 @@ public async Task Logs_at_error_level_on_unhandled_exception()
8888
}
8989

9090
[Fact]
91-
public async Task Logs_at_info_level_on_invalid_request_body()
91+
public async Task Logs_invalid_request_body_error_at_Information_level()
9292
{
9393
// Arrange
9494
var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>();

test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Net;
22
using FluentAssertions;
3+
using JsonApiDotNetCore.Middleware;
34
using JsonApiDotNetCore.Serialization.Objects;
45
using TestBuildingBlocks;
56
using Xunit;
@@ -29,6 +30,9 @@ public async Task Cannot_process_for_missing_request_body()
2930
// Assert
3031
httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest);
3132

33+
httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
34+
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType);
35+
3236
responseDocument.Errors.ShouldHaveCount(1);
3337

3438
ErrorObject error = responseDocument.Errors[0];

test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public AtomicTraceLoggingTests(IntegrationTestContext<TestableStartup<Operations
3333
}
3434

3535
[Fact]
36-
public async Task Logs_execution_flow_at_trace_level_on_operations_request()
36+
public async Task Logs_execution_flow_at_Trace_level_on_operations_request()
3737
{
3838
// Arrange
3939
var loggerFactory = _testContext.Factory.Services.GetRequiredService<FakeLoggerFactory>();

test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs

+65-3
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ public async Task Permits_global_wildcard_in_Accept_headers()
8383

8484
// Assert
8585
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
86+
87+
httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
88+
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
8689
}
8790

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

103106
// Assert
104107
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
108+
109+
httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
110+
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
105111
}
106112

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

125131
// Assert
126132
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
133+
134+
httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
135+
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType);
127136
}
128137

129138
[Fact]
130-
public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint()
139+
public async Task Prefers_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint()
140+
{
141+
// Arrange
142+
var requestBody = new
143+
{
144+
atomic__operations = new[]
145+
{
146+
new
147+
{
148+
op = "add",
149+
data = new
150+
{
151+
type = "policies",
152+
attributes = new
153+
{
154+
name = "some"
155+
}
156+
}
157+
}
158+
}
159+
};
160+
161+
const string route = "/operations";
162+
const string contentType = HeaderConstants.RelaxedAtomicOperationsMediaType;
163+
164+
Action<HttpRequestHeaders> setRequestHeaders = headers =>
165+
{
166+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html"));
167+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some"));
168+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType));
169+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected"));
170+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=atomic-operations; q=0.2"));
171+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.8"));
172+
};
173+
174+
// Act
175+
(HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync<Document>(route, requestBody, contentType, setRequestHeaders);
176+
177+
// Assert
178+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
179+
180+
httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
181+
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType);
182+
}
183+
184+
[Fact]
185+
public async Task Prefers_JsonApi_with_relaxed_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint()
131186
{
132187
// Arrange
133188
var requestBody = new
@@ -158,14 +213,18 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head
158213
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some"));
159214
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType));
160215
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected"));
161-
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};ext=\"https://jsonapi.org/ext/atomic\"; q=0.2"));
216+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.2"));
217+
headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=atomic-operations; q=0.8"));
162218
};
163219

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

167223
// Assert
168224
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
225+
226+
httpResponse.Content.Headers.ContentType.ShouldNotBeNull();
227+
httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.RelaxedAtomicOperationsMediaType);
169228
}
170229

171230
[Fact]
@@ -236,10 +295,13 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint()
236295

237296
responseDocument.Errors.ShouldHaveCount(1);
238297

298+
const string detail =
299+
$"Please include '{HeaderConstants.AtomicOperationsMediaType}' or '{HeaderConstants.RelaxedAtomicOperationsMediaType}' in the Accept header values.";
300+
239301
ErrorObject error = responseDocument.Errors[0];
240302
error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable);
241303
error.Title.Should().Be("The specified Accept header value does not contain any supported media types.");
242-
error.Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values.");
304+
error.Detail.Should().Be(detail);
243305
error.Source.ShouldNotBeNull();
244306
error.Source.Header.Should().Be("Accept");
245307
}

0 commit comments

Comments
 (0)