-
-
Notifications
You must be signed in to change notification settings - Fork 159
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add basic support for third-party JSON:API extensions: configuration,…
… content negotation and exposure of the active extensions (#1623) Bugfix: always require Accept header in atomic:operations requests
- Loading branch information
Showing
36 changed files
with
1,582 additions
and
316 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
16 changes: 16 additions & 0 deletions
16
src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
using JsonApiDotNetCore.Configuration; | ||
using JsonApiDotNetCore.Errors; | ||
|
||
namespace JsonApiDotNetCore.Middleware; | ||
|
||
/// <summary> | ||
/// Performs content negotiation for JSON:API requests. | ||
/// </summary> | ||
public interface IJsonApiContentNegotiator | ||
{ | ||
/// <summary> | ||
/// Validates the Content-Type and Accept HTTP headers from the incoming request. Throws a <see cref="JsonApiException" /> if unsupported. Otherwise, | ||
/// returns the list of negotiated JSON:API extensions, which should always be a subset of <see cref="IJsonApiOptions.Extensions" />. | ||
/// </summary> | ||
IReadOnlySet<JsonApiExtension> Negotiate(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
222 changes: 222 additions & 0 deletions
222
src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,222 @@ | ||
using System.Net; | ||
using JsonApiDotNetCore.Configuration; | ||
using JsonApiDotNetCore.Errors; | ||
using JsonApiDotNetCore.Serialization.Objects; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.AspNetCore.Routing; | ||
|
||
namespace JsonApiDotNetCore.Middleware; | ||
|
||
/// <inheritdoc /> | ||
public class JsonApiContentNegotiator : IJsonApiContentNegotiator | ||
{ | ||
private readonly IJsonApiOptions _options; | ||
private readonly IHttpContextAccessor _httpContextAccessor; | ||
|
||
private HttpContext HttpContext | ||
{ | ||
get | ||
{ | ||
if (_httpContextAccessor.HttpContext == null) | ||
{ | ||
throw new InvalidOperationException("An active HTTP request is required."); | ||
} | ||
|
||
return _httpContextAccessor.HttpContext; | ||
} | ||
} | ||
|
||
public JsonApiContentNegotiator(IJsonApiOptions options, IHttpContextAccessor httpContextAccessor) | ||
{ | ||
ArgumentGuard.NotNull(options); | ||
ArgumentGuard.NotNull(httpContextAccessor); | ||
|
||
_options = options; | ||
_httpContextAccessor = httpContextAccessor; | ||
} | ||
|
||
/// <inheritdoc /> | ||
public IReadOnlySet<JsonApiExtension> Negotiate() | ||
{ | ||
IReadOnlyList<JsonApiMediaType> possibleMediaTypes = GetPossibleMediaTypes(); | ||
|
||
JsonApiMediaType? requestMediaType = ValidateContentType(possibleMediaTypes); | ||
return ValidateAcceptHeader(possibleMediaTypes, requestMediaType); | ||
} | ||
|
||
private JsonApiMediaType? ValidateContentType(IReadOnlyList<JsonApiMediaType> possibleMediaTypes) | ||
{ | ||
if (HttpContext.Request.ContentType == null) | ||
{ | ||
if (HttpContext.Request.ContentLength > 0) | ||
{ | ||
throw CreateContentTypeError(possibleMediaTypes); | ||
} | ||
|
||
return null; | ||
} | ||
|
||
JsonApiMediaType? mediaType = JsonApiMediaType.TryParseContentTypeHeaderValue(HttpContext.Request.ContentType); | ||
|
||
if (mediaType == null || !possibleMediaTypes.Contains(mediaType)) | ||
{ | ||
throw CreateContentTypeError(possibleMediaTypes); | ||
} | ||
|
||
return mediaType; | ||
} | ||
|
||
private IReadOnlySet<JsonApiExtension> ValidateAcceptHeader(IReadOnlyList<JsonApiMediaType> possibleMediaTypes, JsonApiMediaType? requestMediaType) | ||
{ | ||
string[] acceptHeaderValues = HttpContext.Request.Headers.GetCommaSeparatedValues("Accept"); | ||
JsonApiMediaType? bestMatch = null; | ||
|
||
if (acceptHeaderValues.Length == 0 && possibleMediaTypes.Contains(JsonApiMediaType.Default)) | ||
{ | ||
bestMatch = JsonApiMediaType.Default; | ||
} | ||
else | ||
{ | ||
decimal bestQualityFactor = 0m; | ||
|
||
foreach (string acceptHeaderValue in acceptHeaderValues) | ||
{ | ||
(JsonApiMediaType MediaType, decimal QualityFactor)? result = JsonApiMediaType.TryParseAcceptHeaderValue(acceptHeaderValue); | ||
|
||
if (result != null) | ||
{ | ||
if (result.Value.MediaType.Equals(requestMediaType) && possibleMediaTypes.Contains(requestMediaType)) | ||
{ | ||
// Content-Type always wins over other candidates, because JsonApiDotNetCore doesn't support | ||
// different extension sets for the request and response body. | ||
bestMatch = requestMediaType; | ||
break; | ||
} | ||
|
||
bool isBetterMatch = false; | ||
int? currentIndex = null; | ||
|
||
if (bestMatch == null) | ||
{ | ||
isBetterMatch = true; | ||
} | ||
else if (result.Value.QualityFactor > bestQualityFactor) | ||
{ | ||
isBetterMatch = true; | ||
} | ||
else if (result.Value.QualityFactor == bestQualityFactor) | ||
{ | ||
if (result.Value.MediaType.Extensions.Count > bestMatch.Extensions.Count) | ||
{ | ||
isBetterMatch = true; | ||
} | ||
else if (result.Value.MediaType.Extensions.Count == bestMatch.Extensions.Count) | ||
{ | ||
int bestIndex = possibleMediaTypes.FindIndex(bestMatch); | ||
currentIndex = possibleMediaTypes.FindIndex(result.Value.MediaType); | ||
|
||
if (currentIndex != -1 && currentIndex < bestIndex) | ||
{ | ||
isBetterMatch = true; | ||
} | ||
} | ||
} | ||
|
||
if (isBetterMatch) | ||
{ | ||
bool existsInPossibleMediaTypes = currentIndex >= 0 || possibleMediaTypes.Contains(result.Value.MediaType); | ||
|
||
if (existsInPossibleMediaTypes) | ||
{ | ||
bestMatch = result.Value.MediaType; | ||
bestQualityFactor = result.Value.QualityFactor; | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
if (bestMatch == null) | ||
{ | ||
throw CreateAcceptHeaderError(possibleMediaTypes); | ||
} | ||
|
||
if (requestMediaType != null && !bestMatch.Equals(requestMediaType)) | ||
{ | ||
throw CreateAcceptHeaderError(possibleMediaTypes); | ||
} | ||
|
||
return bestMatch.Extensions; | ||
} | ||
|
||
/// <summary> | ||
/// Gets the list of possible combinations of JSON:API extensions that are available at the current endpoint. The set of extensions in the request body | ||
/// must always be the same as in the response body. | ||
/// </summary> | ||
/// <remarks> | ||
/// Override this method to add support for custom JSON:API extensions. Implementations should take <see cref="IJsonApiOptions.Extensions" /> into | ||
/// account. During content negotiation, the first compatible entry with the highest number of extensions is preferred, but beware that clients can | ||
/// overrule this using quality factors in an Accept header. | ||
/// </remarks> | ||
protected virtual IReadOnlyList<JsonApiMediaType> GetPossibleMediaTypes() | ||
{ | ||
List<JsonApiMediaType> mediaTypes = []; | ||
|
||
// Relaxed entries come after JSON:API compliant entries, which makes them less likely to be selected. | ||
|
||
if (IsOperationsEndpoint()) | ||
{ | ||
if (_options.Extensions.Contains(JsonApiExtension.AtomicOperations)) | ||
{ | ||
mediaTypes.Add(JsonApiMediaType.AtomicOperations); | ||
} | ||
|
||
if (_options.Extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) | ||
{ | ||
mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations); | ||
} | ||
} | ||
else | ||
{ | ||
mediaTypes.Add(JsonApiMediaType.Default); | ||
} | ||
|
||
return mediaTypes.AsReadOnly(); | ||
} | ||
|
||
protected bool IsOperationsEndpoint() | ||
{ | ||
RouteValueDictionary routeValues = HttpContext.GetRouteData().Values; | ||
return JsonApiMiddleware.IsRouteForOperations(routeValues); | ||
} | ||
|
||
private JsonApiException CreateContentTypeError(IReadOnlyList<JsonApiMediaType> possibleMediaTypes) | ||
{ | ||
string allowedValues = string.Join(" or ", possibleMediaTypes.Select(mediaType => $"'{mediaType}'")); | ||
|
||
return new JsonApiException(new ErrorObject(HttpStatusCode.UnsupportedMediaType) | ||
{ | ||
Title = "The specified Content-Type header value is not supported.", | ||
Detail = $"Use {allowedValues} instead of '{HttpContext.Request.ContentType}' for the Content-Type header value.", | ||
Source = new ErrorSource | ||
{ | ||
Header = "Content-Type" | ||
} | ||
}); | ||
} | ||
|
||
private static JsonApiException CreateAcceptHeaderError(IReadOnlyList<JsonApiMediaType> possibleMediaTypes) | ||
{ | ||
string allowedValues = string.Join(" or ", possibleMediaTypes.Select(mediaType => $"'{mediaType}'")); | ||
|
||
return new JsonApiException(new ErrorObject(HttpStatusCode.NotAcceptable) | ||
{ | ||
Title = "The specified Accept header value does not contain any supported media types.", | ||
Detail = $"Include {allowedValues} in the Accept header values.", | ||
Source = new ErrorSource | ||
{ | ||
Header = "Accept" | ||
} | ||
}); | ||
} | ||
} |
Oops, something went wrong.