Skip to content

Commit 6247d5c

Browse files
author
Bart Koelman
committed
Breaking: Added option to allow unknown attribute/relationship keys in request body (false by default)
1 parent 23bedc3 commit 6247d5c

File tree

11 files changed

+455
-10
lines changed

11 files changed

+455
-10
lines changed

src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs

+5
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ public interface IJsonApiOptions
118118
/// </summary>
119119
bool AllowUnknownQueryStringParameters { get; }
120120

121+
/// <summary>
122+
/// Whether or not to produce an error on unknown attribute and relationship keys in request bodies. False by default.
123+
/// </summary>
124+
bool AllowUnknownFieldsInRequestBody { get; }
125+
121126
/// <summary>
122127
/// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default.
123128
/// </summary>

src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs

+3
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ public sealed class JsonApiOptions : IJsonApiOptions
7474
/// <inheritdoc />
7575
public bool AllowUnknownQueryStringParameters { get; set; }
7676

77+
/// <inheritdoc />
78+
public bool AllowUnknownFieldsInRequestBody { get; set; }
79+
7780
/// <inheritdoc />
7881
public bool EnableLegacyFilterNotation { get; set; }
7982

src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs

+28-5
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,21 @@ namespace JsonApiDotNetCore.Serialization
1919
public abstract class BaseDeserializer
2020
{
2121
private protected static readonly CollectionConverter CollectionConverter = new();
22+
private readonly IJsonApiOptions _options;
2223

2324
protected IResourceGraph ResourceGraph { get; }
2425
protected IResourceFactory ResourceFactory { get; }
2526
protected int? AtomicOperationIndex { get; set; }
2627

27-
protected BaseDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory)
28+
protected BaseDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options)
2829
{
2930
ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph));
3031
ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory));
32+
ArgumentGuard.NotNull(options, nameof(options));
3133

3234
ResourceGraph = resourceGraph;
3335
ResourceFactory = resourceFactory;
36+
_options = options;
3437
}
3538

3639
/// <summary>
@@ -123,6 +126,8 @@ private IIdentifiable SetAttributes(IIdentifiable resource, IDictionary<string,
123126
{
124127
if (attributeValues.TryGetValue(attr.PublicName, out object newValue))
125128
{
129+
attributeValues.Remove(attr.PublicName);
130+
126131
if (attr.Property.SetMethod == null)
127132
{
128133
throw new JsonApiSerializationException("Attribute is read-only.", $"Attribute '{attr.PublicName}' is read-only.",
@@ -148,6 +153,14 @@ private IIdentifiable SetAttributes(IIdentifiable resource, IDictionary<string,
148153
}
149154
}
150155

156+
if (!_options.AllowUnknownFieldsInRequestBody && attributeValues.Any())
157+
{
158+
string attributeName = attributeValues.First().Key;
159+
160+
throw new JsonApiSerializationException("Request body includes unknown attribute.", $"Attribute '{attributeName}' does not exist.",
161+
atomicOperationIndex: AtomicOperationIndex);
162+
}
163+
151164
return resource;
152165
}
153166

@@ -160,24 +173,26 @@ private IIdentifiable SetAttributes(IIdentifiable resource, IDictionary<string,
160173
/// <param name="relationshipValues">
161174
/// Relationships and their values, as in the serialized content.
162175
/// </param>
163-
/// <param name="relationshipAttributes">
176+
/// <param name="relationships">
164177
/// Exposed relationships for <paramref name="resource" />.
165178
/// </param>
166179
private IIdentifiable SetRelationships(IIdentifiable resource, IDictionary<string, RelationshipObject> relationshipValues,
167-
IReadOnlyCollection<RelationshipAttribute> relationshipAttributes)
180+
IReadOnlyCollection<RelationshipAttribute> relationships)
168181
{
169182
ArgumentGuard.NotNull(resource, nameof(resource));
170-
ArgumentGuard.NotNull(relationshipAttributes, nameof(relationshipAttributes));
183+
ArgumentGuard.NotNull(relationships, nameof(relationships));
171184

172185
if (relationshipValues.IsNullOrEmpty())
173186
{
174187
return resource;
175188
}
176189

177-
foreach (RelationshipAttribute attr in relationshipAttributes)
190+
foreach (RelationshipAttribute attr in relationships)
178191
{
179192
bool relationshipIsProvided = relationshipValues.TryGetValue(attr.PublicName, out RelationshipObject relationshipData);
180193

194+
relationshipValues.Remove(attr.PublicName);
195+
181196
if (!relationshipIsProvided || !relationshipData.Data.IsAssigned)
182197
{
183198
continue;
@@ -193,6 +208,14 @@ private IIdentifiable SetRelationships(IIdentifiable resource, IDictionary<strin
193208
}
194209
}
195210

211+
if (!_options.AllowUnknownFieldsInRequestBody && relationshipValues.Any())
212+
{
213+
string relationshipName = relationshipValues.First().Key;
214+
215+
throw new JsonApiSerializationException("Request body includes unknown relationship.", $"Relationship '{relationshipName}' does not exist.",
216+
atomicOperationIndex: AtomicOperationIndex);
217+
}
218+
196219
return resource;
197220
}
198221

src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs

+3
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ public sealed class ResourceObjectConverter : JsonObjectConverter<ResourceObject
2828

2929
public ResourceObjectConverter(IResourceGraph resourceGraph)
3030
{
31+
ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph));
32+
3133
_resourceGraph = resourceGraph;
3234
}
3335

@@ -206,6 +208,7 @@ private static IDictionary<string, object> ReadAttributes(ref Utf8JsonReader rea
206208
}
207209
else
208210
{
211+
attributes.Add(attributeName!, null);
209212
reader.Skip();
210213
}
211214

src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,11 @@ public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer
2929

3030
public RequestDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory, ITargetedFields targetedFields,
3131
IHttpContextAccessor httpContextAccessor, IJsonApiRequest request, IJsonApiOptions options, IResourceDefinitionAccessor resourceDefinitionAccessor)
32-
: base(resourceGraph, resourceFactory)
32+
: base(resourceGraph, resourceFactory, options)
3333
{
3434
ArgumentGuard.NotNull(targetedFields, nameof(targetedFields));
3535
ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor));
3636
ArgumentGuard.NotNull(request, nameof(request));
37-
ArgumentGuard.NotNull(options, nameof(options));
3837
ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor));
3938

4039
_targetedFields = targetedFields;

test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs

+103
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
using System.Threading.Tasks;
77
using FluentAssertions;
88
using FluentAssertions.Extensions;
9+
using JsonApiDotNetCore.Configuration;
910
using JsonApiDotNetCore.Serialization.Objects;
1011
using Microsoft.EntityFrameworkCore;
12+
using Microsoft.Extensions.DependencyInjection;
1113
using TestBuildingBlocks;
1214
using Xunit;
1315

@@ -28,6 +30,9 @@ public AtomicCreateResourceTests(IntegrationTestContext<TestableStartup<Operatio
2830
testContext.UseController<LyricsController>();
2931
testContext.UseController<MusicTracksController>();
3032
testContext.UseController<PlaylistsController>();
33+
34+
var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService<IJsonApiOptions>();
35+
options.AllowUnknownFieldsInRequestBody = false;
3136
}
3237

3338
[Fact]
@@ -211,10 +216,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
211216
});
212217
}
213218

219+
[Fact]
220+
public async Task Cannot_create_resource_with_unknown_attribute()
221+
{
222+
// Arrange
223+
string newName = _fakers.Playlist.Generate().Name;
224+
225+
var requestBody = new
226+
{
227+
atomic__operations = new[]
228+
{
229+
new
230+
{
231+
op = "add",
232+
data = new
233+
{
234+
type = "playlists",
235+
attributes = new
236+
{
237+
doesNotExist = "ignored",
238+
name = newName
239+
}
240+
}
241+
}
242+
}
243+
};
244+
245+
const string route = "/operations";
246+
247+
// Act
248+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(route, requestBody);
249+
250+
// Assert
251+
httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
252+
253+
responseDocument.Errors.Should().HaveCount(1);
254+
255+
ErrorObject error = responseDocument.Errors[0];
256+
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
257+
error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown attribute.");
258+
error.Detail.Should().Be("Attribute 'doesNotExist' does not exist.");
259+
260+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
261+
}
262+
214263
[Fact]
215264
public async Task Can_create_resource_with_unknown_attribute()
216265
{
217266
// Arrange
267+
var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService<IJsonApiOptions>();
268+
options.AllowUnknownFieldsInRequestBody = true;
269+
218270
string newName = _fakers.Playlist.Generate().Name;
219271

220272
var requestBody = new
@@ -261,10 +313,61 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
261313
});
262314
}
263315

316+
[Fact]
317+
public async Task Cannot_create_resource_with_unknown_relationship()
318+
{
319+
// Arrange
320+
var requestBody = new
321+
{
322+
atomic__operations = new[]
323+
{
324+
new
325+
{
326+
op = "add",
327+
data = new
328+
{
329+
type = "lyrics",
330+
relationships = new
331+
{
332+
doesNotExist = new
333+
{
334+
data = new
335+
{
336+
type = Unknown.ResourceType,
337+
id = Unknown.StringId.Int32
338+
}
339+
}
340+
}
341+
}
342+
}
343+
}
344+
};
345+
346+
const string route = "/operations";
347+
348+
// Act
349+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync<Document>(route, requestBody);
350+
351+
// Assert
352+
httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
353+
354+
responseDocument.Errors.Should().HaveCount(1);
355+
356+
ErrorObject error = responseDocument.Errors[0];
357+
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
358+
error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown relationship.");
359+
error.Detail.Should().Be("Relationship 'doesNotExist' does not exist.");
360+
361+
responseDocument.Meta["requestBody"].ToString().Should().NotBeNullOrEmpty();
362+
}
363+
264364
[Fact]
265365
public async Task Can_create_resource_with_unknown_relationship()
266366
{
267367
// Arrange
368+
var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService<IJsonApiOptions>();
369+
options.AllowUnknownFieldsInRequestBody = true;
370+
268371
var requestBody = new
269372
{
270373
atomic__operations = new[]

0 commit comments

Comments
 (0)