From ca2cda086777c420cccfba03470d6b8fc1cf6b73 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:29:30 +0100 Subject: [PATCH 1/4] IJsonApiEndpointFilter: remove controller action methods at runtime --- .../IAtomicOperationFilter.cs | 2 +- .../JsonApiApplicationBuilder.cs | 1 + .../AlwaysEnabledJsonApiEndpointFilter.cs | 13 +++ .../HttpMethodAttributeExtensions.cs | 56 ++++++++++ .../Middleware/IJsonApiEndpointFilter.cs | 24 +++++ .../Middleware/JsonApiRoutingConvention.cs | 100 +++++++++++------- .../ObfuscatedIdentifiableController.cs | 4 + .../EndpointFilterTests.cs | 61 +++++++++++ .../Controllers/GetJsonApiEndpointTests.cs | 43 ++++++++ 9 files changed, 263 insertions(+), 41 deletions(-) create mode 100644 src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs create mode 100644 src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs create mode 100644 src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/EndpointFilterTests.cs create mode 100644 test/JsonApiDotNetCoreTests/UnitTests/Controllers/GetJsonApiEndpointTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs index 240efbf936..47d534c5b5 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.AtomicOperations; /// -/// Determines whether an operation in an atomic:operations request can be used. +/// Determines whether an operation in an atomic:operations request can be used. For non-operations requests, see . /// /// /// The default implementation relies on the usage of . If you're using explicit diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 8f94195580..8dc8f47b77 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -184,6 +184,7 @@ private void AddMiddlewareLayer() _services.TryAddSingleton(); _services.TryAddSingleton(); _services.TryAddSingleton(provider => provider.GetRequiredService()); + _services.TryAddSingleton(); _services.TryAddSingleton(); _services.TryAddSingleton(); _services.TryAddScoped(); diff --git a/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs b/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs new file mode 100644 index 0000000000..c3918d7462 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.Middleware; + +internal sealed class AlwaysEnabledJsonApiEndpointFilter : IJsonApiEndpointFilter +{ + /// + public bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint) + { + return true; + } +} diff --git a/src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs new file mode 100644 index 0000000000..e00cdd326d --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs @@ -0,0 +1,56 @@ +using JsonApiDotNetCore.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace JsonApiDotNetCore.Middleware; + +internal static class HttpMethodAttributeExtensions +{ + private const string IdTemplate = "{id}"; + private const string RelationshipNameTemplate = "{relationshipName}"; + private const string SecondaryEndpointTemplate = $"{IdTemplate}/{RelationshipNameTemplate}"; + private const string RelationshipEndpointTemplate = $"{IdTemplate}/relationships/{RelationshipNameTemplate}"; + + public static JsonApiEndpoints GetJsonApiEndpoint(this IEnumerable httpMethods) + { + ArgumentGuard.NotNull(httpMethods); + + HttpMethodAttribute[] nonHeadAttributes = httpMethods.Where(attribute => attribute is not HttpHeadAttribute).ToArray(); + + return nonHeadAttributes.Length == 1 ? ResolveJsonApiEndpoint(nonHeadAttributes[0]) : JsonApiEndpoints.None; + } + + private static JsonApiEndpoints ResolveJsonApiEndpoint(HttpMethodAttribute httpMethod) + { + return httpMethod switch + { + HttpGetAttribute httpGet => httpGet.Template switch + { + null => JsonApiEndpoints.GetCollection, + IdTemplate => JsonApiEndpoints.GetSingle, + SecondaryEndpointTemplate => JsonApiEndpoints.GetSecondary, + RelationshipEndpointTemplate => JsonApiEndpoints.GetRelationship, + _ => JsonApiEndpoints.None + }, + HttpPostAttribute httpPost => httpPost.Template switch + { + null => JsonApiEndpoints.Post, + RelationshipEndpointTemplate => JsonApiEndpoints.PostRelationship, + _ => JsonApiEndpoints.None + }, + HttpPatchAttribute httpPatch => httpPatch.Template switch + { + IdTemplate => JsonApiEndpoints.Patch, + RelationshipEndpointTemplate => JsonApiEndpoints.PatchRelationship, + _ => JsonApiEndpoints.None + }, + HttpDeleteAttribute httpDelete => httpDelete.Template switch + { + IdTemplate => JsonApiEndpoints.Delete, + RelationshipEndpointTemplate => JsonApiEndpoints.DeleteRelationship, + _ => JsonApiEndpoints.None + }, + _ => JsonApiEndpoints.None + }; + } +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs new file mode 100644 index 0000000000..6dbf81bce3 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs @@ -0,0 +1,24 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Enables to remove JSON:API controller action methods at startup. For atomic:operation requests, see . +/// +[PublicAPI] +public interface IJsonApiEndpointFilter +{ + /// + /// Determines whether to remove the associated controller action method. + /// + /// + /// The primary resource type of the endpoint. + /// + /// + /// The JSON:API endpoint. Despite being a enum, a single value is always passed here. + /// + bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint); +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 72cac28daa..8403109d19 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -7,43 +7,48 @@ using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Middleware; /// -/// The default routing convention registers the name of the resource as the route using the serializer naming convention. The default for this is a -/// camel case formatter. If the controller directly inherits from and there is no resource directly associated, it -/// uses the name of the controller instead of the name of the type. +/// Registers routes based on the JSON:API resource name, which defaults to camel-case pluralized form of the resource CLR type name. If unavailable (for +/// example, when a controller directly inherits from ), the serializer naming convention is applied on the +/// controller type name (camel-case by default). /// /// { } // => /someResources/relationship/relatedResource +/// // controller name is ignored when resource type is available: +/// public class RandomNameController : JsonApiController { } // => /someResources /// -/// public class RandomNameController : JsonApiController { } // => /someResources/relationship/relatedResource +/// // when using kebab-case naming convention in options: +/// public class RandomNameController : JsonApiController { } // => /some-resources /// -/// // when using kebab-case naming convention: -/// public class SomeResourceController : JsonApiController { } // => /some-resources/relationship/related-resource -/// -/// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource +/// // unable to determine resource type: +/// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustom /// ]]> [PublicAPI] public sealed partial class JsonApiRoutingConvention : IJsonApiRoutingConvention { private readonly IJsonApiOptions _options; private readonly IResourceGraph _resourceGraph; + private readonly IJsonApiEndpointFilter _jsonApiEndpointFilter; private readonly ILogger _logger; private readonly Dictionary _registeredControllerNameByTemplate = []; private readonly Dictionary _resourceTypePerControllerTypeMap = []; private readonly Dictionary _controllerPerResourceTypeMap = []; - public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, ILogger logger) + public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, IJsonApiEndpointFilter jsonApiEndpointFilter, + ILogger logger) { ArgumentGuard.NotNull(options); ArgumentGuard.NotNull(resourceGraph); + ArgumentGuard.NotNull(jsonApiEndpointFilter); ArgumentGuard.NotNull(logger); _options = options; _resourceGraph = resourceGraph; + _jsonApiEndpointFilter = jsonApiEndpointFilter; _logger = logger; } @@ -106,6 +111,8 @@ public void Apply(ApplicationModel application) $"Multiple controllers found for resource type '{resourceType}': '{existingModel.ControllerType}' and '{controller.ControllerType}'."); } + RemoveDisabledActionMethods(controller, resourceType); + _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); _controllerPerResourceTypeMap.Add(resourceType, controller); } @@ -148,34 +155,10 @@ private static bool HasApiControllerAttribute(ControllerModel controller) return controller.ControllerType.GetCustomAttribute() != null; } - private static bool IsRoutingConventionDisabled(ControllerModel controller) - { - return controller.ControllerType.GetCustomAttribute(true) != null; - } - - /// - /// Derives a template from the resource type, and checks if this template was already registered. - /// - private string? TemplateFromResource(ControllerModel model) - { - if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType)) - { - return $"{_options.Namespace}/{resourceType.PublicName}"; - } - - return null; - } - - /// - /// Derives a template from the controller name, and checks if this template was already registered. - /// - private string TemplateFromController(ControllerModel model) + private static bool IsOperationsController(Type type) { - string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null - ? model.ControllerName - : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName); - - return $"{_options.Namespace}/{controllerName}"; + Type baseControllerType = typeof(BaseJsonApiOperationsController); + return baseControllerType.IsAssignableFrom(type); } /// @@ -213,10 +196,47 @@ private string TemplateFromController(ControllerModel model) return currentType?.GetGenericArguments().First(); } - private static bool IsOperationsController(Type type) + private void RemoveDisabledActionMethods(ControllerModel controller, ResourceType resourceType) { - Type baseControllerType = typeof(BaseJsonApiOperationsController); - return baseControllerType.IsAssignableFrom(type); + foreach (ActionModel actionModel in controller.Actions.ToArray()) + { + JsonApiEndpoints endpoint = actionModel.Attributes.OfType().GetJsonApiEndpoint(); + + if (endpoint != JsonApiEndpoints.None && !_jsonApiEndpointFilter.IsEnabled(resourceType, endpoint)) + { + controller.Actions.Remove(actionModel); + } + } + } + + private static bool IsRoutingConventionDisabled(ControllerModel controller) + { + return controller.ControllerType.GetCustomAttribute(true) != null; + } + + /// + /// Derives a template from the resource type, and checks if this template was already registered. + /// + private string? TemplateFromResource(ControllerModel model) + { + if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType)) + { + return $"{_options.Namespace}/{resourceType.PublicName}"; + } + + return null; + } + + /// + /// Derives a template from the controller name, and checks if this template was already registered. + /// + private string TemplateFromController(ControllerModel model) + { + string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null + ? model.ControllerName + : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName); + + return $"{_options.Namespace}/{controllerName}"; } [LoggerMessage(Level = LogLevel.Warning, diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs index d82342a0bc..ffe73f7b9a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs @@ -18,12 +18,14 @@ public abstract class ObfuscatedIdentifiableController( private readonly HexadecimalCodec _codec = new(); [HttpGet] + [HttpHead] public override Task GetAsync(CancellationToken cancellationToken) { return base.GetAsync(cancellationToken); } [HttpGet("{id}")] + [HttpHead("{id}")] public Task GetAsync([Required] string id, CancellationToken cancellationToken) { int idValue = _codec.Decode(id); @@ -31,6 +33,7 @@ public Task GetAsync([Required] string id, CancellationToken canc } [HttpGet("{id}/{relationshipName}")] + [HttpHead("{id}/{relationshipName}")] public Task GetSecondaryAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, CancellationToken cancellationToken) { @@ -39,6 +42,7 @@ public Task GetSecondaryAsync([Required] string id, [Required] [P } [HttpGet("{id}/relationships/{relationshipName}")] + [HttpHead("{id}/relationships/{relationshipName}")] public Task GetRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, CancellationToken cancellationToken) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/EndpointFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/EndpointFilterTests.cs new file mode 100644 index 0000000000..87289ab555 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/RestrictedControllers/EndpointFilterTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.RestrictedControllers; + +public sealed class EndpointFilterTests : IClassFixture, RestrictionDbContext>> +{ + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new(); + + public EndpointFilterTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServices(services => services.AddSingleton()); + } + + [Fact] + public async Task Cannot_get_relationship() + { + // Arrange + Bed bed = _fakers.Bed.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(bed); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/beds/{bed.StringId}/relationships/pillows"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + } + + private sealed class NoRelationshipsAtBedJsonApiEndpointFilter : IJsonApiEndpointFilter + { + public bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint) + { + return !IsGetRelationshipAtBed(endpoint, resourceType); + } + + private static bool IsGetRelationshipAtBed(JsonApiEndpoints endpoint, ResourceType resourceType) + { + bool isRelationshipEndpoint = endpoint is JsonApiEndpoints.GetRelationship or JsonApiEndpoints.PostRelationship or + JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship; + + return isRelationshipEndpoint && resourceType.ClrType == typeof(Bed); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Controllers/GetJsonApiEndpointTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Controllers/GetJsonApiEndpointTests.cs new file mode 100644 index 0000000000..f78edebe49 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Controllers/GetJsonApiEndpointTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.Controllers; + +public sealed class GetJsonApiEndpointTests +{ + [Theory] + [InlineData("GET", null, JsonApiEndpoints.GetCollection)] + [InlineData("GET", "{id}", JsonApiEndpoints.GetSingle)] + [InlineData("GET", "{id}/{relationshipName}", JsonApiEndpoints.GetSecondary)] + [InlineData("GET", "{id}/relationships/{relationshipName}", JsonApiEndpoints.GetRelationship)] + [InlineData("POST", null, JsonApiEndpoints.Post)] + [InlineData("POST", "{id}/relationships/{relationshipName}", JsonApiEndpoints.PostRelationship)] + [InlineData("PATCH", "{id}", JsonApiEndpoints.Patch)] + [InlineData("PATCH", "{id}/relationships/{relationshipName}", JsonApiEndpoints.PatchRelationship)] + [InlineData("DELETE", "{id}", JsonApiEndpoints.Delete)] + [InlineData("DELETE", "{id}/relationships/{relationshipName}", JsonApiEndpoints.DeleteRelationship)] + [InlineData("PUT", null, JsonApiEndpoints.None)] + public void Can_identify_endpoint_from_http_method_and_route_template(string httpMethod, string? routeTemplate, JsonApiEndpoints expected) + { + // Arrange + HttpMethodAttribute attribute = httpMethod switch + { + "GET" => routeTemplate == null ? new HttpGetAttribute() : new HttpGetAttribute(routeTemplate), + "POST" => routeTemplate == null ? new HttpPostAttribute() : new HttpPostAttribute(routeTemplate), + "PATCH" => routeTemplate == null ? new HttpPatchAttribute() : new HttpPatchAttribute(routeTemplate), + "DELETE" => routeTemplate == null ? new HttpDeleteAttribute() : new HttpDeleteAttribute(routeTemplate), + "PUT" => routeTemplate == null ? new HttpPutAttribute() : new HttpPutAttribute(routeTemplate), + _ => throw new ArgumentOutOfRangeException(nameof(httpMethod), httpMethod, null) + }; + + // Act + JsonApiEndpoints endpoint = HttpMethodAttributeExtensions.GetJsonApiEndpoint([attribute]); + + // Assert + endpoint.Should().Be(expected); + } +} From d98a732f130a23e278301e3148587e49af4c376b Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:30:50 +0100 Subject: [PATCH 2/4] Rename for consistency --- .../BaseJsonApiOperationsController.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 1ed6afec83..3c8ebac01a 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -136,11 +136,11 @@ protected virtual void ValidateEnabledOperations(IList opera for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++) { IJsonApiRequest operationRequest = operations[operationIndex].Request; - WriteOperationKind operationKind = operationRequest.WriteOperation!.Value; + WriteOperationKind writeOperation = operationRequest.WriteOperation!.Value; - if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, operationKind)) + if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, writeOperation)) { - string operationCode = GetOperationCodeText(operationKind); + string operationCode = GetOperationCodeText(writeOperation); errors.Add(new ErrorObject(HttpStatusCode.Forbidden) { @@ -153,9 +153,9 @@ protected virtual void ValidateEnabledOperations(IList opera } }); } - else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, operationKind)) + else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, writeOperation)) { - string operationCode = GetOperationCodeText(operationKind); + string operationCode = GetOperationCodeText(writeOperation); errors.Add(new ErrorObject(HttpStatusCode.Forbidden) { @@ -175,9 +175,9 @@ protected virtual void ValidateEnabledOperations(IList opera } } - private static string GetOperationCodeText(WriteOperationKind operationKind) + private static string GetOperationCodeText(WriteOperationKind writeOperation) { - AtomicOperationCode operationCode = operationKind switch + AtomicOperationCode operationCode = writeOperation switch { WriteOperationKind.CreateResource => AtomicOperationCode.Add, WriteOperationKind.UpdateResource => AtomicOperationCode.Update, @@ -185,7 +185,7 @@ private static string GetOperationCodeText(WriteOperationKind operationKind) WriteOperationKind.AddToRelationship => AtomicOperationCode.Add, WriteOperationKind.SetRelationship => AtomicOperationCode.Update, WriteOperationKind.RemoveFromRelationship => AtomicOperationCode.Remove, - _ => throw new NotSupportedException($"Unknown operation kind '{operationKind}'.") + _ => throw new NotSupportedException($"Unknown operation kind '{writeOperation}'.") }; return operationCode.ToString().ToLowerInvariant(); From 5dd48c42835cae3be062c11a7a9862037d6a556f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 Nov 2024 16:31:21 +0100 Subject: [PATCH 3/4] Clarify documenation --- src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index d8d5d63f3e..07817698e0 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -217,7 +217,7 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) } /// - /// Returns all directly and indirectly non-abstract resource types that derive from this resource type. + /// Returns all non-abstract resource types that directly or indirectly derive from this resource type. /// public IReadOnlySet GetAllConcreteDerivedTypes() { From 11aafa435a96c99926122b9a7f63179e34b1b1db Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:07:37 +0100 Subject: [PATCH 4/4] Remove JsonApiEndpoint, reuse logic from base library --- ...onApiActionDescriptorCollectionProvider.cs | 8 +- .../JsonApiEndpoint.cs | 16 -- .../JsonApiMetadata/EndpointResolver.cs | 60 ++----- .../JsonApiEndpointMetadataProvider.cs | 43 +++-- .../JsonApiPathParameter.cs | 8 - .../JsonApiRoutingTemplate.cs | 12 -- .../OpenApiEndpointConvention.cs | 155 +++++++++++------- .../ServiceCollectionExtensions.cs | 1 - .../EndpointOrderingFilter.cs | 4 +- 9 files changed, 132 insertions(+), 175 deletions(-) delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiEndpoint.cs delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiPathParameter.cs delete mode 100644 src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRoutingTemplate.cs diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs index f8fb49d6bf..486d261490 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs @@ -163,7 +163,7 @@ private static List Expand(ActionDescriptor genericEndpoint, N ActionDescriptor expandedEndpoint = Clone(genericEndpoint); - RemovePathParameter(expandedEndpoint.Parameters, JsonApiPathParameter.RelationshipName); + RemovePathParameter(expandedEndpoint.Parameters, "relationshipName"); ExpandTemplate(expandedEndpoint.AttributeRouteInfo!, relationshipName); @@ -212,12 +212,12 @@ private static FilterDescriptor Clone(FilterDescriptor descriptor) private static void RemovePathParameter(ICollection parameters, string parameterName) { - ParameterDescriptor relationshipName = parameters.Single(parameterDescriptor => parameterDescriptor.Name == parameterName); - parameters.Remove(relationshipName); + ParameterDescriptor descriptor = parameters.Single(parameterDescriptor => parameterDescriptor.Name == parameterName); + parameters.Remove(descriptor); } private static void ExpandTemplate(AttributeRouteInfo route, string expansionParameter) { - route.Template = route.Template!.Replace(JsonApiRoutingTemplate.RelationshipNameRoutePlaceholder, expansionParameter); + route.Template = route.Template!.Replace("{relationshipName}", expansionParameter); } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiEndpoint.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiEndpoint.cs deleted file mode 100644 index 570a97699b..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiEndpoint.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace JsonApiDotNetCore.OpenApi.Swashbuckle; - -internal enum JsonApiEndpoint -{ - GetCollection, - GetSingle, - GetSecondary, - GetRelationship, - PostResource, - PostRelationship, - PatchResource, - PatchRelationship, - DeleteResource, - DeleteRelationship, - PostOperations -} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs index fe5da000c9..6107956d78 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs @@ -1,72 +1,38 @@ using System.Reflection; using JsonApiDotNetCore.Controllers; -using Microsoft.AspNetCore.Mvc; +using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Mvc.Routing; namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; internal sealed class EndpointResolver { - public JsonApiEndpoint? Get(MethodInfo controllerAction) + public static EndpointResolver Instance { get; } = new(); + + private EndpointResolver() + { + } + + public JsonApiEndpoints GetEndpoint(MethodInfo controllerAction) { ArgumentGuard.NotNull(controllerAction); if (!IsJsonApiController(controllerAction)) { - return null; - } - - if (IsAtomicOperationsController(controllerAction)) - { - return JsonApiEndpoint.PostOperations; + return JsonApiEndpoints.None; } - HttpMethodAttribute? method = Attribute.GetCustomAttributes(controllerAction, true).OfType().FirstOrDefault(); - - return ResolveJsonApiEndpoint(method); + IEnumerable httpMethodAttributes = controllerAction.GetCustomAttributes(true); + return httpMethodAttributes.GetJsonApiEndpoint(); } - private static bool IsJsonApiController(MethodInfo controllerAction) + private bool IsJsonApiController(MethodInfo controllerAction) { return typeof(CoreJsonApiController).IsAssignableFrom(controllerAction.ReflectedType); } - private static bool IsAtomicOperationsController(MethodInfo controllerAction) + public bool IsAtomicOperationsController(MethodInfo controllerAction) { return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType); } - - private static JsonApiEndpoint? ResolveJsonApiEndpoint(HttpMethodAttribute? httpMethod) - { - return httpMethod switch - { - HttpGetAttribute attr => attr.Template switch - { - null => JsonApiEndpoint.GetCollection, - JsonApiRoutingTemplate.PrimaryEndpoint => JsonApiEndpoint.GetSingle, - JsonApiRoutingTemplate.SecondaryEndpoint => JsonApiEndpoint.GetSecondary, - JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.GetRelationship, - _ => null - }, - HttpPostAttribute attr => attr.Template switch - { - null => JsonApiEndpoint.PostResource, - JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.PostRelationship, - _ => null - }, - HttpPatchAttribute attr => attr.Template switch - { - JsonApiRoutingTemplate.PrimaryEndpoint => JsonApiEndpoint.PatchResource, - JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.PatchRelationship, - _ => null - }, - HttpDeleteAttribute attr => attr.Template switch - { - JsonApiRoutingTemplate.PrimaryEndpoint => JsonApiEndpoint.DeleteResource, - JsonApiRoutingTemplate.RelationshipEndpoint => JsonApiEndpoint.DeleteRelationship, - _ => null - }, - _ => null - }; - } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs index 471c490b2d..1c22b1505e 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -1,5 +1,6 @@ using System.Reflection; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; using JsonApiDotNetCore.Resources.Annotations; @@ -12,18 +13,14 @@ namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; /// internal sealed class JsonApiEndpointMetadataProvider { - private readonly EndpointResolver _endpointResolver; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory; - public JsonApiEndpointMetadataProvider(EndpointResolver endpointResolver, IControllerResourceMapping controllerResourceMapping, - NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory) + public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerResourceMapping, NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory) { - ArgumentGuard.NotNull(endpointResolver); ArgumentGuard.NotNull(controllerResourceMapping); ArgumentGuard.NotNull(nonPrimaryDocumentTypeFactory); - _endpointResolver = endpointResolver; _controllerResourceMapping = controllerResourceMapping; _nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory; } @@ -32,16 +29,16 @@ public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction) { ArgumentGuard.NotNull(controllerAction); - JsonApiEndpoint? endpoint = _endpointResolver.Get(controllerAction); - - if (endpoint == null) + if (EndpointResolver.Instance.IsAtomicOperationsController(controllerAction)) { - throw new NotSupportedException($"Unable to provide metadata for non-JSON:API endpoint '{controllerAction.ReflectedType!.FullName}'."); + return new JsonApiEndpointMetadataContainer(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance); } - if (endpoint == JsonApiEndpoint.PostOperations) + JsonApiEndpoints endpoint = EndpointResolver.Instance.GetEndpoint(controllerAction); + + if (endpoint == JsonApiEndpoints.None) { - return new JsonApiEndpointMetadataContainer(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance); + throw new NotSupportedException($"Unable to provide metadata for non-JSON:API endpoint '{controllerAction.ReflectedType!.FullName}'."); } ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType); @@ -51,19 +48,19 @@ public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction) throw new UnreachableCodeException(); } - IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint.Value, primaryResourceType); - IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint.Value, primaryResourceType); + IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint, primaryResourceType); + IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint, primaryResourceType); return new JsonApiEndpointMetadataContainer(requestMetadata, responseMetadata); } - private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoint endpoint, ResourceType primaryResourceType) + private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType) { return endpoint switch { - JsonApiEndpoint.PostResource => GetPostResourceRequestMetadata(primaryResourceType.ClrType), - JsonApiEndpoint.PatchResource => GetPatchResourceRequestMetadata(primaryResourceType.ClrType), - JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship => GetRelationshipRequestMetadata( - primaryResourceType.Relationships, endpoint != JsonApiEndpoint.PatchRelationship), + JsonApiEndpoints.Post => GetPostResourceRequestMetadata(primaryResourceType.ClrType), + JsonApiEndpoints.Patch => GetPatchResourceRequestMetadata(primaryResourceType.ClrType), + JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => GetRelationshipRequestMetadata( + primaryResourceType.Relationships, endpoint != JsonApiEndpoints.PatchRelationship), _ => null }; } @@ -92,14 +89,14 @@ private RelationshipRequestMetadata GetRelationshipRequestMetadata(IEnumerable - GetPrimaryResponseMetadata(primaryResourceType.ClrType, endpoint == JsonApiEndpoint.GetCollection), - JsonApiEndpoint.GetSecondary => GetSecondaryResponseMetadata(primaryResourceType.Relationships), - JsonApiEndpoint.GetRelationship => GetRelationshipResponseMetadata(primaryResourceType.Relationships), + JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.Post or JsonApiEndpoints.Patch => GetPrimaryResponseMetadata( + primaryResourceType.ClrType, endpoint == JsonApiEndpoints.GetCollection), + JsonApiEndpoints.GetSecondary => GetSecondaryResponseMetadata(primaryResourceType.Relationships), + JsonApiEndpoints.GetRelationship => GetRelationshipResponseMetadata(primaryResourceType.Relationships), _ => null }; } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiPathParameter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiPathParameter.cs deleted file mode 100644 index 9edc5af9eb..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiPathParameter.cs +++ /dev/null @@ -1,8 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static - -namespace JsonApiDotNetCore.OpenApi.Swashbuckle; - -internal static class JsonApiPathParameter -{ - public const string RelationshipName = "relationshipName"; -} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRoutingTemplate.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRoutingTemplate.cs deleted file mode 100644 index 62d118ea56..0000000000 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRoutingTemplate.cs +++ /dev/null @@ -1,12 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static - -namespace JsonApiDotNetCore.OpenApi.Swashbuckle; - -internal static class JsonApiRoutingTemplate -{ - public const string RelationshipNameRoutePlaceholder = "{" + JsonApiPathParameter.RelationshipName + "}"; - public const string RelationshipsPart = "relationships"; - public const string PrimaryEndpoint = "{id}"; - public const string SecondaryEndpoint = "{id}/" + RelationshipNameRoutePlaceholder; - public const string RelationshipEndpoint = "{id}/" + RelationshipsPart + "/" + RelationshipNameRoutePlaceholder; -} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs index c055d2753e..6f202daca8 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs @@ -17,17 +17,14 @@ namespace JsonApiDotNetCore.OpenApi.Swashbuckle; internal sealed class OpenApiEndpointConvention : IActionModelConvention { private readonly IControllerResourceMapping _controllerResourceMapping; - private readonly EndpointResolver _endpointResolver; private readonly IJsonApiOptions _options; - public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping, EndpointResolver endpointResolver, IJsonApiOptions options) + public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options) { ArgumentGuard.NotNull(controllerResourceMapping); - ArgumentGuard.NotNull(endpointResolver); ArgumentGuard.NotNull(options); _controllerResourceMapping = controllerResourceMapping; - _endpointResolver = endpointResolver; _options = options; } @@ -35,9 +32,9 @@ public void Apply(ActionModel action) { ArgumentGuard.NotNull(action); - JsonApiEndpoint? endpoint = _endpointResolver.Get(action.ActionMethod); + JsonApiEndpointWrapper endpoint = JsonApiEndpointWrapper.FromActionModel(action); - if (endpoint == null) + if (endpoint.IsUnknown) { // Not a JSON:API controller, or a non-standard action method in a JSON:API controller. // None of these are yet implemented, so hide them to avoid downstream crashes. @@ -47,36 +44,36 @@ public void Apply(ActionModel action) ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(action.Controller.ControllerType); - if (ShouldSuppressEndpoint(endpoint.Value, resourceType)) + if (ShouldSuppressEndpoint(endpoint, resourceType)) { action.ApiExplorer.IsVisible = false; return; } - SetResponseMetadata(action, endpoint.Value, resourceType); - SetRequestMetadata(action, endpoint.Value); + SetResponseMetadata(action, endpoint, resourceType); + SetRequestMetadata(action, endpoint); } - private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, ResourceType? resourceType) + private bool ShouldSuppressEndpoint(JsonApiEndpointWrapper endpoint, ResourceType? resourceType) { if (resourceType == null) { return false; } - if (!IsEndpointAvailable(endpoint, resourceType)) + if (!IsEndpointAvailable(endpoint.Value, resourceType)) { return true; } - if (IsSecondaryOrRelationshipEndpoint(endpoint)) + if (IsSecondaryOrRelationshipEndpoint(endpoint.Value)) { if (resourceType.Relationships.Count == 0) { return true; } - if (endpoint is JsonApiEndpoint.DeleteRelationship or JsonApiEndpoint.PostRelationship) + if (endpoint.Value is JsonApiEndpoints.DeleteRelationship or JsonApiEndpoints.PostRelationship) { return !resourceType.Relationships.OfType().Any(); } @@ -85,7 +82,7 @@ private bool ShouldSuppressEndpoint(JsonApiEndpoint endpoint, ResourceType? reso return false; } - private static bool IsEndpointAvailable(JsonApiEndpoint endpoint, ResourceType resourceType) + private static bool IsEndpointAvailable(JsonApiEndpoints endpoint, ResourceType resourceType) { JsonApiEndpoints availableEndpoints = GetGeneratedControllerEndpoints(resourceType); @@ -101,16 +98,16 @@ private static bool IsEndpointAvailable(JsonApiEndpoint endpoint, ResourceType r // Otherwise, it is considered to be an action method that throws because the endpoint is unavailable. return endpoint switch { - JsonApiEndpoint.GetCollection => availableEndpoints.HasFlag(JsonApiEndpoints.GetCollection), - JsonApiEndpoint.GetSingle => availableEndpoints.HasFlag(JsonApiEndpoints.GetSingle), - JsonApiEndpoint.GetSecondary => availableEndpoints.HasFlag(JsonApiEndpoints.GetSecondary), - JsonApiEndpoint.GetRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.GetRelationship), - JsonApiEndpoint.PostResource => availableEndpoints.HasFlag(JsonApiEndpoints.Post), - JsonApiEndpoint.PostRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PostRelationship), - JsonApiEndpoint.PatchResource => availableEndpoints.HasFlag(JsonApiEndpoints.Patch), - JsonApiEndpoint.PatchRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PatchRelationship), - JsonApiEndpoint.DeleteResource => availableEndpoints.HasFlag(JsonApiEndpoints.Delete), - JsonApiEndpoint.DeleteRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.DeleteRelationship), + JsonApiEndpoints.GetCollection => availableEndpoints.HasFlag(JsonApiEndpoints.GetCollection), + JsonApiEndpoints.GetSingle => availableEndpoints.HasFlag(JsonApiEndpoints.GetSingle), + JsonApiEndpoints.GetSecondary => availableEndpoints.HasFlag(JsonApiEndpoints.GetSecondary), + JsonApiEndpoints.GetRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.GetRelationship), + JsonApiEndpoints.Post => availableEndpoints.HasFlag(JsonApiEndpoints.Post), + JsonApiEndpoints.PostRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PostRelationship), + JsonApiEndpoints.Patch => availableEndpoints.HasFlag(JsonApiEndpoints.Patch), + JsonApiEndpoints.PatchRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.PatchRelationship), + JsonApiEndpoints.Delete => availableEndpoints.HasFlag(JsonApiEndpoints.Delete), + JsonApiEndpoints.DeleteRelationship => availableEndpoints.HasFlag(JsonApiEndpoints.DeleteRelationship), _ => throw new UnreachableCodeException() }; } @@ -121,13 +118,13 @@ private static JsonApiEndpoints GetGeneratedControllerEndpoints(ResourceType res return resourceAttribute?.GenerateControllerEndpoints ?? JsonApiEndpoints.None; } - private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoint endpoint) + private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoints endpoint) { - return endpoint is JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship or JsonApiEndpoint.PostRelationship or - JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship; + return endpoint is JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship or JsonApiEndpoints.PostRelationship or + JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship; } - private void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint, ResourceType? resourceType) + private void SetResponseMetadata(ActionModel action, JsonApiEndpointWrapper endpoint, ResourceType? resourceType) { JsonApiMediaType mediaType = GetMediaTypeForEndpoint(endpoint); action.Filters.Add(new ProducesAttribute(mediaType.ToString())); @@ -144,57 +141,73 @@ private void SetResponseMetadata(ActionModel action, JsonApiEndpoint endpoint, R } } - private JsonApiMediaType GetMediaTypeForEndpoint(JsonApiEndpoint endpoint) + private JsonApiMediaType GetMediaTypeForEndpoint(JsonApiEndpointWrapper endpoint) { - return endpoint == JsonApiEndpoint.PostOperations ? JsonApiMediaType.RelaxedAtomicOperations : JsonApiMediaType.Default; + return endpoint.IsAtomicOperationsEndpoint ? JsonApiMediaType.RelaxedAtomicOperations : JsonApiMediaType.Default; } - private static HttpStatusCode[] GetSuccessStatusCodesForEndpoint(JsonApiEndpoint endpoint) + private static HttpStatusCode[] GetSuccessStatusCodesForEndpoint(JsonApiEndpointWrapper endpoint) { - return endpoint switch + if (endpoint.IsAtomicOperationsEndpoint) { - JsonApiEndpoint.GetCollection or JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship => + return + [ + HttpStatusCode.OK, + HttpStatusCode.NoContent + ]; + } + + return endpoint.Value switch + { + JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship => [ HttpStatusCode.OK, HttpStatusCode.NotModified ], - JsonApiEndpoint.PostResource => + JsonApiEndpoints.Post => [ HttpStatusCode.Created, HttpStatusCode.NoContent ], - JsonApiEndpoint.PatchResource => + JsonApiEndpoints.Patch => [ HttpStatusCode.OK, HttpStatusCode.NoContent ], - JsonApiEndpoint.DeleteResource or JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship => + JsonApiEndpoints.Delete or JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => [ HttpStatusCode.NoContent ], - JsonApiEndpoint.PostOperations => - [ - HttpStatusCode.OK, - HttpStatusCode.NoContent - ], _ => throw new UnreachableCodeException() }; } - private HttpStatusCode[] GetErrorStatusCodesForEndpoint(JsonApiEndpoint endpoint, ResourceType? resourceType) + private HttpStatusCode[] GetErrorStatusCodesForEndpoint(JsonApiEndpointWrapper endpoint, ResourceType? resourceType) { + if (endpoint.IsAtomicOperationsEndpoint) + { + return + [ + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ]; + } + // Condition doesn't apply to atomic operations, because Forbidden is also used when an operation is not accessible. ClientIdGenerationMode clientIdGeneration = resourceType?.ClientIdGeneration ?? _options.ClientIdGeneration; - return endpoint switch + return endpoint.Value switch { - JsonApiEndpoint.GetCollection => [HttpStatusCode.BadRequest], - JsonApiEndpoint.GetSingle or JsonApiEndpoint.GetSecondary or JsonApiEndpoint.GetRelationship => + JsonApiEndpoints.GetCollection => [HttpStatusCode.BadRequest], + JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship => [ HttpStatusCode.BadRequest, HttpStatusCode.NotFound ], - JsonApiEndpoint.PostResource when clientIdGeneration == ClientIdGenerationMode.Forbidden => + JsonApiEndpoints.Post when clientIdGeneration == ClientIdGenerationMode.Forbidden => [ HttpStatusCode.BadRequest, HttpStatusCode.Forbidden, @@ -202,40 +215,32 @@ private HttpStatusCode[] GetErrorStatusCodesForEndpoint(JsonApiEndpoint endpoint HttpStatusCode.Conflict, HttpStatusCode.UnprocessableEntity ], - JsonApiEndpoint.PostResource => + JsonApiEndpoints.Post => [ HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict, HttpStatusCode.UnprocessableEntity ], - JsonApiEndpoint.PatchResource => + JsonApiEndpoints.Patch => [ HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict, HttpStatusCode.UnprocessableEntity ], - JsonApiEndpoint.DeleteResource => [HttpStatusCode.NotFound], - JsonApiEndpoint.PostRelationship or JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship => + JsonApiEndpoints.Delete => [HttpStatusCode.NotFound], + JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => [ HttpStatusCode.BadRequest, HttpStatusCode.NotFound, HttpStatusCode.Conflict ], - JsonApiEndpoint.PostOperations => - [ - HttpStatusCode.BadRequest, - HttpStatusCode.Forbidden, - HttpStatusCode.NotFound, - HttpStatusCode.Conflict, - HttpStatusCode.UnprocessableEntity - ], _ => throw new UnreachableCodeException() }; } - private void SetRequestMetadata(ActionModel action, JsonApiEndpoint endpoint) + private void SetRequestMetadata(ActionModel action, JsonApiEndpointWrapper endpoint) { if (RequiresRequestBody(endpoint)) { @@ -244,9 +249,35 @@ private void SetRequestMetadata(ActionModel action, JsonApiEndpoint endpoint) } } - private static bool RequiresRequestBody(JsonApiEndpoint endpoint) + private static bool RequiresRequestBody(JsonApiEndpointWrapper endpoint) + { + return endpoint.IsAtomicOperationsEndpoint || endpoint.Value is JsonApiEndpoints.Post or JsonApiEndpoints.Patch or JsonApiEndpoints.PostRelationship or + JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship; + } + + private sealed class JsonApiEndpointWrapper { - return endpoint is JsonApiEndpoint.PostResource or JsonApiEndpoint.PatchResource or JsonApiEndpoint.PostRelationship or - JsonApiEndpoint.PatchRelationship or JsonApiEndpoint.DeleteRelationship or JsonApiEndpoint.PostOperations; + private static readonly JsonApiEndpointWrapper AtomicOperations = new(true, JsonApiEndpoints.None); + + public bool IsAtomicOperationsEndpoint { get; } + public JsonApiEndpoints Value { get; } + public bool IsUnknown => !IsAtomicOperationsEndpoint && Value == JsonApiEndpoints.None; + + private JsonApiEndpointWrapper(bool isAtomicOperationsEndpoint, JsonApiEndpoints value) + { + IsAtomicOperationsEndpoint = isAtomicOperationsEndpoint; + Value = value; + } + + public static JsonApiEndpointWrapper FromActionModel(ActionModel actionModel) + { + if (EndpointResolver.Instance.IsAtomicOperationsController(actionModel.ActionMethod)) + { + return AtomicOperations; + } + + JsonApiEndpoints endpoint = EndpointResolver.Instance.GetEndpoint(actionModel.ActionMethod); + return new JsonApiEndpointWrapper(false, endpoint); + } } } diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs index f7c26217d1..e6507042f6 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs @@ -36,7 +36,6 @@ private static void AddCustomApiExplorer(IServiceCollection services) { services.TryAddSingleton(); services.TryAddSingleton(); - services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/EndpointOrderingFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/EndpointOrderingFilter.cs index d5a5398cb4..48fdcfdbc8 100644 --- a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/EndpointOrderingFilter.cs +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/EndpointOrderingFilter.cs @@ -12,7 +12,7 @@ internal sealed class EndpointOrderingFilter : IDocumentFilter internal sealed partial class EndpointOrderingFilter : IDocumentFilter #endif { - private const string PatternText = $@".*{JsonApiRoutingTemplate.PrimaryEndpoint}/(?>{JsonApiRoutingTemplate.RelationshipsPart}\/)?(?\w+)"; + private const string PatternText = @".*{id}/(?>relationships\/)?(?\w+)"; #if NET6_0 private const RegexOptions RegexOptionsNet60 = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture; @@ -42,7 +42,7 @@ private static string GetPrimaryResourcePublicName(KeyValuePair entry) { - return entry.Key.Contains($"/{JsonApiRoutingTemplate.RelationshipsPart}"); + return entry.Key.Contains("/relationships"); } private static string GetRelationshipName(KeyValuePair entry)