Skip to content

Commit f036db7

Browse files
author
Bart Koelman
committed
Allows incoming empty fieldset (json:api spec compliance). The code adds a default interface property to not break existing implementations.
1 parent 56c3416 commit f036db7

File tree

7 files changed

+69
-18
lines changed

7 files changed

+69
-18
lines changed

src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,19 @@ protected SparseFieldSetExpression ParseSparseFieldSet()
4040
{
4141
var fields = new Dictionary<string, ResourceFieldAttribute>();
4242

43-
ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected.");
44-
ResourceFieldAttribute nextField = nextChain.Fields.Single();
45-
fields[nextField.PublicName] = nextField;
46-
4743
while (TokenStack.Any())
4844
{
49-
EatSingleCharacterToken(TokenKind.Comma);
45+
if (fields.Count > 0)
46+
{
47+
EatSingleCharacterToken(TokenKind.Comma);
48+
}
5049

51-
nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected.");
52-
nextField = nextChain.Fields.Single();
50+
ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected.");
51+
ResourceFieldAttribute nextField = nextChain.Fields.Single();
5352
fields[nextField.PublicName] = nextField;
5453
}
5554

56-
return new SparseFieldSetExpression(fields.Values);
55+
return fields.Any() ? new SparseFieldSetExpression(fields.Values) : null;
5756
}
5857

5958
protected override IReadOnlyCollection<ResourceFieldAttribute> OnResolveFieldChain(string path, FieldChainRequirements chainRequirements)

src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ namespace JsonApiDotNetCore.QueryStrings
88
/// </summary>
99
public interface IQueryStringParameterReader
1010
{
11+
/// <summary>
12+
/// Indicates whether this reader supports empty query string parameter values. Defaults to <c>false</c>.
13+
/// </summary>
14+
bool AllowEmptyValue => false;
15+
1116
/// <summary>
1217
/// Indicates whether usage of this query string parameter is blocked using <see cref="DisableQueryStringAttribute" /> on a controller.
1318
/// </summary>

src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,18 @@ public virtual void ReadAll(DisableQueryStringAttribute disableQueryStringAttrib
3939

4040
foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query)
4141
{
42-
if (string.IsNullOrEmpty(parameterValue))
43-
{
44-
throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.",
45-
$"Missing value for '{parameterName}' query string parameter.");
46-
}
47-
4842
IQueryStringParameterReader reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName));
4943

5044
if (reader != null)
5145
{
5246
_logger.LogDebug($"Query string parameter '{parameterName}' with value '{parameterValue}' was accepted by {reader.GetType().Name}.");
5347

48+
if (!reader.AllowEmptyValue && string.IsNullOrEmpty(parameterValue))
49+
{
50+
throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.",
51+
$"Missing value for '{parameterName}' query string parameter.");
52+
}
53+
5454
if (!reader.IsEnabled(disableQueryStringAttributeNotNull))
5555
{
5656
throw new InvalidQueryStringParameterException(parameterName,

src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using JsonApiDotNetCore.Queries;
1010
using JsonApiDotNetCore.Queries.Expressions;
1111
using JsonApiDotNetCore.Queries.Internal.Parsing;
12+
using JsonApiDotNetCore.Resources;
1213
using JsonApiDotNetCore.Resources.Annotations;
1314
using Microsoft.Extensions.Primitives;
1415

@@ -22,6 +23,9 @@ public class SparseFieldSetQueryStringParameterReader : QueryStringParameterRead
2223
private readonly Dictionary<ResourceContext, SparseFieldSetExpression> _sparseFieldTable = new Dictionary<ResourceContext, SparseFieldSetExpression>();
2324
private string _lastParameterName;
2425

26+
/// <inheritdoc />
27+
bool IQueryStringParameterReader.AllowEmptyValue => true;
28+
2529
public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider)
2630
: base(request, resourceContextProvider)
2731
{
@@ -79,7 +83,16 @@ private ResourceContext GetSparseFieldType(string parameterName)
7983

8084
private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceContext resourceContext)
8185
{
82-
return _sparseFieldSetParser.Parse(parameterValue, resourceContext);
86+
SparseFieldSetExpression sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceContext);
87+
88+
if (sparseFieldSet == null)
89+
{
90+
// We add ID on an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one.
91+
AttrAttribute idAttribute = resourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(Identifiable.Id));
92+
return new SparseFieldSetExpression(ArrayFactory.Create(idAttribute));
93+
}
94+
95+
return sparseFieldSet;
8396
}
8497

8598
/// <inheritdoc />

test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ public async Task Can_use_unknown_query_string_parameter()
6969
[InlineData("include")]
7070
[InlineData("filter")]
7171
[InlineData("sort")]
72-
[InlineData("page")]
73-
[InlineData("fields")]
72+
[InlineData("page[size]")]
73+
[InlineData("page[number]")]
7474
[InlineData("defaults")]
7575
[InlineData("nulls")]
7676
public async Task Cannot_use_empty_query_string_parameter_value(string parameterName)

test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,40 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
579579
postCaptured.Url.Should().BeNull();
580580
}
581581

582+
[Fact]
583+
public async Task Can_select_empty_fieldset()
584+
{
585+
// Arrange
586+
var store = _testContext.Factory.Services.GetRequiredService<ResourceCaptureStore>();
587+
store.Clear();
588+
589+
BlogPost post = _fakers.BlogPost.Generate();
590+
591+
await _testContext.RunOnDatabaseAsync(async dbContext =>
592+
{
593+
await dbContext.ClearTableAsync<BlogPost>();
594+
dbContext.Posts.Add(post);
595+
await dbContext.SaveChangesAsync();
596+
});
597+
598+
const string route = "/blogPosts?fields[blogPosts]=";
599+
600+
// Act
601+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
602+
603+
// Assert
604+
httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
605+
606+
responseDocument.ManyData.Should().HaveCount(1);
607+
responseDocument.ManyData[0].Id.Should().Be(post.StringId);
608+
responseDocument.ManyData[0].Attributes.Should().BeNull();
609+
responseDocument.ManyData[0].Relationships.Should().BeNull();
610+
611+
var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single();
612+
postCaptured.Id.Should().Be(post.Id);
613+
postCaptured.Url.Should().BeNull();
614+
}
615+
582616
[Fact]
583617
public async Task Cannot_select_on_unknown_resource_type()
584618
{

test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled,
6060
[InlineData("fields[ ]", "", "Unexpected whitespace.")]
6161
[InlineData("fields[owner]", "", "Resource type 'owner' does not exist.")]
6262
[InlineData("fields[owner.posts]", "id", "Resource type 'owner.posts' does not exist.")]
63-
[InlineData("fields[blogPosts]", "", "Field name expected.")]
6463
[InlineData("fields[blogPosts]", " ", "Unexpected whitespace.")]
6564
[InlineData("fields[blogPosts]", "some", "Field 'some' does not exist on resource 'blogPosts'.")]
6665
[InlineData("fields[blogPosts]", "id,owner.name", "Field 'owner.name' does not exist on resource 'blogPosts'.")]
@@ -87,6 +86,7 @@ public void Reader_Read_Fails(string parameterName, string parameterValue, strin
8786
[InlineData("fields[blogPosts]", "caption,url,author", "blogPosts(caption,url,author)")]
8887
[InlineData("fields[blogPosts]", "author,comments,labels", "blogPosts(author,comments,labels)")]
8988
[InlineData("fields[blogs]", "id", "blogs(id)")]
89+
[InlineData("fields[blogs]", "", "blogs(id)")]
9090
public void Reader_Read_Succeeds(string parameterName, string parameterValue, string valueExpected)
9191
{
9292
// Act

0 commit comments

Comments
 (0)