Skip to content

Commit 1053099

Browse files
authored
Merge pull request #1570 from json-api-dotnet/merge-master-into-openapi
Merge master into openapi
2 parents 18a4edf + 5df4928 commit 1053099

File tree

20 files changed

+398
-91
lines changed

20 files changed

+398
-91
lines changed

.config/dotnet-tools.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"isRoot": true,
44
"tools": {
55
"jetbrains.resharper.globaltools": {
6-
"version": "2024.1.2",
6+
"version": "2024.1.4",
77
"commands": [
88
"jb"
99
]
@@ -15,7 +15,7 @@
1515
]
1616
},
1717
"dotnet-reportgenerator-globaltool": {
18-
"version": "5.3.0",
18+
"version": "5.3.6",
1919
"commands": [
2020
"reportgenerator"
2121
]

.github/workflows/build.yml

-30
Original file line numberDiff line numberDiff line change
@@ -48,36 +48,6 @@ jobs:
4848
dotnet-version: |
4949
6.0.x
5050
8.0.x
51-
- name: Setup PowerShell (Ubuntu)
52-
if: matrix.os == 'ubuntu-latest'
53-
run: |
54-
dotnet tool install --global PowerShell
55-
- name: Find latest PowerShell version (Windows)
56-
if: matrix.os == 'windows-latest'
57-
shell: pwsh
58-
run: |
59-
$packageName = "powershell"
60-
$outputText = dotnet tool search $packageName --take 1
61-
$outputLine = ("" + $outputText)
62-
$indexOfVersionLine = $outputLine.IndexOf($packageName)
63-
$latestVersion = $outputLine.substring($indexOfVersionLine + $packageName.length).trim().split(" ")[0].trim()
64-
65-
Write-Output "Found PowerShell version: $latestVersion"
66-
Write-Output "POWERSHELL_LATEST_VERSION=$latestVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
67-
- name: Setup PowerShell (Windows)
68-
if: matrix.os == 'windows-latest'
69-
shell: cmd
70-
run: |
71-
set DOWNLOAD_LINK=https://github.com/PowerShell/PowerShell/releases/download/v%POWERSHELL_LATEST_VERSION%/PowerShell-%POWERSHELL_LATEST_VERSION%-win-x64.msi
72-
set OUTPUT_PATH=%RUNNER_TEMP%\PowerShell-%POWERSHELL_LATEST_VERSION%-win-x64.msi
73-
echo Downloading from: %DOWNLOAD_LINK% to: %OUTPUT_PATH%
74-
curl --location --output %OUTPUT_PATH% %DOWNLOAD_LINK%
75-
msiexec.exe /package %OUTPUT_PATH% /quiet USE_MU=1 ENABLE_MU=1 ADD_PATH=1 DISABLE_TELEMETRY=1
76-
- name: Setup PowerShell (macOS)
77-
if: matrix.os == 'macos-latest'
78-
run: |
79-
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
80-
brew install --cask powershell
8151
- name: Show installed versions
8252
shell: pwsh
8353
run: |

docs/usage/writing/bulk-batch-operations.md

+13-2
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,24 @@ public sealed class OperationsController : JsonApiOperationsController
1919
{
2020
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph,
2121
ILoggerFactory loggerFactory, IOperationsProcessor processor,
22-
IJsonApiRequest request, ITargetedFields targetedFields)
23-
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
22+
IJsonApiRequest request, ITargetedFields targetedFields,
23+
IAtomicOperationFilter operationFilter)
24+
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields,
25+
operationFilter)
2426
{
2527
}
2628
}
2729
```
2830

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

3142
```

package-versions.props

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<BenchmarkDotNetVersion>0.13.*</BenchmarkDotNetVersion>
1313
<BlushingPenguinVersion>1.0.*</BlushingPenguinVersion>
1414
<BogusVersion>35.5.*</BogusVersion>
15-
<CodeAnalysisVersion>4.9.*</CodeAnalysisVersion>
15+
<CodeAnalysisVersion>4.10.*</CodeAnalysisVersion>
1616
<CoverletVersion>6.0.*</CoverletVersion>
1717
<DapperVersion>2.1.*</DapperVersion>
1818
<FluentAssertionsVersion>6.12.*</FluentAssertionsVersion>

src/Examples/DapperExample/Controllers/OperationsController.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ namespace DapperExample.Controllers;
88

99
public sealed class OperationsController(
1010
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
11-
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
11+
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
12+
request, targetedFields, operationFilter);

src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ namespace JsonApiDotNetCoreExample.Controllers;
88

99
public sealed class OperationsController(
1010
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
11-
ITargetedFields targetedFields) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields);
11+
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
12+
request, targetedFields, operationFilter);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.Reflection;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Controllers;
4+
using JsonApiDotNetCore.Middleware;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace JsonApiDotNetCore.AtomicOperations;
8+
9+
/// <inheritdoc />
10+
internal sealed class DefaultOperationFilter : IAtomicOperationFilter
11+
{
12+
/// <inheritdoc />
13+
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
14+
{
15+
var resourceAttribute = resourceType.ClrType.GetCustomAttribute<ResourceAttribute>();
16+
return resourceAttribute != null && Contains(resourceAttribute.GenerateControllerEndpoints, writeOperation);
17+
}
18+
19+
private static bool Contains(JsonApiEndpoints endpoints, WriteOperationKind writeOperation)
20+
{
21+
return writeOperation switch
22+
{
23+
WriteOperationKind.CreateResource => endpoints.HasFlag(JsonApiEndpoints.Post),
24+
WriteOperationKind.UpdateResource => endpoints.HasFlag(JsonApiEndpoints.Patch),
25+
WriteOperationKind.DeleteResource => endpoints.HasFlag(JsonApiEndpoints.Delete),
26+
WriteOperationKind.SetRelationship => endpoints.HasFlag(JsonApiEndpoints.PatchRelationship),
27+
WriteOperationKind.AddToRelationship => endpoints.HasFlag(JsonApiEndpoints.PostRelationship),
28+
WriteOperationKind.RemoveFromRelationship => endpoints.HasFlag(JsonApiEndpoints.DeleteRelationship),
29+
_ => false
30+
};
31+
}
32+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Middleware;
4+
using JsonApiDotNetCore.Resources.Annotations;
5+
6+
namespace JsonApiDotNetCore.AtomicOperations;
7+
8+
/// <summary>
9+
/// Determines whether an operation in an atomic:operations request can be used.
10+
/// </summary>
11+
/// <remarks>
12+
/// The default implementation relies on the usage of <see cref="ResourceAttribute.GenerateControllerEndpoints" />. If you're using explicit
13+
/// (non-generated) controllers, register your own implementation to indicate which operations are accessible.
14+
/// </remarks>
15+
[PublicAPI]
16+
public interface IAtomicOperationFilter
17+
{
18+
/// <summary>
19+
/// An <see cref="IAtomicOperationFilter" /> that always returns <c>true</c>. Provided for convenience, to revert to the original behavior from before
20+
/// filtering was introduced.
21+
/// </summary>
22+
public static IAtomicOperationFilter AlwaysEnabled { get; } = new AlwaysEnabledOperationFilter();
23+
24+
/// <summary>
25+
/// Determines whether the specified operation can be used in an atomic:operations request.
26+
/// </summary>
27+
/// <param name="resourceType">
28+
/// The targeted primary resource type of the operation.
29+
/// </param>
30+
/// <param name="writeOperation">
31+
/// The operation kind.
32+
/// </param>
33+
bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation);
34+
35+
private sealed class AlwaysEnabledOperationFilter : IAtomicOperationFilter
36+
{
37+
public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation)
38+
{
39+
return true;
40+
}
41+
}
42+
}

src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -300,5 +300,6 @@ private void AddOperationsLayer()
300300
_services.TryAddScoped<IOperationsProcessor, OperationsProcessor>();
301301
_services.TryAddScoped<IOperationProcessorAccessor, OperationProcessorAccessor>();
302302
_services.TryAddScoped<ILocalIdTracker, LocalIdTracker>();
303+
_services.TryAddSingleton<IAtomicOperationFilter, DefaultOperationFilter>();
303304
}
304305
}

src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs

+70-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
using System.Net;
12
using JetBrains.Annotations;
23
using JsonApiDotNetCore.AtomicOperations;
34
using JsonApiDotNetCore.Configuration;
45
using JsonApiDotNetCore.Errors;
56
using JsonApiDotNetCore.Middleware;
67
using JsonApiDotNetCore.Resources;
8+
using JsonApiDotNetCore.Serialization.Objects;
79
using Microsoft.AspNetCore.Mvc;
810
using Microsoft.AspNetCore.Mvc.ModelBinding;
911
using Microsoft.Extensions.Logging;
@@ -22,23 +24,26 @@ public abstract class BaseJsonApiOperationsController : CoreJsonApiController
2224
private readonly IOperationsProcessor _processor;
2325
private readonly IJsonApiRequest _request;
2426
private readonly ITargetedFields _targetedFields;
27+
private readonly IAtomicOperationFilter _operationFilter;
2528
private readonly TraceLogWriter<BaseJsonApiOperationsController> _traceWriter;
2629

2730
protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory,
28-
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields)
31+
IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields, IAtomicOperationFilter operationFilter)
2932
{
3033
ArgumentGuard.NotNull(options);
3134
ArgumentGuard.NotNull(resourceGraph);
3235
ArgumentGuard.NotNull(loggerFactory);
3336
ArgumentGuard.NotNull(processor);
3437
ArgumentGuard.NotNull(request);
3538
ArgumentGuard.NotNull(targetedFields);
39+
ArgumentGuard.NotNull(operationFilter);
3640

3741
_options = options;
3842
_resourceGraph = resourceGraph;
3943
_processor = processor;
4044
_request = request;
4145
_targetedFields = targetedFields;
46+
_operationFilter = operationFilter;
4247
_traceWriter = new TraceLogWriter<BaseJsonApiOperationsController>(loggerFactory);
4348
}
4449

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

112117
ArgumentGuard.NotNull(operations);
113118

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

130+
protected virtual void ValidateEnabledOperations(IList<OperationContainer> operations)
131+
{
132+
List<ErrorObject> errors = [];
133+
134+
for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++)
135+
{
136+
IJsonApiRequest operationRequest = operations[operationIndex].Request;
137+
WriteOperationKind operationKind = operationRequest.WriteOperation!.Value;
138+
139+
if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, operationKind))
140+
{
141+
string operationCode = GetOperationCodeText(operationKind);
142+
143+
errors.Add(new ErrorObject(HttpStatusCode.Forbidden)
144+
{
145+
Title = "The requested operation is not accessible.",
146+
Detail = $"The '{operationCode}' relationship operation is not accessible for relationship '{operationRequest.Relationship}' " +
147+
$"on resource type '{operationRequest.Relationship.LeftType}'.",
148+
Source = new ErrorSource
149+
{
150+
Pointer = $"/atomic:operations[{operationIndex}]"
151+
}
152+
});
153+
}
154+
else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, operationKind))
155+
{
156+
string operationCode = GetOperationCodeText(operationKind);
157+
158+
errors.Add(new ErrorObject(HttpStatusCode.Forbidden)
159+
{
160+
Title = "The requested operation is not accessible.",
161+
Detail = $"The '{operationCode}' resource operation is not accessible for resource type '{operationRequest.PrimaryResourceType}'.",
162+
Source = new ErrorSource
163+
{
164+
Pointer = $"/atomic:operations[{operationIndex}]"
165+
}
166+
});
167+
}
168+
}
169+
170+
if (errors.Count > 0)
171+
{
172+
throw new JsonApiException(errors);
173+
}
174+
}
175+
176+
private static string GetOperationCodeText(WriteOperationKind operationKind)
177+
{
178+
AtomicOperationCode operationCode = operationKind switch
179+
{
180+
WriteOperationKind.CreateResource => AtomicOperationCode.Add,
181+
WriteOperationKind.UpdateResource => AtomicOperationCode.Update,
182+
WriteOperationKind.DeleteResource => AtomicOperationCode.Remove,
183+
WriteOperationKind.AddToRelationship => AtomicOperationCode.Add,
184+
WriteOperationKind.SetRelationship => AtomicOperationCode.Update,
185+
WriteOperationKind.RemoveFromRelationship => AtomicOperationCode.Remove,
186+
_ => throw new NotSupportedException($"Unknown operation kind '{operationKind}'.")
187+
};
188+
189+
return operationCode.ToString().ToLowerInvariant();
190+
}
191+
123192
protected virtual void ValidateModelState(IList<OperationContainer> operations)
124193
{
125194
// We must validate the resource inside each operation manually, because they are typed as IIdentifiable.

src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ namespace JsonApiDotNetCore.Controllers;
1414
/// </summary>
1515
public abstract class JsonApiOperationsController(
1616
IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
17-
ITargetedFields targetedFields) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields)
17+
ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor,
18+
request, targetedFields, operationFilter)
1819
{
1920
/// <inheritdoc />
2021
[HttpPost]

src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs

+22-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,28 @@ public override void Write(Utf8JsonWriter writer, Document value, JsonSerializer
6161
if (!value.Results.IsNullOrEmpty())
6262
{
6363
writer.WritePropertyName(AtomicResultsText);
64-
WriteSubTree(writer, value.Results, options);
64+
writer.WriteStartArray();
65+
66+
foreach (AtomicResultObject result in value.Results)
67+
{
68+
writer.WriteStartObject();
69+
70+
if (result.Data.IsAssigned)
71+
{
72+
writer.WritePropertyName(DataText);
73+
WriteSubTree(writer, result.Data, options);
74+
}
75+
76+
if (!result.Meta.IsNullOrEmpty())
77+
{
78+
writer.WritePropertyName(MetaText);
79+
WriteSubTree(writer, result.Meta, options);
80+
}
81+
82+
writer.WriteEndObject();
83+
}
84+
85+
writer.WriteEndArray();
6586
}
6687

6788
if (!value.Errors.IsNullOrEmpty())

0 commit comments

Comments
 (0)