Skip to content

Commit ff99353

Browse files
author
Bart Koelman
authored
Changes usage of fields parameter to be json:api spec compliant (#904)
* Changes the use of fields query string parameter to be json:api spec compliant. Before we'd accept a relationship path between the square brackets and would only allow attribute names in the value. Now we expect a resource type between square brackets and the value can contain both attributes and relationships. * Fixed: pass ID attribute in ResourceDefinition callback for relationships.
1 parent 0aa077b commit ff99353

File tree

45 files changed

+829
-284
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+829
-284
lines changed

benchmarks/Query/QueryParserBenchmarks.cs

+5-3
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ public QueryParserBenchmarks()
3131

3232
var request = new JsonApiRequest
3333
{
34-
PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource))
34+
PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)),
35+
IsCollection = true
3536
};
3637

3738
_queryStringReaderForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, request, options, _queryStringAccessor);
@@ -56,6 +57,7 @@ private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGr
5657
{
5758
var resourceFactory = new ResourceFactory(new ServiceContainer());
5859

60+
var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options);
5961
var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options);
6062
var sortReader = new SortQueryStringParameterReader(request, resourceGraph);
6163
var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph);
@@ -65,7 +67,7 @@ private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGr
6567

6668
var readers = new List<IQueryStringParameterReader>
6769
{
68-
filterReader, sortReader, sparseFieldSetReader, paginationReader, defaultsReader, nullsReader
70+
includeReader, filterReader, sortReader, sparseFieldSetReader, paginationReader, defaultsReader, nullsReader
6971
};
7072

7173
return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance);
@@ -92,7 +94,7 @@ public void DescendingSort()
9294
[Benchmark]
9395
public void ComplexQuery() => Run(100, () =>
9496
{
95-
var queryString = $"?filter[{BenchmarkResourcePublicNames.NameAttr}]=abc,eq:abc&sort=-{BenchmarkResourcePublicNames.NameAttr}&include=child&page[size]=1&fields={BenchmarkResourcePublicNames.NameAttr}";
97+
var queryString = $"?filter[{BenchmarkResourcePublicNames.NameAttr}]=abc,eq:abc&sort=-{BenchmarkResourcePublicNames.NameAttr}&include=child&page[size]=1&fields[{BenchmarkResourcePublicNames.Type}]={BenchmarkResourcePublicNames.NameAttr}";
9698

9799
_queryStringAccessor.SetQueryString(queryString);
98100
_queryStringReaderForAll.ReadAll(null);

docs/internals/queries.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ To get a sense of what this all looks like, let's look at an example query strin
3434
filter=has(articles)&
3535
sort=count(articles)&
3636
page[number]=3&
37-
fields=title&
37+
fields[blogs]=title&
3838
filter[articles]=and(not(equals(author.firstName,null)),has(revisions))&
3939
sort[articles]=author.lastName&
4040
fields[articles]=url&
4141
filter[articles.revisions]=and(greaterThan(publishTime,'2001-01-01'),startsWith(author.firstName,'J'))&
4242
sort[articles.revisions]=-publishTime,author.lastName&
43-
fields[articles.revisions]=publishTime
43+
fields[revisions]=publishTime
4444
```
4545

4646
After parsing, the set of scoped expressions is transformed into the following tree by `QueryLayerComposer`:
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
curl -s -f http://localhost:14141/api/books?fields=publishYear
1+
curl -s -f http://localhost:14141/api/books?fields%5Bbooks%5D=publishYear
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,24 @@
11
# Sparse Fieldset Selection
22

3-
As an alternative to returning all attributes from a resource, the `fields` query string parameter can be used to select only a subset.
4-
This can be used on the resource being requested, as well as nested endpoints and/or included resources.
3+
As an alternative to returning all fields (attributes and relationships) from a resource, the `fields[]` query string parameter can be used to select a subset.
4+
Put the resource type to apply the fieldset on between the brackets.
5+
This can be used on the resource being requested, as well as on nested endpoints and/or included resources.
56

67
Top-level example:
78
```http
8-
GET /articles?fields=title,body HTTP/1.1
9+
GET /articles?fields[articles]=title,body,comments HTTP/1.1
910
```
1011

1112
Nested endpoint example:
1213
```http
13-
GET /api/blogs/1/articles?fields=title,body HTTP/1.1
14+
GET /api/blogs/1/articles?fields[articles]=title,body,comments HTTP/1.1
1415
```
1516

17+
When combined with the `include` query string parameter, a subset of related fields can be specified too.
18+
1619
Example for an included HasOne relationship:
1720
```http
18-
GET /articles?include=author&fields[author]=name HTTP/1.1
21+
GET /articles?include=author&fields[authors]=name HTTP/1.1
1922
```
2023

2124
Example for an included HasMany relationship:
@@ -25,9 +28,12 @@ GET /articles?include=revisions&fields[revisions]=publishTime HTTP/1.1
2528

2629
Example for both top-level and relationship:
2730
```http
28-
GET /articles?include=author&fields=title,body&fields[author]=name HTTP/1.1
31+
GET /articles?include=author&fields[articles]=title,body,author&fields[authors]=name HTTP/1.1
2932
```
3033

34+
Note that in the last example, the `author` relationship is also added to the `articles` fieldset, so that the relationship from article to author is returned.
35+
When omitted, you'll get the included resources returned, but without full resource linkage (as described [here](https://jsonapi.org/examples/#sparse-fieldsets)).
36+
3137
## Overriding
3238

3339
As a developer, you can force to include and/or exclude specific fields as [described previously](~/usage/resources/resource-definitions.md).

docs/usage/resources/attributes.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ This can be overridden per attribute.
3939

4040
### Viewability
4141

42-
Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields=`, it results in an HTTP 400 response.
42+
Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response.
4343

4444
```c#
4545
public class User : Identifiable

docs/usage/resources/resource-definitions.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ from Entity Framework Core `IQueryable` execution.
1818

1919
### Excluding fields
2020

21-
There are some cases where you want attributes conditionally excluded from your resource response.
21+
There are some cases where you want attributes (or relationships) conditionally excluded from your resource response.
2222
For example, you may accept some sensitive data that should only be exposed to administrators after creation.
2323

2424
Note: to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]`.

docs/usage/writing/creating.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ POST /articles HTTP/1.1
6161

6262
# Response body
6363

64-
POST requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example:
64+
POST requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields[]`. For example:
6565

6666
```http
67-
POST /articles?include=owner&fields[owner]=firstName HTTP/1.1
67+
POST /articles?include=owner&fields[people]=firstName HTTP/1.1
6868
6969
{
7070
...

docs/usage/writing/updating.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@ By combining the examples above, both attributes and relationships can be update
6969

7070
## Response body
7171

72-
PATCH requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields`. For example:
72+
PATCH requests can be combined with query string parameters that are normally used for reading data, such as `include` and `fields[]`. For example:
7373

7474
```http
75-
PATCH /articles/1?include=owner&fields[owner]=firstName HTTP/1.1
75+
PATCH /articles/1?include=owner&fields[people]=firstName HTTP/1.1
7676
7777
{
7878
...

src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs

-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
using System;
2-
using System.Collections.Generic;
32
using System.ComponentModel.DataAnnotations.Schema;
4-
using System.Linq;
53
using JsonApiDotNetCore.Resources;
64
using JsonApiDotNetCore.Resources.Annotations;
75
using JsonApiDotNetCoreExample.Data;

src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System;
21
using System.Collections.Generic;
32
using System.Linq;
43
using System.Net;

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

+25
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Collections.Generic;
22
using System.Linq;
3+
using JsonApiDotNetCore.Configuration;
34

45
namespace JsonApiDotNetCore.Queries.Expressions
56
{
@@ -191,6 +192,30 @@ public override QueryExpression VisitEqualsAnyOf(EqualsAnyOfExpression expressio
191192
return null;
192193
}
193194

195+
public override QueryExpression VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument)
196+
{
197+
if (expression != null)
198+
{
199+
var newTable = new Dictionary<ResourceContext, SparseFieldSetExpression>();
200+
201+
foreach (var (resourceContext, sparseFieldSet) in expression.Table)
202+
{
203+
if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet)
204+
{
205+
newTable[resourceContext] = newSparseFieldSet;
206+
}
207+
}
208+
209+
if (newTable.Count > 0)
210+
{
211+
var newExpression = new SparseFieldTableExpression(newTable);
212+
return newExpression.Equals(expression) ? expression : newExpression;
213+
}
214+
}
215+
216+
return null;
217+
}
218+
194219
public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument)
195220
{
196221
return expression;

src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs

+5
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ public virtual TResult VisitEqualsAnyOf(EqualsAnyOfExpression expression, TArgum
8080
return DefaultVisit(expression, argument);
8181
}
8282

83+
public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument)
84+
{
85+
return DefaultVisit(expression, argument);
86+
}
87+
8388
public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument)
8489
{
8590
return DefaultVisit(expression, argument);

src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs

+10-10
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@
66
namespace JsonApiDotNetCore.Queries.Expressions
77
{
88
/// <summary>
9-
/// Represents a sparse fieldset, resulting from text such as: firstName,lastName
9+
/// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles
1010
/// </summary>
1111
public class SparseFieldSetExpression : QueryExpression
1212
{
13-
public IReadOnlyCollection<AttrAttribute> Attributes { get; }
13+
public IReadOnlyCollection<ResourceFieldAttribute> Fields { get; }
1414

15-
public SparseFieldSetExpression(IReadOnlyCollection<AttrAttribute> attributes)
15+
public SparseFieldSetExpression(IReadOnlyCollection<ResourceFieldAttribute> fields)
1616
{
17-
Attributes = attributes ?? throw new ArgumentNullException(nameof(attributes));
17+
Fields = fields ?? throw new ArgumentNullException(nameof(fields));
1818

19-
if (!attributes.Any())
19+
if (!fields.Any())
2020
{
21-
throw new ArgumentException("Must have one or more attributes.", nameof(attributes));
21+
throw new ArgumentException("Must have one or more fields.", nameof(fields));
2222
}
2323
}
2424

@@ -29,7 +29,7 @@ public override TResult Accept<TArgument, TResult>(QueryExpressionVisitor<TArgum
2929

3030
public override string ToString()
3131
{
32-
return string.Join(",", Attributes.Select(child => child.PublicName));
32+
return string.Join(",", Fields.Select(child => child.PublicName));
3333
}
3434

3535
public override bool Equals(object obj)
@@ -46,16 +46,16 @@ public override bool Equals(object obj)
4646

4747
var other = (SparseFieldSetExpression) obj;
4848

49-
return Attributes.SequenceEqual(other.Attributes);
49+
return Fields.SequenceEqual(other.Fields);
5050
}
5151

5252
public override int GetHashCode()
5353
{
5454
var hashCode = new HashCode();
5555

56-
foreach (var attribute in Attributes)
56+
foreach (var field in Fields)
5757
{
58-
hashCode.Add(attribute);
58+
hashCode.Add(field);
5959
}
6060

6161
return hashCode.ToHashCode();

src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs

+24-24
Original file line numberDiff line numberDiff line change
@@ -10,76 +10,76 @@ namespace JsonApiDotNetCore.Queries.Expressions
1010
public static class SparseFieldSetExpressionExtensions
1111
{
1212
public static SparseFieldSetExpression Including<TResource>(this SparseFieldSetExpression sparseFieldSet,
13-
Expression<Func<TResource, dynamic>> attributeSelector, IResourceGraph resourceGraph)
13+
Expression<Func<TResource, dynamic>> fieldSelector, IResourceGraph resourceGraph)
1414
where TResource : class, IIdentifiable
1515
{
16-
if (attributeSelector == null)
16+
if (fieldSelector == null)
1717
{
18-
throw new ArgumentNullException(nameof(attributeSelector));
18+
throw new ArgumentNullException(nameof(fieldSelector));
1919
}
2020

2121
if (resourceGraph == null)
2222
{
2323
throw new ArgumentNullException(nameof(resourceGraph));
2424
}
2525

26-
foreach (var attribute in resourceGraph.GetAttributes(attributeSelector))
26+
foreach (var field in resourceGraph.GetFields(fieldSelector))
2727
{
28-
sparseFieldSet = IncludeAttribute(sparseFieldSet, attribute);
28+
sparseFieldSet = IncludeField(sparseFieldSet, field);
2929
}
3030

3131
return sparseFieldSet;
3232
}
3333

34-
private static SparseFieldSetExpression IncludeAttribute(SparseFieldSetExpression sparseFieldSet, AttrAttribute attributeToInclude)
34+
private static SparseFieldSetExpression IncludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToInclude)
3535
{
36-
if (sparseFieldSet == null || sparseFieldSet.Attributes.Contains(attributeToInclude))
36+
if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude))
3737
{
3838
return sparseFieldSet;
3939
}
4040

41-
var attributeSet = sparseFieldSet.Attributes.ToHashSet();
42-
attributeSet.Add(attributeToInclude);
43-
return new SparseFieldSetExpression(attributeSet);
41+
var fieldSet = sparseFieldSet.Fields.ToHashSet();
42+
fieldSet.Add(fieldToInclude);
43+
return new SparseFieldSetExpression(fieldSet);
4444
}
4545

4646
public static SparseFieldSetExpression Excluding<TResource>(this SparseFieldSetExpression sparseFieldSet,
47-
Expression<Func<TResource, dynamic>> attributeSelector, IResourceGraph resourceGraph)
47+
Expression<Func<TResource, dynamic>> fieldSelector, IResourceGraph resourceGraph)
4848
where TResource : class, IIdentifiable
4949
{
50-
if (attributeSelector == null)
50+
if (fieldSelector == null)
5151
{
52-
throw new ArgumentNullException(nameof(attributeSelector));
52+
throw new ArgumentNullException(nameof(fieldSelector));
5353
}
5454

5555
if (resourceGraph == null)
5656
{
5757
throw new ArgumentNullException(nameof(resourceGraph));
5858
}
5959

60-
foreach (var attribute in resourceGraph.GetAttributes(attributeSelector))
60+
foreach (var field in resourceGraph.GetFields(fieldSelector))
6161
{
62-
sparseFieldSet = ExcludeAttribute(sparseFieldSet, attribute);
62+
sparseFieldSet = ExcludeField(sparseFieldSet, field);
6363
}
6464

6565
return sparseFieldSet;
6666
}
6767

68-
private static SparseFieldSetExpression ExcludeAttribute(SparseFieldSetExpression sparseFieldSet, AttrAttribute attributeToExclude)
68+
private static SparseFieldSetExpression ExcludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToExclude)
6969
{
70-
// Design tradeoff: When the sparse fieldset is empty, it means all attributes will be selected.
71-
// Adding an exclusion in that case is a no-op, which results in still retrieving the excluded attribute from data store.
72-
// But later, when serializing the response, the sparse fieldset is first populated with all attributes,
73-
// so then the exclusion will actually be applied and the excluded attribute is not returned to the client.
70+
// Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected.
71+
// Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store.
72+
// But later, when serializing the response, the sparse fieldset is first populated with all fields,
73+
// so then the exclusion will actually be applied and the excluded field is not returned to the client.
7474

75-
if (sparseFieldSet == null || !sparseFieldSet.Attributes.Contains(attributeToExclude))
75+
if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude))
7676
{
7777
return sparseFieldSet;
7878
}
7979

80-
var attributeSet = sparseFieldSet.Attributes.ToHashSet();
81-
attributeSet.Remove(attributeToExclude);
82-
return new SparseFieldSetExpression(attributeSet);
80+
var fieldSet = sparseFieldSet.Fields.ToHashSet();
81+
fieldSet.Remove(fieldToExclude);
82+
return new SparseFieldSetExpression(fieldSet);
8383
}
8484
}
8585
}

0 commit comments

Comments
 (0)