From e6d8c3f860ddb3ade2ae60cbc7d573f3958da1bf Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Wed, 1 Mar 2017 07:31:59 -0600 Subject: [PATCH 1/4] feat(root-links): add pagination links Issue #21 --- .../Builders/DocumentBuilder.cs | 6 +- src/JsonApiDotNetCore/Builders/LinkBuilder.cs | 6 +- src/JsonApiDotNetCore/Internal/PageManager.cs | 32 ++++++++ src/JsonApiDotNetCore/Models/DocumentBase.cs | 8 ++ src/JsonApiDotNetCore/Models/RootLinks.cs | 48 +++++++++++ .../Services/JsonApiContext.cs | 2 +- src/JsonApiDotNetCoreExample/Startup.cs | 2 + .../Spec/DocumentTests/PagingTests.cs | 82 +++++++++++++++++++ 8 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 src/JsonApiDotNetCore/Models/RootLinks.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index 20327020ed..9dc64e06b4 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -26,7 +26,8 @@ public Document Build(IIdentifiable entity) var document = new Document { Data = _getData(contextEntity, entity), - Meta = _getMeta(entity) + Meta = _getMeta(entity), + Links = _jsonApiContext.PageManager.GetPageLinks(new LinkBuilder(_jsonApiContext)) }; document.Included = _appendIncludedObject(document.Included, contextEntity, entity); @@ -45,7 +46,8 @@ public Documents Build(IEnumerable entities) var documents = new Documents { Data = new List(), - Meta = _getMeta(entities.FirstOrDefault()) + Meta = _getMeta(entities.FirstOrDefault()), + Links = _jsonApiContext.PageManager.GetPageLinks(new LinkBuilder(_jsonApiContext)) }; foreach (var entity in entities) diff --git a/src/JsonApiDotNetCore/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Builders/LinkBuilder.cs index d819aab253..5fd25793d7 100644 --- a/src/JsonApiDotNetCore/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/LinkBuilder.cs @@ -1,4 +1,3 @@ - using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; @@ -45,5 +44,10 @@ public string GetRelatedRelationLink(string parent, string parentId, string chil { return $"{_context.BasePath}/{parent.Dasherize()}/{parentId}/{child.Dasherize()}"; } + + public string GetPageLink(int pageOffset, int pageSize) + { + return $"{_context.BasePath}/{_context.RequestEntity.EntityName.Dasherize()}?page[size]={pageSize}&page[number]={pageOffset}"; + } } } diff --git a/src/JsonApiDotNetCore/Internal/PageManager.cs b/src/JsonApiDotNetCore/Internal/PageManager.cs index fd8ad6b4f4..c85d81b1e9 100644 --- a/src/JsonApiDotNetCore/Internal/PageManager.cs +++ b/src/JsonApiDotNetCore/Internal/PageManager.cs @@ -1,10 +1,42 @@ +using System; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Models; + namespace JsonApiDotNetCore.Internal { public class PageManager { public int TotalRecords { get; set; } public int PageSize { get; set; } + public int DefaultPageSize { get; set; } public int CurrentPage { get; set; } public bool IsPaginated { get { return PageSize > 0; } } + public int TotalPages { + get { return (TotalRecords == 0) ? -1: (int)Math.Ceiling(decimal.Divide(TotalRecords, PageSize)); } + } + + public RootLinks GetPageLinks(LinkBuilder linkBuilder) + { + if(!IsPaginated || (CurrentPage == 1 && TotalPages <= 0)) + return null; + + var rootLinks = new RootLinks(); + + var includePageSize = DefaultPageSize != PageSize; + + if(CurrentPage > 1) + rootLinks.First = linkBuilder.GetPageLink(1, PageSize); + + if(CurrentPage > 1) + rootLinks.Prev = linkBuilder.GetPageLink(CurrentPage - 1, PageSize); + + if(CurrentPage < TotalPages) + rootLinks.Next = linkBuilder.GetPageLink(CurrentPage + 1, PageSize); + + if(TotalPages > 0) + rootLinks.Last = linkBuilder.GetPageLink(TotalPages, PageSize); + + return rootLinks; + } } } diff --git a/src/JsonApiDotNetCore/Models/DocumentBase.cs b/src/JsonApiDotNetCore/Models/DocumentBase.cs index 36ce795a43..1cb31595ec 100644 --- a/src/JsonApiDotNetCore/Models/DocumentBase.cs +++ b/src/JsonApiDotNetCore/Models/DocumentBase.cs @@ -5,6 +5,9 @@ namespace JsonApiDotNetCore.Models { public class DocumentBase { + [JsonProperty("links")] + public RootLinks Links { get; set; } + [JsonProperty("included")] public List Included { get; set; } @@ -21,5 +24,10 @@ public bool ShouldSerializeMeta() { return (Meta != null); } + + public bool ShouldSerializeLinks() + { + return (Links != null); + } } } diff --git a/src/JsonApiDotNetCore/Models/RootLinks.cs b/src/JsonApiDotNetCore/Models/RootLinks.cs new file mode 100644 index 0000000000..42b0a7863f --- /dev/null +++ b/src/JsonApiDotNetCore/Models/RootLinks.cs @@ -0,0 +1,48 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models +{ + public class RootLinks + { + [JsonProperty("self")] + public string Self { get; set; } + + [JsonProperty("next")] + public string Next { get; set; } + + [JsonProperty("prev")] + public string Prev { get; set; } + + [JsonProperty("first")] + public string First { get; set; } + + [JsonProperty("last")] + public string Last { get; set; } + + // http://www.newtonsoft.com/json/help/html/ConditionalProperties.htm + public bool ShouldSerializeSelf() + { + return (!string.IsNullOrEmpty(Self)); + } + + public bool ShouldSerializeFirst() + { + return (!string.IsNullOrEmpty(First)); + } + + public bool ShouldSerializeNext() + { + return (!string.IsNullOrEmpty(Next)); + } + + public bool ShouldSerializePrev() + { + return (!string.IsNullOrEmpty(Prev)); + } + + public bool ShouldSerializeLast() + { + return (!string.IsNullOrEmpty(Last)); + } + } +} diff --git a/src/JsonApiDotNetCore/Services/JsonApiContext.cs b/src/JsonApiDotNetCore/Services/JsonApiContext.cs index cfd0bff144..88cd045ea1 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiContext.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Builders; @@ -58,6 +57,7 @@ private PageManager GetPageManager() var query = QuerySet?.PageQuery ?? new PageQuery(); return new PageManager { + DefaultPageSize = Options.DefaultPageSize, CurrentPage = query.PageOffset > 0 ? query.PageOffset : 1, PageSize = query.PageSize > 0 ? query.PageSize : Options.DefaultPageSize }; diff --git a/src/JsonApiDotNetCoreExample/Startup.cs b/src/JsonApiDotNetCoreExample/Startup.cs index 6ae05f93d3..2abea4baad 100644 --- a/src/JsonApiDotNetCoreExample/Startup.cs +++ b/src/JsonApiDotNetCoreExample/Startup.cs @@ -44,6 +44,7 @@ public virtual IServiceProvider ConfigureServices(IServiceCollection services) { opt.Namespace = "api/v1"; opt.DefaultPageSize = 5; + opt.IncludeTotalRecordCount = true; }); services.AddDocumentationConfiguration(Config); @@ -52,6 +53,7 @@ public virtual IServiceProvider ConfigureServices(IServiceCollection services) var appContext = provider.GetRequiredService(); if(appContext == null) throw new ArgumentException(); + return provider; } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs new file mode 100644 index 0000000000..d62d5120f2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using DotNetCoreDocs; +using DotNetCoreDocs.Writers; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; +using Person = JsonApiDotNetCoreExample.Models.Person; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; +using Bogus; +using JsonApiDotNetCoreExample.Models; +using System.Linq; +using System; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests +{ + [Collection("WebHostCollection")] + public class PagingTests + { + private DocsFixture _fixture; + private AppDbContext _context; + private Faker _personFaker; + private Faker _todoItemFaker; + private Faker _todoItemCollectionFaker; + + public PagingTests(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + _personFaker = new Faker() + .RuleFor(p => p.FirstName, f => f.Name.FirstName()) + .RuleFor(p => p.LastName, f => f.Name.LastName()); + + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); + + _todoItemCollectionFaker = new Faker() + .RuleFor(t => t.Name, f => f.Company.CatchPhrase()); + } + + [Fact] + public async Task Server_IncludesPagination_Links() + { + // arrange + var pageSize = 5; + var numberOfTodoItems = _context.TodoItems.Count(); + var numberOfPages = (int)Math.Ceiling(decimal.Divide(numberOfTodoItems, pageSize)); + var startPageNumber = 2; + + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items?page[number]=2"; + + var server = new TestServer(builder); + var client = server.CreateClient(); + var request = new HttpRequestMessage(httpMethod, route); + + // act + var response = await client.SendAsync(request); + var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var links = documents.Links; + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotEmpty(links.First); + Assert.NotEmpty(links.Next); + Assert.NotEmpty(links.Last); + + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]={startPageNumber+1}", links.Next); + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]={startPageNumber-1}", links.Prev); + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]={numberOfPages}", links.Last); + Assert.Equal($"http://localhost/api/v1/todo-items?page[size]={pageSize}&page[number]=1", links.First); + } + } +} From 1ffd68ec6042deb20fef31116d69fe927b7a9649 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Wed, 1 Mar 2017 07:41:23 -0600 Subject: [PATCH 2/4] publish to MyGet on staging branch --- appveyor.yml | 2 +- src/JsonApiDotNetCore/project.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index 45405958fe..dc0da18919 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -19,7 +19,7 @@ deploy: secure: 6CeYcZ4Ze+57gxfeuHzqP6ldbUkPtF6pfpVM1Gw/K2jExFrAz763gNAQ++tiacq3 skip_symbols: true on: - branch: master + branch: staging - provider: NuGet name: production api_key: diff --git a/src/JsonApiDotNetCore/project.json b/src/JsonApiDotNetCore/project.json index d56b00e58f..8785c130a7 100644 --- a/src/JsonApiDotNetCore/project.json +++ b/src/JsonApiDotNetCore/project.json @@ -1,5 +1,5 @@ { - "version": "0.2.12", + "version": "1.0.0-beta1-*", "dependencies": { "Microsoft.NETCore.App": { From 7be572e796ae8ddcc4ba8e54b6d6440a3eea7c13 Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Wed, 1 Mar 2017 07:44:00 -0600 Subject: [PATCH 3/4] feat(ci): run builds on staging --- .travis.yml | 3 ++- appveyor.yml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d24277b532..274f9e6228 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,5 +10,6 @@ dotnet: 1.0.0-preview2-1-003177 branches: only: - master + - staging script: - - ./build.sh \ No newline at end of file + - ./build.sh diff --git a/appveyor.yml b/appveyor.yml index dc0da18919..65646e6b38 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,6 +4,7 @@ pull_requests: branches: only: - master + - staging nuget: disable_publish_on_pr: true build_script: From e490683163271904c4eaf24292d4b920a2bf0fbf Mon Sep 17 00:00:00 2001 From: Jared Nance Date: Wed, 1 Mar 2017 08:05:52 -0600 Subject: [PATCH 4/4] test(*): fix test that are dependent on some items being defined No tests should be dependent upon any items existing in the context previously --- .../Spec/DocumentTests/PagingTests.cs | 11 +++++-- .../Spec/DocumentTests/Relationships.cs | 29 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs index d62d5120f2..8a9a33f14d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/PagingTests.cs @@ -48,8 +48,15 @@ public async Task Server_IncludesPagination_Links() { // arrange var pageSize = 5; - var numberOfTodoItems = _context.TodoItems.Count(); - var numberOfPages = (int)Math.Ceiling(decimal.Divide(numberOfTodoItems, pageSize)); + const int minimumNumberOfRecords = 11; + _context.TodoItems.RemoveRange(_context.TodoItems); + + for(var i=0; i < minimumNumberOfRecords; i++) + _context.TodoItems.Add(_todoItemFaker.Generate()); + + await _context.SaveChangesAsync(); + + var numberOfPages = (int)Math.Ceiling(decimal.Divide(minimumNumberOfRecords, pageSize)); var startPageNumber = 2; var builder = new WebHostBuilder() diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs index 32637a64e2..744a395ce5 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs @@ -11,6 +11,8 @@ using JsonApiDotNetCore.Models; using JsonApiDotNetCoreExample.Data; using System.Linq; +using Bogus; +using JsonApiDotNetCoreExample.Models; namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests { @@ -19,10 +21,15 @@ public class Relationships { private DocsFixture _fixture; private AppDbContext _context; + private Faker _todoItemFaker; + public Relationships(DocsFixture fixture) { _fixture = fixture; _context = fixture.GetService(); + _todoItemFaker = new Faker() + .RuleFor(t => t.Description, f => f.Lorem.Sentence()) + .RuleFor(t => t.Ordinal, f => f.Random.Number()); } [Fact] @@ -31,9 +38,13 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() // arrange var builder = new WebHostBuilder() .UseStartup(); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items"; + var route = $"/api/v1/todo-items/{todoItem.Id}"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -41,8 +52,8 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() // act var response = await client.SendAsync(request); - var documents = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); - var data = documents.Data[0]; + var document = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync()); + var data = document.Data; var expectedOwnerSelfLink = $"http://localhost/api/v1/todo-items/{data.Id}/relationships/owner"; var expectedOwnerRelatedLink = $"http://localhost/api/v1/todo-items/{data.Id}/owner"; @@ -56,13 +67,15 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() { // arrange - var todoItemId = _context.TodoItems.Last().Id; - var builder = new WebHostBuilder() .UseStartup(); + + var todoItem = _todoItemFaker.Generate(); + _context.TodoItems.Add(todoItem); + await _context.SaveChangesAsync(); var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todo-items/{todoItemId}"; + var route = $"/api/v1/todo-items/{todoItem.Id}"; var server = new TestServer(builder); var client = server.CreateClient(); @@ -72,8 +85,8 @@ public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() var response = await client.SendAsync(request); var responseString = await response.Content.ReadAsStringAsync(); var data = JsonConvert.DeserializeObject(responseString).Data; - var expectedOwnerSelfLink = $"http://localhost/api/v1/todo-items/{todoItemId}/relationships/owner"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/todo-items/{todoItemId}/owner"; + var expectedOwnerSelfLink = $"http://localhost/api/v1/todo-items/{todoItem.Id}/relationships/owner"; + var expectedOwnerRelatedLink = $"http://localhost/api/v1/todo-items/{todoItem.Id}/owner"; // assert Assert.Equal(HttpStatusCode.OK, response.StatusCode);