Skip to content

Commit

Permalink
Merge pull request #1561 from json-api-dotnet/constrained-operations
Browse files Browse the repository at this point in the history
Filter operations based on GenerateControllerEndpoints usage
  • Loading branch information
bkoelman authored Jun 18, 2024
2 parents 461d6d3 + e47faf3 commit 4e0f2a7
Show file tree
Hide file tree
Showing 15 changed files with 364 additions and 46 deletions.
15 changes: 13 additions & 2 deletions docs/usage/writing/bulk-batch-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ public sealed class OperationsController : JsonApiOperationsController
{
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph,
ILoggerFactory loggerFactory, IOperationsProcessor processor,
IJsonApiRequest request, ITargetedFields targetedFields)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
IJsonApiRequest request, ITargetedFields targetedFields,
IAtomicOperationFilter operationFilter)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields,
operationFilter)
{
}
}
```

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

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ namespace DapperExample.Controllers;

public sealed class OperationsController(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
request, targetedFields, operationFilter);
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ namespace JsonApiDotNetCoreExample.Controllers;

public sealed class OperationsController(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
request, targetedFields, operationFilter);
32 changes: 32 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Reflection;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCore.AtomicOperations;

/// <inheritdoc />
internal sealed class DefaultOperationFilter : IAtomicOperationFilter
{
/// <inheritdoc />
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
{
var resourceAttribute = resourceType.ClrType.GetCustomAttribute<ResourceAttribute>();
return resourceAttribute != null && Contains(resourceAttribute.GenerateControllerEndpoints, writeOperation);
}

private static bool Contains(JsonApiEndpoints endpoints, WriteOperationKind writeOperation)
{
return writeOperation switch
{
WriteOperationKind.CreateResource => endpoints.HasFlag(JsonApiEndpoints.Post),
WriteOperationKind.UpdateResource => endpoints.HasFlag(JsonApiEndpoints.Patch),
WriteOperationKind.DeleteResource => endpoints.HasFlag(JsonApiEndpoints.Delete),
WriteOperationKind.SetRelationship => endpoints.HasFlag(JsonApiEndpoints.PatchRelationship),
WriteOperationKind.AddToRelationship => endpoints.HasFlag(JsonApiEndpoints.PostRelationship),
WriteOperationKind.RemoveFromRelationship => endpoints.HasFlag(JsonApiEndpoints.DeleteRelationship),
_ => false
};
}
}
42 changes: 42 additions & 0 deletions src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCore.AtomicOperations;

/// <summary>
/// Determines whether an operation in an atomic:operations request can be used.
/// </summary>
/// <remarks>
/// The default implementation relies on the usage of <see cref="ResourceAttribute.GenerateControllerEndpoints" />. If you're using explicit
/// (non-generated) controllers, register your own implementation to indicate which operations are accessible.
/// </remarks>
[PublicAPI]
public interface IAtomicOperationFilter
{
/// <summary>
/// An <see cref="IAtomicOperationFilter" /> that always returns <c>true</c>. Provided for convenience, to revert to the original behavior from before
/// filtering was introduced.
/// </summary>
public static IAtomicOperationFilter AlwaysEnabled { get; } = new AlwaysEnabledOperationFilter();

/// <summary>
/// Determines whether the specified operation can be used in an atomic:operations request.
/// </summary>
/// <param name="resourceType">
/// The targeted primary resource type of the operation.
/// </param>
/// <param name="writeOperation">
/// The operation kind.
/// </param>
bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation);

private sealed class AlwaysEnabledOperationFilter : IAtomicOperationFilter
{
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
{
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -300,5 +300,6 @@ private void AddOperationsLayer()
_services.TryAddScoped<IOperationsProcessor, OperationsProcessor>();
_services.TryAddScoped<IOperationProcessorAccessor, OperationProcessorAccessor>();
_services.TryAddScoped<ILocalIdTracker, LocalIdTracker>();
_services.TryAddSingleton<IAtomicOperationFilter, DefaultOperationFilter>();
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System.Net;
using JetBrains.Annotations;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;
Expand All @@ -22,23 +24,26 @@ public abstract class BaseJsonApiOperationsController : CoreJsonApiController
private readonly IOperationsProcessor _processor;
private readonly IJsonApiRequest _request;
private readonly ITargetedFields _targetedFields;
private readonly IAtomicOperationFilter _operationFilter;
private readonly TraceLogWriter<BaseJsonApiOperationsController> _traceWriter;

protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory,
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields)
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields, IAtomicOperationFilter operationFilter)
{
ArgumentGuard.NotNull(options);
ArgumentGuard.NotNull(resourceGraph);
ArgumentGuard.NotNull(loggerFactory);
ArgumentGuard.NotNull(processor);
ArgumentGuard.NotNull(request);
ArgumentGuard.NotNull(targetedFields);
ArgumentGuard.NotNull(operationFilter);

_options = options;
_resourceGraph = resourceGraph;
_processor = processor;
_request = request;
_targetedFields = targetedFields;
_operationFilter = operationFilter;
_traceWriter = new TraceLogWriter<BaseJsonApiOperationsController>(loggerFactory);
}

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

ArgumentGuard.NotNull(operations);

ValidateEnabledOperations(operations);

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

protected virtual void ValidateEnabledOperations(IList<OperationContainer> operations)
{
List<ErrorObject> errors = [];

for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++)
{
IJsonApiRequest operationRequest = operations[operationIndex].Request;
WriteOperationKind operationKind = operationRequest.WriteOperation!.Value;

if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, operationKind))
{
string operationCode = GetOperationCodeText(operationKind);

errors.Add(new ErrorObject(HttpStatusCode.UnprocessableEntity)
{
Title = "The requested operation is not accessible.",
Detail = $"The '{operationCode}' relationship operation is not accessible for relationship '{operationRequest.Relationship}' " +
$"on resource type '{operationRequest.Relationship.LeftType}'.",
Source = new ErrorSource
{
Pointer = $"/atomic:operations[{operationIndex}]"
}
});
}
else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, operationKind))
{
string operationCode = GetOperationCodeText(operationKind);

errors.Add(new ErrorObject(HttpStatusCode.UnprocessableEntity)
{
Title = "The requested operation is not accessible.",
Detail = $"The '{operationCode}' resource operation is not accessible for resource type '{operationRequest.PrimaryResourceType}'.",
Source = new ErrorSource
{
Pointer = $"/atomic:operations[{operationIndex}]"
}
});
}
}

if (errors.Count > 0)
{
throw new JsonApiException(errors);
}
}

private static string GetOperationCodeText(WriteOperationKind operationKind)
{
AtomicOperationCode operationCode = operationKind 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}'.")
};

return operationCode.ToString().ToLowerInvariant();
}

protected virtual void ValidateModelState(IList<OperationContainer> operations)
{
// We must validate the resource inside each operation manually, because they are typed as IIdentifiable.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ namespace JsonApiDotNetCore.Controllers;
/// </summary>
public abstract class JsonApiOperationsController(
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
ITargetedFields targetedFields) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields)
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
request, targetedFields, operationFilter)
{
/// <inheritdoc />
[HttpPost]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@

namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers;

public sealed class AtomicConstrainedOperationsControllerTests
public sealed class AtomicCustomConstrainedOperationsControllerTests
: IClassFixture<IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext>>
{
private readonly IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> _testContext;
private readonly OperationsFakers _fakers = new();

public AtomicConstrainedOperationsControllerTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> testContext)
public AtomicCustomConstrainedOperationsControllerTests(IntegrationTestContext<TestableStartup<OperationsDbContext>, OperationsDbContext> testContext)
{
_testContext = testContext;

Expand Down Expand Up @@ -102,14 +102,14 @@ public async Task Cannot_create_resource_for_mismatching_resource_type()

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

[Fact]
public async Task Cannot_update_resources_for_matching_resource_type()
public async Task Cannot_update_resource_for_matching_resource_type()
{
// Arrange
MusicTrack existingTrack = _fakers.MusicTrack.Generate();
Expand Down Expand Up @@ -151,8 +151,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>

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

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

0 comments on commit 4e0f2a7

Please sign in to comment.