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);