Skip to content

Use relationships as navigations in sparse field queries #584

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Oct 22, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 57 additions & 27 deletions src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,41 +39,71 @@ public List<AttrAttribute> Get(RelationshipAttribute relationship = null)
_selectedRelationshipFields.TryGetValue(relationship, out var fields);
return fields;
}

/// <inheritdoc/>
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
{
// expected: fields[TYPE]=prop1,prop2
var typeName = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1];
{ // expected: articles?fields=prop1,prop2
// articles?fields[articles]=prop1,prop2 <-- this form in invalid UNLESS "articles" is actually a relationship on Article
// articles?fields[relationship]=prop1,prop2
var fields = new List<string> { nameof(Identifiable.Id) };
fields.AddRange(((string)queryParameter.Value).Split(QueryConstants.COMMA));

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

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

if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields))
_selectedRelationshipFields.Add(relationship, registeredFields = new List<AttrAttribute>());
registeredFields.Add(attr);
}
else
{
var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field));
if (attr == null)
throw new JsonApiException(400, $"'{_requestResource.EntityName}' does not contain '{field}'.");
if (navigation.Contains(QueryConstants.DOT))
throw new JsonApiException(400, $"fields[{navigation}] is not valid: deeply nested sparse field selection is not yet supported.");

var relationship = _requestResource.Relationships.SingleOrDefault(a => a.Is(navigation));
if (relationship == null)
throw new JsonApiException(400, $"\"{navigation}\" in \"fields[{navigation}]\" is not a valid relationship of {_requestResource.EntityName}");

foreach (var field in fields)
RegisterRelatedResourceField(field, relationship);

(_selectedFields = _selectedFields ?? new List<AttrAttribute>()).Add(attr);
}
}
}

/// <summary>
/// Registers field selection queries of the form articles?fields[author]=first-name
/// </summary>
private void RegisterRelatedResourceField(string field, RelationshipAttribute relationship)
{
var relationProperty = _contextEntityProvider.GetContextEntity(relationship.DependentType);
var attr = relationProperty.Attributes.SingleOrDefault(a => a.Is(field));
if (attr == null)
throw new JsonApiException(400, $"'{relationship.DependentType.Name}' does not contain '{field}'.");

if (!_selectedRelationshipFields.TryGetValue(relationship, out var registeredFields))
_selectedRelationshipFields.Add(relationship, registeredFields = new List<AttrAttribute>());
registeredFields.Add(attr);
}

/// <summary>
/// Registers field selection queries of the form articles?fields=title
/// </summary>
private void RegisterRequestResourceField(string field)
{
var attr = _requestResource.Attributes.SingleOrDefault(a => a.Is(field));
if (attr == null)
throw new JsonApiException(400, $"'{_requestResource.EntityName}' does not contain '{field}'.");

(_selectedFields = _selectedFields ?? new List<AttrAttribute>()).Add(attr);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public async Task Fields_Query_Selects_Sparse_Field_Sets()
var server = new TestServer(builder);
var client = server.CreateClient();

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

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

[Fact]
public async Task Fields_Query_Selects_Sparse_Field_Sets_With_Type_As_Navigation()
{
// arrange
var todoItem = new TodoItem
{
Description = "description",
Ordinal = 1,
CreatedDate = DateTime.Now
};
_dbContext.TodoItems.Add(todoItem);
await _dbContext.SaveChangesAsync();

var builder = new WebHostBuilder()
.UseStartup<Startup>();
var httpMethod = new HttpMethod("GET");
var server = new TestServer(builder);
var client = server.CreateClient();
var route = $"/api/v1/todo-items/{todoItem.Id}?fields[todo-items]=description,created-date";
var request = new HttpRequestMessage(httpMethod, route);

// act
var response = await client.SendAsync(request);
var body = await response.Content.ReadAsStringAsync();

// assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("relationships only", body);
}

[Fact]
public async Task Fields_Query_Selects_All_Fieldset_With_HasOne()
{
Expand Down
53 changes: 52 additions & 1 deletion test/UnitTests/QueryParameters/SparseFieldsServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public void Parse_ValidSelection_CanParse()
var attribute = new AttrAttribute(attrName) { InternalAttributeName = internalAttrName };
var idAttribute = new AttrAttribute("id") { InternalAttributeName = "Id" };

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

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

[Fact]
public void Parse_TypeNameAsNavigation_ThrowsJsonApiException()
{
// arrange
const string type = "articles";
const string attrName = "some-field";
const string internalAttrName = "SomeField";
var attribute = new AttrAttribute(attrName) { InternalAttributeName = internalAttrName };
var idAttribute = new AttrAttribute("id") { InternalAttributeName = "Id" };

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

var contextEntity = new ContextEntity
{
EntityName = type,
Attributes = new List<AttrAttribute> { attribute, idAttribute },
Relationships = new List<RelationshipAttribute>()
};
var service = GetService(contextEntity);

// act, assert
var ex = Assert.Throws<JsonApiException>(() => service.Parse(query));
Assert.Contains("relationships only", ex.Message);
}

[Fact]
public void Parse_DeeplyNestedSelection_ThrowsJsonApiException()
{
// arrange
const string type = "articles";
const string relationship = "author.employer";
const string attrName = "some-field";
const string internalAttrName = "SomeField";
var attribute = new AttrAttribute(attrName) { InternalAttributeName = internalAttrName };
var idAttribute = new AttrAttribute("id") { InternalAttributeName = "Id" };

var query = new KeyValuePair<string, StringValues>($"fields[{relationship}]", new StringValues(attrName));

var contextEntity = new ContextEntity
{
EntityName = type,
Attributes = new List<AttrAttribute> { attribute, idAttribute },
Relationships = new List<RelationshipAttribute>()
};
var service = GetService(contextEntity);

// act, assert
var ex = Assert.Throws<JsonApiException>(() => service.Parse(query));
Assert.Contains("deeply nested", ex.Message);
}

[Fact]
public void Parse_InvalidField_ThrowsJsonApiException()
{
Expand Down