diff --git a/docs/usage/writing/bulk-batch-operations.md b/docs/usage/writing/bulk-batch-operations.md index 1ac35fd3fc..5756755b51 100644 --- a/docs/usage/writing/bulk-batch-operations.md +++ b/docs/usage/writing/bulk-batch-operations.md @@ -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: ``` diff --git a/src/Examples/DapperExample/Controllers/OperationsController.cs b/src/Examples/DapperExample/Controllers/OperationsController.cs index 6fe0eedd1d..2b9daf492f 100644 --- a/src/Examples/DapperExample/Controllers/OperationsController.cs +++ b/src/Examples/DapperExample/Controllers/OperationsController.cs @@ -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); diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs index e38b30d861..9d8d944967 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -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); diff --git a/src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs b/src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs new file mode 100644 index 0000000000..d1ec1bd65c --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.AtomicOperations; + +/// +internal sealed class DefaultOperationFilter : IAtomicOperationFilter +{ + /// + public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation) + { + var resourceAttribute = resourceType.ClrType.GetCustomAttribute(); + 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 + }; + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs new file mode 100644 index 0000000000..240efbf936 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs @@ -0,0 +1,42 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Determines whether an operation in an atomic:operations request can be used. +/// +/// +/// The default implementation relies on the usage of . If you're using explicit +/// (non-generated) controllers, register your own implementation to indicate which operations are accessible. +/// +[PublicAPI] +public interface IAtomicOperationFilter +{ + /// + /// An that always returns true. Provided for convenience, to revert to the original behavior from before + /// filtering was introduced. + /// + public static IAtomicOperationFilter AlwaysEnabled { get; } = new AlwaysEnabledOperationFilter(); + + /// + /// Determines whether the specified operation can be used in an atomic:operations request. + /// + /// + /// The targeted primary resource type of the operation. + /// + /// + /// The operation kind. + /// + bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation); + + private sealed class AlwaysEnabledOperationFilter : IAtomicOperationFilter + { + public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation) + { + return true; + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 2973a664f6..2f725e8c68 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -300,5 +300,6 @@ private void AddOperationsLayer() _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddScoped(); + _services.TryAddSingleton(); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 596b22794d..5485fad3e0 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -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; @@ -22,10 +24,11 @@ public abstract class BaseJsonApiOperationsController : CoreJsonApiController private readonly IOperationsProcessor _processor; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; + private readonly IAtomicOperationFilter _operationFilter; private readonly TraceLogWriter _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); @@ -33,12 +36,14 @@ protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGrap 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(loggerFactory); } @@ -111,6 +116,8 @@ public virtual async Task PostOperationsAsync([FromBody] IList PostOperationsAsync([FromBody] IList result != null) ? Ok(results) : NoContent(); } + protected virtual void ValidateEnabledOperations(IList operations) + { + List 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 operations) { // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index bc14d4886e..168800b571 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -14,7 +14,8 @@ namespace JsonApiDotNetCore.Controllers; /// 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) { /// [HttpPost] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs similarity index 86% rename from test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs rename to test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs index e56e9119bf..51cb1a53a2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs @@ -6,13 +6,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; -public sealed class AtomicConstrainedOperationsControllerTests +public sealed class AtomicCustomConstrainedOperationsControllerTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); - public AtomicConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicCustomConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -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(); @@ -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]"); } @@ -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]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs new file mode 100644 index 0000000000..14dc1ab83b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; + +public sealed class AtomicDefaultConstrainedOperationsControllerTests + : IClassFixture, OperationsDbContext>> +{ + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicDefaultConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Cannot_delete_resource_for_disabled_resource_endpoint() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("The requested operation is not accessible."); + error.Detail.Should().Be("The 'remove' resource operation is not accessible for resource type 'textLanguages'."); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_change_ToMany_relationship_for_disabled_resource_endpoints() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + Lyric existingLyric = _fakers.Lyric.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLanguage, existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId, + relationship = "lyrics" + }, + data = new[] + { + new + { + type = "lyrics", + id = existingLyric.StringId + } + } + }, + new + { + op = "add", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId, + relationship = "lyrics" + }, + data = new[] + { + new + { + type = "lyrics", + id = existingLyric.StringId + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId, + relationship = "lyrics" + }, + data = new[] + { + new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("The requested operation is not accessible."); + error1.Detail.Should().Be("The 'update' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); + error1.Source.ShouldNotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("The requested operation is not accessible."); + error2.Detail.Should().Be("The 'add' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); + error2.Source.ShouldNotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[1]"); + + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error3.Title.Should().Be("The requested operation is not accessible."); + error3.Detail.Should().Be("The 'remove' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); + error3.Source.ShouldNotBeNull(); + error3.Source.Pointer.Should().Be("/atomic:operations[2]"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index 01a378e5aa..b3f98df0bc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -1,12 +1,9 @@ -using System.Net; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -16,35 +13,20 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; [Route("/operations/musicTracks/create")] public sealed class CreateMusicTrackOperationsController( IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields) + ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, + OnlyCreateMusicTracksOperationFilter.Instance) { - public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) + private sealed class OnlyCreateMusicTracksOperationFilter : IAtomicOperationFilter { - AssertOnlyCreatingMusicTracks(operations); + public static readonly OnlyCreateMusicTracksOperationFilter Instance = new(); - return await base.PostOperationsAsync(operations, cancellationToken); - } - - private static void AssertOnlyCreatingMusicTracks(IEnumerable operations) - { - int index = 0; - - foreach (OperationContainer operation in operations) + private OnlyCreateMusicTracksOperationFilter() { - if (operation.Request.WriteOperation != WriteOperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) - { - throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) - { - Title = "Unsupported combination of operation code and resource type at this endpoint.", - Detail = "This endpoint can only be used to create resources of type 'musicTracks'.", - Source = new ErrorSource - { - Pointer = $"/atomic:operations[{index}]" - } - }); - } + } - index++; + public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation) + { + return writeOperation == WriteOperationKind.CreateResource && resourceType.ClrType == typeof(MusicTrack); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs index 5380300ede..78426804b3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs @@ -9,4 +9,5 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; 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); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs index 02e8bf6278..e4e440600d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -1,11 +1,13 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations", + GenerateControllerEndpoints = JsonApiEndpoints.Post | JsonApiEndpoints.Patch)] public sealed class TextLanguage : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs index 357ff7ef5a..de1cd02c20 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs @@ -12,7 +12,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; 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) { public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs index dfe7282eac..24300dfc5c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs @@ -9,4 +9,5 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; 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);