Skip to content

Commit b5207ec

Browse files
authored
Support query param operations on nested resources (#634)
* fix: support include on nested resources route * fix: related unit test * test: add nested resource filter test * chore: abort attempt to fix filter/sort/select/page * fix: rogue character * feat: 400 on unsupported nested resources query params * fix: redundant spacing
1 parent 1ac9d9c commit b5207ec

File tree

10 files changed

+169
-14
lines changed

10 files changed

+169
-14
lines changed

src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ public TodoItem()
3838
public DateTimeOffset? OffsetDate { get; set; }
3939

4040
public int? OwnerId { get; set; }
41+
4142
public int? AssigneeId { get; set; }
43+
4244
public Guid? CollectionId { get; set; }
4345

4446
[HasOne]
@@ -49,6 +51,7 @@ public TodoItem()
4951

5052
[HasOne]
5153
public virtual Person OneToOnePerson { get; set; }
54+
5255
public virtual int? OneToOnePersonId { get; set; }
5356

5457
[HasMany]
@@ -59,13 +62,16 @@ public TodoItem()
5962

6063
// cyclical to-one structure
6164
public virtual int? DependentOnTodoId { get; set; }
65+
6266
[HasOne]
6367
public virtual TodoItem DependentOnTodo { get; set; }
6468

6569
// cyclical to-many structure
6670
public virtual int? ParentTodoId {get; set;}
71+
6772
[HasOne]
6873
public virtual TodoItem ParentTodo { get; set; }
74+
6975
[HasMany]
7076
public virtual List<TodoItem> ChildrenTodos { get; set; }
7177
}

src/JsonApiDotNetCore/Controllers/JsonApiController.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ namespace JsonApiDotNetCore.Controllers
99
{
1010
public class JsonApiController<T, TId> : BaseJsonApiController<T, TId> where T : class, IIdentifiable<TId>
1111
{
12-
1312
/// <param name="jsonApiOptions"></param>
1413
/// <param name="resourceService"></param>
1514
/// <param name="loggerFactory"></param>

src/JsonApiDotNetCore/QueryParameterServices/Common/QueryParameterService.cs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
using System.Collections.Generic;
2-
using System.Linq;
1+
using System.Linq;
32
using System.Text.RegularExpressions;
43
using JsonApiDotNetCore.Internal;
54
using JsonApiDotNetCore.Internal.Contracts;
65
using JsonApiDotNetCore.Managers.Contracts;
76
using JsonApiDotNetCore.Models;
8-
using Microsoft.Extensions.Primitives;
97

108
namespace JsonApiDotNetCore.Query
119
{
@@ -16,11 +14,20 @@ public abstract class QueryParameterService
1614
{
1715
protected readonly IResourceGraph _resourceGraph;
1816
protected readonly ResourceContext _requestResource;
17+
private readonly ResourceContext _mainRequestResource;
1918

2019
protected QueryParameterService(IResourceGraph resourceGraph, ICurrentRequest currentRequest)
2120
{
21+
_mainRequestResource = currentRequest.GetRequestResource();
2222
_resourceGraph = resourceGraph;
23-
_requestResource = currentRequest.GetRequestResource();
23+
if (currentRequest.RequestRelationship != null)
24+
{
25+
_requestResource= resourceGraph.GetResourceContext(currentRequest.RequestRelationship.RightType);
26+
}
27+
else
28+
{
29+
_requestResource = _mainRequestResource;
30+
}
2431
}
2532

2633
protected QueryParameterService() { }
@@ -70,5 +77,16 @@ protected RelationshipAttribute GetRelationship(string propertyName)
7077

7178
return relationship;
7279
}
80+
81+
/// <summary>
82+
/// Throw an exception if query parameters are requested that are unsupported on nested resource routes.
83+
/// </summary>
84+
protected void EnsureNoNestedResourceRoute()
85+
{
86+
if (_requestResource != _mainRequestResource)
87+
{
88+
throw new JsonApiException(400, $"Query parameter {Name} is currently not supported on nested resource endpoints (i.e. of the form '/article/1/author?{Name}=...'");
89+
}
90+
}
7391
}
7492
}

src/JsonApiDotNetCore/QueryParameterServices/FilterService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public List<FilterQueryContext> Get()
3131
/// <inheritdoc/>
3232
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
3333
{
34+
EnsureNoNestedResourceRoute();
3435
var queries = GetFilterQueries(queryParameter);
3536
_filters.AddRange(queries.Select(GetQueryContexts));
3637
}

src/JsonApiDotNetCore/QueryParameterServices/PageService.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
using System.Collections.Generic;
33
using JsonApiDotNetCore.Configuration;
44
using JsonApiDotNetCore.Internal;
5+
using JsonApiDotNetCore.Internal.Contracts;
56
using JsonApiDotNetCore.Internal.Query;
7+
using JsonApiDotNetCore.Managers.Contracts;
68
using Microsoft.Extensions.Primitives;
79

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

15-
public PageService(IJsonApiOptions options)
17+
public PageService(IJsonApiOptions options, IResourceGraph resourceGraph, ICurrentRequest currentRequest) : base(resourceGraph, currentRequest)
18+
{
19+
_options = options;
20+
PageSize = _options.DefaultPageSize;
21+
}
22+
23+
/// <summary>
24+
/// constructor used for unit testing
25+
/// </summary>
26+
internal PageService(IJsonApiOptions options)
1627
{
1728
_options = options;
1829
PageSize = _options.DefaultPageSize;
@@ -39,6 +50,7 @@ public PageService(IJsonApiOptions options)
3950
/// <inheritdoc/>
4051
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
4152
{
53+
EnsureNoNestedResourceRoute();
4254
// expected input = page[size]=<integer>
4355
// page[number]=<integer > 0>
4456
var propertyName = queryParameter.Key.Split(QueryConstants.OPEN_BRACKET, QueryConstants.CLOSE_BRACKET)[1];

src/JsonApiDotNetCore/QueryParameterServices/SortService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public SortService(IResourceDefinitionProvider resourceDefinitionProvider,
2828
/// <inheritdoc/>
2929
public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
3030
{
31+
EnsureNoNestedResourceRoute();
3132
CheckIfProcessed(); // disallow multiple sort parameters.
3233
var queries = BuildQueries(queryParameter.Value);
3334

src/JsonApiDotNetCore/QueryParameterServices/SparseFieldsService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ public virtual void Parse(KeyValuePair<string, StringValues> queryParameter)
4545
{ // expected: articles?fields=prop1,prop2
4646
// articles?fields[articles]=prop1,prop2 <-- this form in invalid UNLESS "articles" is actually a relationship on Article
4747
// articles?fields[relationship]=prop1,prop2
48+
EnsureNoNestedResourceRoute();
4849
var fields = new List<string> { nameof(Identifiable.Id) };
4950
fields.AddRange(((string)queryParameter.Value).Split(QueryConstants.COMMA));
5051

src/JsonApiDotNetCore/Services/DefaultResourceService.cs

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public virtual async Task<bool> DeleteAsync(TId id)
8080
if (!IsNull(_hookExecutor, entity)) _hookExecutor.AfterDelete(AsList(entity), ResourcePipeline.Delete, succeeded);
8181
return succeeded;
8282
}
83-
83+
8484
public virtual async Task<IEnumerable<TResource>> GetAsync()
8585
{
8686
_hookExecutor?.BeforeRead<TResource>(ResourcePipeline.Get);
@@ -134,10 +134,15 @@ public virtual async Task<TResource> GetRelationshipsAsync(TId id, string relati
134134

135135
// TODO: it would be better if we could distinguish whether or not the relationship was not found,
136136
// vs the relationship not being set on the instance of T
137-
var entityQuery = _repository.Include(_repository.Get(id), new RelationshipAttribute[] { relationship });
137+
138+
var entityQuery = ApplyInclude(_repository.Get(id), chainPrefix: new List<RelationshipAttribute> { relationship });
138139
var entity = await _repository.FirstOrDefaultAsync(entityQuery);
139-
if (entity == null) // this does not make sense. If the parent entity is not found, this error is thrown?
140+
if (entity == null)
141+
{
142+
/// TODO: this does not make sense. If the **parent** entity is not found, this error is thrown?
143+
/// this error should be thrown when the relationship is not found.
140144
throw new JsonApiException(404, $"Relationship '{relationshipName}' not found.");
145+
}
141146

142147
if (!IsNull(_hookExecutor, entity))
143148
{ // AfterRead and OnReturn resource hook execution.
@@ -251,12 +256,34 @@ protected virtual IQueryable<TResource> ApplyFilter(IQueryable<TResource> entiti
251256
/// </summary>
252257
/// <param name="entities"></param>
253258
/// <returns></returns>
254-
protected virtual IQueryable<TResource> ApplyInclude(IQueryable<TResource> entities)
259+
protected virtual IQueryable<TResource> ApplyInclude(IQueryable<TResource> entities, IEnumerable<RelationshipAttribute> chainPrefix = null)
255260
{
256261
var chains = _includeService.Get();
257-
if (chains != null && chains.Any())
258-
foreach (var r in chains)
259-
entities = _repository.Include(entities, r.ToArray());
262+
bool hasInclusionChain = chains.Any();
263+
264+
if (chains == null)
265+
{
266+
throw new Exception();
267+
}
268+
269+
if (chainPrefix != null && !hasInclusionChain)
270+
{
271+
hasInclusionChain = true;
272+
chains.Add(new List<RelationshipAttribute>());
273+
}
274+
275+
276+
if (hasInclusionChain)
277+
{
278+
foreach (var inclusionChain in chains)
279+
{
280+
if (chainPrefix != null)
281+
{
282+
inclusionChain.InsertRange(0, chainPrefix);
283+
}
284+
entities = _repository.Include(entities, inclusionChain.ToArray());
285+
}
286+
}
260287

261288
return entities;
262289
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System.Linq;
2+
using System.Net;
3+
using System.Threading.Tasks;
4+
using Bogus;
5+
using JsonApiDotNetCoreExample.Models;
6+
using JsonApiDotNetCoreExampleTests.Helpers.Models;
7+
using Xunit;
8+
using Person = JsonApiDotNetCoreExample.Models.Person;
9+
10+
namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec
11+
{
12+
public class NestedResourceTests : FunctionalTestCollection<StandardApplicationFactory>
13+
{
14+
private readonly Faker<TodoItem> _todoItemFaker;
15+
private readonly Faker<Person> _personFaker;
16+
private readonly Faker<Passport> _passportFaker;
17+
18+
public NestedResourceTests(StandardApplicationFactory factory) : base(factory)
19+
{
20+
_todoItemFaker = new Faker<TodoItem>()
21+
.RuleFor(t => t.Description, f => f.Lorem.Sentence())
22+
.RuleFor(t => t.Ordinal, f => f.Random.Number())
23+
.RuleFor(t => t.CreatedDate, f => f.Date.Past());
24+
_personFaker = new Faker<Person>()
25+
.RuleFor(t => t.FirstName, f => f.Name.FirstName())
26+
.RuleFor(t => t.LastName, f => f.Name.LastName());
27+
_passportFaker = new Faker<Passport>()
28+
.RuleFor(t => t.SocialSecurityNumber, f => f.Random.Number());
29+
}
30+
31+
[Fact]
32+
public async Task NestedResourceRoute_RequestWithIncludeQueryParam_ReturnsRequestedRelationships()
33+
{
34+
// Arrange
35+
var assignee = _dbContext.Add(_personFaker.Generate()).Entity;
36+
var todo = _dbContext.Add(_todoItemFaker.Generate()).Entity;
37+
var owner = _dbContext.Add(_personFaker.Generate()).Entity;
38+
var passport = _dbContext.Add(_passportFaker.Generate()).Entity;
39+
_dbContext.SaveChanges();
40+
todo.AssigneeId = assignee.Id;
41+
todo.OwnerId = owner.Id;
42+
owner.PassportId = passport.Id;
43+
_dbContext.SaveChanges();
44+
45+
// Act
46+
var (body, response) = await Get($"/api/v1/people/{assignee.Id}/assignedTodoItems?include=owner.passport");
47+
48+
// Assert
49+
AssertEqualStatusCode(HttpStatusCode.OK, response);
50+
var resultTodoItem = _deserializer.DeserializeList<TodoItemClient>(body).Data.SingleOrDefault();
51+
Assert.Equal(todo.Id, resultTodoItem.Id);
52+
Assert.Equal(todo.Owner.Id, resultTodoItem.Owner.Id);
53+
Assert.Equal(todo.Owner.Passport.Id, resultTodoItem.Owner.Passport.Id);
54+
}
55+
56+
[Theory]
57+
[InlineData("filter[ordinal]=1")]
58+
[InlineData("fields=ordinal")]
59+
[InlineData("sort=ordinal")]
60+
[InlineData("page[number]=1")]
61+
[InlineData("page[size]=10")]
62+
public async Task NestedResourceRoute_RequestWithUnsupportedQueryParam_ReturnsBadRequest(string queryParam)
63+
{
64+
// Act
65+
var (body, response) = await Get($"/api/v1/people/1/assignedTodoItems?{queryParam}");
66+
67+
// Assert
68+
AssertEqualStatusCode(HttpStatusCode.BadRequest, response);
69+
Assert.Contains("currently not supported", body);
70+
}
71+
}
72+
}

test/UnitTests/Services/EntityResourceService_Tests.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,21 @@ public class EntityResourceService_Tests
1919
{
2020
private readonly Mock<IResourceRepository<TodoItem>> _repositoryMock = new Mock<IResourceRepository<TodoItem>>();
2121
private readonly IResourceGraph _resourceGraph;
22+
private readonly Mock<IIncludeService> _includeService;
23+
private readonly Mock<ISparseFieldsService> _sparseFieldsService;
24+
private readonly Mock<IPageService> _pageService;
25+
26+
public Mock<ISortService> _sortService { get; }
27+
public Mock<IFilterService> _filterService { get; }
2228

2329
public EntityResourceService_Tests()
2430
{
31+
_includeService = new Mock<IIncludeService>();
32+
_includeService.Setup(m => m.Get()).Returns(new List<List<RelationshipAttribute>>());
33+
_sparseFieldsService = new Mock<ISparseFieldsService>();
34+
_pageService = new Mock<IPageService>();
35+
_sortService = new Mock<ISortService>();
36+
_filterService = new Mock<IFilterService>();
2537
_resourceGraph = new ResourceGraphBuilder()
2638
.AddResource<TodoItem>()
2739
.AddResource<TodoItemCollection, Guid>()
@@ -87,7 +99,13 @@ public async Task GetRelationshipAsync_Returns_Relationship_Value()
8799

88100
private DefaultResourceService<TodoItem> GetService()
89101
{
90-
return new DefaultResourceService<TodoItem>(new List<IQueryParameterService>(), new JsonApiOptions(), _repositoryMock.Object, _resourceGraph);
102+
var queryParamServices = new List<IQueryParameterService>
103+
{
104+
_includeService.Object, _pageService.Object, _filterService.Object,
105+
_sortService.Object, _sparseFieldsService.Object
106+
};
107+
108+
return new DefaultResourceService<TodoItem>(queryParamServices, new JsonApiOptions(), _repositoryMock.Object, _resourceGraph);
91109
}
92110
}
93111
}

0 commit comments

Comments
 (0)