Skip to content

Commit 928ac1e

Browse files
authored
Merge pull request #584 from json-api-dotnet/fix/sparse-field-navigation
Use relationships as navigations in sparse field queries
2 parents a94f318 + b24bb60 commit 928ac1e

File tree

3 files changed

+140
-29
lines changed

3 files changed

+140
-29
lines changed

src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs

+57-27
Original file line numberDiff line numberDiff line change
@@ -39,41 +39,71 @@ public List<AttrAttribute> Get(RelationshipAttribute relationship = null)
3939
_selectedRelationshipFields.TryGetValue(relationship, out var fields);
4040
return fields;
4141
}
42-
42+
4343
/// <inheritdoc/>
4444
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
45-
{
46-
// expected: fields[TYPE]=prop1,prop2
47-
var typeName = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1];
45+
{ // expected: articles?fields=prop1,prop2
46+
// articles?fields[articles]=prop1,prop2 <-- this form in invalid UNLESS "articles" is actually a relationship on Article
47+
// articles?fields[relationship]=prop1,prop2
4848
var fields = new List<string> { nameof(Identifiable.Id) };
49+
fields.AddRange(((string)queryParameter.Value).Split(QueryConstants.COMMA));
4950

50-
var relationship = _requestResource.Relationships.SingleOrDefault(a => a.Is(typeName));
51-
if (relationship == null && string.Equals(typeName, _requestResource.EntityName, StringComparison.OrdinalIgnoreCase) == false)
52-
throw new JsonApiException(400, $"fields[{typeName}] is invalid");
51+
var keySplitted = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET);
5352

54-
fields.AddRange(((string)queryParameter.Value).Split(QueryConstants.COMMA));
55-
foreach (var field in fields)
56-
{
57-
if (relationship != default)
58-
{
59-
var relationProperty = _contextEntityProvider.GetContextEntity(relationship.DependentType);
60-
var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field));
61-
if (attr == null)
62-
throw new JsonApiException(400, $"'{relationship.DependentType.Name}' does not contain '{field}'.");
53+
if (keySplitted.Count() == 1)
54+
{ // input format: fields=prop1,prop2
55+
foreach (var field in fields)
56+
RegisterRequestResourceField(field);
57+
}
58+
else
59+
{ // input format: fields[articles]=prop1,prop2
60+
string navigation = keySplitted[1];
61+
// it is possible that the request resource has a relationship
62+
// that is equal to the resource name, like with self-referering data types (eg directory structures)
63+
// if not, no longer support this type of sparse field selection.
64+
if (navigation == _requestResource.EntityName && !_requestResource.Relationships.Any(a => a.Is(navigation)))
65+
throw new JsonApiException(400, $"Use \"?fields=...\" instead of \"fields[{navigation}]\":" +
66+
$" the square bracket navigations is now reserved " +
67+
$"for relationships only. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/555#issuecomment-543100865");
6368

64-
if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields))
65-
_selectedRelationshipFields.Add(relationship, registeredFields = new List<AttrAttribute>());
66-
registeredFields.Add(attr);
67-
}
68-
else
69-
{
70-
var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field));
71-
if (attr == null)
72-
throw new JsonApiException(400, $"'{_requestResource.EntityName}' does not contain '{field}'.");
69+
if (navigation.Contains(QueryConstants.DOT))
70+
throw new JsonApiException(400, $"fields[{navigation}] is not valid: deeply nested sparse field selection is not yet supported.");
71+
72+
var relationship = _requestResource.Relationships.SingleOrDefault(a => a.Is(navigation));
73+
if (relationship == null)
74+
throw new JsonApiException(400, $"\"{navigation}\" in \"fields[{navigation}]\" is not a valid relationship of {_requestResource.EntityName}");
75+
76+
foreach (var field in fields)
77+
RegisterRelatedResourceField(field, relationship);
7378

74-
(_selectedFields = _selectedFields ?? new List<AttrAttribute>()).Add(attr);
75-
}
7679
}
7780
}
81+
82+
/// <summary>
83+
/// Registers field selection queries of the form articles?fields[author]=first-name
84+
/// </summary>
85+
private void RegisterRelatedResourceField(string field, RelationshipAttribute relationship)
86+
{
87+
var relationProperty = _contextEntityProvider.GetContextEntity(relationship.DependentType);
88+
var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field));
89+
if (attr == null)
90+
throw new JsonApiException(400, $"'{relationship.DependentType.Name}' does not contain '{field}'.");
91+
92+
if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields))
93+
_selectedRelationshipFields.Add(relationship, registeredFields = new List<AttrAttribute>());
94+
registeredFields.Add(attr);
95+
}
96+
97+
/// <summary>
98+
/// Registers field selection queries of the form articles?fields=title
99+
/// </summary>
100+
private void RegisterRequestResourceField(string field)
101+
{
102+
var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field));
103+
if (attr == null)
104+
throw new JsonApiException(400, $"'{_requestResource.EntityName}' does not contain '{field}'.");
105+
106+
(_selectedFields = _selectedFields ?? new List<AttrAttribute>()).Add(attr);
107+
}
78108
}
79109
}

test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/SparseFieldSetTests.cs

+31-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets()
104104
var server = new TestServer(builder);
105105
var client = server.CreateClient();
106106

107-
var route = $"/api/v1/todo-items/{todoItem.Id}?fields[todo-items]=description,created-date";
107+
var route = $"/api/v1/todo-items/{todoItem.Id}?fields=description,created-date";
108108
var request = new HttpRequestMessage(httpMethod, route);
109109

110110
// act
@@ -119,6 +119,36 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets()
119119
Assert.Equal(todoItem.CreatedDate.ToString("G"), ((DateTime)deserializeBody.SingleData.Attributes["created-date"]).ToString("G"));
120120
}
121121

122+
[Fact]
123+
public async Task Fields_Query_Selects_Sparse_Field_Sets_With_Type_As_Navigation()
124+
{
125+
// arrange
126+
var todoItem = new TodoItem
127+
{
128+
Description = "description",
129+
Ordinal = 1,
130+
CreatedDate = DateTime.Now
131+
};
132+
_dbContext.TodoItems.Add(todoItem);
133+
await _dbContext.SaveChangesAsync();
134+
135+
var builder = new WebHostBuilder()
136+
.UseStartup<Startup>();
137+
var httpMethod = new HttpMethod("GET");
138+
var server = new TestServer(builder);
139+
var client = server.CreateClient();
140+
var route = $"/api/v1/todo-items/{todoItem.Id}?fields[todo-items]=description,created-date";
141+
var request = new HttpRequestMessage(httpMethod, route);
142+
143+
// act
144+
var response = await client.SendAsync(request);
145+
var body = await response.Content.ReadAsStringAsync();
146+
147+
// assert
148+
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
149+
Assert.Contains("relationships only", body);
150+
}
151+
122152
[Fact]
123153
public async Task Fields_Query_Selects_All_Fieldset_With_HasOne()
124154
{

test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs

+52-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public void Parse_ValidSelection_CanParse()
3838
var attribute = new AttrAttribute(attrName) { InternalAttributeName = internalAttrName };
3939
var idAttribute = new AttrAttribute("id") { InternalAttributeName = "Id" };
4040

41-
var query = new KeyValuePair<string, StringValues>($"fields[{type}]", new StringValues(attrName));
41+
var query = new KeyValuePair<string, StringValues>($"fields", new StringValues(attrName));
4242

4343
var contextEntity = new ContextEntity
4444
{
@@ -58,6 +58,57 @@ public void Parse_ValidSelection_CanParse()
5858
Assert.Equal(attribute, result[1]);
5959
}
6060

61+
[Fact]
62+
public void Parse_TypeNameAsNavigation_Throws400ErrorWithRelationshipsOnlyMessage()
63+
{
64+
// arrange
65+
const string type = "articles";
66+
const string attrName = "some-field";
67+
const string internalAttrName = "SomeField";
68+
var attribute = new AttrAttribute(attrName) { InternalAttributeName = internalAttrName };
69+
var idAttribute = new AttrAttribute("id") { InternalAttributeName = "Id" };
70+
71+
var query = new KeyValuePair<string, StringValues>($"fields[{type}]", new StringValues(attrName));
72+
73+
var contextEntity = new ContextEntity
74+
{
75+
EntityName = type,
76+
Attributes = new List<AttrAttribute> { attribute, idAttribute },
77+
Relationships = new List<RelationshipAttribute>()
78+
};
79+
var service = GetService(contextEntity);
80+
81+
// act, assert
82+
var ex = Assert.Throws<JsonApiException>(() => service.Parse(query));
83+
Assert.Contains("relationships only", ex.Message);
84+
}
85+
86+
[Fact]
87+
public void Parse_DeeplyNestedSelection_Throws400ErrorWithDeeplyNestedMessage()
88+
{
89+
// arrange
90+
const string type = "articles";
91+
const string relationship = "author.employer";
92+
const string attrName = "some-field";
93+
const string internalAttrName = "SomeField";
94+
var attribute = new AttrAttribute(attrName) { InternalAttributeName = internalAttrName };
95+
var idAttribute = new AttrAttribute("id") { InternalAttributeName = "Id" };
96+
97+
var query = new KeyValuePair<string, StringValues>($"fields[{relationship}]", new StringValues(attrName));
98+
99+
var contextEntity = new ContextEntity
100+
{
101+
EntityName = type,
102+
Attributes = new List<AttrAttribute> { attribute, idAttribute },
103+
Relationships = new List<RelationshipAttribute>()
104+
};
105+
var service = GetService(contextEntity);
106+
107+
// act, assert
108+
var ex = Assert.Throws<JsonApiException>(() => service.Parse(query));
109+
Assert.Contains("deeply nested", ex.Message);
110+
}
111+
61112
[Fact]
62113
public void Parse_InvalidField_ThrowsJsonApiException()
63114
{

0 commit comments

Comments
 (0)