Skip to content

Commit f145564

Browse files
author
Bart Koelman
authored
Fix empty fields (#1009)
* Allows incoming empty fieldset (json:api spec compliance). The code adds a default interface property to not break existing implementations. * Workaround for bug dotnet/roslyn#13624
1 parent 8100643 commit f145564

File tree

10 files changed

+256
-18
lines changed

10 files changed

+256
-18
lines changed
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.Http.Features;
5+
using Microsoft.Extensions.Primitives;
6+
7+
namespace JsonApiDotNetCore.Middleware
8+
{
9+
/// <summary>
10+
/// Replacement implementation for the ASP.NET built-in <see cref="QueryFeature" />, to workaround bug https://github.com/dotnet/aspnetcore/issues/33394.
11+
/// This is identical to the built-in version, except it calls <see cref="FixedQueryHelpers.ParseNullableQuery" />.
12+
/// </summary>
13+
internal sealed class FixedQueryFeature : IQueryFeature
14+
{
15+
// Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624
16+
private static readonly Func<IFeatureCollection, IHttpRequestFeature> NullRequestFeature = _ => null;
17+
18+
private FeatureReferences<IHttpRequestFeature> _features;
19+
20+
private string _original;
21+
private IQueryCollection _parsedValues;
22+
23+
private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature);
24+
25+
/// <inheritdoc />
26+
public IQueryCollection Query
27+
{
28+
get
29+
{
30+
if (_features.Collection == null)
31+
{
32+
return _parsedValues ??= QueryCollection.Empty;
33+
}
34+
35+
string current = HttpRequestFeature.QueryString;
36+
37+
if (_parsedValues == null || !string.Equals(_original, current, StringComparison.Ordinal))
38+
{
39+
_original = current;
40+
41+
Dictionary<string, StringValues> result = FixedQueryHelpers.ParseNullableQuery(current);
42+
43+
_parsedValues = result == null ? QueryCollection.Empty : new QueryCollection(result);
44+
}
45+
46+
return _parsedValues;
47+
}
48+
set
49+
{
50+
_parsedValues = value;
51+
52+
if (_features.Collection != null)
53+
{
54+
if (value == null)
55+
{
56+
_original = string.Empty;
57+
HttpRequestFeature.QueryString = string.Empty;
58+
}
59+
else
60+
{
61+
_original = QueryString.Create(_parsedValues).ToString();
62+
HttpRequestFeature.QueryString = _original;
63+
}
64+
}
65+
}
66+
}
67+
68+
/// <summary>
69+
/// Initializes a new instance of <see cref="QueryFeature" />.
70+
/// </summary>
71+
/// <param name="features">
72+
/// The <see cref="IFeatureCollection" /> to initialize.
73+
/// </param>
74+
public FixedQueryFeature(IFeatureCollection features)
75+
{
76+
ArgumentGuard.NotNull(features, nameof(features));
77+
78+
_features.Initalize(features);
79+
}
80+
}
81+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Microsoft.AspNetCore.WebUtilities;
4+
using Microsoft.Extensions.Primitives;
5+
6+
#pragma warning disable AV1008 // Class should not be static
7+
#pragma warning disable AV1708 // Type name contains term that should be avoided
8+
#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type
9+
#pragma warning disable AV1532 // Loop statement contains nested loop
10+
11+
namespace JsonApiDotNetCore.Middleware
12+
{
13+
/// <summary>
14+
/// Replacement implementation for the ASP.NET built-in <see cref="QueryHelpers" />, to workaround bug https://github.com/dotnet/aspnetcore/issues/33394.
15+
/// This is identical to the built-in version, except it properly un-escapes query string keys without a value.
16+
/// </summary>
17+
internal static class FixedQueryHelpers
18+
{
19+
/// <summary>
20+
/// Parse a query string into its component key and value parts.
21+
/// </summary>
22+
/// <param name="queryString">
23+
/// The raw query string value, with or without the leading '?'.
24+
/// </param>
25+
/// <returns>
26+
/// A collection of parsed keys and values, null if there are no entries.
27+
/// </returns>
28+
public static Dictionary<string, StringValues> ParseNullableQuery(string queryString)
29+
{
30+
var accumulator = new KeyValueAccumulator();
31+
32+
if (string.IsNullOrEmpty(queryString) || queryString == "?")
33+
{
34+
return null;
35+
}
36+
37+
int scanIndex = 0;
38+
39+
if (queryString[0] == '?')
40+
{
41+
scanIndex = 1;
42+
}
43+
44+
int textLength = queryString.Length;
45+
int equalIndex = queryString.IndexOf('=');
46+
47+
if (equalIndex == -1)
48+
{
49+
equalIndex = textLength;
50+
}
51+
52+
while (scanIndex < textLength)
53+
{
54+
int delimiterIndex = queryString.IndexOf('&', scanIndex);
55+
56+
if (delimiterIndex == -1)
57+
{
58+
delimiterIndex = textLength;
59+
}
60+
61+
if (equalIndex < delimiterIndex)
62+
{
63+
while (scanIndex != equalIndex && char.IsWhiteSpace(queryString[scanIndex]))
64+
{
65+
++scanIndex;
66+
}
67+
68+
string name = queryString.Substring(scanIndex, equalIndex - scanIndex);
69+
string value = queryString.Substring(equalIndex + 1, delimiterIndex - equalIndex - 1);
70+
accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), Uri.UnescapeDataString(value.Replace('+', ' ')));
71+
equalIndex = queryString.IndexOf('=', delimiterIndex);
72+
73+
if (equalIndex == -1)
74+
{
75+
equalIndex = textLength;
76+
}
77+
}
78+
else
79+
{
80+
if (delimiterIndex > scanIndex)
81+
{
82+
// original code:
83+
// accumulator.Append(queryString.Substring(scanIndex, delimiterIndex - scanIndex), string.Empty);
84+
85+
// replacement:
86+
string name = queryString.Substring(scanIndex, delimiterIndex - scanIndex);
87+
accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), string.Empty);
88+
}
89+
}
90+
91+
scanIndex = delimiterIndex + 1;
92+
}
93+
94+
if (!accumulator.HasValues)
95+
{
96+
return null;
97+
}
98+
99+
return accumulator.GetResults();
100+
}
101+
}
102+
}

src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using JsonApiDotNetCore.Serialization;
1313
using JsonApiDotNetCore.Serialization.Objects;
1414
using Microsoft.AspNetCore.Http;
15+
using Microsoft.AspNetCore.Http.Features;
1516
using Microsoft.AspNetCore.Mvc;
1617
using Microsoft.AspNetCore.Mvc.Controllers;
1718
using Microsoft.AspNetCore.Routing;
@@ -78,6 +79,9 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
7879
httpContext.RegisterJsonApiRequest();
7980
}
8081

82+
// Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394
83+
httpContext.Features.Set<IQueryFeature>(new FixedQueryFeature(httpContext.Features));
84+
8185
await _next(httpContext);
8286
}
8387

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)