diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 91764e80f2..35524d4221 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
- "version": "2024.1.2",
+ "version": "2024.1.4",
"commands": [
"jb"
]
@@ -15,7 +15,7 @@
]
},
"dotnet-reportgenerator-globaltool": {
- "version": "5.3.0",
+ "version": "5.3.6",
"commands": [
"reportgenerator"
]
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0ed54e41f8..8f8e5d2437 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -48,36 +48,6 @@ jobs:
dotnet-version: |
6.0.x
8.0.x
- - name: Setup PowerShell (Ubuntu)
- if: matrix.os == 'ubuntu-latest'
- run: |
- dotnet tool install --global PowerShell
- - name: Find latest PowerShell version (Windows)
- if: matrix.os == 'windows-latest'
- shell: pwsh
- run: |
- $packageName = "powershell"
- $outputText = dotnet tool search $packageName --take 1
- $outputLine = ("" + $outputText)
- $indexOfVersionLine = $outputLine.IndexOf($packageName)
- $latestVersion = $outputLine.substring($indexOfVersionLine + $packageName.length).trim().split(" ")[0].trim()
-
- Write-Output "Found PowerShell version: $latestVersion"
- Write-Output "POWERSHELL_LATEST_VERSION=$latestVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- - name: Setup PowerShell (Windows)
- if: matrix.os == 'windows-latest'
- shell: cmd
- run: |
- set DOWNLOAD_LINK=https://github.com/PowerShell/PowerShell/releases/download/v%POWERSHELL_LATEST_VERSION%/PowerShell-%POWERSHELL_LATEST_VERSION%-win-x64.msi
- set OUTPUT_PATH=%RUNNER_TEMP%\PowerShell-%POWERSHELL_LATEST_VERSION%-win-x64.msi
- echo Downloading from: %DOWNLOAD_LINK% to: %OUTPUT_PATH%
- curl --location --output %OUTPUT_PATH% %DOWNLOAD_LINK%
- msiexec.exe /package %OUTPUT_PATH% /quiet USE_MU=1 ENABLE_MU=1 ADD_PATH=1 DISABLE_TELEMETRY=1
- - name: Setup PowerShell (macOS)
- if: matrix.os == 'macos-latest'
- run: |
- /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- brew install --cask powershell
- name: Show installed versions
shell: pwsh
run: |
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/package-versions.props b/package-versions.props
index 5b844b4c96..b9a5c36ce0 100644
--- a/package-versions.props
+++ b/package-versions.props
@@ -12,7 +12,7 @@
0.13.*
1.0.*
35.5.*
- 4.9.*
+ 4.10.*
6.0.*
2.1.*
6.12.*
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..b169bdd005 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.Forbidden)
+ {
+ 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.Forbidden)
+ {
+ 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/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs
index 2597afacac..a8f3e7f81e 100644
--- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs
+++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs
@@ -61,7 +61,28 @@ public override void Write(Utf8JsonWriter writer, Document value, JsonSerializer
if (!value.Results.IsNullOrEmpty())
{
writer.WritePropertyName(AtomicResultsText);
- WriteSubTree(writer, value.Results, options);
+ writer.WriteStartArray();
+
+ foreach (AtomicResultObject result in value.Results)
+ {
+ writer.WriteStartObject();
+
+ if (result.Data.IsAssigned)
+ {
+ writer.WritePropertyName(DataText);
+ WriteSubTree(writer, result.Data, options);
+ }
+
+ if (!result.Meta.IsNullOrEmpty())
+ {
+ writer.WritePropertyName(MetaText);
+ WriteSubTree(writer, result.Meta, options);
+ }
+
+ writer.WriteEndObject();
+ }
+
+ writer.WriteEndArray();
}
if (!value.Errors.IsNullOrEmpty())
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs
similarity index 78%
rename from test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs
rename to test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs
index e56e9119bf..099168b124 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;
@@ -69,7 +69,7 @@ public async Task Can_create_resources_for_matching_resource_type()
}
[Fact]
- public async Task Cannot_create_resource_for_mismatching_resource_type()
+ public async Task Cannot_create_resource_for_inaccessible_operation()
{
// Arrange
var requestBody = new
@@ -96,20 +96,20 @@ public async Task Cannot_create_resource_for_mismatching_resource_type()
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody);
// Assert
- httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden);
responseDocument.Errors.ShouldHaveCount(1);
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.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ 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_inaccessible_operation()
{
// Arrange
MusicTrack existingTrack = _fakers.MusicTrack.Generate();
@@ -145,20 +145,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody);
// Assert
- httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden);
responseDocument.Errors.ShouldHaveCount(1);
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.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ 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]");
}
[Fact]
- public async Task Cannot_add_to_ToMany_relationship_for_matching_resource_type()
+ public async Task Cannot_add_to_ToMany_relationship_for_inaccessible_operation()
{
// Arrange
MusicTrack existingTrack = _fakers.MusicTrack.Generate();
@@ -201,14 +201,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody);
// Assert
- httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity);
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden);
responseDocument.Errors.ShouldHaveCount(1);
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.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ 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..caffc32e2b
--- /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_inaccessible_operation()
+ {
+ // 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.Forbidden);
+
+ responseDocument.Errors.ShouldHaveCount(1);
+
+ ErrorObject error = responseDocument.Errors[0];
+ error.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ 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_inaccessible_operations()
+ {
+ // 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.Forbidden);
+
+ responseDocument.Errors.ShouldHaveCount(3);
+
+ ErrorObject error1 = responseDocument.Errors[0];
+ error1.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ 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.Forbidden);
+ 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.Forbidden);
+ 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/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs
index 4f6a2f0a3c..fc6d366f94 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs
@@ -101,9 +101,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
"self": "http://localhost/operations"
},
"atomic:results": [
- {
- "data": null
- },
+ {},
{
"data": {
"type": "textLanguages",
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);