From 8ff29000a494a980a65323fd4b4fb997e65117aa Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Mon, 21 Oct 2024 23:15:13 +0200 Subject: [PATCH] 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 --- src/JsonApiDotNetCore/CollectionExtensions.cs | 15 + .../Configuration/IJsonApiOptions.cs | 14 + .../JsonApiApplicationBuilder.cs | 1 + .../Configuration/JsonApiOptions.cs | 28 ++ .../Middleware/HeaderConstants.cs | 5 + .../Middleware/IJsonApiContentNegotiator.cs | 16 + .../Middleware/IJsonApiRequest.cs | 5 + .../Middleware/JsonApiContentNegotiator.cs | 222 ++++++++++++ .../Middleware/JsonApiExtension.cs | 52 +++ .../Middleware/JsonApiMediaType.cs | 188 ++++++++++ .../Middleware/JsonApiMiddleware.cs | 206 ++++------- .../Middleware/JsonApiRequest.cs | 6 + .../Middleware/JsonApiRoutingConvention.cs | 5 + .../Serialization/Response/JsonApiWriter.cs | 46 +-- .../Mixed/AtomicRequestBodyTests.cs | 2 +- .../Mixed/AtomicTraceLoggingTests.cs | 3 +- .../Scopes/ScopeOperationsTests.cs | 10 +- .../ContentNegotiation/AcceptHeaderTests.cs | 155 +++++---- .../ContentTypeHeaderTests.cs | 226 ++++++++++-- .../CapturingDocumentAdapter.cs | 26 ++ .../CustomExtensionsAcceptHeaderTests.cs | 156 +++++++++ .../CustomExtensionsContentTypeTests.cs | 327 ++++++++++++++++++ .../CustomExtensions/RequestDocumentStore.cs | 8 + .../ServerTimeContentNegotiator.cs | 57 +++ .../CustomExtensions/ServerTimeExtensions.cs | 11 + .../CustomExtensions/ServerTimeMediaTypes.cs | 21 ++ .../ServerTimeResponseMeta.cs | 33 ++ .../ReadWrite/Creating/CreateResourceTests.cs | 2 +- .../AddToToManyRelationshipTests.cs | 2 +- .../RemoveFromToManyRelationshipTests.cs | 2 +- .../ReplaceToManyRelationshipTests.cs | 2 +- .../UpdateToOneRelationshipTests.cs | 2 +- .../Updating/Resources/UpdateResourceTests.cs | 2 +- .../Serialization/ETagTests.cs | 3 +- .../Middleware/JsonApiMiddlewareTests.cs | 8 +- test/TestBuildingBlocks/IntegrationTest.cs | 31 +- 36 files changed, 1582 insertions(+), 316 deletions(-) create mode 100644 src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs create mode 100644 src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs create mode 100644 src/JsonApiDotNetCore/Middleware/JsonApiExtension.cs create mode 100644 src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CapturingDocumentAdapter.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/RequestDocumentStore.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeExtensions.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index 6e9c74aed4..05ed03f328 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -16,6 +16,21 @@ public static bool IsNullOrEmpty([NotNullWhen(false)] this IEnumerable? so return !source.Any(); } + public static int FindIndex(this IReadOnlyList source, T item) + { + ArgumentGuard.NotNull(source); + + for (int index = 0; index < source.Count; index++) + { + if (EqualityComparer.Default.Equals(source[index], item)) + { + return index; + } + } + + return -1; + } + public static int FindIndex(this IReadOnlyList source, Predicate match) { ArgumentGuard.NotNull(source); diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index b5c03a05a5..51ab46d57b 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -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; @@ -172,6 +174,18 @@ public interface IJsonApiOptions /// IsolationLevel? TransactionIsolationLevel { get; } + /// + /// Lists the JSON:API extensions that are turned on. Empty by default, but if your project contains a controller that derives from + /// , the and + /// extensions are automatically added. + /// + /// + /// To implement a custom JSON:API extension, add it here and override to indicate which + /// combinations of extensions are available, depending on the current endpoint. Use to obtain the active + /// extensions when implementing extension-specific logic. + /// + IReadOnlySet Extensions { get; } + /// /// Enables to customize the settings that are used by the . /// diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index f2a0da8d02..913d7a3060 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -184,6 +184,7 @@ private void AddMiddlewareLayer() _services.TryAddSingleton(); _services.TryAddSingleton(provider => provider.GetRequiredService()); _services.TryAddSingleton(); + _services.TryAddSingleton(); _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddScoped(); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index fb0b310118..0446fef4f8 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -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; @@ -11,6 +12,7 @@ namespace JsonApiDotNetCore.Configuration; [PublicAPI] public sealed class JsonApiOptions : IJsonApiOptions { + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); private readonly Lazy _lazySerializerWriteOptions; private readonly Lazy _lazySerializerReadOptions; @@ -97,6 +99,9 @@ public bool AllowClientGeneratedIds /// public IsolationLevel? TransactionIsolationLevel { get; set; } + /// + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; + /// public JsonSerializerOptions SerializerOptions { get; } = new() { @@ -130,4 +135,27 @@ public JsonApiOptions() } }, LazyThreadSafetyMode.ExecutionAndPublication); } + + /// + /// Adds the specified JSON:API extensions to the existing set. + /// + /// + /// The JSON:API extensions to add. + /// + public void IncludeExtensions(params JsonApiExtension[] extensionsToAdd) + { + ArgumentGuard.NotNull(extensionsToAdd); + + if (!Extensions.IsSupersetOf(extensionsToAdd)) + { + var extensions = new HashSet(Extensions); + + foreach (JsonApiExtension extension in extensionsToAdd) + { + extensions.Add(extension); + } + + Extensions = extensions.AsReadOnly(); + } + } } diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index 2e223e7fea..190efb74f2 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -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"; } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs new file mode 100644 index 0000000000..130f0b09d2 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Performs content negotiation for JSON:API requests. +/// +public interface IJsonApiContentNegotiator +{ + /// + /// Validates the Content-Type and Accept HTTP headers from the incoming request. Throws a if unsupported. Otherwise, + /// returns the list of negotiated JSON:API extensions, which should always be a subset of . + /// + IReadOnlySet Negotiate(); +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 1d66bf517f..c8e9acae7c 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -60,6 +60,11 @@ public interface IJsonApiRequest /// string? TransactionId { get; } + /// + /// The JSON:API extensions enabled for the current request. This is always a subset of . + /// + IReadOnlySet Extensions { get; } + /// /// Performs a shallow copy. /// diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs new file mode 100644 index 0000000000..a425437ce7 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs @@ -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; + +/// +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; + } + + /// + public IReadOnlySet Negotiate() + { + IReadOnlyList possibleMediaTypes = GetPossibleMediaTypes(); + + JsonApiMediaType? requestMediaType = ValidateContentType(possibleMediaTypes); + return ValidateAcceptHeader(possibleMediaTypes, requestMediaType); + } + + private JsonApiMediaType? ValidateContentType(IReadOnlyList 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 ValidateAcceptHeader(IReadOnlyList 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; + } + + /// + /// 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. + /// + /// + /// Override this method to add support for custom JSON:API extensions. Implementations should take 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. + /// + protected virtual IReadOnlyList GetPossibleMediaTypes() + { + List 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 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 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" + } + }); + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExtension.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExtension.cs new file mode 100644 index 0000000000..d41cdf29f9 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiExtension.cs @@ -0,0 +1,52 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Represents a JSON:API extension (in unescaped format), which occurs as an "ext" parameter inside an HTTP Accept or Content-Type header. +/// +[PublicAPI] +public sealed class JsonApiExtension : IEquatable +{ + public static readonly JsonApiExtension AtomicOperations = new("https://jsonapi.org/ext/atomic"); + public static readonly JsonApiExtension RelaxedAtomicOperations = new("atomic-operations"); + + public string UnescapedValue { get; } + + public JsonApiExtension(string unescapedValue) + { + ArgumentGuard.NotNullNorEmpty(unescapedValue); + + UnescapedValue = unescapedValue; + } + + public override string ToString() + { + return UnescapedValue; + } + + public bool Equals(JsonApiExtension? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return UnescapedValue == other.UnescapedValue; + } + + public override bool Equals(object? other) + { + return Equals(other as JsonApiExtension); + } + + public override int GetHashCode() + { + return UnescapedValue.GetHashCode(); + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs new file mode 100644 index 0000000000..b2fb0f64e1 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs @@ -0,0 +1,188 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Represents the JSON:API media type (application/vnd.api+json) with an optional set of extensions. +/// +[PublicAPI] +public sealed class JsonApiMediaType : IEquatable +{ + private static readonly StringSegment BaseMediaTypeSegment = new("application/vnd.api+json"); + private static readonly StringSegment ExtSegment = new("ext"); + private static readonly StringSegment QualitySegment = new("q"); + + /// + /// Gets the JSON:API media type without any extensions. + /// + public static readonly JsonApiMediaType Default = new([]); + + /// + /// Gets the JSON:API media type with the "https://jsonapi.org/ext/atomic" extension. + /// + public static readonly JsonApiMediaType AtomicOperations = new([JsonApiExtension.AtomicOperations]); + + /// + /// Gets the JSON:API media type with the "atomic-operations" extension. + /// + public static readonly JsonApiMediaType RelaxedAtomicOperations = new([JsonApiExtension.RelaxedAtomicOperations]); + + public IReadOnlySet Extensions { get; } + + public JsonApiMediaType(IReadOnlySet extensions) + { + ArgumentGuard.NotNull(extensions); + + Extensions = extensions; + } + + public JsonApiMediaType(IEnumerable extensions) + { + ArgumentGuard.NotNull(extensions); + + Extensions = extensions.ToHashSet().AsReadOnly(); + } + + internal static JsonApiMediaType? TryParseContentTypeHeaderValue(string value) + { + (JsonApiMediaType MediaType, decimal QualityFactor)? result = TryParse(value, false, false); + return result?.MediaType; + } + + internal static (JsonApiMediaType MediaType, decimal QualityFactor)? TryParseAcceptHeaderValue(string value) + { + return TryParse(value, true, true); + } + + private static (JsonApiMediaType MediaType, decimal QualityFactor)? TryParse(string value, bool allowSuperset, bool allowQualityFactor) + { + // Parameter names are case-insensitive, according to https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.1. + // But JSON:API doesn't define case-insensitive for the "ext" parameter value. + + if (MediaTypeHeaderValue.TryParse(value, out MediaTypeHeaderValue? headerValue)) + { + bool isBaseMatch = allowSuperset + ? headerValue.MatchesMediaType(BaseMediaTypeSegment) + : BaseMediaTypeSegment.Equals(headerValue.MediaType, StringComparison.OrdinalIgnoreCase); + + if (isBaseMatch) + { + HashSet extensions = []; + + decimal qualityFactor = 1.0m; + + foreach (NameValueHeaderValue parameter in headerValue.Parameters) + { + if (allowQualityFactor && parameter.Name.Equals(QualitySegment, StringComparison.OrdinalIgnoreCase) && + decimal.TryParse(parameter.Value, out decimal qualityValue)) + { + qualityFactor = qualityValue; + continue; + } + + if (!parameter.Name.Equals(ExtSegment, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + ParseExtensions(parameter, extensions); + } + + return (new JsonApiMediaType(extensions), qualityFactor); + } + } + + return null; + } + + private static void ParseExtensions(NameValueHeaderValue parameter, HashSet extensions) + { + string parameterValue = parameter.GetUnescapedValue().ToString(); + + foreach (string extValue in parameterValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var extension = new JsonApiExtension(extValue); + extensions.Add(extension); + } + } + + public override string ToString() + { + var baseHeaderValue = new MediaTypeHeaderValue(BaseMediaTypeSegment); + List parameters = []; + bool requiresEscape = false; + + foreach (JsonApiExtension extension in Extensions) + { + var extHeaderValue = new NameValueHeaderValue(ExtSegment); + extHeaderValue.SetAndEscapeValue(extension.UnescapedValue); + + if (extHeaderValue.Value != extension.UnescapedValue) + { + requiresEscape = true; + } + + parameters.Add(extHeaderValue); + } + + if (parameters.Count == 1) + { + baseHeaderValue.Parameters.Add(parameters[0]); + } + else if (parameters.Count > 1) + { + if (requiresEscape) + { + // JSON:API requires all 'ext' parameters combined into a single space-separated value. + string compositeValue = string.Join(' ', parameters.Select(parameter => parameter.GetUnescapedValue().ToString())); + var compositeParameter = new NameValueHeaderValue(ExtSegment); + compositeParameter.SetAndEscapeValue(compositeValue); + baseHeaderValue.Parameters.Add(compositeParameter); + } + else + { + // Relaxed mode: use separate 'ext' parameters. + foreach (NameValueHeaderValue parameter in parameters) + { + baseHeaderValue.Parameters.Add(parameter); + } + } + } + + return baseHeaderValue.ToString(); + } + + public bool Equals(JsonApiMediaType? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Extensions.SetEquals(other.Extensions); + } + + public override bool Equals(object? other) + { + return Equals(other as JsonApiMediaType); + } + + public override int GetHashCode() + { + int hashCode = 0; + + foreach (JsonApiExtension extension in Extensions) + { + hashCode = HashCode.Combine(hashCode, extension); + } + + return hashCode; + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 8983c00a6f..9eab8505e7 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; @@ -20,37 +21,25 @@ namespace JsonApiDotNetCore.Middleware; [PublicAPI] public sealed partial class JsonApiMiddleware { - private static readonly string[] NonOperationsContentTypes = [HeaderConstants.MediaType]; - private static readonly MediaTypeHeaderValue[] NonOperationsMediaTypes = [MediaTypeHeaderValue.Parse(HeaderConstants.MediaType)]; - - private static readonly string[] OperationsContentTypes = - [ - HeaderConstants.AtomicOperationsMediaType, - HeaderConstants.RelaxedAtomicOperationsMediaType - ]; - - private static readonly MediaTypeHeaderValue[] OperationsMediaTypes = - [ - MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType), - MediaTypeHeaderValue.Parse(HeaderConstants.RelaxedAtomicOperationsMediaType) - ]; - private readonly RequestDelegate? _next; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly IJsonApiOptions _options; + private readonly IJsonApiContentNegotiator _contentNegotiator; private readonly ILogger _logger; public JsonApiMiddleware(RequestDelegate? next, IHttpContextAccessor httpContextAccessor, IControllerResourceMapping controllerResourceMapping, - IJsonApiOptions options, ILogger logger) + IJsonApiOptions options, IJsonApiContentNegotiator contentNegotiator, ILogger logger) { ArgumentGuard.NotNull(httpContextAccessor); ArgumentGuard.NotNull(controllerResourceMapping); ArgumentGuard.NotNull(options); + ArgumentGuard.NotNull(contentNegotiator); ArgumentGuard.NotNull(logger); _next = next; _controllerResourceMapping = controllerResourceMapping; _options = options; + _contentNegotiator = contentNegotiator; _logger = logger; #pragma warning disable CA2000 // Dispose objects before losing scope @@ -66,37 +55,35 @@ public async Task InvokeAsync(HttpContext httpContext, IJsonApiRequest request) using (CodeTimingSessionManager.Current.Measure("JSON:API middleware")) { - if (!await ValidateIfMatchHeaderAsync(httpContext, _options.SerializerWriteOptions)) - { - return; - } - RouteValueDictionary routeValues = httpContext.GetRouteData().Values; ResourceType? primaryResourceType = CreatePrimaryResourceType(httpContext, _controllerResourceMapping); - if (primaryResourceType != null) + bool isResourceRequest = primaryResourceType != null; + bool isOperationsRequest = IsRouteForOperations(routeValues); + + if (isResourceRequest || isOperationsRequest) { - if (!await ValidateContentTypeHeaderAsync(NonOperationsContentTypes, httpContext, _options.SerializerWriteOptions) || - !await ValidateAcceptHeaderAsync(NonOperationsMediaTypes, httpContext, _options.SerializerWriteOptions)) + try { - return; + ValidateIfMatchHeader(httpContext.Request); + IReadOnlySet extensions = _contentNegotiator.Negotiate(); + + if (isResourceRequest) + { + SetupResourceRequest((JsonApiRequest)request, primaryResourceType!, routeValues, httpContext.Request, extensions); + } + else + { + SetupOperationsRequest((JsonApiRequest)request, extensions); + } + + httpContext.RegisterJsonApiRequest(); } - - SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request); - - httpContext.RegisterJsonApiRequest(); - } - else if (IsRouteForOperations(routeValues)) - { - if (!await ValidateContentTypeHeaderAsync(OperationsContentTypes, httpContext, _options.SerializerWriteOptions) || - !await ValidateAcceptHeaderAsync(OperationsMediaTypes, httpContext, _options.SerializerWriteOptions)) + catch (JsonApiException exception) { + await FlushResponseAsync(httpContext.Response, _options.SerializerWriteOptions, exception); return; } - - SetupOperationsRequest((JsonApiRequest)request); - - httpContext.RegisterJsonApiRequest(); } if (_next != null) @@ -117,11 +104,11 @@ public async Task InvokeAsync(HttpContext httpContext, IJsonApiRequest request) } } - private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, JsonSerializerOptions serializerOptions) + private void ValidateIfMatchHeader(HttpRequest httpRequest) { - if (httpContext.Request.Headers.ContainsKey(HeaderNames.IfMatch)) + if (httpRequest.Headers.ContainsKey(HeaderNames.IfMatch)) { - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.PreconditionFailed) + throw new JsonApiException(new ErrorObject(HttpStatusCode.PreconditionFailed) { Title = "Detection of mid-air edit collisions using ETags is not supported.", Source = new ErrorSource @@ -129,11 +116,7 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso Header = "If-Match" } }); - - return false; } - - return true; } private static ResourceType? CreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) @@ -146,100 +129,11 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso : null; } - private static async Task ValidateContentTypeHeaderAsync(string[] allowedContentTypes, HttpContext httpContext, - JsonSerializerOptions serializerOptions) - { - string? contentType = httpContext.Request.ContentType; - - if (contentType != null && !allowedContentTypes.Contains(contentType, StringComparer.OrdinalIgnoreCase)) - { - string allowedValues = string.Join(" or ", allowedContentTypes.Select(value => $"'{value}'")); - - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.UnsupportedMediaType) - { - Title = "The specified Content-Type header value is not supported.", - Detail = $"Please specify {allowedValues} instead of '{contentType}' for the Content-Type header value.", - Source = new ErrorSource - { - Header = "Content-Type" - } - }); - - return false; - } - - return true; - } - - private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue[] allowedMediaTypes, HttpContext httpContext, - JsonSerializerOptions serializerOptions) - { - string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept"); - - if (acceptHeaders.Length == 0) - { - return true; - } - - bool seenCompatibleMediaType = false; - - foreach (string acceptHeader in acceptHeaders) - { - if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue)) - { - if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*") - { - seenCompatibleMediaType = true; - break; - } - - headerValue.Quality = null; - - if (allowedMediaTypes.Contains(headerValue)) - { - seenCompatibleMediaType = true; - break; - } - } - } - - if (!seenCompatibleMediaType) - { - string allowedValues = string.Join(" or ", allowedMediaTypes.Select(value => $"'{value}'")); - - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.NotAcceptable) - { - Title = "The specified Accept header value does not contain any supported media types.", - Detail = $"Please include {allowedValues} in the Accept header values.", - Source = new ErrorSource - { - Header = "Accept" - } - }); - - return false; - } - - return true; - } - - private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error) - { - httpResponse.ContentType = HeaderConstants.MediaType; - httpResponse.StatusCode = (int)error.StatusCode; - - var errorDocument = new Document - { - Errors = [error] - }; - - await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions); - await httpResponse.Body.FlushAsync(); - } - private static void SetupResourceRequest(JsonApiRequest request, ResourceType primaryResourceType, RouteValueDictionary routeValues, - HttpRequest httpRequest) + HttpRequest httpRequest, IReadOnlySet extensions) { + AssertNoAtomicOperationsExtension(extensions); + request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method; request.PrimaryResourceType = primaryResourceType; request.PrimaryId = GetPrimaryRequestId(routeValues); @@ -287,6 +181,15 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr bool isGetAll = request.PrimaryId == null && request.IsReadOnly; request.IsCollection = isGetAll || request.Relationship is HasManyAttribute; + request.Extensions = extensions; + } + + private static void AssertNoAtomicOperationsExtension(IReadOnlySet extensions) + { + if (extensions.Contains(JsonApiExtension.AtomicOperations) || extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) + { + throw new InvalidOperationException("Incorrect content negotiation implementation detected: Unexpected atomic:operations extension found."); + } } private static string? GetPrimaryRequestId(RouteValueDictionary routeValues) @@ -305,16 +208,41 @@ private static bool IsRouteForRelationship(RouteValueDictionary routeValues) return actionName.EndsWith("Relationship", StringComparison.Ordinal); } - private static bool IsRouteForOperations(RouteValueDictionary routeValues) + internal static bool IsRouteForOperations(RouteValueDictionary routeValues) { string actionName = (string)routeValues["action"]!; return actionName == "PostOperations"; } - private static void SetupOperationsRequest(JsonApiRequest request) + private static void SetupOperationsRequest(JsonApiRequest request, IReadOnlySet extensions) { + AssertHasAtomicOperationsExtension(extensions); + request.IsReadOnly = false; request.Kind = EndpointKind.AtomicOperations; + request.Extensions = extensions; + } + + private static void AssertHasAtomicOperationsExtension(IReadOnlySet extensions) + { + if (!extensions.Contains(JsonApiExtension.AtomicOperations) && !extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) + { + throw new InvalidOperationException("Incorrect content negotiation implementation detected: Missing atomic:operations extension."); + } + } + + private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, JsonApiException exception) + { + httpResponse.ContentType = JsonApiMediaType.Default.ToString(); + httpResponse.StatusCode = (int)ErrorObject.GetResponseStatusCode(exception.Errors); + + var errorDocument = new Document + { + Errors = exception.Errors.ToList() + }; + + await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions); + await httpResponse.Body.FlushAsync(); } [LoggerMessage(Level = LogLevel.Information, SkipEnabledCheck = true, diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 81e0564311..f883e4bd61 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -8,6 +8,8 @@ namespace JsonApiDotNetCore.Middleware; [PublicAPI] public sealed class JsonApiRequest : IJsonApiRequest { + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); + /// public EndpointKind Kind { get; set; } @@ -35,6 +37,9 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public string? TransactionId { get; set; } + /// + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; + /// public void CopyFrom(IJsonApiRequest other) { @@ -49,5 +54,6 @@ public void CopyFrom(IJsonApiRequest other) IsReadOnly = other.IsReadOnly; WriteOperation = other.WriteOperation; TransactionId = other.TransactionId; + Extensions = other.Extensions; } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index d0b4ec13b4..9424f97eb8 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -110,6 +110,11 @@ public void Apply(ApplicationModel application) _controllerPerResourceTypeMap.Add(resourceType, controller); } } + else + { + var options = (JsonApiOptions)_options; + options.IncludeExtensions(JsonApiExtension.AtomicOperations, JsonApiExtension.RelaxedAtomicOperations); + } if (IsRoutingConventionDisabled(controller)) { diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 57d166ee36..dad82f7c48 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -19,15 +19,6 @@ namespace JsonApiDotNetCore.Serialization.Response; /// public sealed partial class JsonApiWriter : IJsonApiWriter { - private static readonly MediaTypeHeaderValue OperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType); - private static readonly MediaTypeHeaderValue RelaxedOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.RelaxedAtomicOperationsMediaType); - - private static readonly MediaTypeHeaderValue[] AllowedOperationsMediaTypes = - [ - OperationsMediaType, - RelaxedOperationsMediaType - ]; - private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; private readonly IResponseModelAdapter _responseModelAdapter; @@ -79,8 +70,8 @@ public async Task WriteAsync(object? model, HttpContext httpContext) LogResponse(requestMethod, requestUrl, responseBody, httpContext.Response.StatusCode); } - string responseContentType = GetResponseContentType(httpContext.Request); - await SendResponseBodyAsync(httpContext.Response, responseBody, responseContentType); + var responseMediaType = new JsonApiMediaType(_request.Extensions); + await SendResponseBodyAsync(httpContext.Response, responseBody, responseMediaType.ToString()); } private static bool CanWriteBody(HttpStatusCode statusCode) @@ -177,39 +168,6 @@ private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders return false; } - private string GetResponseContentType(HttpRequest httpRequest) - { - if (_request.Kind != EndpointKind.AtomicOperations) - { - return HeaderConstants.MediaType; - } - - MediaTypeHeaderValue? bestMatch = null; - - foreach (MediaTypeHeaderValue headerValue in httpRequest.GetTypedHeaders().Accept) - { - double quality = headerValue.Quality ?? 1.0; - headerValue.Quality = null; - - if (AllowedOperationsMediaTypes.Contains(headerValue)) - { - if (bestMatch == null || bestMatch.Quality < quality) - { - headerValue.Quality = quality; - bestMatch = headerValue; - } - } - } - - if (bestMatch == null) - { - return httpRequest.ContentType ?? HeaderConstants.AtomicOperationsMediaType; - } - - bestMatch.Quality = null; - return RelaxedOperationsMediaType.Equals(bestMatch) ? HeaderConstants.RelaxedAtomicOperationsMediaType : HeaderConstants.AtomicOperationsMediaType; - } - private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody, string contentType) { if (!string.IsNullOrEmpty(responseBody)) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index c4416bbac1..bedb5d7da2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -31,7 +31,7 @@ public async Task Cannot_process_for_missing_request_body() httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); responseDocument.Errors.ShouldHaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs index e56d88e419..6eb2ce3a37 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -200,7 +200,8 @@ [TRACE] Entering PostOperationsAsync(operations: [ "PrimaryResourceType": "musicTracks", "IsCollection": false, "IsReadOnly": false, - "WriteOperation": "UpdateResource" + "WriteOperation": "UpdateResource", + "Extensions": [] } } ]) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs index d19378c0ce..330ea277df 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using FluentAssertions; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; @@ -121,12 +122,17 @@ public async Task Cannot_create_resource_with_read_scope() }; const string route = "/operations"; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); - Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:genres"); + Action setRequestHeaders = headers => + { + headers.Add(ScopeHeaderName, "read:genres"); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString())); + }; // Act (HttpResponseMessage httpResponse, Document responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody, setRequestHeaders: setRequestHeaders); + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 27e89d98f8..b1f92ffd9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -20,52 +20,6 @@ public AcceptHeaderTests(IntegrationTestContext testContext.UseController(); } - [Fact] - public async Task Permits_no_Accept_headers() - { - // Arrange - const string route = "/policies"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Permits_no_Accept_headers_at_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - } - } - }; - - const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - } - [Fact] public async Task Permits_global_wildcard_in_Accept_headers() { @@ -85,7 +39,7 @@ public async Task Permits_global_wildcard_in_Accept_headers() httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] @@ -107,7 +61,7 @@ public async Task Permits_application_wildcard_in_Accept_headers() httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] @@ -119,10 +73,10 @@ public async Task Permits_JsonApi_without_parameters_in_Accept_headers() Action setRequestHeaders = headers => { headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; q=0.3")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; q=0.3")); }; // Act @@ -132,7 +86,7 @@ public async Task Permits_JsonApi_without_parameters_in_Accept_headers() httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] @@ -159,16 +113,16 @@ public async Task Prefers_JsonApi_with_AtomicOperations_extension_in_Accept_head }; const string route = "/operations"; - const string contentType = HeaderConstants.RelaxedAtomicOperationsMediaType; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); Action setRequestHeaders = headers => { headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=atomic-operations; q=0.2")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=atomic-operations; q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.2")); }; // Act @@ -178,7 +132,7 @@ public async Task Prefers_JsonApi_with_AtomicOperations_extension_in_Accept_head httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); } [Fact] @@ -205,16 +159,16 @@ public async Task Prefers_JsonApi_with_relaxed_AtomicOperations_extension_in_Acc }; const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); Action setRequestHeaders = headers => { headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.2")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};EXT=atomic-operations; q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=atomic-operations; q=0.2")); }; // Act @@ -224,7 +178,7 @@ public async Task Prefers_JsonApi_with_relaxed_AtomicOperations_extension_in_Acc httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.RelaxedAtomicOperationsMediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.RelaxedAtomicOperations.ToString()); } [Fact] @@ -236,10 +190,10 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() Action setRequestHeaders = headers => { headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType)); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString())); }; // Act @@ -253,7 +207,54 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); - error.Detail.Should().Be("Please include 'application/vnd.api+json' in the Accept header values."); + error.Detail.Should().Be($"Include '{JsonApiMediaType.Default}' in the Accept header values."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Accept"); + } + + [Fact] + public async Task Denies_no_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); + + Action requestHeaders = _ => + { + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, requestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotAcceptable); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); + error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); + error.Detail.Should().Be($"Include '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' in the Accept header values."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Accept"); } @@ -282,9 +283,10 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() }; const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); - Action setRequestHeaders = headers => headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); // Act (HttpResponseMessage httpResponse, Document responseDocument) = @@ -295,13 +297,10 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() responseDocument.Errors.ShouldHaveCount(1); - const string detail = - $"Please include '{HeaderConstants.AtomicOperationsMediaType}' or '{HeaderConstants.RelaxedAtomicOperationsMediaType}' in the Accept header values."; - ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); - error.Detail.Should().Be(detail); + error.Detail.Should().Be($"Include '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' in the Accept header values."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Accept"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 4cabba7843..19948c0092 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; @@ -32,7 +33,7 @@ public async Task Returns_JsonApi_ContentType_header() httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] @@ -67,7 +68,7 @@ public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_exten httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); } [Fact] @@ -94,16 +95,19 @@ public async Task Returns_JsonApi_ContentType_header_with_relaxed_AtomicOperatio }; const string route = "/operations"; - const string contentType = HeaderConstants.RelaxedAtomicOperationsMediaType; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.RelaxedAtomicOperations.ToString())); // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody, contentType); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.RelaxedAtomicOperationsMediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.RelaxedAtomicOperations.ToString()); } [Fact] @@ -131,12 +135,63 @@ public async Task Denies_unknown_ContentType_header() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'text/html' for the Content-Type header value."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of 'text/html' for the Content-Type header value."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } + + [Fact] + public async Task Denies_unknown_ContentType_header_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + const string contentType = "text/html"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.ShouldHaveCount(1); + + string detail = + $"Use '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' instead of 'text/html' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -158,14 +213,45 @@ public async Task Permits_JsonApi_ContentType_header() }; const string route = "/policies"; - const string contentType = HeaderConstants.MediaType; + string contentType = JsonApiMediaType.Default.ToString(); + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_in_upper_case() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = JsonApiMediaType.Default.ToString().ToUpperInvariant(); // Act - // ReSharper disable once RedundantArgumentDefaultValue (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] @@ -192,13 +278,63 @@ public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_exten }; const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); + } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_operations_endpoint_in_upper_case() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.AtomicOperations.ToString().ToUpperInvariant(); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.ShouldHaveCount(1); + + string detail = + $"Use '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' instead of '{contentType}' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); } [Fact] @@ -225,17 +361,23 @@ public async Task Permits_JsonApi_ContentType_header_with_relaxed_AtomicOperatio }; const string route = "/operations"; - const string contentType = HeaderConstants.RelaxedAtomicOperationsMediaType; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.RelaxedAtomicOperations.ToString())); // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.RelaxedAtomicOperations.ToString()); } [Fact] - public async Task Denies_JsonApi_ContentType_header_with_profile() + public async Task Denies_JsonApi_ContentType_header_with_unknown_extension() { // Arrange var requestBody = new @@ -251,7 +393,7 @@ public async Task Denies_JsonApi_ContentType_header_with_profile() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; profile=something"; + string contentType = $"{JsonApiMediaType.Default}; ext=something"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -259,18 +401,21 @@ public async Task Denies_JsonApi_ContentType_header_with_profile() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } [Fact] - public async Task Denies_JsonApi_ContentType_header_with_extension() + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() { // Arrange var requestBody = new @@ -286,7 +431,7 @@ public async Task Denies_JsonApi_ContentType_header_with_extension() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; ext=something"; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -294,18 +439,21 @@ public async Task Denies_JsonApi_ContentType_header_with_extension() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } [Fact] - public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() + public async Task Denies_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension_at_resource_endpoint() { // Arrange var requestBody = new @@ -321,7 +469,7 @@ public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extens }; const string route = "/policies"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -329,18 +477,21 @@ public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extens // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } [Fact] - public async Task Denies_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension_at_resource_endpoint() + public async Task Denies_JsonApi_ContentType_header_with_profile() { // Arrange var requestBody = new @@ -356,7 +507,7 @@ public async Task Denies_JsonApi_ContentType_header_with_relaxed_AtomicOperation }; const string route = "/policies"; - const string contentType = HeaderConstants.RelaxedAtomicOperationsMediaType; + string contentType = $"{JsonApiMediaType.Default}; profile=something"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -364,12 +515,15 @@ public async Task Denies_JsonApi_ContentType_header_with_relaxed_AtomicOperation // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -391,7 +545,7 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; charset=ISO-8859-4"; + string contentType = $"{JsonApiMediaType.Default}; charset=ISO-8859-4"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -399,12 +553,15 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -426,7 +583,7 @@ public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; unknown=unexpected"; + string contentType = $"{JsonApiMediaType.Default}; unknown=unexpected"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -434,12 +591,15 @@ public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); error.Source.ShouldNotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -468,19 +628,21 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() }; const string route = "/operations"; - const string contentType = HeaderConstants.MediaType; + string contentType = JsonApiMediaType.Default.ToString(); // Act - // ReSharper disable once RedundantArgumentDefaultValue (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + responseDocument.Errors.ShouldHaveCount(1); - const string detail = - $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' or '{HeaderConstants.RelaxedAtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; + string detail = + $"Use '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' instead of '{contentType}' for the Content-Type header value."; ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CapturingDocumentAdapter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CapturingDocumentAdapter.cs new file mode 100644 index 0000000000..705d5e6ed6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CapturingDocumentAdapter.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class CapturingDocumentAdapter : IDocumentAdapter +{ + private readonly IDocumentAdapter _innerAdapter; + private readonly RequestDocumentStore _requestDocumentStore; + + public CapturingDocumentAdapter(IDocumentAdapter innerAdapter, RequestDocumentStore requestDocumentStore) + { + ArgumentGuard.NotNull(innerAdapter); + ArgumentGuard.NotNull(requestDocumentStore); + + _innerAdapter = innerAdapter; + _requestDocumentStore = requestDocumentStore; + } + + public object? Convert(Document document) + { + _requestDocumentStore.Document = document; + return _innerAdapter.Convert(document); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs new file mode 100644 index 0000000000..9be730554b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +public sealed class CustomExtensionsAcceptHeaderTests : IClassFixture, PolicyDbContext>> +{ + private readonly IntegrationTestContext, PolicyDbContext> _testContext; + + public CustomExtensionsAcceptHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + }); + + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.IncludeExtensions(ServerTimeExtensions.ServerTime, ServerTimeExtensions.RelaxedServerTime); + } + + [Fact] + public async Task Permits_JsonApi_without_parameters_in_Accept_headers() + { + // Arrange + const string route = "/policies"; + + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; q=0.3")); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Prefers_first_match_from_GetPossibleMediaTypes_with_largest_number_of_extensions() + { + // Arrange + const string route = "/policies"; + + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.RelaxedServerTime.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.ServerTime.ToString())); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.ServerTime.ToString()); + } + + [Fact] + public async Task Prefers_quality_factor_over_largest_number_of_extensions() + { + // Arrange + const string route = "/policies"; + + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{ServerTimeMediaTypes.ServerTime}; q=0.2")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; q=0.8")); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Denies_extensions_mismatch_between_ContentType_and_Accept_header() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotAcceptable); + + responseDocument.Errors.ShouldHaveCount(1); + + string detail = $"Include '{JsonApiMediaType.AtomicOperations}' or '{ServerTimeMediaTypes.AtomicOperationsWithServerTime}' or " + + $"'{JsonApiMediaType.RelaxedAtomicOperations}' or '{ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime}' in the Accept header values."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); + error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); + error.Detail.Should().Be(detail); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Accept"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs new file mode 100644 index 0000000000..2a44fb89be --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs @@ -0,0 +1,327 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +public sealed class CustomExtensionsContentTypeTests : IClassFixture, PolicyDbContext>> +{ + private readonly IntegrationTestContext, PolicyDbContext> _testContext; + + public CustomExtensionsContentTypeTests(IntegrationTestContext, PolicyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddSingleton(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(serviceProvider => + { + var documentAdapter = serviceProvider.GetRequiredService(); + var requestDocumentStore = serviceProvider.GetRequiredService(); + return new CapturingDocumentAdapter(documentAdapter, requestDocumentStore); + }); + }); + + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.IncludeExtensions(ServerTimeExtensions.ServerTime, ServerTimeExtensions.RelaxedServerTime); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = JsonApiMediaType.Default.ToString(); + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_ServerTime_extension() + { + // Arrange + var requestBody = new + { + meta = new + { + useLocalTime = true + }, + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = ServerTimeMediaTypes.ServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.ServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.ServerTime.ToString()); + + responseDocument.Meta.ShouldContainKey("localServerTime"); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_relaxed_ServerTime_extension() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = ServerTimeMediaTypes.RelaxedServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.RelaxedServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.RelaxedServerTime.ToString()); + + responseDocument.Meta.ShouldContainKey("utcServerTime"); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_and_ServerTime_extension_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString()); + + responseDocument.Meta.ShouldContainKey("utcServerTime"); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_relaxed_AtomicOperations_and_relaxed_ServerTime_extension_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + meta = new + { + useLocalTime = true + }, + + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime.ToString()); + + responseDocument.Meta.ShouldContainKey("localServerTime"); + } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_and_ServerTime_at_resource_endpoint() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString(); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.ShouldHaveCount(1); + + string detail = $"Use '{JsonApiMediaType.Default}' or '{ServerTimeMediaTypes.ServerTime}' or " + + $"'{ServerTimeMediaTypes.RelaxedServerTime}' instead of '{contentType}' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_relaxed_ServerTime_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.RelaxedServerTime.ToString(); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.ShouldHaveCount(1); + + string detail = $"Use '{JsonApiMediaType.AtomicOperations}' or '{ServerTimeMediaTypes.AtomicOperationsWithServerTime}' or " + + $"'{JsonApiMediaType.RelaxedAtomicOperations}' or '{ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime}' " + + $"instead of '{contentType}' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/RequestDocumentStore.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/RequestDocumentStore.cs new file mode 100644 index 0000000000..e972cdd078 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/RequestDocumentStore.cs @@ -0,0 +1,8 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class RequestDocumentStore +{ + public Document? Document { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs new file mode 100644 index 0000000000..af50817579 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs @@ -0,0 +1,57 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class ServerTimeContentNegotiator(IJsonApiOptions options, IHttpContextAccessor httpContextAccessor) + : JsonApiContentNegotiator(options, httpContextAccessor) +{ + private readonly IJsonApiOptions _options = options; + + protected override IReadOnlyList GetPossibleMediaTypes() + { + List 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.AtomicOperations) && _options.Extensions.Contains(ServerTimeExtensions.ServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.AtomicOperationsWithServerTime); + } + + if (_options.Extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) + { + mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations); + } + + if (_options.Extensions.Contains(JsonApiExtension.RelaxedAtomicOperations) && _options.Extensions.Contains(ServerTimeExtensions.RelaxedServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime); + } + } + else + { + mediaTypes.Add(JsonApiMediaType.Default); + + if (_options.Extensions.Contains(ServerTimeExtensions.ServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.ServerTime); + } + + if (_options.Extensions.Contains(ServerTimeExtensions.RelaxedServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.RelaxedServerTime); + } + } + + return mediaTypes.AsReadOnly(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeExtensions.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeExtensions.cs new file mode 100644 index 0000000000..d320c110f4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeExtensions.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Middleware; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal static class ServerTimeExtensions +{ + public static readonly JsonApiExtension ServerTime = new("https://www.jsonapi.net/ext/server-time"); + public static readonly JsonApiExtension RelaxedServerTime = new("server-time"); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs new file mode 100644 index 0000000000..dc355536f5 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Middleware; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal static class ServerTimeMediaTypes +{ + public static readonly JsonApiMediaType ServerTime = new([ServerTimeExtensions.ServerTime]); + public static readonly JsonApiMediaType RelaxedServerTime = new([ServerTimeExtensions.RelaxedServerTime]); + + public static readonly JsonApiMediaType AtomicOperationsWithServerTime = new([ + JsonApiExtension.AtomicOperations, + ServerTimeExtensions.ServerTime + ]); + + public static readonly JsonApiMediaType RelaxedAtomicOperationsWithRelaxedServerTime = new([ + JsonApiExtension.RelaxedAtomicOperations, + ServerTimeExtensions.RelaxedServerTime + ]); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs new file mode 100644 index 0000000000..3432e118af --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs @@ -0,0 +1,33 @@ +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Response; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class ServerTimeResponseMeta(IJsonApiRequest request, RequestDocumentStore documentStore) : IResponseMeta +{ + private readonly RequestDocumentStore _documentStore = documentStore; + + public IDictionary? GetMeta() + { + if (request.Extensions.Contains(ServerTimeExtensions.ServerTime) || request.Extensions.Contains(ServerTimeExtensions.RelaxedServerTime)) + { + if (_documentStore.Document is not { Meta: not null } || !_documentStore.Document.Meta.TryGetValue("useLocalTime", out object? useLocalTimeValue) || + useLocalTimeValue == null || !bool.TryParse(useLocalTimeValue.ToString(), out bool useLocalTime)) + { + useLocalTime = false; + } + + return useLocalTime + ? new Dictionary + { + ["localServerTime"] = DateTime.Now.ToString("O") + } + : new Dictionary + { + ["utcServerTime"] = DateTime.UtcNow.ToString("O") + }; + } + + return null; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 1e3ce6b66f..f3aa021389 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -478,7 +478,7 @@ public async Task Cannot_create_resource_for_missing_request_body() httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); responseDocument.Errors.ShouldHaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs index 3afa0d80de..250729c31d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -198,7 +198,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); responseDocument.Errors.ShouldHaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index adf50e1926..612e9df143 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -310,7 +310,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); responseDocument.Errors.ShouldHaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index 82776c63c4..3e998a12e8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -227,7 +227,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); responseDocument.Errors.ShouldHaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index 40504b862f..573489425e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -263,7 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); responseDocument.Errors.ShouldHaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 42df980d4e..efd498c53c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -665,7 +665,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); responseDocument.Errors.ShouldHaveCount(1); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs index 2f089f5595..326fecad7f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Serialization/ETagTests.cs @@ -158,8 +158,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Action setRequestHeaders = headers => headers.IfMatch.ParseAdd("\"12345\""); // Act - (HttpResponseMessage httpResponse, Document responseDocument) = - await _testContext.ExecutePatchAsync(route, requestBody, setRequestHeaders: setRequestHeaders); + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody, setRequestHeaders); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.PreconditionFailed); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs index a07287649f..0e6c78def2 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -47,6 +47,8 @@ public async Task Sets_request_properties_correctly(string requestMethod, string { // Arrange var options = new JsonApiOptions(); + options.IncludeExtensions(JsonApiExtension.AtomicOperations); + var request = new JsonApiRequest(); // @formatter:wrap_chained_method_calls chop_always @@ -69,7 +71,10 @@ public async Task Sets_request_properties_correctly(string requestMethod, string HttpContext = httpContext }; - var middleware = new JsonApiMiddleware(null, httpContextAccessor, controllerResourceMapping, options, NullLogger.Instance); + var contentNegotiator = new JsonApiContentNegotiator(options, httpContextAccessor); + + var middleware = new JsonApiMiddleware(null, httpContextAccessor, controllerResourceMapping, options, contentNegotiator, + NullLogger.Instance); // Act await middleware.InvokeAsync(httpContext, request); @@ -145,6 +150,7 @@ private static FakeControllerResourceMapping SetupRoutes(HttpContext httpContext else if (pathSegments.Contains("operations")) { feature.RouteValues["action"] = "PostOperations"; + httpContext.Request.Headers.Accept = JsonApiMediaType.AtomicOperations.ToString(); } httpContext.Features.Set(feature); diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs index 83a375a7db..79c88b743e 100644 --- a/test/TestBuildingBlocks/IntegrationTest.cs +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -12,6 +12,11 @@ namespace TestBuildingBlocks; /// public abstract class IntegrationTest : IAsyncLifetime { + private static readonly MediaTypeHeaderValue DefaultMediaType = MediaTypeHeaderValue.Parse(JsonApiMediaType.Default.ToString()); + + private static readonly MediaTypeWithQualityHeaderValue OperationsMediaType = + MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString()); + private static readonly SemaphoreSlim ThrottleSemaphore = GetDefaultThrottleSemaphore(); protected abstract JsonSerializerOptions SerializerOptions { get; } @@ -34,32 +39,38 @@ private static SemaphoreSlim GetDefaultThrottleSemaphore() return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, setRequestHeaders); } +#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAsync(string requestUrl, - object requestBody, string contentType = HeaderConstants.MediaType, Action? setRequestHeaders = null) + object requestBody, string? contentType = null, Action? setRequestHeaders = null) +#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks { - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); + MediaTypeHeaderValue mediaType = contentType == null ? DefaultMediaType : MediaTypeHeaderValue.Parse(contentType); + + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, mediaType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePostAtomicAsync(string requestUrl, - object requestBody, string contentType = HeaderConstants.AtomicOperationsMediaType, Action? setRequestHeaders = null) + object requestBody) { - return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, contentType, setRequestHeaders); + Action setRequestHeaders = headers => headers.Accept.Add(OperationsMediaType); + + return await ExecuteRequestAsync(HttpMethod.Post, requestUrl, requestBody, OperationsMediaType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecutePatchAsync(string requestUrl, - object requestBody, string contentType = HeaderConstants.MediaType, Action? setRequestHeaders = null) + object requestBody, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, setRequestHeaders); + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); } public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteDeleteAsync(string requestUrl, - object? requestBody = null, string contentType = HeaderConstants.MediaType, Action? setRequestHeaders = null) + object? requestBody = null, Action? setRequestHeaders = null) { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, setRequestHeaders); + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, DefaultMediaType, setRequestHeaders); } private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> ExecuteRequestAsync(HttpMethod method, - string requestUrl, object? requestBody, string? contentType, Action? setRequestHeaders) + string requestUrl, object? requestBody, MediaTypeHeaderValue? contentType, Action? setRequestHeaders) { using var request = new HttpRequestMessage(method, requestUrl); string? requestText = SerializeRequest(requestBody); @@ -72,7 +83,7 @@ private static SemaphoreSlim GetDefaultThrottleSemaphore() if (contentType != null) { - request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + request.Content.Headers.ContentType = contentType; } }