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

Basic support for third-party JSON:API extensions #1623

Merged
merged 1 commit into from
Oct 21, 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
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
Loading