Skip to content

Commit 8f9e5f8

Browse files
committed
Add IAtomicOperationFilter, which is used to constrain the exposed atomic:operations.
1 parent 461d6d3 commit 8f9e5f8

File tree

15 files changed

+362
-46
lines changed

15 files changed

+362
-46
lines changed

Diff for: docs/usage/writing/bulk-batch-operations.md

+13-2
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,24 @@ public sealed class OperationsController : JsonApiOperationsController
1919
{
2020
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph,
2121
ILoggerFactory loggerFactory, IOperationsProcessor processor,
22-
IJsonApiRequest request, ITargetedFields targetedFields)
23-
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
22+
IJsonApiRequest request, ITargetedFields targetedFields,
23+
IAtomicOperationFilter operationFilter)
24+
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields,
25+
operationFilter)
2426
{
2527
}
2628
}
2729
```
2830

31+
> [!IMPORTANT]
32+
> Since v5.6.0, the set of exposed operations is based on
33+
> [`GenerateControllerEndpoints` usage](~/usage/extensibility/controllers.md#resource-access-control).
34+
> Earlier versions always exposed all operations for all resource types.
35+
> If you're using [explicit controllers](~/usage/extensibility/controllers.md#explicit-controllers),
36+
> register and implement your own
37+
> [`IAtomicOperationFilter`](~/api/JsonApiDotNetCore.AtomicOperations.IAtomicOperationFilter.yml)
38+
> to indicate which operations to expose.
39+
2940
You'll need to send the next Content-Type in a POST request for operations:
3041

3142
```

Diff for: src/Examples/DapperExample/Controllers/OperationsController.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ namespace DapperExample.Controllers;
88

99
public sealed class OperationsController(
1010
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
11-
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
11+
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
12+
request, targetedFields, operationFilter);

Diff for: src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ namespace JsonApiDotNetCoreExample.Controllers;
88

99
public sealed class OperationsController(
1010
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
11-
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
11+
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
12+
request, targetedFields, operationFilter);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.Reflection;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Controllers;
4+
using JsonApiDotNetCore.Middleware;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace JsonApiDotNetCore.AtomicOperations;
8+
9+
/// <inheritdoc />
10+
internal sealed class DefaultOperationFilter : IAtomicOperationFilter
11+
{
12+
/// <inheritdoc />
13+
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
14+
{
15+
var resourceAttribute = resourceType.ClrType.GetCustomAttribute<ResourceAttribute>();
16+
return resourceAttribute != null && Contains(resourceAttribute.GenerateControllerEndpoints, writeOperation);
17+
}
18+
19+
private static bool Contains(JsonApiEndpoints endpoints, WriteOperationKind writeOperation)
20+
{
21+
return writeOperation switch
22+
{
23+
WriteOperationKind.CreateResource => endpoints.HasFlag(JsonApiEndpoints.Post),
24+
WriteOperationKind.UpdateResource => endpoints.HasFlag(JsonApiEndpoints.Patch),
25+
WriteOperationKind.DeleteResource => endpoints.HasFlag(JsonApiEndpoints.Delete),
26+
WriteOperationKind.SetRelationship => endpoints.HasFlag(JsonApiEndpoints.PatchRelationship),
27+
WriteOperationKind.AddToRelationship => endpoints.HasFlag(JsonApiEndpoints.PostRelationship),
28+
WriteOperationKind.RemoveFromRelationship => endpoints.HasFlag(JsonApiEndpoints.DeleteRelationship),
29+
_ => false
30+
};
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using JsonApiDotNetCore.Configuration;
2+
using JsonApiDotNetCore.Middleware;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCore.AtomicOperations;
6+
7+
/// <summary>
8+
/// Determines whether an operation in an atomic:operations request can be used.
9+
/// </summary>
10+
/// <remarks>
11+
/// The default implementation relies on the usage of <see cref="ResourceAttribute.GenerateControllerEndpoints" />. If you're using explicit
12+
/// (non-generated) controllers, register your own implementation to indicate which operations are accessible.
13+
/// </remarks>
14+
public interface IAtomicOperationFilter
15+
{
16+
/// <summary>
17+
/// An <see cref="IAtomicOperationFilter" /> that always returns <c>true</c>. Provided for convenience, to revert to the original behavior from before
18+
/// filtering was introduced.
19+
/// </summary>
20+
public static IAtomicOperationFilter AlwaysEnabled { get; } = new AlwaysEnabledOperationFilter();
21+
22+
/// <summary>
23+
/// Determines whether the specified operation can be used in an atomic:operations request.
24+
/// </summary>
25+
/// <param name="resourceType">
26+
/// The targeted primary resource type of the operation.
27+
/// </param>
28+
/// <param name="writeOperation">
29+
/// The operation kind.
30+
/// </param>
31+
bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation);
32+
33+
private sealed class AlwaysEnabledOperationFilter : IAtomicOperationFilter
34+
{
35+
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
36+
{
37+
return true;
38+
}
39+
}
40+
}

Diff for: src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -300,5 +300,6 @@ private void AddOperationsLayer()
300300
_services.TryAddScoped<IOperationsProcessor, OperationsProcessor>();
301301
_services.TryAddScoped<IOperationProcessorAccessor, OperationProcessorAccessor>();
302302
_services.TryAddScoped<ILocalIdTracker, LocalIdTracker>();
303+
_services.TryAddSingleton<IAtomicOperationFilter, DefaultOperationFilter>();
303304
}
304305
}

Diff for: src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs

+70-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
using System.Net;
12
using JetBrains.Annotations;
23
using JsonApiDotNetCore.AtomicOperations;
34
using JsonApiDotNetCore.Configuration;
45
using JsonApiDotNetCore.Errors;
56
using JsonApiDotNetCore.Middleware;
67
using JsonApiDotNetCore.Resources;
8+
using JsonApiDotNetCore.Serialization.Objects;
79
using Microsoft.AspNetCore.Mvc;
810
using Microsoft.AspNetCore.Mvc.ModelBinding;
911
using Microsoft.Extensions.Logging;
@@ -22,23 +24,26 @@ public abstract class BaseJsonApiOperationsController : CoreJsonApiController
2224
private readonly IOperationsProcessor _processor;
2325
private readonly IJsonApiRequest _request;
2426
private readonly ITargetedFields _targetedFields;
27+
private readonly IAtomicOperationFilter _operationFilter;
2528
private readonly TraceLogWriter<BaseJsonApiOperationsController> _traceWriter;
2629

2730
protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory,
28-
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields)
31+
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields, IAtomicOperationFilter operationFilter)
2932
{
3033
ArgumentGuard.NotNull(options);
3134
ArgumentGuard.NotNull(resourceGraph);
3235
ArgumentGuard.NotNull(loggerFactory);
3336
ArgumentGuard.NotNull(processor);
3437
ArgumentGuard.NotNull(request);
3538
ArgumentGuard.NotNull(targetedFields);
39+
ArgumentGuard.NotNull(operationFilter);
3640

3741
_options = options;
3842
_resourceGraph = resourceGraph;
3943
_processor = processor;
4044
_request = request;
4145
_targetedFields = targetedFields;
46+
_operationFilter = operationFilter;
4247
_traceWriter = new TraceLogWriter<BaseJsonApiOperationsController>(loggerFactory);
4348
}
4449

@@ -111,6 +116,8 @@ public virtual async Task<IActionResult> PostOperationsAsync([FromBody] IList<Op
111116

112117
ArgumentGuard.NotNull(operations);
113118

119+
ValidateEnabledOperations(operations);
120+
114121
if (_options.ValidateModelState)
115122
{
116123
ValidateModelState(operations);
@@ -120,6 +127,68 @@ public virtual async Task<IActionResult> PostOperationsAsync([FromBody] IList<Op
120127
return results.Any(result => result != null) ? Ok(results) : NoContent();
121128
}
122129

130+
protected virtual void ValidateEnabledOperations(IList<OperationContainer> operations)
131+
{
132+
List<ErrorObject> errors = [];
133+
134+
for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++)
135+
{
136+
IJsonApiRequest operationRequest = operations[operationIndex].Request;
137+
WriteOperationKind operationKind = operationRequest.WriteOperation!.Value;
138+
139+
if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, operationKind))
140+
{
141+
string operationCode = GetOperationCodeText(operationKind);
142+
143+
errors.Add(new ErrorObject(HttpStatusCode.UnprocessableEntity)
144+
{
145+
Title = "The requested operation is not accessible.",
146+
Detail = $"The '{operationCode}' relationship operation is not accessible for relationship '{operationRequest.Relationship}' " +
147+
$"on resource type '{operationRequest.Relationship.LeftType}'.",
148+
Source = new ErrorSource
149+
{
150+
Pointer = $"/atomic:operations[{operationIndex}]"
151+
}
152+
});
153+
}
154+
else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, operationKind))
155+
{
156+
string operationCode = GetOperationCodeText(operationKind);
157+
158+
errors.Add(new ErrorObject(HttpStatusCode.UnprocessableEntity)
159+
{
160+
Title = "The requested operation is not accessible.",
161+
Detail = $"The '{operationCode}' resource operation is not accessible for resource type '{operationRequest.PrimaryResourceType}'.",
162+
Source = new ErrorSource
163+
{
164+
Pointer = $"/atomic:operations[{operationIndex}]"
165+
}
166+
});
167+
}
168+
}
169+
170+
if (errors.Count > 0)
171+
{
172+
throw new JsonApiException(errors);
173+
}
174+
}
175+
176+
private static string GetOperationCodeText(WriteOperationKind operationKind)
177+
{
178+
AtomicOperationCode operationCode = operationKind switch
179+
{
180+
WriteOperationKind.CreateResource => AtomicOperationCode.Add,
181+
WriteOperationKind.UpdateResource => AtomicOperationCode.Update,
182+
WriteOperationKind.DeleteResource => AtomicOperationCode.Remove,
183+
WriteOperationKind.AddToRelationship => AtomicOperationCode.Add,
184+
WriteOperationKind.SetRelationship => AtomicOperationCode.Update,
185+
WriteOperationKind.RemoveFromRelationship => AtomicOperationCode.Remove,
186+
_ => throw new NotSupportedException($"Unknown operation kind '{operationKind}'.")
187+
};
188+
189+
return operationCode.ToString().ToLowerInvariant();
190+
}
191+
123192
protected virtual void ValidateModelState(IList<OperationContainer> operations)
124193
{
125194
// We must validate the resource inside each operation manually, because they are typed as IIdentifiable.

Diff for: src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ namespace JsonApiDotNetCore.Controllers;
1414
/// </summary>
1515
public abstract class JsonApiOperationsController(
1616
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
17-
ITargetedFields targetedFields) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields)
17+
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
18+
request, targetedFields, operationFilter)
1819
{
1920
/// <inheritdoc />
2021
[HttpPost]

Diff for: test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs renamed to test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs

+9-9
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66

77
namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers;
88

9-
public sealed class AtomicConstrainedOperationsControllerTests
9+
public sealed class AtomicCustomConstrainedOperationsControllerTests
1010
: IClassFixture<IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext>>
1111
{
1212
private readonly IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> _testContext;
1313
private readonly OperationsFakers _fakers = new();
1414

15-
public AtomicConstrainedOperationsControllerTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> testContext)
15+
public AtomicCustomConstrainedOperationsControllerTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> testContext)
1616
{
1717
_testContext = testContext;
1818

@@ -102,14 +102,14 @@ public async Task Cannot_create_resource_for_mismatching_resource_type()
102102

103103
ErrorObject error = responseDocument.Errors[0];
104104
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
105-
error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint.");
106-
error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'.");
105+
error.Title.Should().Be("The requested operation is not accessible.");
106+
error.Detail.Should().Be("The 'add' resource operation is not accessible for resource type 'performers'.");
107107
error.Source.ShouldNotBeNull();
108108
error.Source.Pointer.Should().Be("/atomic:operations[0]");
109109
}
110110

111111
[Fact]
112-
public async Task Cannot_update_resources_for_matching_resource_type()
112+
public async Task Cannot_update_resource_for_matching_resource_type()
113113
{
114114
// Arrange
115115
MusicTrack existingTrack = _fakers.MusicTrack.Generate();
@@ -151,8 +151,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
151151

152152
ErrorObject error = responseDocument.Errors[0];
153153
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
154-
error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint.");
155-
error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'.");
154+
error.Title.Should().Be("The requested operation is not accessible.");
155+
error.Detail.Should().Be("The 'update' resource operation is not accessible for resource type 'musicTracks'.");
156156
error.Source.ShouldNotBeNull();
157157
error.Source.Pointer.Should().Be("/atomic:operations[0]");
158158
}
@@ -207,8 +207,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
207207

208208
ErrorObject error = responseDocument.Errors[0];
209209
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
210-
error.Title.Should().Be("Unsupported combination of operation code and resource type at this endpoint.");
211-
error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'.");
210+
error.Title.Should().Be("The requested operation is not accessible.");
211+
error.Detail.Should().Be("The 'add' relationship operation is not accessible for relationship 'performers' on resource type 'musicTracks'.");
212212
error.Source.ShouldNotBeNull();
213213
error.Source.Pointer.Should().Be("/atomic:operations[0]");
214214
}

0 commit comments

Comments
 (0)