diff --git a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs index d8d5d63f3..07817698e 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() { diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs index 240efbf93..47d534c5b 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 8f9419558..8dc8f47b7 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/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 1ed6afec8..3c8ebac01 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(); diff --git a/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs b/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs new file mode 100644 index 000000000..c3918d746 --- /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 000000000..e00cdd326 --- /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 000000000..6dbf81bce --- /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 72cac28da..8403109d1 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 d82342a0b..ffe73f7b9 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 000000000..87289ab55 --- /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 000000000..f78edebe4 --- /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); + } +}