Skip to content

Commit

Permalink
Merge branch 'master' into merge-master-into-openapi
Browse files Browse the repository at this point in the history
  • Loading branch information
bkoelman committed Jun 25, 2024
2 parents 18a4edf + 491b003 commit 5df4928
Show file tree
Hide file tree
Showing 20 changed files with 398 additions and 91 deletions.
4 changes: 2 additions & 2 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2024.1.2",
"version": "2024.1.4",
"commands": [
"jb"
]
Expand All @@ -15,7 +15,7 @@
]
},
"dotnet-reportgenerator-globaltool": {
"version": "5.3.0",
"version": "5.3.6",
"commands": [
"reportgenerator"
]
Expand Down
30 changes: 0 additions & 30 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
15 changes: 13 additions & 2 deletions docs/usage/writing/bulk-batch-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ public sealed class OperationsController : JsonApiOperationsController
{
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph,
ILoggerFactory loggerFactory, IOperationsProcessor processor,
IJsonApiRequest request, ITargetedFields targetedFields)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
IJsonApiRequest request, ITargetedFields targetedFields,
IAtomicOperationFilter operationFilter)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields,
operationFilter)
{
}
}
```

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

```
Expand Down
2 changes: 1 addition & 1 deletion package-versions.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<BenchmarkDotNetVersion>0.13.*</BenchmarkDotNetVersion>
<BlushingPenguinVersion>1.0.*</BlushingPenguinVersion>
<BogusVersion>35.5.*</BogusVersion>
<CodeAnalysisVersion>4.9.*</CodeAnalysisVersion>
<CodeAnalysisVersion>4.10.*</CodeAnalysisVersion>
<CoverletVersion>6.0.*</CoverletVersion>
<DapperVersion>2.1.*</DapperVersion>
<FluentAssertionsVersion>6.12.*</FluentAssertionsVersion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ namespace DapperExample.Controllers;

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

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

namespace JsonApiDotNetCore.AtomicOperations;

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

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

namespace JsonApiDotNetCore.AtomicOperations;

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

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

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

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

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

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

ArgumentGuard.NotNull(operations);

ValidateEnabledOperations(operations);

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

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

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

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

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

0 comments on commit 5df4928

Please sign in to comment.