Skip to content

Merge master into openapi #1570

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
461d6d3
Bump dotnet-reportgenerator-globaltool from 5.3.0 to 5.3.4 (#1555)
dependabot[bot] May 29, 2024
e47faf3
Add IAtomicOperationFilter, which is used to constrain the exposed at…
bkoelman Jun 18, 2024
4e0f2a7
Merge pull request #1561 from json-api-dotnet/constrained-operations
bkoelman Jun 18, 2024
c3d1f7f
Return Forbidden when operation is inaccessible, to match resource en…
bkoelman Jun 19, 2024
8b0b90b
Remove installing PowerShell in cibuild, this is no longer needed
bkoelman Jun 19, 2024
a7389ed
Merge pull request #1562 from json-api-dotnet/fix-inaccessible-operat…
bkoelman Jun 19, 2024
0941d7c
Merge pull request #1563 from json-api-dotnet/remove-install-powershell
bkoelman Jun 19, 2024
81b82ad
Fixed: return empty object instead of data:null in operation results
bkoelman Jun 19, 2024
5583e4d
Merge pull request #1564 from json-api-dotnet/fix-operations-empty-re…
bkoelman Jun 19, 2024
efab186
Bump jetbrains.resharper.globaltools from 2024.1.2 to 2024.1.3 (#1558)
dependabot[bot] Jun 19, 2024
ee184d6
Bump dotnet-reportgenerator-globaltool from 5.3.4 to 5.3.6 (#1557)
dependabot[bot] Jun 19, 2024
4f3d5bd
Update Microsoft.CodeAnalysis.CSharp from 4.9.* to 4.10.* in tests (#…
dependabot[bot] Jun 19, 2024
2a4df13
Exclude kiota-lock.json from source control due to bug in Kiota
bkoelman Feb 26, 2024
491b003
Bump jetbrains.resharper.globaltools from 2024.1.3 to 2024.1.4 (#1569)
dependabot[bot] Jun 25, 2024
5df4928
Merge branch 'master' into merge-master-into-openapi
bkoelman Jun 25, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading