Skip to content

Support query param operations on nested resources #634

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 7 commits into from
Nov 25, 2019
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ public TodoItem()
public DateTimeOffset? OffsetDate { get; set; }

public int? OwnerId { get; set; }

public int? AssigneeId { get; set; }

public Guid? CollectionId { get; set; }

[HasOne]
Expand All @@ -49,6 +51,7 @@ public TodoItem()

[HasOne]
public virtual Person OneToOnePerson { get; set; }

public virtual int? OneToOnePersonId { get; set; }

[HasMany]
Expand All @@ -59,13 +62,16 @@ public TodoItem()

// cyclical to-one structure
public virtual int? DependentOnTodoId { get; set; }

[HasOne]
public virtual TodoItem DependentOnTodo { get; set; }

// cyclical to-many structure
public virtual int? ParentTodoId {get; set;}

[HasOne]
public virtual TodoItem ParentTodo { get; set; }

[HasMany]
public virtual List<TodoItem> ChildrenTodos { get; set; }
}
Expand Down
1 change: 0 additions & 1 deletion src/JsonApiDotNetCore/Controllers/JsonApiController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ namespace JsonApiDotNetCore.Controllers
{
public class JsonApiController<T, TId> : BaseJsonApiController<T, TId> where T : class, IIdentifiable<TId>
{

/// <param name="jsonApiOptions"></param>
/// <param name="resourceService"></param>
/// <param name="loggerFactory"></param>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Linq;
using System.Text.RegularExpressions;
using JsonApiDotNetCore.Internal;
using JsonApiDotNetCore.Internal.Contracts;
using JsonApiDotNetCore.Managers.Contracts;
using JsonApiDotNetCore.Models;
using Microsoft.Extensions.Primitives;

namespace JsonApiDotNetCore.Query
{
Expand All @@ -16,11 +14,20 @@ public abstract class QueryParameterService
{
protected readonly IResourceGraph _resourceGraph;
protected readonly ResourceContext _requestResource;
private readonly ResourceContext _mainRequestResource;

protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest currentRequest)
{
_mainRequestResource = currentRequest.GetRequestResource();
_resourceGraph = resourceGraph;
_requestResource = currentRequest.GetRequestResource();
if (currentRequest.RequestRelationship != null)
{
_requestResource= resourceGraph.GetResourceContext(currentRequest.RequestRelationship.RightType);
}
else
{
_requestResource = _mainRequestResource;
}
}

protected QueryParameterService() { }
Expand Down Expand Up @@ -70,5 +77,16 @@ protected RelationshipAttribute GetRelationship(string propertyName)

return relationship;
}

/// <summary>
/// Throw an exception if query parameters are requested that are unsupported on nested resource routes.
/// </summary>
protected void EnsureNoNestedResourceRoute()
{
if (_requestResource != _mainRequestResource)
{
throw new JsonApiException(400, $"Query parameter {Name} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{Name}=...'");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public List<FilterQueryContext> Get()
/// <inheritdoc/>
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
{
EnsureNoNestedResourceRoute();
var queries = GetFilterQueries(queryParameter);
_filters.AddRange(queries.Select(GetQueryContexts));
}
Expand Down
14 changes: 13 additions & 1 deletion src/JsonApiDotNetCore/QueryParameterServices/PageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
using System.Collections.Generic;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Internal;
using JsonApiDotNetCore.Internal.Contracts;
using JsonApiDotNetCore.Internal.Query;
using JsonApiDotNetCore.Managers.Contracts;
using Microsoft.Extensions.Primitives;

namespace JsonApiDotNetCore.Query
Expand All @@ -12,7 +14,16 @@ public class PageService : QueryParameterService, IPageService
{
private readonly IJsonApiOptions _options;

public PageService(IJsonApiOptions options)
public PageService(IJsonApiOptions options, IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest)
{
_options = options;
PageSize = _options.DefaultPageSize;
}

/// <summary>
/// constructor used for unit testing
/// </summary>
internal PageService(IJsonApiOptions options)
{
_options = options;
PageSize = _options.DefaultPageSize;
Expand All @@ -39,6 +50,7 @@ public PageService(IJsonApiOptions options)
/// <inheritdoc/>
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
{
EnsureNoNestedResourceRoute();
// expected input = page[size]=<integer>
// page[number]=<integer > 0>
var propertyName = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public SortService(IResourceDefinitionProvider resourceDefinitionProvider,
/// <inheritdoc/>
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
{
EnsureNoNestedResourceRoute();
CheckIfProcessed(); // disallow multiple sort parameters.
var queries = BuildQueries(queryParameter.Value);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
{ // 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
EnsureNoNestedResourceRoute();
var fields = new List<string> { nameof(Identifiable.Id) };
fields.AddRange(((string)queryParameter.Value).Split(QueryConstants.COMMA));

Expand Down
41 changes: 34 additions & 7 deletions src/JsonApiDotNetCore/Services/DefaultResourceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ public virtual async Task<bool> DeleteAsync(TId id)
if (!IsNull(_hookExecutor, entity)) _hookExecutor.AfterDelete(AsList(entity), ResourcePipeline.Delete, succeeded);
return succeeded;
}

public virtual async Task<IEnumerable<TResource>> GetAsync()
{
_hookExecutor?.BeforeRead<TResource>(ResourcePipeline.Get);
Expand Down Expand Up @@ -134,10 +134,15 @@ public virtual async Task<TResource> GetRelationshipsAsync(TId id, string relati

// TODO: it would be better if we could distinguish whether or not the relationship was not found,
// vs the relationship not being set on the instance of T
var entityQuery = _repository.Include(_repository.Get(id), new RelationshipAttribute[] { relationship });

var entityQuery = ApplyInclude(_repository.Get(id), chainPrefix: new List<RelationshipAttribute> { relationship });
var entity = await _repository.FirstOrDefaultAsync(entityQuery);
if (entity == null) // this does not make sense. If the parent entity is not found, this error is thrown?
if (entity == null)
{
/// TODO: this does not make sense. If the **parent** entity is not found, this error is thrown?
/// this error should be thrown when the relationship is not found.
throw new JsonApiException(404, $"Relationship '{relationshipName}' not found.");
}

if (!IsNull(_hookExecutor, entity))
{ // AfterRead and OnReturn resource hook execution.
Expand Down Expand Up @@ -251,12 +256,34 @@ protected virtual IQueryable<TResource> ApplyFilter(IQueryable<TResource> entiti
/// </summary>
/// <param name="entities"></param>
/// <returns></returns>
protected virtual IQueryable<TResource> ApplyInclude(IQueryable<TResource> entities)
protected virtual IQueryable<TResource> ApplyInclude(IQueryable<TResource> entities, IEnumerable<RelationshipAttribute> chainPrefix = null)
{
var chains = _includeService.Get();
if (chains != null && chains.Any())
foreach (var r in chains)
entities = _repository.Include(entities, r.ToArray());
bool hasInclusionChain = chains.Any();

if (chains == null)
{
throw new Exception();
}

if (chainPrefix != null && !hasInclusionChain)
{
hasInclusionChain = true;
chains.Add(new List<RelationshipAttribute>());
}


if (hasInclusionChain)
{
foreach (var inclusionChain in chains)
{
if (chainPrefix != null)
{
inclusionChain.InsertRange(0, chainPrefix);
}
entities = _repository.Include(entities, inclusionChain.ToArray());
}
}

return entities;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Bogus;
using JsonApiDotNetCoreExample.Models;
using JsonApiDotNetCoreExampleTests.Helpers.Models;
using Xunit;
using Person = JsonApiDotNetCoreExample.Models.Person;

namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec
{
public class NestedResourceTests : FunctionalTestCollection<StandardApplicationFactory>
{
private readonly Faker<TodoItem> _todoItemFaker;
private readonly Faker<Person> _personFaker;
private readonly Faker<Passport> _passportFaker;

public NestedResourceTests(StandardApplicationFactory factory) : base(factory)
{
_todoItemFaker = new Faker<TodoItem>()
.RuleFor(t => t.Description, f => f.Lorem.Sentence())
.RuleFor(t => t.Ordinal, f => f.Random.Number())
.RuleFor(t => t.CreatedDate, f => f.Date.Past());
_personFaker = new Faker<Person>()
.RuleFor(t => t.FirstName, f => f.Name.FirstName())
.RuleFor(t => t.LastName, f => f.Name.LastName());
_passportFaker = new Faker<Passport>()
.RuleFor(t => t.SocialSecurityNumber, f => f.Random.Number());
}

[Fact]
public async Task NestedResourceRoute_RequestWithIncludeQueryParam_ReturnsRequestedRelationships()
{
// Arrange
var assignee = _dbContext.Add(_personFaker.Generate()).Entity;
var todo = _dbContext.Add(_todoItemFaker.Generate()).Entity;
var owner = _dbContext.Add(_personFaker.Generate()).Entity;
var passport = _dbContext.Add(_passportFaker.Generate()).Entity;
_dbContext.SaveChanges();
todo.AssigneeId = assignee.Id;
todo.OwnerId = owner.Id;
owner.PassportId = passport.Id;
_dbContext.SaveChanges();

// Act
var (body, response) = await Get($"/api/v1/people/{assignee.Id}/assignedTodoItems?include=owner.passport");

// Assert
AssertEqualStatusCode(HttpStatusCode.OK, response);
var resultTodoItem = _deserializer.DeserializeList<TodoItemClient>(body).Data.SingleOrDefault();
Assert.Equal(todo.Id, resultTodoItem.Id);
Assert.Equal(todo.Owner.Id, resultTodoItem.Owner.Id);
Assert.Equal(todo.Owner.Passport.Id, resultTodoItem.Owner.Passport.Id);
}

[Theory]
[InlineData("filter[ordinal]=1")]
[InlineData("fields=ordinal")]
[InlineData("sort=ordinal")]
[InlineData("page[number]=1")]
[InlineData("page[size]=10")]
public async Task NestedResourceRoute_RequestWithUnsupportedQueryParam_ReturnsBadRequest(string queryParam)
{
// Act
var (body, response) = await Get($"/api/v1/people/1/assignedTodoItems?{queryParam}");

// Assert
AssertEqualStatusCode(HttpStatusCode.BadRequest, response);
Assert.Contains("currently not supported", body);
}
}
}
20 changes: 19 additions & 1 deletion test/UnitTests/Services/EntityResourceService_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,21 @@ public class EntityResourceService_Tests
{
private readonly Mock<IResourceRepository<TodoItem>> _repositoryMock = new Mock<IResourceRepository<TodoItem>>();
private readonly IResourceGraph _resourceGraph;
private readonly Mock<IIncludeService> _includeService;
private readonly Mock<ISparseFieldsService> _sparseFieldsService;
private readonly Mock<IPageService> _pageService;

public Mock<ISortService> _sortService { get; }
public Mock<IFilterService> _filterService { get; }

public EntityResourceService_Tests()
{
_includeService = new Mock<IIncludeService>();
_includeService.Setup(m => m.Get()).Returns(new List<List<RelationshipAttribute>>());
_sparseFieldsService = new Mock<ISparseFieldsService>();
_pageService = new Mock<IPageService>();
_sortService = new Mock<ISortService>();
_filterService = new Mock<IFilterService>();
_resourceGraph = new ResourceGraphBuilder()
.AddResource<TodoItem>()
.AddResource<TodoItemCollection, Guid>()
Expand Down Expand Up @@ -87,7 +99,13 @@ public async Task GetRelationshipAsync_Returns_Relationship_Value()

private DefaultResourceService<TodoItem> GetService()
{
return new DefaultResourceService<TodoItem>(new List<IQueryParameterService>(), new JsonApiOptions(), _repositoryMock.Object, _resourceGraph);
var queryParamServices = new List<IQueryParameterService>
{
_includeService.Object, _pageService.Object, _filterService.Object,
_sortService.Object, _sparseFieldsService.Object
};

return new DefaultResourceService<TodoItem>(queryParamServices, new JsonApiOptions(), _repositoryMock.Object, _resourceGraph);
}
}
}