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