diff --git a/README.md b/README.md index 4bc63c9f0f..fc66167495 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ JsonApiDotnetCore provides a framework for building [json:api](http://jsonapi.or - [Pagination](#pagination) - [Filtering](#filtering) - [Sorting](#sorting) + - [Meta](#meta) - [Tests](#tests) ## Installation @@ -240,6 +241,18 @@ when setting up the services: opt => opt.DefaultPageSize = 10); ``` +**Total Record Count** + +The total number of records can be added to the document meta by setting it in the options: + +``` +services.AddJsonApi(opt => +{ + opt.DefaultPageSize = 5; + opt.IncludeTotalRecordCount = true; +}); +``` + ### Filtering You can filter resources by attributes using the `filter` query parameter. @@ -270,6 +283,25 @@ Resources can be sorted by an attribute: ?sort=-attribute // descending ``` +### Meta + +Resource meta can be defined by implementing `IHasMeta` on the model class: + +``` +public class Person : Identifiable, IHasMeta +{ + // ... + + public Dictionary GetMeta(IJsonApiContext context) + { + return new Dictionary { + { "copyright", "Copyright 2015 Example Corp." }, + { "authors", new string[] { "Jared Nance" } } + }; + } +} +``` + ## Tests I am using DotNetCoreDocs to generate sample requests and documentation. diff --git a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs index dd9a6ff2d2..eda58f66a3 100644 --- a/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/DocumentBuilder.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; @@ -24,7 +25,8 @@ public Document Build(IIdentifiable entity) var document = new Document { - Data = _getData(contextEntity, entity) + Data = _getData(contextEntity, entity), + Meta = _getMeta(entity) }; document.Included = _appendIncludedObject(document.Included, contextEntity, entity); @@ -42,7 +44,8 @@ public Documents Build(IEnumerable entities) var documents = new Documents { - Data = new List() + Data = new List(), + Meta = _getMeta(entities.FirstOrDefault()) }; foreach (var entity in entities) @@ -54,6 +57,23 @@ public Documents Build(IEnumerable entities) return documents; } + private Dictionary _getMeta(IIdentifiable entity) + { + if (entity == null) return null; + + var meta = new Dictionary(); + var metaEntity = entity as IHasMeta; + + if(metaEntity != null) + meta = metaEntity.GetMeta(_jsonApiContext); + + if(_jsonApiContext.Options.IncludeTotalRecordCount) + meta["total-records"] = _jsonApiContext.TotalRecords; + + if(meta.Count > 0) return meta; + return null; + } + private List _appendIncludedObject(List includedObject, ContextEntity contextEntity, IIdentifiable entity) { var includedEntities = _getIncludedEntities(contextEntity, entity); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index ffa688f1e4..8285921fa2 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -4,5 +4,6 @@ public class JsonApiOptions { public string Namespace { get; set; } public int DefaultPageSize { get; set; } + public bool IncludeTotalRecordCount { get; set; } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index e07f76952f..1378a373c5 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -64,6 +64,9 @@ public virtual async Task GetAsync() if (_jsonApiContext.QuerySet != null && _jsonApiContext.QuerySet.IncludedRelationships != null && _jsonApiContext.QuerySet.IncludedRelationships.Count > 0) entities = IncludeRelationships(entities, _jsonApiContext.QuerySet.IncludedRelationships); + if (_jsonApiContext.Options.IncludeTotalRecordCount) + _jsonApiContext.TotalRecords = await entities.CountAsync(); + // pagination should be done last since it will execute the query var pagedEntities = await ApplyPageQueryAsync(entities); diff --git a/src/JsonApiDotNetCore/Models/Document.cs b/src/JsonApiDotNetCore/Models/Document.cs index 20d058a702..71922e5573 100644 --- a/src/JsonApiDotNetCore/Models/Document.cs +++ b/src/JsonApiDotNetCore/Models/Document.cs @@ -1,14 +1,10 @@ -using System.Collections.Generic; using Newtonsoft.Json; namespace JsonApiDotNetCore.Models { - public class Document + public class Document : DocumentBase { [JsonProperty("data")] public DocumentData Data { get; set; } - - [JsonProperty("included")] - public List Included { get; set; } } } diff --git a/src/JsonApiDotNetCore/Models/DocumentBase.cs b/src/JsonApiDotNetCore/Models/DocumentBase.cs new file mode 100644 index 0000000000..7864c72d13 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/DocumentBase.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models +{ + public class DocumentBase + { + [JsonProperty("included")] + public List Included { get; set; } + public Dictionary Meta { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/Documents.cs b/src/JsonApiDotNetCore/Models/Documents.cs index df5bf57c2a..5ba8203bc4 100644 --- a/src/JsonApiDotNetCore/Models/Documents.cs +++ b/src/JsonApiDotNetCore/Models/Documents.cs @@ -3,12 +3,9 @@ namespace JsonApiDotNetCore.Models { - public class Documents + public class Documents : DocumentBase { [JsonProperty("data")] public List Data { get; set; } - - [JsonProperty("included")] - public List Included { get; set; } } } diff --git a/src/JsonApiDotNetCore/Models/IHasMeta.cs b/src/JsonApiDotNetCore/Models/IHasMeta.cs new file mode 100644 index 0000000000..50d86e6034 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/IHasMeta.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.Models +{ + public interface IHasMeta + { + Dictionary GetMeta(IJsonApiContext context); + } +} diff --git a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs index 42f3da02a2..30faf32fea 100644 --- a/src/JsonApiDotNetCore/Services/IJsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/IJsonApiContext.cs @@ -15,5 +15,6 @@ public interface IJsonApiContext QuerySet QuerySet { get; set; } bool IsRelationshipData { get; set; } List IncludedRelationships { get; set; } + int TotalRecords { get; set; } } } diff --git a/src/JsonApiDotNetCore/Services/JsonApiContext.cs b/src/JsonApiDotNetCore/Services/JsonApiContext.cs index 94dc5d314f..8423eb4450 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiContext.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiContext.cs @@ -20,6 +20,7 @@ public JsonApiContext( _httpContextAccessor = httpContextAccessor; Options = options; } + public JsonApiOptions Options { get; set; } public IContextGraph ContextGraph { get; set; } public ContextEntity RequestEntity { get; set; } @@ -27,6 +28,7 @@ public JsonApiContext( public QuerySet QuerySet { get; set; } public bool IsRelationshipData { get; set; } public List IncludedRelationships { get; set; } + public int TotalRecords { get; set; } public IJsonApiContext ApplyContext() { diff --git a/src/JsonApiDotNetCore/project.json b/src/JsonApiDotNetCore/project.json index eb3ef3ac01..d56b00e58f 100644 --- a/src/JsonApiDotNetCore/project.json +++ b/src/JsonApiDotNetCore/project.json @@ -1,5 +1,5 @@ { - "version": "0.2.11", + "version": "0.2.12", "dependencies": { "Microsoft.NETCore.App": { diff --git a/src/JsonApiDotNetCoreExample/Models/Person.cs b/src/JsonApiDotNetCoreExample/Models/Person.cs index 45c1eb54d0..4c09bd01f5 100644 --- a/src/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,10 +1,11 @@ using System.Collections.Generic; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; namespace JsonApiDotNetCoreExample.Models { - public class Person : Identifiable + public class Person : Identifiable, IHasMeta { public override int Id { get; set; } @@ -15,5 +16,13 @@ public class Person : Identifiable public string LastName { get; set; } public virtual List TodoItems { get; set; } + + public Dictionary GetMeta(IJsonApiContext context) + { + return new Dictionary { + { "copyright", "Copyright 2015 Example Corp." }, + { "authors", new string[] { "Jared Nance" } } + }; + } } } diff --git a/src/JsonApiDotNetCoreExample/Startup.cs b/src/JsonApiDotNetCoreExample/Startup.cs index c47a4a3201..6ae05f93d3 100644 --- a/src/JsonApiDotNetCoreExample/Startup.cs +++ b/src/JsonApiDotNetCoreExample/Startup.cs @@ -15,7 +15,7 @@ namespace JsonApiDotNetCoreExample { public class Startup { - private readonly IConfiguration _config; + public readonly IConfiguration Config; public Startup(IHostingEnvironment env) { @@ -25,10 +25,10 @@ public Startup(IHostingEnvironment env) .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) .AddEnvironmentVariables(); - _config = builder.Build(); + Config = builder.Build(); } - public IServiceProvider ConfigureServices(IServiceCollection services) + public virtual IServiceProvider ConfigureServices(IServiceCollection services) { var loggerFactory = new LoggerFactory(); loggerFactory @@ -37,7 +37,7 @@ public IServiceProvider ConfigureServices(IServiceCollection services) services.AddDbContext(options => { - options.UseNpgsql(_getDbConnectionString()); + options.UseNpgsql(GetDbConnectionString()); }, ServiceLifetime.Transient); services.AddJsonApi(opt => @@ -46,7 +46,7 @@ public IServiceProvider ConfigureServices(IServiceCollection services) opt.DefaultPageSize = 5; }); - services.AddDocumentationConfiguration(_config); + services.AddDocumentationConfiguration(Config); var provider = services.BuildServiceProvider(); var appContext = provider.GetRequiredService(); @@ -55,7 +55,7 @@ public IServiceProvider ConfigureServices(IServiceCollection services) return provider; } - public void Configure( + public virtual void Configure( IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, @@ -63,7 +63,7 @@ public void Configure( { context.Database.Migrate(); - loggerFactory.AddConsole(_config.GetSection("Logging")); + loggerFactory.AddConsole(Config.GetSection("Logging")); loggerFactory.AddDebug(); app.UseDocs(); @@ -71,9 +71,9 @@ public void Configure( app.UseJsonApi(); } - private string _getDbConnectionString() + public string GetDbConnectionString() { - return _config["Data:DefaultConnection"]; + return Config["Data:DefaultConnection"]; } } } \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs new file mode 100644 index 0000000000..0be777a4f7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Meta.cs @@ -0,0 +1,97 @@ +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 JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Data; +using System.Linq; +using JsonApiDotNetCoreExampleTests.Startups; +using JsonApiDotNetCoreExample.Models; +using System.Collections; + +namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests +{ + [Collection("WebHostCollection")] + public class Meta + { + private DocsFixture _fixture; + private AppDbContext _context; + public Meta(DocsFixture fixture) + { + _fixture = fixture; + _context = fixture.GetService(); + } + + [Fact] + public async Task Total_Record_Count_Included() + { + // arrange + var expectedCount = _context.TodoItems.Count(); + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/todo-items"; + + 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()); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(documents.Meta); + Assert.Equal((long)expectedCount, (long)documents.Meta["total-records"]); + } + + [Fact] + public async Task EntityThatImplements_IHasMeta_Contains_MetaData() + { + // arrange + var person = new Person(); + var expectedMeta = person.GetMeta(null); + var builder = new WebHostBuilder() + .UseStartup(); + + var httpMethod = new HttpMethod("GET"); + var route = $"/api/v1/people"; + + 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()); + + // assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(documents.Meta); + Assert.NotNull(expectedMeta); + Assert.NotEmpty(expectedMeta); + + foreach(var hash in expectedMeta) + { + if(hash.Value is IList) + { + var listValue = (IList)hash.Value; + for(var i=0; i < listValue.Count; i++) + Assert.Equal(listValue[i].ToString(), ((IList)documents.Meta[hash.Key])[i].ToString()); + } + else + { + Assert.Equal(hash.Value, documents.Meta[hash.Key]); + } + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/MetaStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/MetaStartup.cs new file mode 100644 index 0000000000..0c84733632 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Startups/MetaStartup.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using JsonApiDotNetCoreExample.Data; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Extensions; +using DotNetCoreDocs.Configuration; +using System; +using JsonApiDotNetCoreExample; + +namespace JsonApiDotNetCoreExampleTests.Startups +{ + public class MetaStartup : Startup + { + public MetaStartup(IHostingEnvironment env) + : base (env) + { } + + public override IServiceProvider ConfigureServices(IServiceCollection services) + { + var loggerFactory = new LoggerFactory(); + + loggerFactory + .AddConsole(LogLevel.Trace); + + services.AddSingleton(loggerFactory); + + services.AddDbContext(options => + { + options.UseNpgsql(GetDbConnectionString()); + }, ServiceLifetime.Transient); + + services.AddJsonApi(opt => + { + opt.Namespace = "api/v1"; + opt.DefaultPageSize = 5; + opt.IncludeTotalRecordCount = true; + }); + + services.AddDocumentationConfiguration(Config); + + return services.BuildServiceProvider(); + } + } +}