diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs
new file mode 100644
index 0000000000..9ce2da0489
--- /dev/null
+++ b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Primitives;
+
+namespace JsonApiDotNetCore.Middleware
+{
+ ///
+ /// Replacement implementation for the ASP.NET built-in , to workaround bug https://github.com/dotnet/aspnetcore/issues/33394.
+ /// This is identical to the built-in version, except it calls .
+ ///
+ internal sealed class FixedQueryFeature : IQueryFeature
+ {
+ // Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624
+ private static readonly Func NullRequestFeature = _ => null;
+
+ private FeatureReferences _features;
+
+ private string _original;
+ private IQueryCollection _parsedValues;
+
+ private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature);
+
+ ///
+ public IQueryCollection Query
+ {
+ get
+ {
+ if (_features.Collection == null)
+ {
+ return _parsedValues ??= QueryCollection.Empty;
+ }
+
+ string current = HttpRequestFeature.QueryString;
+
+ if (_parsedValues == null || !string.Equals(_original, current, StringComparison.Ordinal))
+ {
+ _original = current;
+
+ Dictionary result = FixedQueryHelpers.ParseNullableQuery(current);
+
+ _parsedValues = result == null ? QueryCollection.Empty : new QueryCollection(result);
+ }
+
+ return _parsedValues;
+ }
+ set
+ {
+ _parsedValues = value;
+
+ if (_features.Collection != null)
+ {
+ if (value == null)
+ {
+ _original = string.Empty;
+ HttpRequestFeature.QueryString = string.Empty;
+ }
+ else
+ {
+ _original = QueryString.Create(_parsedValues).ToString();
+ HttpRequestFeature.QueryString = _original;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ ///
+ /// The to initialize.
+ ///
+ public FixedQueryFeature(IFeatureCollection features)
+ {
+ ArgumentGuard.NotNull(features, nameof(features));
+
+ _features.Initalize(features);
+ }
+ }
+}
diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs
new file mode 100644
index 0000000000..621aca493d
--- /dev/null
+++ b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Primitives;
+
+#pragma warning disable AV1008 // Class should not be static
+#pragma warning disable AV1708 // Type name contains term that should be avoided
+#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type
+#pragma warning disable AV1532 // Loop statement contains nested loop
+
+namespace JsonApiDotNetCore.Middleware
+{
+ ///
+ /// Replacement implementation for the ASP.NET built-in , to workaround bug https://github.com/dotnet/aspnetcore/issues/33394.
+ /// This is identical to the built-in version, except it properly un-escapes query string keys without a value.
+ ///
+ internal static class FixedQueryHelpers
+ {
+ ///
+ /// Parse a query string into its component key and value parts.
+ ///
+ ///
+ /// The raw query string value, with or without the leading '?'.
+ ///
+ ///
+ /// A collection of parsed keys and values, null if there are no entries.
+ ///
+ public static Dictionary ParseNullableQuery(string queryString)
+ {
+ var accumulator = new KeyValueAccumulator();
+
+ if (string.IsNullOrEmpty(queryString) || queryString == "?")
+ {
+ return null;
+ }
+
+ int scanIndex = 0;
+
+ if (queryString[0] == '?')
+ {
+ scanIndex = 1;
+ }
+
+ int textLength = queryString.Length;
+ int equalIndex = queryString.IndexOf('=');
+
+ if (equalIndex == -1)
+ {
+ equalIndex = textLength;
+ }
+
+ while (scanIndex < textLength)
+ {
+ int delimiterIndex = queryString.IndexOf('&', scanIndex);
+
+ if (delimiterIndex == -1)
+ {
+ delimiterIndex = textLength;
+ }
+
+ if (equalIndex < delimiterIndex)
+ {
+ while (scanIndex != equalIndex && char.IsWhiteSpace(queryString[scanIndex]))
+ {
+ ++scanIndex;
+ }
+
+ string name = queryString.Substring(scanIndex, equalIndex - scanIndex);
+ string value = queryString.Substring(equalIndex + 1, delimiterIndex - equalIndex - 1);
+ accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), Uri.UnescapeDataString(value.Replace('+', ' ')));
+ equalIndex = queryString.IndexOf('=', delimiterIndex);
+
+ if (equalIndex == -1)
+ {
+ equalIndex = textLength;
+ }
+ }
+ else
+ {
+ if (delimiterIndex > scanIndex)
+ {
+ // original code:
+ // accumulator.Append(queryString.Substring(scanIndex, delimiterIndex - scanIndex), string.Empty);
+
+ // replacement:
+ string name = queryString.Substring(scanIndex, delimiterIndex - scanIndex);
+ accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), string.Empty);
+ }
+ }
+
+ scanIndex = delimiterIndex + 1;
+ }
+
+ if (!accumulator.HasValues)
+ {
+ return null;
+ }
+
+ return accumulator.GetResults();
+ }
+ }
+}
diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
index bfb486ed68..6d55598a81 100644
--- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
+++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs
@@ -12,6 +12,7 @@
using JsonApiDotNetCore.Serialization;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Routing;
@@ -78,6 +79,9 @@ public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMappin
httpContext.RegisterJsonApiRequest();
}
+ // Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394
+ httpContext.Features.Set(new FixedQueryFeature(httpContext.Features));
+
await _next(httpContext);
}
diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs
index 52fbe6610b..7ce7168a0d 100644
--- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs
+++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs
@@ -40,20 +40,19 @@ protected SparseFieldSetExpression ParseSparseFieldSet()
{
var fields = new Dictionary();
- ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected.");
- ResourceFieldAttribute nextField = nextChain.Fields.Single();
- fields[nextField.PublicName] = nextField;
-
while (TokenStack.Any())
{
- EatSingleCharacterToken(TokenKind.Comma);
+ if (fields.Count > 0)
+ {
+ EatSingleCharacterToken(TokenKind.Comma);
+ }
- nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected.");
- nextField = nextChain.Fields.Single();
+ ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected.");
+ ResourceFieldAttribute nextField = nextChain.Fields.Single();
fields[nextField.PublicName] = nextField;
}
- return new SparseFieldSetExpression(fields.Values);
+ return fields.Any() ? new SparseFieldSetExpression(fields.Values) : null;
}
protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements)
diff --git a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs
index a7688bf8f7..024ee564f2 100644
--- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs
@@ -8,6 +8,11 @@ namespace JsonApiDotNetCore.QueryStrings
///
public interface IQueryStringParameterReader
{
+ ///
+ /// Indicates whether this reader supports empty query string parameter values. Defaults to false.
+ ///
+ bool AllowEmptyValue => false;
+
///
/// Indicates whether usage of this query string parameter is blocked using on a controller.
///
diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs
index 19673f7fb3..fe4064a9c2 100644
--- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs
@@ -39,18 +39,18 @@ public virtual void ReadAll(DisableQueryStringAttribute disableQueryStringAttrib
foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query)
{
- if (string.IsNullOrEmpty(parameterValue))
- {
- throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.",
- $"Missing value for '{parameterName}' query string parameter.");
- }
-
IQueryStringParameterReader reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName));
if (reader != null)
{
_logger.LogDebug($"Query string parameter '{parameterName}' with value '{parameterValue}' was accepted by {reader.GetType().Name}.");
+ if (!reader.AllowEmptyValue && string.IsNullOrEmpty(parameterValue))
+ {
+ throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.",
+ $"Missing value for '{parameterName}' query string parameter.");
+ }
+
if (!reader.IsEnabled(disableQueryStringAttributeNotNull))
{
throw new InvalidQueryStringParameterException(parameterName,
diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs
index 1ab65dc332..f9e804797c 100644
--- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs
@@ -9,6 +9,7 @@
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Queries.Expressions;
using JsonApiDotNetCore.Queries.Internal.Parsing;
+using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using Microsoft.Extensions.Primitives;
@@ -22,6 +23,9 @@ public class SparseFieldSetQueryStringParameterReader : QueryStringParameterRead
private readonly Dictionary _sparseFieldTable = new Dictionary();
private string _lastParameterName;
+ ///
+ bool IQueryStringParameterReader.AllowEmptyValue => true;
+
public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider)
: base(request, resourceContextProvider)
{
@@ -79,7 +83,16 @@ private ResourceContext GetSparseFieldType(string parameterName)
private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceContext resourceContext)
{
- return _sparseFieldSetParser.Parse(parameterValue, resourceContext);
+ SparseFieldSetExpression sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceContext);
+
+ if (sparseFieldSet == null)
+ {
+ // We add ID on an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one.
+ AttrAttribute idAttribute = resourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(Identifiable.Id));
+ return new SparseFieldSetExpression(ArrayFactory.Create(idAttribute));
+ }
+
+ return sparseFieldSet;
}
///
diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs
index d7a346d6a1..2ff57e16f4 100644
--- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs
+++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs
@@ -69,8 +69,8 @@ public async Task Can_use_unknown_query_string_parameter()
[InlineData("include")]
[InlineData("filter")]
[InlineData("sort")]
- [InlineData("page")]
- [InlineData("fields")]
+ [InlineData("page[size]")]
+ [InlineData("page[number]")]
[InlineData("defaults")]
[InlineData("nulls")]
public async Task Cannot_use_empty_query_string_parameter_value(string parameterName)
diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs
index 8518a9237b..6fc881e6f1 100644
--- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs
+++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs
@@ -579,6 +579,40 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
postCaptured.Url.Should().BeNull();
}
+ [Fact]
+ public async Task Can_select_empty_fieldset()
+ {
+ // Arrange
+ var store = _testContext.Factory.Services.GetRequiredService();
+ store.Clear();
+
+ BlogPost post = _fakers.BlogPost.Generate();
+
+ await _testContext.RunOnDatabaseAsync(async dbContext =>
+ {
+ await dbContext.ClearTableAsync();
+ dbContext.Posts.Add(post);
+ await dbContext.SaveChangesAsync();
+ });
+
+ const string route = "/blogPosts?fields[blogPosts]=";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.Should().HaveStatusCode(HttpStatusCode.OK);
+
+ responseDocument.ManyData.Should().HaveCount(1);
+ responseDocument.ManyData[0].Id.Should().Be(post.StringId);
+ responseDocument.ManyData[0].Attributes.Should().BeNull();
+ responseDocument.ManyData[0].Relationships.Should().BeNull();
+
+ var postCaptured = (BlogPost)store.Resources.Should().ContainSingle(resource => resource is BlogPost).And.Subject.Single();
+ postCaptured.Id.Should().Be(post.Id);
+ postCaptured.Url.Should().BeNull();
+ }
+
[Fact]
public async Task Cannot_select_on_unknown_resource_type()
{
diff --git a/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs
index 70cd830ce0..42e63a5a84 100644
--- a/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs
+++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs
@@ -60,7 +60,6 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled,
[InlineData("fields[ ]", "", "Unexpected whitespace.")]
[InlineData("fields[owner]", "", "Resource type 'owner' does not exist.")]
[InlineData("fields[owner.posts]", "id", "Resource type 'owner.posts' does not exist.")]
- [InlineData("fields[blogPosts]", "", "Field name expected.")]
[InlineData("fields[blogPosts]", " ", "Unexpected whitespace.")]
[InlineData("fields[blogPosts]", "some", "Field 'some' does not exist on resource 'blogPosts'.")]
[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
[InlineData("fields[blogPosts]", "caption,url,author", "blogPosts(caption,url,author)")]
[InlineData("fields[blogPosts]", "author,comments,labels", "blogPosts(author,comments,labels)")]
[InlineData("fields[blogs]", "id", "blogs(id)")]
+ [InlineData("fields[blogs]", "", "blogs(id)")]
public void Reader_Read_Succeeds(string parameterName, string parameterValue, string valueExpected)
{
// Act