Skip to content

Commit 8ff2900

Browse files
authored
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
1 parent eb2ef95 commit 8ff2900

36 files changed

+1582
-316
lines changed

src/JsonApiDotNetCore/CollectionExtensions.cs

+15
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ public static bool IsNullOrEmpty<T>([NotNullWhen(false)] this IEnumerable<T>? so
1616
return !source.Any();
1717
}
1818

19+
public static int FindIndex<T>(this IReadOnlyList<T> source, T item)
20+
{
21+
ArgumentGuard.NotNull(source);
22+
23+
for (int index = 0; index < source.Count; index++)
24+
{
25+
if (EqualityComparer<T>.Default.Equals(source[index], item))
26+
{
27+
return index;
28+
}
29+
}
30+
31+
return -1;
32+
}
33+
1934
public static int FindIndex<T>(this IReadOnlyList<T> source, Predicate<T> match)
2035
{
2136
ArgumentGuard.NotNull(source);

src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs

+14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using System.Data;
22
using System.Text.Json;
33
using JetBrains.Annotations;
4+
using JsonApiDotNetCore.Controllers;
5+
using JsonApiDotNetCore.Middleware;
46
using JsonApiDotNetCore.Resources.Annotations;
57
using JsonApiDotNetCore.Serialization.Objects;
68

@@ -172,6 +174,18 @@ public interface IJsonApiOptions
172174
/// </summary>
173175
IsolationLevel? TransactionIsolationLevel { get; }
174176

177+
/// <summary>
178+
/// Lists the JSON:API extensions that are turned on. Empty by default, but if your project contains a controller that derives from
179+
/// <see cref="BaseJsonApiOperationsController" />, the <see cref="JsonApiExtension.AtomicOperations" /> and
180+
/// <see cref="JsonApiExtension.RelaxedAtomicOperations" /> extensions are automatically added.
181+
/// </summary>
182+
/// <remarks>
183+
/// To implement a custom JSON:API extension, add it here and override <see cref="JsonApiContentNegotiator.GetPossibleMediaTypes" /> to indicate which
184+
/// combinations of extensions are available, depending on the current endpoint. Use <see cref="IJsonApiRequest.Extensions" /> to obtain the active
185+
/// extensions when implementing extension-specific logic.
186+
/// </remarks>
187+
IReadOnlySet<JsonApiExtension> Extensions { get; }
188+
175189
/// <summary>
176190
/// Enables to customize the settings that are used by the <see cref="JsonSerializer" />.
177191
/// </summary>

src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ private void AddMiddlewareLayer()
184184
_services.TryAddSingleton<IJsonApiRoutingConvention, JsonApiRoutingConvention>();
185185
_services.TryAddSingleton<IControllerResourceMapping>(provider => provider.GetRequiredService<IJsonApiRoutingConvention>());
186186
_services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
187+
_services.TryAddSingleton<IJsonApiContentNegotiator, JsonApiContentNegotiator>();
187188
_services.TryAddScoped<IJsonApiRequest, JsonApiRequest>();
188189
_services.TryAddScoped<IJsonApiWriter, JsonApiWriter>();
189190
_services.TryAddScoped<IJsonApiReader, JsonApiReader>();

src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs

+28
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Text.Encodings.Web;
33
using System.Text.Json;
44
using JetBrains.Annotations;
5+
using JsonApiDotNetCore.Middleware;
56
using JsonApiDotNetCore.Resources.Annotations;
67
using JsonApiDotNetCore.Serialization.JsonConverters;
78

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

@@ -97,6 +99,9 @@ public bool AllowClientGeneratedIds
9799
/// <inheritdoc />
98100
public IsolationLevel? TransactionIsolationLevel { get; set; }
99101

102+
/// <inheritdoc />
103+
public IReadOnlySet<JsonApiExtension> Extensions { get; set; } = EmptyExtensionSet;
104+
100105
/// <inheritdoc />
101106
public JsonSerializerOptions SerializerOptions { get; } = new()
102107
{
@@ -130,4 +135,27 @@ public JsonApiOptions()
130135
}
131136
}, LazyThreadSafetyMode.ExecutionAndPublication);
132137
}
138+
139+
/// <summary>
140+
/// Adds the specified JSON:API extensions to the existing <see cref="Extensions" /> set.
141+
/// </summary>
142+
/// <param name="extensionsToAdd">
143+
/// The JSON:API extensions to add.
144+
/// </param>
145+
public void IncludeExtensions(params JsonApiExtension[] extensionsToAdd)
146+
{
147+
ArgumentGuard.NotNull(extensionsToAdd);
148+
149+
if (!Extensions.IsSupersetOf(extensionsToAdd))
150+
{
151+
var extensions = new HashSet<JsonApiExtension>(Extensions);
152+
153+
foreach (JsonApiExtension extension in extensionsToAdd)
154+
{
155+
extensions.Add(extension);
156+
}
157+
158+
Extensions = extensions.AsReadOnly();
159+
}
160+
}
133161
}

src/JsonApiDotNetCore/Middleware/HeaderConstants.cs

+5
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ namespace JsonApiDotNetCore.Middleware;
77
[PublicAPI]
88
public static class HeaderConstants
99
{
10+
[Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.Default)}.ToString() instead.")]
1011
public const string MediaType = "application/vnd.api+json";
12+
13+
[Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.AtomicOperations)}.ToString() instead.")]
1114
public const string AtomicOperationsMediaType = $"{MediaType}; ext=\"https://jsonapi.org/ext/atomic\"";
15+
16+
[Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.RelaxedAtomicOperations)}.ToString() instead.")]
1217
public const string RelaxedAtomicOperationsMediaType = $"{MediaType}; ext=atomic-operations";
1318
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JsonApiDotNetCore.Configuration;
2+
using JsonApiDotNetCore.Errors;
3+
4+
namespace JsonApiDotNetCore.Middleware;
5+
6+
/// <summary>
7+
/// Performs content negotiation for JSON:API requests.
8+
/// </summary>
9+
public interface IJsonApiContentNegotiator
10+
{
11+
/// <summary>
12+
/// Validates the Content-Type and Accept HTTP headers from the incoming request. Throws a <see cref="JsonApiException" /> if unsupported. Otherwise,
13+
/// returns the list of negotiated JSON:API extensions, which should always be a subset of <see cref="IJsonApiOptions.Extensions" />.
14+
/// </summary>
15+
IReadOnlySet<JsonApiExtension> Negotiate();
16+
}

src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs

+5
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ public interface IJsonApiRequest
6060
/// </summary>
6161
string? TransactionId { get; }
6262

63+
/// <summary>
64+
/// The JSON:API extensions enabled for the current request. This is always a subset of <see cref="IJsonApiOptions.Extensions" />.
65+
/// </summary>
66+
IReadOnlySet<JsonApiExtension> Extensions { get; }
67+
6368
/// <summary>
6469
/// Performs a shallow copy.
6570
/// </summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
using System.Net;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Errors;
4+
using JsonApiDotNetCore.Serialization.Objects;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Routing;
7+
8+
namespace JsonApiDotNetCore.Middleware;
9+
10+
/// <inheritdoc />
11+
public class JsonApiContentNegotiator : IJsonApiContentNegotiator
12+
{
13+
private readonly IJsonApiOptions _options;
14+
private readonly IHttpContextAccessor _httpContextAccessor;
15+
16+
private HttpContext HttpContext
17+
{
18+
get
19+
{
20+
if (_httpContextAccessor.HttpContext == null)
21+
{
22+
throw new InvalidOperationException("An active HTTP request is required.");
23+
}
24+
25+
return _httpContextAccessor.HttpContext;
26+
}
27+
}
28+
29+
public JsonApiContentNegotiator(IJsonApiOptions options, IHttpContextAccessor httpContextAccessor)
30+
{
31+
ArgumentGuard.NotNull(options);
32+
ArgumentGuard.NotNull(httpContextAccessor);
33+
34+
_options = options;
35+
_httpContextAccessor = httpContextAccessor;
36+
}
37+
38+
/// <inheritdoc />
39+
public IReadOnlySet<JsonApiExtension> Negotiate()
40+
{
41+
IReadOnlyList<JsonApiMediaType> possibleMediaTypes = GetPossibleMediaTypes();
42+
43+
JsonApiMediaType? requestMediaType = ValidateContentType(possibleMediaTypes);
44+
return ValidateAcceptHeader(possibleMediaTypes, requestMediaType);
45+
}
46+
47+
private JsonApiMediaType? ValidateContentType(IReadOnlyList<JsonApiMediaType> possibleMediaTypes)
48+
{
49+
if (HttpContext.Request.ContentType == null)
50+
{
51+
if (HttpContext.Request.ContentLength > 0)
52+
{
53+
throw CreateContentTypeError(possibleMediaTypes);
54+
}
55+
56+
return null;
57+
}
58+
59+
JsonApiMediaType? mediaType = JsonApiMediaType.TryParseContentTypeHeaderValue(HttpContext.Request.ContentType);
60+
61+
if (mediaType == null || !possibleMediaTypes.Contains(mediaType))
62+
{
63+
throw CreateContentTypeError(possibleMediaTypes);
64+
}
65+
66+
return mediaType;
67+
}
68+
69+
private IReadOnlySet<JsonApiExtension> ValidateAcceptHeader(IReadOnlyList<JsonApiMediaType> possibleMediaTypes, JsonApiMediaType? requestMediaType)
70+
{
71+
string[] acceptHeaderValues = HttpContext.Request.Headers.GetCommaSeparatedValues("Accept");
72+
JsonApiMediaType? bestMatch = null;
73+
74+
if (acceptHeaderValues.Length == 0 && possibleMediaTypes.Contains(JsonApiMediaType.Default))
75+
{
76+
bestMatch = JsonApiMediaType.Default;
77+
}
78+
else
79+
{
80+
decimal bestQualityFactor = 0m;
81+
82+
foreach (string acceptHeaderValue in acceptHeaderValues)
83+
{
84+
(JsonApiMediaType MediaType, decimal QualityFactor)? result = JsonApiMediaType.TryParseAcceptHeaderValue(acceptHeaderValue);
85+
86+
if (result != null)
87+
{
88+
if (result.Value.MediaType.Equals(requestMediaType) && possibleMediaTypes.Contains(requestMediaType))
89+
{
90+
// Content-Type always wins over other candidates, because JsonApiDotNetCore doesn't support
91+
// different extension sets for the request and response body.
92+
bestMatch = requestMediaType;
93+
break;
94+
}
95+
96+
bool isBetterMatch = false;
97+
int? currentIndex = null;
98+
99+
if (bestMatch == null)
100+
{
101+
isBetterMatch = true;
102+
}
103+
else if (result.Value.QualityFactor > bestQualityFactor)
104+
{
105+
isBetterMatch = true;
106+
}
107+
else if (result.Value.QualityFactor == bestQualityFactor)
108+
{
109+
if (result.Value.MediaType.Extensions.Count > bestMatch.Extensions.Count)
110+
{
111+
isBetterMatch = true;
112+
}
113+
else if (result.Value.MediaType.Extensions.Count == bestMatch.Extensions.Count)
114+
{
115+
int bestIndex = possibleMediaTypes.FindIndex(bestMatch);
116+
currentIndex = possibleMediaTypes.FindIndex(result.Value.MediaType);
117+
118+
if (currentIndex != -1 && currentIndex < bestIndex)
119+
{
120+
isBetterMatch = true;
121+
}
122+
}
123+
}
124+
125+
if (isBetterMatch)
126+
{
127+
bool existsInPossibleMediaTypes = currentIndex >= 0 || possibleMediaTypes.Contains(result.Value.MediaType);
128+
129+
if (existsInPossibleMediaTypes)
130+
{
131+
bestMatch = result.Value.MediaType;
132+
bestQualityFactor = result.Value.QualityFactor;
133+
}
134+
}
135+
}
136+
}
137+
}
138+
139+
if (bestMatch == null)
140+
{
141+
throw CreateAcceptHeaderError(possibleMediaTypes);
142+
}
143+
144+
if (requestMediaType != null && !bestMatch.Equals(requestMediaType))
145+
{
146+
throw CreateAcceptHeaderError(possibleMediaTypes);
147+
}
148+
149+
return bestMatch.Extensions;
150+
}
151+
152+
/// <summary>
153+
/// 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
154+
/// must always be the same as in the response body.
155+
/// </summary>
156+
/// <remarks>
157+
/// Override this method to add support for custom JSON:API extensions. Implementations should take <see cref="IJsonApiOptions.Extensions" /> into
158+
/// account. During content negotiation, the first compatible entry with the highest number of extensions is preferred, but beware that clients can
159+
/// overrule this using quality factors in an Accept header.
160+
/// </remarks>
161+
protected virtual IReadOnlyList<JsonApiMediaType> GetPossibleMediaTypes()
162+
{
163+
List<JsonApiMediaType> mediaTypes = [];
164+
165+
// Relaxed entries come after JSON:API compliant entries, which makes them less likely to be selected.
166+
167+
if (IsOperationsEndpoint())
168+
{
169+
if (_options.Extensions.Contains(JsonApiExtension.AtomicOperations))
170+
{
171+
mediaTypes.Add(JsonApiMediaType.AtomicOperations);
172+
}
173+
174+
if (_options.Extensions.Contains(JsonApiExtension.RelaxedAtomicOperations))
175+
{
176+
mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations);
177+
}
178+
}
179+
else
180+
{
181+
mediaTypes.Add(JsonApiMediaType.Default);
182+
}
183+
184+
return mediaTypes.AsReadOnly();
185+
}
186+
187+
protected bool IsOperationsEndpoint()
188+
{
189+
RouteValueDictionary routeValues = HttpContext.GetRouteData().Values;
190+
return JsonApiMiddleware.IsRouteForOperations(routeValues);
191+
}
192+
193+
private JsonApiException CreateContentTypeError(IReadOnlyList<JsonApiMediaType> possibleMediaTypes)
194+
{
195+
string allowedValues = string.Join(" or ", possibleMediaTypes.Select(mediaType => $"'{mediaType}'"));
196+
197+
return new JsonApiException(new ErrorObject(HttpStatusCode.UnsupportedMediaType)
198+
{
199+
Title = "The specified Content-Type header value is not supported.",
200+
Detail = $"Use {allowedValues} instead of '{HttpContext.Request.ContentType}' for the Content-Type header value.",
201+
Source = new ErrorSource
202+
{
203+
Header = "Content-Type"
204+
}
205+
});
206+
}
207+
208+
private static JsonApiException CreateAcceptHeaderError(IReadOnlyList<JsonApiMediaType> possibleMediaTypes)
209+
{
210+
string allowedValues = string.Join(" or ", possibleMediaTypes.Select(mediaType => $"'{mediaType}'"));
211+
212+
return new JsonApiException(new ErrorObject(HttpStatusCode.NotAcceptable)
213+
{
214+
Title = "The specified Accept header value does not contain any supported media types.",
215+
Detail = $"Include {allowedValues} in the Accept header values.",
216+
Source = new ErrorSource
217+
{
218+
Header = "Accept"
219+
}
220+
});
221+
}
222+
}

0 commit comments

Comments
 (0)