Skip to content

Commit

Permalink
Add basic support for third-party JSON:API extensions: configuration,…
Browse files Browse the repository at this point in the history
… content negotation and exposure of the active extensions (#1623)

Bugfix: always require Accept header in atomic:operations requests
  • Loading branch information
bkoelman authored Oct 21, 2024
1 parent eb2ef95 commit 8ff2900
Show file tree
Hide file tree
Showing 36 changed files with 1,582 additions and 316 deletions.
15 changes: 15 additions & 0 deletions src/JsonApiDotNetCore/CollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ public static bool IsNullOrEmpty<T>([NotNullWhen(false)] this IEnumerable<T>? so
return !source.Any();
}

public static int FindIndex<T>(this IReadOnlyList<T> source, T item)
{
ArgumentGuard.NotNull(source);

for (int index = 0; index < source.Count; index++)
{
if (EqualityComparer<T>.Default.Equals(source[index], item))
{
return index;
}
}

return -1;
}

public static int FindIndex<T>(this IReadOnlyList<T> source, Predicate<T> match)
{
ArgumentGuard.NotNull(source);
Expand Down
14 changes: 14 additions & 0 deletions src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Data;
using System.Text.Json;
using JetBrains.Annotations;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;
using JsonApiDotNetCore.Serialization.Objects;

Expand Down Expand Up @@ -172,6 +174,18 @@ public interface IJsonApiOptions
/// </summary>
IsolationLevel? TransactionIsolationLevel { get; }

/// <summary>
/// Lists the JSON:API extensions that are turned on. Empty by default, but if your project contains a controller that derives from
/// <see cref="BaseJsonApiOperationsController" />, the <see cref="JsonApiExtension.AtomicOperations" /> and
/// <see cref="JsonApiExtension.RelaxedAtomicOperations" /> extensions are automatically added.
/// </summary>
/// <remarks>
/// To implement a custom JSON:API extension, add it here and override <see cref="JsonApiContentNegotiator.GetPossibleMediaTypes" /> to indicate which
/// combinations of extensions are available, depending on the current endpoint. Use <see cref="IJsonApiRequest.Extensions" /> to obtain the active
/// extensions when implementing extension-specific logic.
/// </remarks>
IReadOnlySet<JsonApiExtension> Extensions { get; }

/// <summary>
/// Enables to customize the settings that are used by the <see cref="JsonSerializer" />.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ private void AddMiddlewareLayer()
_services.TryAddSingleton<IJsonApiRoutingConvention, JsonApiRoutingConvention>();
_services.TryAddSingleton<IControllerResourceMapping>(provider => provider.GetRequiredService<IJsonApiRoutingConvention>());
_services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
_services.TryAddSingleton<IJsonApiContentNegotiator, JsonApiContentNegotiator>();
_services.TryAddScoped<IJsonApiRequest, JsonApiRequest>();
_services.TryAddScoped<IJsonApiWriter, JsonApiWriter>();
_services.TryAddScoped<IJsonApiReader, JsonApiReader>();
Expand Down
28 changes: 28 additions & 0 deletions src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using JetBrains.Annotations;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;
using JsonApiDotNetCore.Serialization.JsonConverters;

Expand All @@ -11,6 +12,7 @@ namespace JsonApiDotNetCore.Configuration;
[PublicAPI]
public sealed class JsonApiOptions : IJsonApiOptions
{
private static readonly IReadOnlySet<JsonApiExtension> EmptyExtensionSet = new HashSet<JsonApiExtension>().AsReadOnly();
private readonly Lazy<JsonSerializerOptions> _lazySerializerWriteOptions;
private readonly Lazy<JsonSerializerOptions> _lazySerializerReadOptions;

Expand Down Expand Up @@ -97,6 +99,9 @@ public bool AllowClientGeneratedIds
/// <inheritdoc />
public IsolationLevel? TransactionIsolationLevel { get; set; }

/// <inheritdoc />
public IReadOnlySet<JsonApiExtension> Extensions { get; set; } = EmptyExtensionSet;

/// <inheritdoc />
public JsonSerializerOptions SerializerOptions { get; } = new()
{
Expand Down Expand Up @@ -130,4 +135,27 @@ public JsonApiOptions()
}
}, LazyThreadSafetyMode.ExecutionAndPublication);
}

/// <summary>
/// Adds the specified JSON:API extensions to the existing <see cref="Extensions" /> set.
/// </summary>
/// <param name="extensionsToAdd">
/// The JSON:API extensions to add.
/// </param>
public void IncludeExtensions(params JsonApiExtension[] extensionsToAdd)
{
ArgumentGuard.NotNull(extensionsToAdd);

if (!Extensions.IsSupersetOf(extensionsToAdd))
{
var extensions = new HashSet<JsonApiExtension>(Extensions);

foreach (JsonApiExtension extension in extensionsToAdd)
{
extensions.Add(extension);
}

Extensions = extensions.AsReadOnly();
}
}
}
5 changes: 5 additions & 0 deletions src/JsonApiDotNetCore/Middleware/HeaderConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ namespace JsonApiDotNetCore.Middleware;
[PublicAPI]
public static class HeaderConstants
{
[Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.Default)}.ToString() instead.")]
public const string MediaType = "application/vnd.api+json";

[Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.AtomicOperations)}.ToString() instead.")]
public const string AtomicOperationsMediaType = $"{MediaType}; ext=\"https://jsonapi.org/ext/atomic\"";

[Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.RelaxedAtomicOperations)}.ToString() instead.")]
public const string RelaxedAtomicOperationsMediaType = $"{MediaType}; ext=atomic-operations";
}
16 changes: 16 additions & 0 deletions src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs
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();
}
5 changes: 5 additions & 0 deletions src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public interface IJsonApiRequest
/// </summary>
string? TransactionId { get; }

/// <summary>
/// The JSON:API extensions enabled for the current request. This is always a subset of <see cref="IJsonApiOptions.Extensions" />.
/// </summary>
IReadOnlySet<JsonApiExtension> Extensions { get; }

/// <summary>
/// Performs a shallow copy.
/// </summary>
Expand Down
222 changes: 222 additions & 0 deletions src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs
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"
}
});
}
}
Loading

0 comments on commit 8ff2900

Please sign in to comment.