diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs index e1b5a308..b189e933 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs @@ -112,5 +112,34 @@ public async Task Post_with_empty_id() } } } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Post_with_empty_id_and_include() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "posts?include=author", @"Fixtures\CreatingResources\Requests\Post_with_empty_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\Post_with_empty_id_and_include_author_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.Posts.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.Id == "230"); + actualPost.Id.Should().Be("230"); + actualPost.Title.Should().Be("New post"); + actualPost.Content.Should().Be("The server generated my ID"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 04, 13, 12, 09, 0, new TimeSpan(0, 3, 0, 0))); + actualPost.AuthorId.Should().Be("401"); + } + } + } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs index 36321862..18e83042 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs @@ -99,6 +99,23 @@ public async Task Get_related_to_many() } } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_related_to_many_included() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/201/comments?include=author"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_related_to_many_include_response.json", HttpStatusCode.OK); + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] @@ -115,6 +132,23 @@ public async Task Get_related_to_one() } } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_included_to_one() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/201?include=author"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_included_to_one_response.json", HttpStatusCode.OK); + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json new file mode 100644 index 00000000..f9996946 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "230", + "attributes": { + "content": "The server generated my ID", + "created": "2015-04-13T09:09:00.0000000+00:00", + "title": "New post" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/230/relationships/author", + "related": "https://www.example.com/posts/230/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/230/relationships/comments", + "related": "https://www.example.com/posts/230/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/230/relationships/tags", + "related": "https://www.example.com/posts/230/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json new file mode 100644 index 00000000..98fcd282 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "201", + "attributes": { + "content": "Post 1 content", + "created": "2015-01-31T14:00:00.0000000+00:00", + "title": "Post 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/201/relationships/author", + "related": "https://www.example.com/posts/201/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/201/relationships/comments", + "related": "https://www.example.com/posts/201/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/201/relationships/tags", + "related": "https://www.example.com/posts/201/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json new file mode 100644 index 00000000..e1a01091 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json @@ -0,0 +1,140 @@ +{ + "data": [ + { + "type": "comments", + "id": "101", + "attributes": { + "created": "2015-01-31T14:30:00.0000000+00:00", + "text": "Comment 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/101/relationships/author", + "related": "https://www.example.com/comments/101/author" + }, + "data": { + "type": "users", + "id": "403" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/101/relationships/post", + "related": "https://www.example.com/comments/101/post" + } + } + } + }, + { + "type": "comments", + "id": "102", + "attributes": { + "created": "2015-01-31T14:35:00.0000000+00:00", + "text": "Comment 2" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/102/relationships/author", + "related": "https://www.example.com/comments/102/author" + }, + "data": { + "type": "users", + "id": "402" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/102/relationships/post", + "related": "https://www.example.com/comments/102/post" + } + } + } + }, + { + "type": "comments", + "id": "103", + "attributes": { + "created": "2015-01-31T14:41:00.0000000+00:00", + "text": "Comment 3" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/103/relationships/author", + "related": "https://www.example.com/comments/103/author" + }, + "data": { + "type": "users", + "id": "403" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/103/relationships/post", + "related": "https://www.example.com/comments/103/post" + } + } + } + } + ], + "included": [ + { + "type": "users", + "id": "403", + "attributes": { + "first-name": "Charlie", + "last-name": "Michaels" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/403/relationships/comments", + "related": "https://www.example.com/users/403/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/403/relationships/posts", + "related": "https://www.example.com/users/403/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/403/relationships/user-groups", + "related": "https://www.example.com/users/403/user-groups" + } + } + } + }, + { + "type": "users", + "id": "402", + "attributes": { + "first-name": "Bob", + "last-name": "Jones" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/402/relationships/comments", + "related": "https://www.example.com/users/402/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/402/relationships/posts", + "related": "https://www.example.com/users/402/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/402/relationships/user-groups", + "related": "https://www.example.com/users/402/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json new file mode 100644 index 00000000..6bff40d9 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "New post title" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/202/relationships/author", + "related": "https://www.example.com/posts/202/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/202/relationships/comments", + "related": "https://www.example.com/posts/202/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/202/relationships/tags", + "related": "https://www.example.com/posts/202/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index b335e6c5..b8546fbf 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -271,6 +271,10 @@ + + + + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs index 48df90f9..e24ae49b 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs @@ -41,6 +41,35 @@ public async Task PatchWithAttributeUpdate() } } + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task PatchWithAttributeUpdateAndInclude() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPatch(effortConnection, "posts/202?include=author", @"Fixtures\UpdatingResources\Requests\PatchWithAttributeUpdateRequest.json"); + + await AssertResponseContent(response, @"Fixtures\UpdatingResources\Responses\PatchWithAttributeUpdateWithIncludeResponse.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.Posts.Include(p => p.Tags).ToArray(); + allPosts.Length.Should().Be(4); + var actualPost = allPosts.First(t => t.Id == "202"); + actualPost.Id.Should().Be("202"); + actualPost.Title.Should().Be("New post title"); + actualPost.Content.Should().Be("Post 2 content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 02, 05, 08, 10, 0, new TimeSpan(0))); + actualPost.AuthorId.Should().Be("401"); + actualPost.Tags.Select(t => t.Id).Should().BeEquivalentTo("302", "303"); + } + } + } + [TestMethod] [DeploymentItem(@"Data\PostID.csv", @"Data")] public async Task PatchWithAttributeUpdateID() diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs index 5cb186b3..9fb646b3 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs @@ -17,8 +17,9 @@ public class StarshipOfficersRelatedResourceMaterializer : EntityFrameworkToMany public StarshipOfficersRelatedResourceMaterializer(ResourceTypeRelationship relationship, DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(relationship, dbContext, queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, primaryTypeRegistration) + : base(relationship, dbContext, queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor, primaryTypeRegistration) { _dbContext = dbContext; } diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index d388dafc..9c57ca4a 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -166,6 +166,7 @@ protected override void Load(ContainerBuilder builder) // Misc builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); } } } diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index 0b86166f..114f7877 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -9,6 +9,7 @@ using JSONAPI.Core; using JSONAPI.Documents; using JSONAPI.Documents.Builders; +using JSONAPI.Extensions; using JSONAPI.Http; namespace JSONAPI.EntityFramework.Http @@ -24,6 +25,7 @@ public class EntityFrameworkDocumentMaterializer : IDocumentMaterializer wher private readonly ISingleResourceDocumentBuilder _singleResourceDocumentBuilder; private readonly IEntityFrameworkResourceObjectMaterializer _entityFrameworkResourceObjectMaterializer; private readonly ISortExpressionExtractor _sortExpressionExtractor; + private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; /// @@ -36,6 +38,7 @@ public EntityFrameworkDocumentMaterializer( ISingleResourceDocumentBuilder singleResourceDocumentBuilder, IEntityFrameworkResourceObjectMaterializer entityFrameworkResourceObjectMaterializer, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IBaseUrlService baseUrlService) { DbContext = dbContext; @@ -44,24 +47,27 @@ public EntityFrameworkDocumentMaterializer( _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _entityFrameworkResourceObjectMaterializer = entityFrameworkResourceObjectMaterializer; _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; } public virtual Task GetRecords(HttpRequestMessage request, CancellationToken cancellationToken) { - var query = DbContext.Set().AsQueryable(); var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); - return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var query = QueryIncludeNavigationProperties(null, GetNavigationPropertiesIncludes(includes)); + return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, includes); } public virtual async Task GetRecordById(string id, HttpRequestMessage request, CancellationToken cancellationToken) { var apiBaseUrl = GetBaseUrlFromRequest(request); - var singleResource = await FilterById(id, _resourceTypeRegistration).FirstOrDefaultAsync(cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var singleResource = await FilterById(id, _resourceTypeRegistration, GetNavigationPropertiesIncludes(includes)).FirstOrDefaultAsync(cancellationToken); if (singleResource == null) throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", _resourceTypeRegistration.ResourceTypeName, id)); - return _singleResourceDocumentBuilder.BuildDocument(singleResource, apiBaseUrl, null, null); + return _singleResourceDocumentBuilder.BuildDocument(singleResource, apiBaseUrl, includes, null); } public virtual async Task CreateRecord(ISingleResourceDocument requestDocument, @@ -71,7 +77,8 @@ public virtual async Task CreateRecord(ISingleResourceD var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); await OnCreate(newRecord); await DbContext.SaveChangesAsync(cancellationToken); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, null, null); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, includes, null); return returnDocument; } @@ -83,8 +90,9 @@ public virtual async Task UpdateRecord(string id, ISing var apiBaseUrl = GetBaseUrlFromRequest(request); var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); await OnUpdate(newRecord); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, null, null); await DbContext.SaveChangesAsync(cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, includes, null); return returnDocument; } @@ -116,50 +124,7 @@ protected virtual async Task MaterializeAsync(IResourceObject resourceObject, return (T) await _entityFrameworkResourceObjectMaterializer.MaterializeResourceObject(resourceObject, cancellationToken); } - /// - /// Generic method for getting the related resources for a to-many relationship - /// - protected async Task GetRelatedToMany(string id, - ResourceTypeRelationship relationship, HttpRequestMessage request, CancellationToken cancellationToken) - { - var param = Expression.Parameter(typeof(T)); - var accessorExpr = Expression.Property(param, relationship.Property); - var lambda = Expression.Lambda>>(accessorExpr, param); - - var primaryEntityQuery = FilterById(id, _resourceTypeRegistration); - - // We have to see if the resource even exists, so we can throw a 404 if it doesn't - var relatedResource = await primaryEntityQuery.FirstOrDefaultAsync(cancellationToken); - if (relatedResource == null) - throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", - _resourceTypeRegistration.ResourceTypeName, id)); - - var relatedResourceQuery = primaryEntityQuery.SelectMany(lambda); - var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); - - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(relatedResourceQuery, request, sortExpressions, cancellationToken); - } - - /// - /// Generic method for getting the related resources for a to-one relationship - /// - protected async Task GetRelatedToOne(string id, - ResourceTypeRelationship relationship, HttpRequestMessage request, CancellationToken cancellationToken) - { - var param = Expression.Parameter(typeof(T)); - var accessorExpr = Expression.Property(param, relationship.Property); - var lambda = Expression.Lambda>(accessorExpr, param); - - var primaryEntityQuery = FilterById(id, _resourceTypeRegistration); - var primaryEntityExists = await primaryEntityQuery.AnyAsync(cancellationToken); - if (!primaryEntityExists) - throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", - _resourceTypeRegistration.ResourceTypeName, id)); - var relatedResource = await primaryEntityQuery.Select(lambda).FirstOrDefaultAsync(cancellationToken); - return _singleResourceDocumentBuilder.BuildDocument(relatedResource, GetBaseUrlFromRequest(request), null, null); - } - - + /// /// Manipulate entity before create. /// @@ -189,11 +154,34 @@ protected virtual async Task OnDelete(Task record) await record; } - private IQueryable Filter(Expression> predicate, + /// + /// This method allows to include into query. + /// This can reduce the number of queries (eager loading) + /// + /// + /// + /// + protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) + { + List>> list = new List>>(); + foreach (var include in includes) + { + var incl = include.Pascalize(); + var param = Expression.Parameter(typeof(TResource)); + var lambda = + Expression.Lambda>( + Expression.PropertyOrField(param, incl),param); + list.Add(lambda); + } + return list.ToArray(); + } + + + private IQueryable QueryIncludeNavigationProperties(Expression> predicate, params Expression>[] includes) where TResource : class { IQueryable query = DbContext.Set(); - if (includes != null && includes.Any()) + if (includes != null && includes.Any()) // eager loading query = includes.Aggregate(query, (current, include) => current.Include(include)); if (predicate != null) @@ -208,7 +196,7 @@ private IQueryable FilterById(string id, IResourceTypeRegi var param = Expression.Parameter(typeof(TResource)); var filterByIdExpression = resourceTypeRegistration.GetFilterByIdExpression(param, id); var predicate = Expression.Lambda>(filterByIdExpression, param); - return Filter(predicate, includes); + return QueryIncludeNavigationProperties(predicate, includes); } } } diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs index 2a52338e..50004e16 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using JSONAPI.Core; using JSONAPI.Documents.Builders; +using JSONAPI.Extensions; using JSONAPI.Http; namespace JSONAPI.EntityFramework.Http @@ -29,8 +30,9 @@ public EntityFrameworkToManyRelatedResourceDocumentMaterializer( DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor) + : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor) { _relationship = relationship; _dbContext = dbContext; @@ -43,8 +45,7 @@ protected override async Task> GetRelatedQuery(string prima var param = Expression.Parameter(typeof (TPrimaryResource)); var accessorExpr = Expression.Property(param, _relationship.Property); var lambda = Expression.Lambda>>(accessorExpr, param); - - var primaryEntityQuery = FilterById(primaryResourceId, _primaryTypeRegistration); + var primaryEntityQuery = FilterById(primaryResourceId, _primaryTypeRegistration, GetNavigationPropertiesIncludes(Includes)); // We have to see if the resource even exists, so we can throw a 404 if it doesn't var relatedResource = await primaryEntityQuery.FirstOrDefaultAsync(cancellationToken); @@ -56,6 +57,29 @@ protected override async Task> GetRelatedQuery(string prima return primaryEntityQuery.SelectMany(lambda); } + + /// + /// This method allows to include into query. + /// This can reduce the number of queries (eager loading) + /// + /// + /// + /// + protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) + { + List>> list = new List>>(); + foreach (var include in includes) + { + var incl = include.Pascalize(); + var param = Expression.Parameter(typeof(TResource)); + var lambda = + Expression.Lambda>( + Expression.PropertyOrField(param, incl), param); + list.Add(lambda); + } + return list.ToArray(); + } + private IQueryable FilterById(string id, IResourceTypeRegistration resourceTypeRegistration, params Expression>[] includes) where TResource : class @@ -63,10 +87,10 @@ private IQueryable FilterById(string id, var param = Expression.Parameter(typeof (TResource)); var filterByIdExpression = resourceTypeRegistration.GetFilterByIdExpression(param, id); var predicate = Expression.Lambda>(filterByIdExpression, param); - return Filter(predicate, includes); + return QueryIncludeNavigationProperties(predicate, includes); } - private IQueryable Filter(Expression> predicate, + private IQueryable QueryIncludeNavigationProperties(Expression> predicate, params Expression>[] includes) where TResource : class { IQueryable query = _dbContext.Set(); diff --git a/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs b/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs index 24943f4a..a6b9f14d 100644 --- a/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs +++ b/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs @@ -30,9 +30,10 @@ public async Task Creates_single_resource_document_for_registered_non_collection var objectContent = new Fruit { Id = "984", Name = "Kiwi" }; var mockDocument = new Mock(MockBehavior.Strict); + var includePathExpression = new string[] {}; var singleResourceDocumentBuilder = new Mock(MockBehavior.Strict); - singleResourceDocumentBuilder.Setup(b => b.BuildDocument(objectContent, It.IsAny(), null, null, null)).Returns(mockDocument.Object); + singleResourceDocumentBuilder.Setup(b => b.BuildDocument(objectContent, It.IsAny(), includePathExpression, null, null)).Returns(mockDocument.Object); var mockQueryableDocumentBuilder = new Mock(MockBehavior.Strict); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); @@ -44,11 +45,14 @@ public async Task Creates_single_resource_document_for_registered_non_collection mockBaseUrlService.Setup(s => s.GetBaseUrl(request)).Returns("https://www.example.com"); var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); - mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new [] { "id "}); + mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new[] { "id " }); + + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(includePathExpression); // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(objectContent, request, cancellationTokenSource.Token); // Assert @@ -75,12 +79,14 @@ public async Task Creates_resource_collection_document_for_queryables() mockBaseUrlService.Setup(s => s.GetBaseUrl(request)).Returns("https://www.example.com/"); var sortExpressions = new[] { "id" }; + + var includeExpressions = new string[] { }; var cancellationTokenSource = new CancellationTokenSource(); var mockQueryableDocumentBuilder = new Mock(MockBehavior.Strict); mockQueryableDocumentBuilder - .Setup(b => b.BuildDocument(items, request, sortExpressions, cancellationTokenSource.Token, null)) + .Setup(b => b.BuildDocument(items, request, sortExpressions, cancellationTokenSource.Token, includeExpressions)) .Returns(Task.FromResult(mockDocument.Object)); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); @@ -88,9 +94,12 @@ public async Task Creates_resource_collection_document_for_queryables() var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(sortExpressions); + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(includeExpressions); + // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(items, request, cancellationTokenSource.Token); // Assert @@ -127,9 +136,12 @@ public async Task Creates_resource_collection_document_for_non_queryable_enumera var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new[] { "id " }); + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(new string[] { }); + // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(items, request, cancellationTokenSource.Token); // Assert diff --git a/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs b/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs new file mode 100644 index 00000000..0fb65380 --- /dev/null +++ b/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs @@ -0,0 +1,57 @@ +using System.Net.Http; +using FluentAssertions; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.Tests.Http +{ + [TestClass] + public class DefaultIncludeExpressionExtractorTests + { + [TestMethod] + public void ExtractsSingleIncludeExpressionFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?include=boss"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpressions = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpressions.Should().BeEquivalentTo("boss"); + } + + + [TestMethod] + public void ExtractsMultipleIncludeExpressionsFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?include=boss,office-address"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpressions = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpressions.Should().BeEquivalentTo("boss", "office-address"); + } + + [TestMethod] + public void ExtractsNothingWhenThereIsNoIncludeParam() + { + // Arrange + const string uri = "http://api.example.com/dummies"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpression = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpression.Length.Should().Be(0); + } + } +} diff --git a/JSONAPI.Tests/JSONAPI.Tests.csproj b/JSONAPI.Tests/JSONAPI.Tests.csproj index 4a86feae..7f487003 100644 --- a/JSONAPI.Tests/JSONAPI.Tests.csproj +++ b/JSONAPI.Tests/JSONAPI.Tests.csproj @@ -91,6 +91,7 @@ + diff --git a/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs b/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs index f6882571..8208f274 100644 --- a/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs @@ -18,6 +18,7 @@ public class FallbackDocumentBuilder : IFallbackDocumentBuilder private readonly IQueryableResourceCollectionDocumentBuilder _queryableResourceCollectionDocumentBuilder; private readonly IResourceCollectionDocumentBuilder _resourceCollectionDocumentBuilder; private readonly ISortExpressionExtractor _sortExpressionExtractor; + private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; private readonly Lazy _openBuildDocumentFromQueryableMethod; private readonly Lazy _openBuildDocumentFromEnumerableMethod; @@ -29,12 +30,14 @@ public FallbackDocumentBuilder(ISingleResourceDocumentBuilder singleResourceDocu IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IBaseUrlService baseUrlService) { _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _resourceCollectionDocumentBuilder = resourceCollectionDocumentBuilder; _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; _openBuildDocumentFromQueryableMethod = @@ -53,6 +56,9 @@ public async Task BuildDocument(object obj, HttpRequestMessage { var type = obj.GetType(); + // TODO: test includes + var includeExpressions = _includeExpressionExtractor.ExtractIncludeExpressions(requestMessage); + var queryableInterfaces = type.GetInterfaces(); var queryableInterface = queryableInterfaces.FirstOrDefault( @@ -66,7 +72,7 @@ public async Task BuildDocument(object obj, HttpRequestMessage var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(requestMessage); dynamic materializedQueryTask = buildDocumentMethod.Invoke(_queryableResourceCollectionDocumentBuilder, - new[] { obj, requestMessage, sortExpressions, cancellationToken, null }); + new[] { obj, requestMessage, sortExpressions, cancellationToken, includeExpressions }); return await materializedQueryTask; } @@ -89,7 +95,7 @@ public async Task BuildDocument(object obj, HttpRequestMessage } // Single resource object - return _singleResourceDocumentBuilder.BuildDocument(obj, linkBaseUrl, null, null); + return _singleResourceDocumentBuilder.BuildDocument(obj, linkBaseUrl, includeExpressions, null); } private static Type GetEnumerableElementType(Type collectionType) diff --git a/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs b/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs new file mode 100644 index 00000000..cb040b19 --- /dev/null +++ b/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs @@ -0,0 +1,21 @@ +using System.Linq; +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Default implementation of + /// + public class DefaultIncludeExpressionExtractor: IIncludeExpressionExtractor + { + private const string IncludeQueryParamKey = "include"; + + public string[] ExtractIncludeExpressions(HttpRequestMessage requestMessage) + { + var queryParams = requestMessage.GetQueryNameValuePairs(); + var includeParam = queryParams.FirstOrDefault(kvp => kvp.Key == IncludeQueryParamKey); + if (includeParam.Key != IncludeQueryParamKey) return new string[] { }; + return includeParam.Value.Split(','); + } + } +} diff --git a/JSONAPI/Http/IIncludeExpressionExtractor.cs b/JSONAPI/Http/IIncludeExpressionExtractor.cs new file mode 100644 index 00000000..c8cf1358 --- /dev/null +++ b/JSONAPI/Http/IIncludeExpressionExtractor.cs @@ -0,0 +1,17 @@ +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Service to extract include expressions from an HTTP request + /// + public interface IIncludeExpressionExtractor + { + /// + /// Extracts include expressions from the request + /// + /// + /// + string[] ExtractIncludeExpressions(HttpRequestMessage requestMessage); + } +} diff --git a/JSONAPI/Http/JsonApiController.cs b/JSONAPI/Http/JsonApiController.cs index 09deb1b1..8bc9bf22 100644 --- a/JSONAPI/Http/JsonApiController.cs +++ b/JSONAPI/Http/JsonApiController.cs @@ -63,7 +63,7 @@ public virtual async Task Post(string resourceType, [FromBody } /// - /// Updates the record with the given ID with data from the request payloaad. + /// Updates the record with the given ID with data from the request payload. /// public virtual async Task Patch(string resourceType, string id, [FromBody]ISingleResourceDocument requestDocument, CancellationToken cancellationToken) { diff --git a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs index 7d190960..6aa2286f 100644 --- a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs @@ -15,27 +15,36 @@ public abstract class QueryableToManyRelatedResourceDocumentMaterializer + /// List of includes given by url. + /// + protected string[] Includes = {}; /// /// Creates a new QueryableRelatedResourceDocumentMaterializer /// protected QueryableToManyRelatedResourceDocumentMaterializer( IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, - ISortExpressionExtractor sortExpressionExtractor) + ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor) { _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; } public async Task GetRelatedResourceDocument(string primaryResourceId, HttpRequestMessage request, CancellationToken cancellationToken) { + Includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); var query = await GetRelatedQuery(primaryResourceId, cancellationToken); - var includes = GetIncludePaths(); var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); if (sortExpressions == null || sortExpressions.Length < 1) sortExpressions = GetDefaultSortExpressions(); - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, includes); // TODO: allow implementors to specify metadata + + + return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, Includes); // TODO: allow implementors to specify metadata } /// @@ -43,15 +52,6 @@ public async Task GetRelatedResourceDocument(string primaryRes /// protected abstract Task> GetRelatedQuery(string primaryResourceId, CancellationToken cancellationToken); - /// - /// Gets a list of relationship paths to include - /// - /// - protected virtual string[] GetIncludePaths() - { - return null; - } - /// /// If the client doesn't request any sort expressions, these expressions will be used for sorting instead. /// diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index d6ed5c23..4336261f 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -90,10 +90,12 @@ + +