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