Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IJsonApiEndpointFilter: remove controller action methods at runtime #1646

Merged
merged 3 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading