Skip to content

Commit

Permalink
Merge pull request #1646 from json-api-dotnet/endpoint-filter
Browse files Browse the repository at this point in the history
IJsonApiEndpointFilter: remove controller action methods at runtime
  • Loading branch information
bkoelman authored Nov 27, 2024
2 parents 980a67d + 5dd48c4 commit c4dde57
Show file tree
Hide file tree
Showing 11 changed files with 272 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName)
}

/// <summary>
/// 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.
/// </summary>
public IReadOnlySet<ResourceType> GetAllConcreteDerivedTypes()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace JsonApiDotNetCore.AtomicOperations;

/// <summary>
/// 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 <see cref="IJsonApiEndpointFilter" />.
/// </summary>
/// <remarks>
/// The default implementation relies on the usage of <see cref="ResourceAttribute.GenerateControllerEndpoints" />. If you're using explicit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ private void AddMiddlewareLayer()
_services.TryAddSingleton<IJsonApiOutputFormatter, JsonApiOutputFormatter>();
_services.TryAddSingleton<IJsonApiRoutingConvention, JsonApiRoutingConvention>();
_services.TryAddSingleton<IControllerResourceMapping>(provider => provider.GetRequiredService<IJsonApiRoutingConvention>());
_services.TryAddSingleton<IJsonApiEndpointFilter, AlwaysEnabledJsonApiEndpointFilter>();
_services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
_services.TryAddSingleton<IJsonApiContentNegotiator, JsonApiContentNegotiator>();
_services.TryAddScoped<IJsonApiRequest, JsonApiRequest>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,11 @@ protected virtual void ValidateEnabledOperations(IList<OperationContainer> 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)
{
Expand All @@ -153,9 +153,9 @@ protected virtual void ValidateEnabledOperations(IList<OperationContainer> 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)
{
Expand All @@ -175,17 +175,17 @@ protected virtual void ValidateEnabledOperations(IList<OperationContainer> 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,
WriteOperationKind.DeleteResource => AtomicOperationCode.Remove,
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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;

namespace JsonApiDotNetCore.Middleware;

internal sealed class AlwaysEnabledJsonApiEndpointFilter : IJsonApiEndpointFilter
{
/// <inheritdoc />
public bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint)
{
return true;
}
}
56 changes: 56 additions & 0 deletions src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<HttpMethodAttribute> 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
};
}
}
24 changes: 24 additions & 0 deletions src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;

namespace JsonApiDotNetCore.Middleware;

/// <summary>
/// Enables to remove JSON:API controller action methods at startup. For atomic:operation requests, see <see cref="IAtomicOperationFilter" />.
/// </summary>
[PublicAPI]
public interface IJsonApiEndpointFilter
{
/// <summary>
/// Determines whether to remove the associated controller action method.
/// </summary>
/// <param name="resourceType">
/// The primary resource type of the endpoint.
/// </param>
/// <param name="endpoint">
/// The JSON:API endpoint. Despite <see cref="JsonApiEndpoints" /> being a <see cref="FlagsAttribute" /> enum, a single value is always passed here.
/// </param>
bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint);
}
100 changes: 60 additions & 40 deletions src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// 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 <see cref="CoreJsonApiController" /> 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 <see cref="CoreJsonApiController" />), the serializer naming convention is applied on the
/// controller type name (camel-case by default).
/// </summary>
/// <example><![CDATA[
/// public class SomeResourceController : JsonApiController<SomeResource> { } // => /someResources/relationship/relatedResource
/// // controller name is ignored when resource type is available:
/// public class RandomNameController<SomeResource> : JsonApiController<SomeResource> { } // => /someResources
///
/// public class RandomNameController<SomeResource> : JsonApiController<SomeResource> { } // => /someResources/relationship/relatedResource
/// // when using kebab-case naming convention in options:
/// public class RandomNameController<SomeResource> : JsonApiController<SomeResource> { } // => /some-resources
///
/// // when using kebab-case naming convention:
/// public class SomeResourceController<SomeResource> : JsonApiController<SomeResource> { } // => /some-resources/relationship/related-resource
///
/// public class SomeVeryCustomController<SomeResource> : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource
/// // unable to determine resource type:
/// public class SomeVeryCustomController<SomeResource> : CoreJsonApiController { } // => /someVeryCustom
/// ]]></example>
[PublicAPI]
public sealed partial class JsonApiRoutingConvention : IJsonApiRoutingConvention
{
private readonly IJsonApiOptions _options;
private readonly IResourceGraph _resourceGraph;
private readonly IJsonApiEndpointFilter _jsonApiEndpointFilter;
private readonly ILogger<JsonApiRoutingConvention> _logger;
private readonly Dictionary<string, string> _registeredControllerNameByTemplate = [];
private readonly Dictionary<Type, ResourceType> _resourceTypePerControllerTypeMap = [];
private readonly Dictionary<ResourceType, ControllerModel> _controllerPerResourceTypeMap = [];

public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, ILogger<JsonApiRoutingConvention> logger)
public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, IJsonApiEndpointFilter jsonApiEndpointFilter,
ILogger<JsonApiRoutingConvention> logger)
{
ArgumentGuard.NotNull(options);
ArgumentGuard.NotNull(resourceGraph);
ArgumentGuard.NotNull(jsonApiEndpointFilter);
ArgumentGuard.NotNull(logger);

_options = options;
_resourceGraph = resourceGraph;
_jsonApiEndpointFilter = jsonApiEndpointFilter;
_logger = logger;
}

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -148,34 +155,10 @@ private static bool HasApiControllerAttribute(ControllerModel controller)
return controller.ControllerType.GetCustomAttribute<ApiControllerAttribute>() != null;
}

private static bool IsRoutingConventionDisabled(ControllerModel controller)
{
return controller.ControllerType.GetCustomAttribute<DisableRoutingConventionAttribute>(true) != null;
}

/// <summary>
/// Derives a template from the resource type, and checks if this template was already registered.
/// </summary>
private string? TemplateFromResource(ControllerModel model)
{
if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType))
{
return $"{_options.Namespace}/{resourceType.PublicName}";
}

return null;
}

/// <summary>
/// Derives a template from the controller name, and checks if this template was already registered.
/// </summary>
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);
}

/// <summary>
Expand Down Expand Up @@ -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<HttpMethodAttribute>().GetJsonApiEndpoint();

if (endpoint != JsonApiEndpoints.None && !_jsonApiEndpointFilter.IsEnabled(resourceType, endpoint))
{
controller.Actions.Remove(actionModel);
}
}
}

private static bool IsRoutingConventionDisabled(ControllerModel controller)
{
return controller.ControllerType.GetCustomAttribute<DisableRoutingConventionAttribute>(true) != null;
}

/// <summary>
/// Derives a template from the resource type, and checks if this template was already registered.
/// </summary>
private string? TemplateFromResource(ControllerModel model)
{
if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType))
{
return $"{_options.Namespace}/{resourceType.PublicName}";
}

return null;
}

/// <summary>
/// Derives a template from the controller name, and checks if this template was already registered.
/// </summary>
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,22 @@ public abstract class ObfuscatedIdentifiableController<TResource>(
private readonly HexadecimalCodec _codec = new();

[HttpGet]
[HttpHead]
public override Task<IActionResult> GetAsync(CancellationToken cancellationToken)
{
return base.GetAsync(cancellationToken);
}

[HttpGet("{id}")]
[HttpHead("{id}")]
public Task<IActionResult> GetAsync([Required] string id, CancellationToken cancellationToken)
{
int idValue = _codec.Decode(id);
return base.GetAsync(idValue, cancellationToken);
}

[HttpGet("{id}/{relationshipName}")]
[HttpHead("{id}/{relationshipName}")]
public Task<IActionResult> GetSecondaryAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
CancellationToken cancellationToken)
{
Expand All @@ -39,6 +42,7 @@ public Task<IActionResult> GetSecondaryAsync([Required] string id, [Required] [P
}

[HttpGet("{id}/relationships/{relationshipName}")]
[HttpHead("{id}/relationships/{relationshipName}")]
public Task<IActionResult> GetRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
CancellationToken cancellationToken)
{
Expand Down
Loading

0 comments on commit c4dde57

Please sign in to comment.