Skip to content

Commit c4dde57

Browse files
authored
Merge pull request #1646 from json-api-dotnet/endpoint-filter
IJsonApiEndpointFilter: remove controller action methods at runtime
2 parents 980a67d + 5dd48c4 commit c4dde57

File tree

11 files changed

+272
-50
lines changed

11 files changed

+272
-50
lines changed

src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName)
217217
}
218218

219219
/// <summary>
220-
/// Returns all directly and indirectly non-abstract resource types that derive from this resource type.
220+
/// Returns all non-abstract resource types that directly or indirectly derive from this resource type.
221221
/// </summary>
222222
public IReadOnlySet<ResourceType> GetAllConcreteDerivedTypes()
223223
{

src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
namespace JsonApiDotNetCore.AtomicOperations;
77

88
/// <summary>
9-
/// Determines whether an operation in an atomic:operations request can be used.
9+
/// Determines whether an operation in an atomic:operations request can be used. For non-operations requests, see <see cref="IJsonApiEndpointFilter" />.
1010
/// </summary>
1111
/// <remarks>
1212
/// The default implementation relies on the usage of <see cref="ResourceAttribute.GenerateControllerEndpoints" />. If you're using explicit

src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ private void AddMiddlewareLayer()
184184
_services.TryAddSingleton<IJsonApiOutputFormatter, JsonApiOutputFormatter>();
185185
_services.TryAddSingleton<IJsonApiRoutingConvention, JsonApiRoutingConvention>();
186186
_services.TryAddSingleton<IControllerResourceMapping>(provider => provider.GetRequiredService<IJsonApiRoutingConvention>());
187+
_services.TryAddSingleton<IJsonApiEndpointFilter, AlwaysEnabledJsonApiEndpointFilter>();
187188
_services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
188189
_services.TryAddSingleton<IJsonApiContentNegotiator, JsonApiContentNegotiator>();
189190
_services.TryAddScoped<IJsonApiRequest, JsonApiRequest>();

src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs

+8-8
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,11 @@ protected virtual void ValidateEnabledOperations(IList<OperationContainer> opera
136136
for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++)
137137
{
138138
IJsonApiRequest operationRequest = operations[operationIndex].Request;
139-
WriteOperationKind operationKind = operationRequest.WriteOperation!.Value;
139+
WriteOperationKind writeOperation = operationRequest.WriteOperation!.Value;
140140

141-
if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, operationKind))
141+
if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, writeOperation))
142142
{
143-
string operationCode = GetOperationCodeText(operationKind);
143+
string operationCode = GetOperationCodeText(writeOperation);
144144

145145
errors.Add(new ErrorObject(HttpStatusCode.Forbidden)
146146
{
@@ -153,9 +153,9 @@ protected virtual void ValidateEnabledOperations(IList<OperationContainer> opera
153153
}
154154
});
155155
}
156-
else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, operationKind))
156+
else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, writeOperation))
157157
{
158-
string operationCode = GetOperationCodeText(operationKind);
158+
string operationCode = GetOperationCodeText(writeOperation);
159159

160160
errors.Add(new ErrorObject(HttpStatusCode.Forbidden)
161161
{
@@ -175,17 +175,17 @@ protected virtual void ValidateEnabledOperations(IList<OperationContainer> opera
175175
}
176176
}
177177

178-
private static string GetOperationCodeText(WriteOperationKind operationKind)
178+
private static string GetOperationCodeText(WriteOperationKind writeOperation)
179179
{
180-
AtomicOperationCode operationCode = operationKind switch
180+
AtomicOperationCode operationCode = writeOperation switch
181181
{
182182
WriteOperationKind.CreateResource => AtomicOperationCode.Add,
183183
WriteOperationKind.UpdateResource => AtomicOperationCode.Update,
184184
WriteOperationKind.DeleteResource => AtomicOperationCode.Remove,
185185
WriteOperationKind.AddToRelationship => AtomicOperationCode.Add,
186186
WriteOperationKind.SetRelationship => AtomicOperationCode.Update,
187187
WriteOperationKind.RemoveFromRelationship => AtomicOperationCode.Remove,
188-
_ => throw new NotSupportedException($"Unknown operation kind '{operationKind}'.")
188+
_ => throw new NotSupportedException($"Unknown operation kind '{writeOperation}'.")
189189
};
190190

191191
return operationCode.ToString().ToLowerInvariant();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using JsonApiDotNetCore.Configuration;
2+
using JsonApiDotNetCore.Controllers;
3+
4+
namespace JsonApiDotNetCore.Middleware;
5+
6+
internal sealed class AlwaysEnabledJsonApiEndpointFilter : IJsonApiEndpointFilter
7+
{
8+
/// <inheritdoc />
9+
public bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint)
10+
{
11+
return true;
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using JsonApiDotNetCore.Controllers;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.AspNetCore.Mvc.Routing;
4+
5+
namespace JsonApiDotNetCore.Middleware;
6+
7+
internal static class HttpMethodAttributeExtensions
8+
{
9+
private const string IdTemplate = "{id}";
10+
private const string RelationshipNameTemplate = "{relationshipName}";
11+
private const string SecondaryEndpointTemplate = $"{IdTemplate}/{RelationshipNameTemplate}";
12+
private const string RelationshipEndpointTemplate = $"{IdTemplate}/relationships/{RelationshipNameTemplate}";
13+
14+
public static JsonApiEndpoints GetJsonApiEndpoint(this IEnumerable<HttpMethodAttribute> httpMethods)
15+
{
16+
ArgumentGuard.NotNull(httpMethods);
17+
18+
HttpMethodAttribute[] nonHeadAttributes = httpMethods.Where(attribute => attribute is not HttpHeadAttribute).ToArray();
19+
20+
return nonHeadAttributes.Length == 1 ? ResolveJsonApiEndpoint(nonHeadAttributes[0]) : JsonApiEndpoints.None;
21+
}
22+
23+
private static JsonApiEndpoints ResolveJsonApiEndpoint(HttpMethodAttribute httpMethod)
24+
{
25+
return httpMethod switch
26+
{
27+
HttpGetAttribute httpGet => httpGet.Template switch
28+
{
29+
null => JsonApiEndpoints.GetCollection,
30+
IdTemplate => JsonApiEndpoints.GetSingle,
31+
SecondaryEndpointTemplate => JsonApiEndpoints.GetSecondary,
32+
RelationshipEndpointTemplate => JsonApiEndpoints.GetRelationship,
33+
_ => JsonApiEndpoints.None
34+
},
35+
HttpPostAttribute httpPost => httpPost.Template switch
36+
{
37+
null => JsonApiEndpoints.Post,
38+
RelationshipEndpointTemplate => JsonApiEndpoints.PostRelationship,
39+
_ => JsonApiEndpoints.None
40+
},
41+
HttpPatchAttribute httpPatch => httpPatch.Template switch
42+
{
43+
IdTemplate => JsonApiEndpoints.Patch,
44+
RelationshipEndpointTemplate => JsonApiEndpoints.PatchRelationship,
45+
_ => JsonApiEndpoints.None
46+
},
47+
HttpDeleteAttribute httpDelete => httpDelete.Template switch
48+
{
49+
IdTemplate => JsonApiEndpoints.Delete,
50+
RelationshipEndpointTemplate => JsonApiEndpoints.DeleteRelationship,
51+
_ => JsonApiEndpoints.None
52+
},
53+
_ => JsonApiEndpoints.None
54+
};
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.AtomicOperations;
3+
using JsonApiDotNetCore.Configuration;
4+
using JsonApiDotNetCore.Controllers;
5+
6+
namespace JsonApiDotNetCore.Middleware;
7+
8+
/// <summary>
9+
/// Enables to remove JSON:API controller action methods at startup. For atomic:operation requests, see <see cref="IAtomicOperationFilter" />.
10+
/// </summary>
11+
[PublicAPI]
12+
public interface IJsonApiEndpointFilter
13+
{
14+
/// <summary>
15+
/// Determines whether to remove the associated controller action method.
16+
/// </summary>
17+
/// <param name="resourceType">
18+
/// The primary resource type of the endpoint.
19+
/// </param>
20+
/// <param name="endpoint">
21+
/// The JSON:API endpoint. Despite <see cref="JsonApiEndpoints" /> being a <see cref="FlagsAttribute" /> enum, a single value is always passed here.
22+
/// </param>
23+
bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint);
24+
}

src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs

+60-40
Original file line numberDiff line numberDiff line change
@@ -7,43 +7,48 @@
77
using JsonApiDotNetCore.Resources;
88
using Microsoft.AspNetCore.Mvc;
99
using Microsoft.AspNetCore.Mvc.ApplicationModels;
10+
using Microsoft.AspNetCore.Mvc.Routing;
1011
using Microsoft.Extensions.Logging;
1112

1213
namespace JsonApiDotNetCore.Middleware;
1314

1415
/// <summary>
15-
/// The default routing convention registers the name of the resource as the route using the serializer naming convention. The default for this is a
16-
/// camel case formatter. If the controller directly inherits from <see cref="CoreJsonApiController" /> and there is no resource directly associated, it
17-
/// uses the name of the controller instead of the name of the type.
16+
/// 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
17+
/// example, when a controller directly inherits from <see cref="CoreJsonApiController" />), the serializer naming convention is applied on the
18+
/// controller type name (camel-case by default).
1819
/// </summary>
1920
/// <example><![CDATA[
20-
/// public class SomeResourceController : JsonApiController<SomeResource> { } // => /someResources/relationship/relatedResource
21+
/// // controller name is ignored when resource type is available:
22+
/// public class RandomNameController<SomeResource> : JsonApiController<SomeResource> { } // => /someResources
2123
///
22-
/// public class RandomNameController<SomeResource> : JsonApiController<SomeResource> { } // => /someResources/relationship/relatedResource
24+
/// // when using kebab-case naming convention in options:
25+
/// public class RandomNameController<SomeResource> : JsonApiController<SomeResource> { } // => /some-resources
2326
///
24-
/// // when using kebab-case naming convention:
25-
/// public class SomeResourceController<SomeResource> : JsonApiController<SomeResource> { } // => /some-resources/relationship/related-resource
26-
///
27-
/// public class SomeVeryCustomController<SomeResource> : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource
27+
/// // unable to determine resource type:
28+
/// public class SomeVeryCustomController<SomeResource> : CoreJsonApiController { } // => /someVeryCustom
2829
/// ]]></example>
2930
[PublicAPI]
3031
public sealed partial class JsonApiRoutingConvention : IJsonApiRoutingConvention
3132
{
3233
private readonly IJsonApiOptions _options;
3334
private readonly IResourceGraph _resourceGraph;
35+
private readonly IJsonApiEndpointFilter _jsonApiEndpointFilter;
3436
private readonly ILogger<JsonApiRoutingConvention> _logger;
3537
private readonly Dictionary<string, string> _registeredControllerNameByTemplate = [];
3638
private readonly Dictionary<Type, ResourceType> _resourceTypePerControllerTypeMap = [];
3739
private readonly Dictionary<ResourceType, ControllerModel> _controllerPerResourceTypeMap = [];
3840

39-
public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, ILogger<JsonApiRoutingConvention> logger)
41+
public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, IJsonApiEndpointFilter jsonApiEndpointFilter,
42+
ILogger<JsonApiRoutingConvention> logger)
4043
{
4144
ArgumentGuard.NotNull(options);
4245
ArgumentGuard.NotNull(resourceGraph);
46+
ArgumentGuard.NotNull(jsonApiEndpointFilter);
4347
ArgumentGuard.NotNull(logger);
4448

4549
_options = options;
4650
_resourceGraph = resourceGraph;
51+
_jsonApiEndpointFilter = jsonApiEndpointFilter;
4752
_logger = logger;
4853
}
4954

@@ -106,6 +111,8 @@ public void Apply(ApplicationModel application)
106111
$"Multiple controllers found for resource type '{resourceType}': '{existingModel.ControllerType}' and '{controller.ControllerType}'.");
107112
}
108113

114+
RemoveDisabledActionMethods(controller, resourceType);
115+
109116
_resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType);
110117
_controllerPerResourceTypeMap.Add(resourceType, controller);
111118
}
@@ -148,34 +155,10 @@ private static bool HasApiControllerAttribute(ControllerModel controller)
148155
return controller.ControllerType.GetCustomAttribute<ApiControllerAttribute>() != null;
149156
}
150157

151-
private static bool IsRoutingConventionDisabled(ControllerModel controller)
152-
{
153-
return controller.ControllerType.GetCustomAttribute<DisableRoutingConventionAttribute>(true) != null;
154-
}
155-
156-
/// <summary>
157-
/// Derives a template from the resource type, and checks if this template was already registered.
158-
/// </summary>
159-
private string? TemplateFromResource(ControllerModel model)
160-
{
161-
if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType))
162-
{
163-
return $"{_options.Namespace}/{resourceType.PublicName}";
164-
}
165-
166-
return null;
167-
}
168-
169-
/// <summary>
170-
/// Derives a template from the controller name, and checks if this template was already registered.
171-
/// </summary>
172-
private string TemplateFromController(ControllerModel model)
158+
private static bool IsOperationsController(Type type)
173159
{
174-
string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null
175-
? model.ControllerName
176-
: _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName);
177-
178-
return $"{_options.Namespace}/{controllerName}";
160+
Type baseControllerType = typeof(BaseJsonApiOperationsController);
161+
return baseControllerType.IsAssignableFrom(type);
179162
}
180163

181164
/// <summary>
@@ -213,10 +196,47 @@ private string TemplateFromController(ControllerModel model)
213196
return currentType?.GetGenericArguments().First();
214197
}
215198

216-
private static bool IsOperationsController(Type type)
199+
private void RemoveDisabledActionMethods(ControllerModel controller, ResourceType resourceType)
217200
{
218-
Type baseControllerType = typeof(BaseJsonApiOperationsController);
219-
return baseControllerType.IsAssignableFrom(type);
201+
foreach (ActionModel actionModel in controller.Actions.ToArray())
202+
{
203+
JsonApiEndpoints endpoint = actionModel.Attributes.OfType<HttpMethodAttribute>().GetJsonApiEndpoint();
204+
205+
if (endpoint != JsonApiEndpoints.None && !_jsonApiEndpointFilter.IsEnabled(resourceType, endpoint))
206+
{
207+
controller.Actions.Remove(actionModel);
208+
}
209+
}
210+
}
211+
212+
private static bool IsRoutingConventionDisabled(ControllerModel controller)
213+
{
214+
return controller.ControllerType.GetCustomAttribute<DisableRoutingConventionAttribute>(true) != null;
215+
}
216+
217+
/// <summary>
218+
/// Derives a template from the resource type, and checks if this template was already registered.
219+
/// </summary>
220+
private string? TemplateFromResource(ControllerModel model)
221+
{
222+
if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType))
223+
{
224+
return $"{_options.Namespace}/{resourceType.PublicName}";
225+
}
226+
227+
return null;
228+
}
229+
230+
/// <summary>
231+
/// Derives a template from the controller name, and checks if this template was already registered.
232+
/// </summary>
233+
private string TemplateFromController(ControllerModel model)
234+
{
235+
string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null
236+
? model.ControllerName
237+
: _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName);
238+
239+
return $"{_options.Namespace}/{controllerName}";
220240
}
221241

222242
[LoggerMessage(Level = LogLevel.Warning,

test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs

+4
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,22 @@ public abstract class ObfuscatedIdentifiableController<TResource>(
1818
private readonly HexadecimalCodec _codec = new();
1919

2020
[HttpGet]
21+
[HttpHead]
2122
public override Task<IActionResult> GetAsync(CancellationToken cancellationToken)
2223
{
2324
return base.GetAsync(cancellationToken);
2425
}
2526

2627
[HttpGet("{id}")]
28+
[HttpHead("{id}")]
2729
public Task<IActionResult> GetAsync([Required] string id, CancellationToken cancellationToken)
2830
{
2931
int idValue = _codec.Decode(id);
3032
return base.GetAsync(idValue, cancellationToken);
3133
}
3234

3335
[HttpGet("{id}/{relationshipName}")]
36+
[HttpHead("{id}/{relationshipName}")]
3437
public Task<IActionResult> GetSecondaryAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
3538
CancellationToken cancellationToken)
3639
{
@@ -39,6 +42,7 @@ public Task<IActionResult> GetSecondaryAsync([Required] string id, [Required] [P
3942
}
4043

4144
[HttpGet("{id}/relationships/{relationshipName}")]
45+
[HttpHead("{id}/relationships/{relationshipName}")]
4246
public Task<IActionResult> GetRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName,
4347
CancellationToken cancellationToken)
4448
{

0 commit comments

Comments
 (0)