diff --git a/Build.ps1 b/Build.ps1 index 6d4621d7e0..0700d6f253 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -23,6 +23,9 @@ $revision = "{0:D4}" -f [convert]::ToInt32($revision, 10) dotnet restore +dotnet build ./src/Examples/GettingStarted/GettingStarted.csproj +CheckLastExitCode + dotnet test ./test/UnitTests/UnitTests.csproj CheckLastExitCode @@ -38,7 +41,10 @@ CheckLastExitCode dotnet test ./test/ResourceEntitySeparationExampleTests/ResourceEntitySeparationExampleTests.csproj CheckLastExitCode -dotnet build ./src/JsonApiDotNetCore -c Release +dotnet test ./test/DiscoveryTests/DiscoveryTests.csproj +CheckLastExitCode + +dotnet build ./src/JsonApiDotNetCore/JsonApiDotNetCore.csproj -c Release CheckLastExitCode Write-Output "APPVEYOR_REPO_TAG: $env:APPVEYOR_REPO_TAG" diff --git a/JsonApiDotnetCore.sln b/JsonApiDotnetCore.sln index ce0219b1c8..d046b23819 100644 --- a/JsonApiDotnetCore.sln +++ b/JsonApiDotnetCore.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.27130.2010 MinimumVisualStudioVersion = 10.0.40219.1 @@ -45,6 +45,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResourceEntitySeparationExa EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ResourceEntitySeparationExampleTests", "test\ResourceEntitySeparationExampleTests\ResourceEntitySeparationExampleTests.csproj", "{6DFA30D7-1679-4333-9779-6FB678E48EF5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscoveryTests", "test\DiscoveryTests\DiscoveryTests.csproj", "{09C0C8D8-B721-4955-8889-55CB149C3B5C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -187,6 +191,30 @@ Global {6DFA30D7-1679-4333-9779-6FB678E48EF5}.Release|x64.Build.0 = Release|Any CPU {6DFA30D7-1679-4333-9779-6FB678E48EF5}.Release|x86.ActiveCfg = Release|Any CPU {6DFA30D7-1679-4333-9779-6FB678E48EF5}.Release|x86.Build.0 = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x64.Build.0 = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Debug|x86.Build.0 = Debug|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|Any CPU.Build.0 = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x64.ActiveCfg = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x64.Build.0 = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x86.ActiveCfg = Release|Any CPU + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71}.Release|x86.Build.0 = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x64.ActiveCfg = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x64.Build.0 = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x86.ActiveCfg = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Debug|x86.Build.0 = Debug|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|Any CPU.Build.0 = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x64.ActiveCfg = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x64.Build.0 = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x86.ActiveCfg = Release|Any CPU + {09C0C8D8-B721-4955-8889-55CB149C3B5C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -205,6 +233,8 @@ Global {9CD2C116-D133-4FE4-97DA-A9FEAFF045F1} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {F4097194-9415-418A-AB4E-315C5D5466AF} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {6DFA30D7-1679-4333-9779-6FB678E48EF5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {9B2A5AD7-0BF4-472E-A1B4-8BB623FDEB71} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {09C0C8D8-B721-4955-8889-55CB149C3B5C} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/src/Examples/GettingStarted/.gitignore b/src/Examples/GettingStarted/.gitignore new file mode 100644 index 0000000000..3997beadf8 --- /dev/null +++ b/src/Examples/GettingStarted/.gitignore @@ -0,0 +1 @@ +*.db \ No newline at end of file diff --git a/src/Examples/GettingStarted/Controllers/ArticlesController.cs b/src/Examples/GettingStarted/Controllers/ArticlesController.cs new file mode 100644 index 0000000000..53517540b1 --- /dev/null +++ b/src/Examples/GettingStarted/Controllers/ArticlesController.cs @@ -0,0 +1,15 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; + +namespace GettingStarted +{ + public class ArticlesController : JsonApiController
+ { + public ArticlesController( + IJsonApiContext jsonApiContext, + IResourceService
resourceService) + : base(jsonApiContext, resourceService) + { } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Controllers/PeopleController.cs b/src/Examples/GettingStarted/Controllers/PeopleController.cs new file mode 100644 index 0000000000..f3c0c4b868 --- /dev/null +++ b/src/Examples/GettingStarted/Controllers/PeopleController.cs @@ -0,0 +1,15 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; + +namespace GettingStarted +{ + public class PeopleController : JsonApiController + { + public PeopleController( + IJsonApiContext jsonApiContext, + IResourceService resourceService) + : base(jsonApiContext, resourceService) + { } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs new file mode 100644 index 0000000000..2f8fefb405 --- /dev/null +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -0,0 +1,17 @@ +using GettingStarted.Models; +using GettingStarted.ResourceDefinitionExample; +using Microsoft.EntityFrameworkCore; + +namespace GettingStarted +{ + public class SampleDbContext : DbContext + { + public SampleDbContext(DbContextOptions options) + : base(options) + { } + + public DbSet
Articles { get; set; } + public DbSet People { get; set; } + public DbSet Models { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj new file mode 100644 index 0000000000..6e00daefae --- /dev/null +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.0 + + + + + + + + + + + + + + + + + diff --git a/src/Examples/GettingStarted/Models/Article.cs b/src/Examples/GettingStarted/Models/Article.cs new file mode 100644 index 0000000000..68cecf060d --- /dev/null +++ b/src/Examples/GettingStarted/Models/Article.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Models; + +namespace GettingStarted.Models +{ + public class Article : Identifiable + { + [Attr] + public string Title { get; set; } + + [HasOne] + public Person Author { get; set; } + public int AuthorId { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs new file mode 100644 index 0000000000..625cf26ab6 --- /dev/null +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace GettingStarted.Models +{ + public class Person : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public List
Articles { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs new file mode 100644 index 0000000000..fdc5046542 --- /dev/null +++ b/src/Examples/GettingStarted/Program.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace GettingStarted +{ + public class Program + { + public static void Main(string[] args) + { + BuildWebHost(args).Run(); + } + + public static IWebHost BuildWebHost(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup() + .UseUrls("http://localhost:5001") + .Build(); + } +} diff --git a/src/Examples/GettingStarted/README.md b/src/Examples/GettingStarted/README.md new file mode 100644 index 0000000000..d2c91c6d6a --- /dev/null +++ b/src/Examples/GettingStarted/README.md @@ -0,0 +1,14 @@ +## Sample project + +## Usage + +`dotnet run` to run the project + +You can verify the project is running by checking this endpoint: +`localhost:5001/api/sample-model` + +For further documentation and implementation of a JsonApiDotnetCore Application see the documentation or GitHub page: + +Repository: https://github.com/json-api-dotnet/JsonApiDotNetCore + +Documentation: https://json-api-dotnet.github.io/ \ No newline at end of file diff --git a/src/Examples/GettingStarted/ResourceDefinitionExample/Model.cs b/src/Examples/GettingStarted/ResourceDefinitionExample/Model.cs new file mode 100644 index 0000000000..0bee86efe0 --- /dev/null +++ b/src/Examples/GettingStarted/ResourceDefinitionExample/Model.cs @@ -0,0 +1,10 @@ +using JsonApiDotNetCore.Models; + +namespace GettingStarted.ResourceDefinitionExample +{ + public class Model : Identifiable + { + [Attr] + public string DontExpose { get; set; } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/ResourceDefinitionExample/ModelDefinition.cs b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelDefinition.cs new file mode 100644 index 0000000000..fc41350664 --- /dev/null +++ b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelDefinition.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace GettingStarted.ResourceDefinitionExample +{ + public class ModelDefinition : ResourceDefinition + { + // this allows POST / PATCH requests to set the value of a + // property, but we don't include this value in the response + // this might be used if the incoming value gets hashed or + // encrypted prior to being persisted and this value should + // never be sent back to the client + protected override List OutputAttrs() + => Remove(model => model.DontExpose); + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/ResourceDefinitionExample/ModelsController.cs b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelsController.cs new file mode 100644 index 0000000000..a14394e830 --- /dev/null +++ b/src/Examples/GettingStarted/ResourceDefinitionExample/ModelsController.cs @@ -0,0 +1,15 @@ +using GettingStarted.Models; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; + +namespace GettingStarted.ResourceDefinitionExample +{ + public class ModelsController : JsonApiController + { + public ModelsController( + IJsonApiContext jsonApiContext, + IResourceService resourceService) + : base(jsonApiContext, resourceService) + { } + } +} \ No newline at end of file diff --git a/src/Examples/GettingStarted/Startup.cs b/src/Examples/GettingStarted/Startup.cs new file mode 100644 index 0000000000..5d0fa8dc91 --- /dev/null +++ b/src/Examples/GettingStarted/Startup.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Extensions; + +namespace GettingStarted +{ + public class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddDbContext(options => + { + options.UseSqlite("Data Source=sample.db"); + }); + + var mvcCoreBuilder = services.AddMvcCore(); + services.AddJsonApi( + options => options.Namespace = "api", + mvcCoreBuilder, + discover => discover.AddCurrentAssembly()); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env, SampleDbContext context) + { + context.Database.EnsureDeleted(); // indicies need to be reset + context.Database.EnsureCreated(); + + app.UseJsonApi(); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startup.cs index 29dfc9e4b5..68ea93a7fc 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startup.cs @@ -41,10 +41,9 @@ public virtual IServiceProvider ConfigureServices(IServiceCollection services) options.IncludeTotalRecordCount = true; }, mvcBuilder, - discovery => discovery.AddCurrentAssemblyServices()); + discovery => discovery.AddCurrentAssembly()); - var provider = services.BuildServiceProvider(); - return provider; + return services.BuildServiceProvider(); } public virtual void Configure( diff --git a/src/Examples/ReportsExample/Startup.cs b/src/Examples/ReportsExample/Startup.cs index 43a910a0e6..b71b7fa74a 100644 --- a/src/Examples/ReportsExample/Startup.cs +++ b/src/Examples/ReportsExample/Startup.cs @@ -25,10 +25,10 @@ public Startup(IHostingEnvironment env) public virtual void ConfigureServices(IServiceCollection services) { var mvcBuilder = services.AddMvcCore(); - services.AddJsonApi( - opt => opt.Namespace = "api", - mvcBuilder, - discovery => discovery.AddCurrentAssemblyServices()); + services.AddJsonApi( + opt => opt.Namespace = "api", + mvcBuilder, + discovery => discovery.AddCurrentAssembly()); } public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) diff --git a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs index ba71dbce06..b343243463 100644 --- a/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ContextGraphBuilder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; @@ -22,24 +23,36 @@ public interface IContextGraphBuilder /// Add a json:api resource /// /// The resource model type - /// The pluralized name that should be exposed by the API - IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable; + /// + /// The pluralized name that should be exposed by the API. + /// If nothing is specified, the configured name formatter will be used. + /// See . + /// + IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable; /// /// Add a json:api resource /// /// The resource model type /// The resource model identifier type - /// The pluralized name that should be exposed by the API - IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable; + /// + /// The pluralized name that should be exposed by the API. + /// If nothing is specified, the configured name formatter will be used. + /// See . + /// + IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable; /// /// Add a json:api resource /// /// The resource model type /// The resource model identifier type - /// The pluralized name that should be exposed by the API - IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName); + /// + /// The pluralized name that should be exposed by the API. + /// If nothing is specified, the configured name formatter will be used. + /// See . + /// + IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName = null); /// /// Add all the models that are part of the provided @@ -64,9 +77,8 @@ public class ContextGraphBuilder : IContextGraphBuilder { private List _entities = new List(); private List _validationResults = new List(); - private bool _usesDbContext; - private IResourceNameFormatter _resourceNameFormatter = new DefaultResourceNameFormatter(); + private IResourceNameFormatter _resourceNameFormatter = JsonApiOptions.ResourceNameFormatter; public Link DocumentLinks { get; set; } = Link.All; @@ -80,18 +92,20 @@ public IContextGraph Build() } /// - public IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable + public IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable => AddResource(pluralizedTypeName); /// - public IContextGraphBuilder AddResource(string pluralizedTypeName) where TResource : class, IIdentifiable + public IContextGraphBuilder AddResource(string pluralizedTypeName = null) where TResource : class, IIdentifiable => AddResource(typeof(TResource), typeof(TId), pluralizedTypeName); /// - public IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName) + public IContextGraphBuilder AddResource(Type entityType, Type idType, string pluralizedTypeName = null) { AssertEntityIsNotAlreadyDefined(entityType); + pluralizedTypeName = pluralizedTypeName ?? _resourceNameFormatter.FormatResourceName(entityType); + _entities.Add(GetEntity(pluralizedTypeName, entityType, idType)); return this; @@ -128,6 +142,7 @@ protected virtual List GetAttributes(Type entityType) if (attribute == null) continue; + attribute.PublicAttributeName = attribute.PublicAttributeName ?? JsonApiOptions.ResourceNameFormatter.FormatPropertyName(prop); attribute.InternalAttributeName = prop.Name; attribute.PropertyInfo = prop; @@ -146,6 +161,8 @@ protected virtual List GetRelationships(Type entityType) { var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); if (attribute == null) continue; + + attribute.PublicRelationshipName = attribute.PublicRelationshipName ?? JsonApiOptions.ResourceNameFormatter.FormatPropertyName(prop); attribute.InternalRelationshipName = prop.Name; attribute.Type = GetRelationshipType(attribute, prop); attributes.Add(attribute); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 54fcf5afaf..66386aac19 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; @@ -16,6 +17,11 @@ namespace JsonApiDotNetCore.Configuration /// public class JsonApiOptions { + /// + /// Provides an interface for formatting resource names by convention + /// + public static IResourceNameFormatter ResourceNameFormatter { get; set; } = new DefaultResourceNameFormatter(); + /// /// Whether or not stack traces should be serialized in Error objects /// diff --git a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs index 512f45fe3c..808c1929a4 100644 --- a/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs +++ b/src/JsonApiDotNetCore/Data/DefaultEntityRepository.cs @@ -19,11 +19,19 @@ public class DefaultEntityRepository IEntityRepository where TEntity : class, IIdentifiable { + public DefaultEntityRepository( + IJsonApiContext jsonApiContext, + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) + : base(jsonApiContext, contextResolver, resourceDefinition) + { } + public DefaultEntityRepository( ILoggerFactory loggerFactory, IJsonApiContext jsonApiContext, - IDbContextResolver contextResolver) - : base(loggerFactory, jsonApiContext, contextResolver) + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) + : base(loggerFactory, jsonApiContext, contextResolver, resourceDefinition) { } } @@ -41,17 +49,32 @@ public class DefaultEntityRepository private readonly ILogger _logger; private readonly IJsonApiContext _jsonApiContext; private readonly IGenericProcessorFactory _genericProcessorFactory; + private readonly ResourceDefinition _resourceDefinition; + + public DefaultEntityRepository( + IJsonApiContext jsonApiContext, + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) + { + _context = contextResolver.GetContext(); + _dbSet = contextResolver.GetDbSet(); + _jsonApiContext = jsonApiContext; + _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; + _resourceDefinition = resourceDefinition; + } public DefaultEntityRepository( ILoggerFactory loggerFactory, IJsonApiContext jsonApiContext, - IDbContextResolver contextResolver) + IDbContextResolver contextResolver, + ResourceDefinition resourceDefinition = null) { _context = contextResolver.GetContext(); _dbSet = contextResolver.GetDbSet(); _jsonApiContext = jsonApiContext; _logger = loggerFactory.CreateLogger>(); _genericProcessorFactory = _jsonApiContext.GenericProcessorFactory; + _resourceDefinition = resourceDefinition; } /// @@ -66,13 +89,38 @@ public virtual IQueryable Get() /// public virtual IQueryable Filter(IQueryable entities, FilterQuery filterQuery) { + if(_resourceDefinition != null) + { + var defaultQueryFilters = _resourceDefinition.GetQueryFilters(); + if(defaultQueryFilters != null && defaultQueryFilters.TryGetValue(filterQuery.Attribute, out var defaultQueryFilter) == true) + { + return defaultQueryFilter(entities, filterQuery.Value); + } + } + return entities.Filter(_jsonApiContext, filterQuery); } /// public virtual IQueryable Sort(IQueryable entities, List sortQueries) { - return entities.Sort(sortQueries); + if (sortQueries != null && sortQueries.Count > 0) + return entities.Sort(sortQueries); + + if(_resourceDefinition != null) + { + var defaultSortOrder = _resourceDefinition.DefaultSort(); + if(defaultSortOrder != null && defaultSortOrder.Count > 0) + { + foreach(var sortProp in defaultSortOrder) + { + // this is dumb...add an overload, don't allocate for no reason + entities.Sort(new SortQuery(sortProp.Item2, sortProp.Item1)); + } + } + } + + return entities; } /// @@ -84,7 +132,7 @@ public virtual async Task GetAsync(TId id) /// public virtual async Task GetAndIncludeAsync(TId id, string relationshipName) { - _logger.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); + _logger?.LogDebug($"[JADN] GetAndIncludeAsync({id}, {relationshipName})"); var includedSet = Include(Get(), relationshipName); var result = await includedSet.SingleOrDefaultAsync(e => e.Id.Equals(id)); diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 4c2d6c2f79..74168c8b0d 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -1,4 +1,8 @@ using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; @@ -7,6 +11,7 @@ using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Generics; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Services; using JsonApiDotNetCore.Services.Operations; @@ -42,9 +47,7 @@ public static IServiceCollection AddJsonApi( IMvcCoreBuilder mvcBuilder) where TContext : DbContext { var config = new JsonApiOptions(); - options(config); - config.BuildContextGraph(builder => builder.AddDbContext()); mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, config)); @@ -182,5 +185,64 @@ public static void SerializeAsJsonApi(this MvcOptions options, JsonApiOptions js options.Conventions.Insert(0, new DasherizedRoutingConvention(jsonApiOptions.Namespace)); } + + /// + /// Adds all required registrations for the service to the container + /// + /// + public static IServiceCollection AddResourceService(this IServiceCollection services) + { + var typeImplementsAnExpectedInterface = false; + + var serviceImplementationType = typeof(T); + + // it is _possible_ that a single concrete type could be used for multiple resources... + var resourceDescriptors = GetResourceTypesFromServiceImplementation(serviceImplementationType); + + foreach(var resourceDescriptor in resourceDescriptors) + { + foreach(var openGenericType in ServiceDiscoveryFacade.ServiceInterfaces) + { + // A shorthand interface is one where the id type is ommitted + // e.g. IResourceService is the shorthand for IResourceService + var isShorthandInterface = (openGenericType.GetTypeInfo().GenericTypeParameters.Length == 1); + if(isShorthandInterface && resourceDescriptor.IdType != typeof(int)) + continue; // we can't create a shorthand for id types other than int + + var concreteGenericType = isShorthandInterface + ? openGenericType.MakeGenericType(resourceDescriptor.ResourceType) + : openGenericType.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); + + if(concreteGenericType.IsAssignableFrom(serviceImplementationType)) { + services.AddScoped(concreteGenericType, serviceImplementationType); + typeImplementsAnExpectedInterface = true; + } + } + } + + if(typeImplementsAnExpectedInterface == false) + throw new JsonApiSetupException($"{serviceImplementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); + + return services; + } + + private static HashSet GetResourceTypesFromServiceImplementation(Type type) + { + var resourceDecriptors = new HashSet(); + var interfaces = type.GetInterfaces(); + foreach(var i in interfaces) + { + if(i.IsGenericType) + { + var firstGenericArgument = i.GenericTypeArguments.FirstOrDefault(); + if(TypeLocator.TryGetResourceDescriptor(firstGenericArgument, out var resourceDescriptor) == true) + { + resourceDecriptors.Add(resourceDescriptor); + } + } + } + + return resourceDecriptors; + } } } diff --git a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs index 57baca0901..8f5bdf3bb2 100644 --- a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs @@ -17,6 +17,11 @@ public interface IResourceNameFormatter /// Get the publicly visible resource name from the internal type name /// string FormatResourceName(Type resourceType); + + /// + /// Get the publicly visible name for the given property + /// + string FormatPropertyName(PropertyInfo property); } public class DefaultResourceNameFormatter : IResourceNameFormatter @@ -47,5 +52,22 @@ public string FormatResourceName(Type type) throw new InvalidOperationException($"Cannot define multiple {nameof(ResourceAttribute)}s on type '{type}'.", e); } } + + /// + /// Uses the internal PropertyInfo to determine the external resource name. + /// By default the name will be formatted to kebab-case. + /// + /// + /// Given the following property: + /// + /// public string CompoundProperty { get; set; } + /// + /// The public attribute will be formatted like so: + /// + /// _default.FormatPropertyName(compoundProperty).Dump(); + /// // > "compound-property" + /// + /// + public string FormatPropertyName(PropertyInfo property) => str.Dasherize(property.Name); } } diff --git a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs index e90bcdc22c..2cb1a8b812 100644 --- a/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Graph/ResourceDescriptor.cs @@ -12,5 +12,7 @@ public ResourceDescriptor(Type resourceType, Type idType) public Type ResourceType { get; set; } public Type IdType { get; set; } + + internal static ResourceDescriptor Empty => new ResourceDescriptor(null, null); } } diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index b29df8bca1..f8a1300c7f 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Data; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; @@ -6,6 +7,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -13,10 +15,45 @@ namespace JsonApiDotNetCore.Graph { public class ServiceDiscoveryFacade { + internal static HashSet ServiceInterfaces = new HashSet { + typeof(IResourceService<>), + typeof(IResourceService<,>), + typeof(IResourceCmdService<>), + typeof(IResourceCmdService<,>), + typeof(IResourceQueryService<>), + typeof(IResourceQueryService<,>), + typeof(ICreateService<>), + typeof(ICreateService<,>), + typeof(IGetAllService<>), + typeof(IGetAllService<,>), + typeof(IGetByIdService<>), + typeof(IGetByIdService<,>), + typeof(IGetRelationshipService<>), + typeof(IGetRelationshipService<,>), + typeof(IGetRelationshipsService<>), + typeof(IGetRelationshipsService<,>), + typeof(IUpdateService<>), + typeof(IUpdateService<,>), + typeof(IDeleteService<>), + typeof(IDeleteService<,>) + }; + + internal static HashSet RepositoryInterfaces = new HashSet { + typeof(IEntityRepository<>), + typeof(IEntityRepository<,>), + typeof(IEntityWriteRepository<>), + typeof(IEntityWriteRepository<,>), + typeof(IEntityReadRepository<>), + typeof(IEntityReadRepository<,>) + }; + private readonly IServiceCollection _services; private readonly IContextGraphBuilder _graphBuilder; + private readonly List _identifiables = new List(); - public ServiceDiscoveryFacade(IServiceCollection services, IContextGraphBuilder graphBuilder) + public ServiceDiscoveryFacade( + IServiceCollection services, + IContextGraphBuilder graphBuilder) { _services = services; _graphBuilder = graphBuilder; @@ -25,21 +62,23 @@ public ServiceDiscoveryFacade(IServiceCollection services, IContextGraphBuilder /// /// Add resources, services and repository implementations to the container. /// - /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddCurrentAssemblyServices(IResourceNameFormatter resourceNameFormatter = null) - => AddAssemblyServices(Assembly.GetCallingAssembly(), resourceNameFormatter); + public ServiceDiscoveryFacade AddCurrentAssembly() => AddAssembly(Assembly.GetCallingAssembly()); /// /// Add resources, services and repository implementations to the container. /// /// The assembly to search for resources in. - /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + public ServiceDiscoveryFacade AddAssembly(Assembly assembly) { AddDbContextResolvers(assembly); - AddAssemblyResources(assembly, resourceNameFormatter); - AddAssemblyServices(assembly); - AddAssemblyRepositories(assembly); + + var resourceDescriptors = TypeLocator.GetIdentifableTypes(assembly); + foreach (var resourceDescriptor in resourceDescriptors) + { + AddResource(assembly, resourceDescriptor); + AddServices(assembly, resourceDescriptor); + AddRepositories(assembly, resourceDescriptor); + } return this; } @@ -58,19 +97,21 @@ private void AddDbContextResolvers(Assembly assembly) /// Adds resources to the graph and registers types on the container. /// /// The assembly to search for resources in. - /// The type name formatter used to get the string representation of resource names. - public ServiceDiscoveryFacade AddAssemblyResources(Assembly assembly, IResourceNameFormatter resourceNameFormatter = null) + public ServiceDiscoveryFacade AddResources(Assembly assembly) { var identifiables = TypeLocator.GetIdentifableTypes(assembly); foreach (var identifiable in identifiables) - { - RegisterResourceDefinition(assembly, identifiable); - AddResourceToGraph(identifiable, resourceNameFormatter); - } + AddResource(assembly, identifiable); return this; } + private void AddResource(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + RegisterResourceDefinition(assembly, resourceDescriptor); + AddResourceToGraph(resourceDescriptor); + } + private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) { try @@ -83,83 +124,66 @@ private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor id } catch (InvalidOperationException e) { - // TODO: need a better way to communicate failure since this is unlikely to occur during a web request - throw new JsonApiException(500, - $"Cannot define multiple ResourceDefinition<> implementations for '{identifiable.ResourceType}'", e); + throw new JsonApiSetupException($"Cannot define multiple ResourceDefinition<> implementations for '{identifiable.ResourceType}'", e); } } - private void AddResourceToGraph(ResourceDescriptor identifiable, IResourceNameFormatter resourceNameFormatter = null) + private void AddResourceToGraph(ResourceDescriptor identifiable) { - var resourceName = FormatResourceName(identifiable.ResourceType, resourceNameFormatter); + var resourceName = FormatResourceName(identifiable.ResourceType); _graphBuilder.AddResource(identifiable.ResourceType, identifiable.IdType, resourceName); } - private string FormatResourceName(Type resourceType, IResourceNameFormatter resourceNameFormatter) - { - resourceNameFormatter = resourceNameFormatter ?? new DefaultResourceNameFormatter(); - return resourceNameFormatter.FormatResourceName(resourceType); - } + private string FormatResourceName(Type resourceType) + => JsonApiOptions.ResourceNameFormatter.FormatResourceName(resourceType); /// /// Add implementations to container. /// /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddAssemblyServices(Assembly assembly) + public ServiceDiscoveryFacade AddServices(Assembly assembly) { - RegisterServiceImplementations(assembly, typeof(IResourceService<>)); - RegisterServiceImplementations(assembly, typeof(IResourceService<,>)); - - RegisterServiceImplementations(assembly, typeof(ICreateService<>)); - RegisterServiceImplementations(assembly, typeof(ICreateService<,>)); - - RegisterServiceImplementations(assembly, typeof(IGetAllService<>)); - RegisterServiceImplementations(assembly, typeof(IGetAllService<,>)); - - RegisterServiceImplementations(assembly, typeof(IGetByIdService<>)); - RegisterServiceImplementations(assembly, typeof(IGetByIdService<,>)); - - RegisterServiceImplementations(assembly, typeof(IGetRelationshipService<>)); - RegisterServiceImplementations(assembly, typeof(IGetRelationshipService<,>)); - - RegisterServiceImplementations(assembly, typeof(IUpdateService<>)); - RegisterServiceImplementations(assembly, typeof(IUpdateService<,>)); - - RegisterServiceImplementations(assembly, typeof(IDeleteService<>)); - RegisterServiceImplementations(assembly, typeof(IDeleteService<,>)); + var resourceDescriptors = TypeLocator.GetIdentifableTypes(assembly); + foreach (var resourceDescriptor in resourceDescriptors) + AddServices(assembly, resourceDescriptor); return this; } + private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach(var serviceInterface in ServiceInterfaces) + RegisterServiceImplementations(assembly, serviceInterface, resourceDescriptor); + } + /// /// Add implementations to container. /// /// The assembly to search for resources in. - public ServiceDiscoveryFacade AddAssemblyRepositories(Assembly assembly) + public ServiceDiscoveryFacade AddRepositories(Assembly assembly) { - RegisterServiceImplementations(assembly, typeof(IEntityRepository<>)); - RegisterServiceImplementations(assembly, typeof(IEntityRepository<,>)); - - RegisterServiceImplementations(assembly, typeof(IEntityWriteRepository<>)); - RegisterServiceImplementations(assembly, typeof(IEntityWriteRepository<,>)); - - RegisterServiceImplementations(assembly, typeof(IEntityReadRepository<>)); - RegisterServiceImplementations(assembly, typeof(IEntityReadRepository<,>)); + var resourceDescriptors = TypeLocator.GetIdentifableTypes(assembly); + foreach (var resourceDescriptor in resourceDescriptors) + AddRepositories(assembly, resourceDescriptor); return this; } - private ServiceDiscoveryFacade RegisterServiceImplementations(Assembly assembly, Type interfaceType) + private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) { - var identifiables = TypeLocator.GetIdentifableTypes(assembly); - foreach (var identifiable in identifiables) - { - var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, identifiable.ResourceType, identifiable.IdType); - if (service.implementation != null) - _services.AddScoped(service.registrationInterface, service.implementation); - } + foreach(var serviceInterface in RepositoryInterfaces) + RegisterServiceImplementations(assembly, serviceInterface, resourceDescriptor); + } - return this; + private void RegisterServiceImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) + { + var genericArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 + ? new [] { resourceDescriptor.ResourceType, resourceDescriptor.IdType } + : new [] { resourceDescriptor.ResourceType }; + + var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); + if (service.implementation != null) + _services.AddScoped(service.registrationInterface, service.implementation); } } } diff --git a/src/JsonApiDotNetCore/Graph/TypeLocator.cs b/src/JsonApiDotNetCore/Graph/TypeLocator.cs index 223845859a..f96e17ffe0 100644 --- a/src/JsonApiDotNetCore/Graph/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Graph/TypeLocator.cs @@ -46,24 +46,44 @@ private static Type[] GetAssemblyTypes(Assembly assembly) } /// - /// Get all implementations of . in the assembly + /// Get all implementations of in the assembly /// - public static List GetIdentifableTypes(Assembly assembly) + public static IEnumerable GetIdentifableTypes(Assembly assembly) + => (_identifiableTypeCache.TryGetValue(assembly, out var descriptors) == false) + ? FindIdentifableTypes(assembly) + : _identifiableTypeCache[assembly]; + + private static IEnumerable FindIdentifableTypes(Assembly assembly) { - if (_identifiableTypeCache.TryGetValue(assembly, out var descriptors) == false) - { - descriptors = new List(); - _identifiableTypeCache[assembly] = descriptors; + var descriptors = new List(); + _identifiableTypeCache[assembly] = descriptors; - foreach (var type in assembly.GetTypes()) + foreach (var type in assembly.GetTypes()) + { + if (TryGetResourceDescriptor(type, out var descriptor)) { - var possible = GetIdType(type); - if (possible.isJsonApiResource) - descriptors.Add(new ResourceDescriptor(type, possible.idType)); + descriptors.Add(descriptor); + yield return descriptor; } } + } - return descriptors; + /// + /// Attempts to get a descriptor of the resource type. + /// + /// + /// True if the type is a valid json:api type (must implement ), false otherwise. + /// + internal static bool TryGetResourceDescriptor(Type type, out ResourceDescriptor descriptor) + { + var possible = GetIdType(type); + if (possible.isJsonApiResource) { + descriptor = new ResourceDescriptor(type, possible.idType); + return true; + } + + descriptor = ResourceDescriptor.Empty; + return false; } /// diff --git a/src/JsonApiDotNetCore/Internal/JsonApiSetupException.cs b/src/JsonApiDotNetCore/Internal/JsonApiSetupException.cs new file mode 100644 index 0000000000..34765066be --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/JsonApiSetupException.cs @@ -0,0 +1,13 @@ +using System; + +namespace JsonApiDotNetCore.Internal +{ + public class JsonApiSetupException : Exception + { + public JsonApiSetupException(string message) + : base(message) { } + + public JsonApiSetupException(string message, Exception innerException) + : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Models/AttrAttribute.cs b/src/JsonApiDotNetCore/Models/AttrAttribute.cs index d5a30221bb..a5e594ea0c 100644 --- a/src/JsonApiDotNetCore/Models/AttrAttribute.cs +++ b/src/JsonApiDotNetCore/Models/AttrAttribute.cs @@ -26,7 +26,7 @@ public class AttrAttribute : Attribute /// /// /// - public AttrAttribute(string publicName, bool isImmutable = false, bool isFilterable = true, bool isSortable = true) + public AttrAttribute(string publicName = null, bool isImmutable = false, bool isFilterable = true, bool isSortable = true) { PublicAttributeName = publicName; IsImmutable = isImmutable; @@ -34,7 +34,7 @@ public AttrAttribute(string publicName, bool isImmutable = false, bool isFiltera IsSortable = isSortable; } - public AttrAttribute(string publicName, string internalName, bool isImmutable = false) + internal AttrAttribute(string publicName, string internalName, bool isImmutable = false) { PublicAttributeName = publicName; InternalAttributeName = internalName; @@ -44,7 +44,7 @@ public AttrAttribute(string publicName, string internalName, bool isImmutable = /// /// How this attribute is exposed through the API /// - public string PublicAttributeName { get; } + public string PublicAttributeName { get; internal set;} /// /// The internal property name this attribute belongs to. diff --git a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs index 5bbea86783..11479819f4 100644 --- a/src/JsonApiDotNetCore/Models/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasManyAttribute.cs @@ -23,7 +23,7 @@ public class HasManyAttribute : RelationshipAttribute /// /// /// - public HasManyAttribute(string publicName, Link documentLinks = Link.All, bool canInclude = true) + public HasManyAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true) : base(publicName, documentLinks, canInclude) { } diff --git a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs index a80734817d..2d83c3dd69 100644 --- a/src/JsonApiDotNetCore/Models/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore/Models/HasOneAttribute.cs @@ -26,7 +26,7 @@ public class HasOneAttribute : RelationshipAttribute /// /// /// - public HasOneAttribute(string publicName, Link documentLinks = Link.All, bool canInclude = true, string withForeignKey = null) + public HasOneAttribute(string publicName = null, Link documentLinks = Link.All, bool canInclude = true, string withForeignKey = null) : base(publicName, documentLinks, canInclude) { _explicitIdentifiablePropertyName = withForeignKey; diff --git a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs index 7408df0998..b479d3bb12 100644 --- a/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore/Models/RelationshipAttribute.cs @@ -11,7 +11,7 @@ protected RelationshipAttribute(string publicName, Link documentLinks, bool canI CanInclude = canInclude; } - public string PublicRelationshipName { get; } + public string PublicRelationshipName { get; internal set; } public string InternalRelationshipName { get; internal set; } /// diff --git a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs index 64ff918116..c9cffb7062 100644 --- a/src/JsonApiDotNetCore/Models/ResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Models/ResourceDefinition.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Query; using System; using System.Collections.Generic; using System.Linq; @@ -13,7 +14,10 @@ public interface IResourceDefinition } /// - /// A scoped service used to... + /// exposes developer friendly hooks into how their resources are exposed. + /// It is intended to improve the experience and reduce boilerplate for commonly required features. + /// The goal of this class is to reduce the frequency with which developers have to override the + /// service and repository layers. /// /// The resource type public class ResourceDefinition : IResourceDefinition where T : class, IIdentifiable @@ -45,8 +49,8 @@ private bool InstanceOutputAttrsAreSpecified() .FirstOrDefault(); var declaringType = instanceMethod?.DeclaringType; return declaringType == derivedType; - } - + } + // TODO: need to investigate options for caching these protected List Remove(Expression> filter, List from = null) { @@ -64,7 +68,7 @@ protected List Remove(Expression> filter, List(); foreach (var attr in _contextEntity.Attributes) if (newExpression.Members.Any(m => m.Name == attr.InternalAttributeName) == false) - attributes.Add(attr); + attributes.Add(attr); return attributes; } @@ -76,12 +80,24 @@ protected List Remove(Expression> filter, List + /// Allows POST / PATCH requests to set the value of an + /// attribute, but exclude the attribute in the response + /// this might be used if the incoming value gets hashed or + /// encrypted prior to being persisted and this value should + /// never be sent back to the client. + /// /// Called once per filtered resource in request. /// protected virtual List OutputAttrs() => _contextEntity.Attributes; /// - /// Called for every instance of a resource + /// Allows POST / PATCH requests to set the value of an + /// attribute, but exclude the attribute in the response + /// this might be used if the incoming value gets hashed or + /// encrypted prior to being persisted and this value should + /// never be sent back to the client. + /// + /// Called for every instance of a resource. /// protected virtual List OutputAttrs(T instance) => _contextEntity.Attributes; @@ -103,5 +119,93 @@ private List GetOutputAttrs() return _requestCachedAttrs; } + + /// + /// Define a set of custom query expressions that can be applied + /// instead of the default query behavior. A common use-case for this + /// is including related resources and filtering on them. + /// + /// + /// A set of custom queries that will be applied instead of the default + /// queries for the given key. Null will be returned if default behavior + /// is desired. + /// + /// + /// + /// protected override QueryFilters GetQueryFilters() => { + /// { "facility", (t, value) => t.Include(t => t.Tenant) + /// .Where(t => t.Facility == value) } + /// } + /// + /// + /// If the logic is simply too complex for an in-line expression, you can + /// delegate to a private method: + /// + /// protected override QueryFilters GetQueryFilters() + /// => new QueryFilters { + /// { "is-active", FilterIsActive } + /// }; + /// + /// private IQueryable<Model> FilterIsActive(IQueryable<Model> query, string value) + /// { + /// // some complex logic goes here... + /// return query.Where(x => x.IsActive == computedValue); + /// } + /// + /// + public virtual QueryFilters GetQueryFilters() => null; + + /// + /// This is an alias type intended to simplify the implementation's + /// method signature. + /// See for usage details. + /// + public class QueryFilters : Dictionary, string, IQueryable>> { } + + /// + /// Define a the default sort order if no sort key is provided. + /// + /// + /// A list of properties and the direction they should be sorted. + /// + /// + /// + /// protected override PropertySortOrder GetDefaultSortOrder() + /// => new PropertySortOrder { + /// (t => t.Prop1, SortDirection.Ascending), + /// (t => t.Prop2, SortDirection.Descending), + /// }; + /// + /// + protected virtual PropertySortOrder GetDefaultSortOrder() => null; + + internal List<(AttrAttribute, SortDirection)> DefaultSort() + { + var defaultSortOrder = GetDefaultSortOrder(); + if(defaultSortOrder != null && defaultSortOrder.Count > 0) + { + var order = new List<(AttrAttribute, SortDirection)>(); + foreach(var sortProp in defaultSortOrder) + { + // TODO: error handling, log or throw? + if (sortProp.Item1.Body is MemberExpression memberExpression) + order.Add( + (_contextEntity.Attributes.SingleOrDefault(a => a.InternalAttributeName != memberExpression.Member.Name), + sortProp.Item2) + ); + } + + return order; + } + + return null; + } + + /// + /// This is an alias type intended to simplify the implementation's + /// method signature. + /// See for usage details. + /// + public class PropertySortOrder : List<(Expression>, SortDirection)> { } } } diff --git a/src/JsonApiDotNetCore/Services/EntityResourceService.cs b/src/JsonApiDotNetCore/Services/EntityResourceService.cs index 1c8dabb74b..2ce3b9147d 100644 --- a/src/JsonApiDotNetCore/Services/EntityResourceService.cs +++ b/src/JsonApiDotNetCore/Services/EntityResourceService.cs @@ -16,7 +16,7 @@ public class EntityResourceService : EntityResourceService entityRepository, - ILoggerFactory loggerFactory) : + ILoggerFactory loggerFactory = null) : base(jsonApiContext, entityRepository, loggerFactory) { } } @@ -28,7 +28,7 @@ public class EntityResourceService : EntityResourceService entityRepository, - ILoggerFactory loggerFactory) : + ILoggerFactory loggerFactory = null) : base(jsonApiContext, entityRepository, loggerFactory) { } } @@ -46,7 +46,7 @@ public class EntityResourceService : public EntityResourceService( IJsonApiContext jsonApiContext, IEntityRepository entityRepository, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory = null) { // no mapper provided, TResource & TEntity must be the same type if (typeof(TResource) != typeof(TEntity)) @@ -56,7 +56,7 @@ public EntityResourceService( _jsonApiContext = jsonApiContext; _entities = entityRepository; - _logger = loggerFactory.CreateLogger>(); + _logger = loggerFactory?.CreateLogger>(); } public EntityResourceService( @@ -223,8 +223,7 @@ protected virtual IQueryable ApplySortAndFilterQuery(IQueryable 0) - entities = _entities.Sort(entities, query.SortParameters); + entities = _entities.Sort(entities, query.SortParameters); return entities; } diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj new file mode 100644 index 0000000000..2846b365e5 --- /dev/null +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -0,0 +1,19 @@ + + + + $(NetCoreAppVersion) + false + + + + + + + + + + + + + + diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs new file mode 100644 index 0000000000..433d23557b --- /dev/null +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -0,0 +1,89 @@ +using System; +using GettingStarted.Models; +using GettingStarted.ResourceDefinitionExample; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Graph; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace DiscoveryTests +{ + public class ServiceDiscoveryFacadeTests + { + private readonly IServiceCollection _services = new ServiceCollection(); + private readonly ContextGraphBuilder _graphBuilder = new ContextGraphBuilder(); + private ServiceDiscoveryFacade _facade => new ServiceDiscoveryFacade(_services, _graphBuilder); + + [Fact] + public void AddAssembly_Adds_All_Resources_To_Graph() + { + // arrange, act + _facade.AddAssembly(typeof(Person).Assembly); + + // assert + var graph = _graphBuilder.Build(); + var personResource = graph.GetContextEntity(typeof(Person)); + var articleResource = graph.GetContextEntity(typeof(Article)); + var modelResource = graph.GetContextEntity(typeof(Model)); + + Assert.NotNull(personResource); + Assert.NotNull(articleResource); + Assert.NotNull(modelResource); + } + + [Fact] + public void AddCurrentAssembly_Adds_Resources_To_Graph() + { + // arrange, act + _facade.AddCurrentAssembly(); + + // assert + var graph = _graphBuilder.Build(); + var testModelResource = graph.GetContextEntity(typeof(TestModel)); + Assert.NotNull(testModelResource); + } + + [Fact] + public void AddCurrentAssembly_Adds_Services_To_Container() + { + // arrange, act + _facade.AddCurrentAssembly(); + + // assert + var services = _services.BuildServiceProvider(); + Assert.IsType(services.GetService>()); + } + + [Fact] + public void AddCurrentAssembly_Adds_Repositories_To_Container() + { + // arrange, act + _facade.AddCurrentAssembly(); + + // assert + var services = _services.BuildServiceProvider(); + Assert.IsType(services.GetService>()); + } + + public class TestModel : Identifiable { } + + public class TestModelService : EntityResourceService + { + private static IEntityRepository _repo = new Mock>().Object; + private static IJsonApiContext _jsonApiContext = new Mock().Object; + public TestModelService() : base(_jsonApiContext, _repo) { } + } + + public class TestModelRepository : DefaultEntityRepository + { + private static IDbContextResolver _dbContextResolver = new Mock().Object; + private static IJsonApiContext _jsonApiContext = new Mock().Object; + public TestModelRepository() : base(_jsonApiContext, _dbContextResolver) { } + } + } +} diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index d5207fb6ef..ab4412de2c 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -1,4 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Humanizer; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; @@ -15,6 +23,11 @@ class TestContext : DbContext { public DbSet DbResources { get; set; } } + public ContextGraphBuilder_Tests() + { + JsonApiOptions.ResourceNameFormatter = new DefaultResourceNameFormatter(); + } + [Fact] public void Can_Build_ContextGraph_Using_Builder() { @@ -37,5 +50,100 @@ public void Can_Build_ContextGraph_Using_Builder() Assert.Equal(typeof(NonDbResource), nonDbResource.EntityType); Assert.Equal(typeof(ResourceDefinition), nonDbResource.ResourceType); } + + [Fact] + public void Resources_Without_Names_Specified_Will_Use_Default_Formatter() + { + // arrange + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("test-resources", resource.EntityName); + } + + [Fact] + public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() + { + // arrange + JsonApiOptions.ResourceNameFormatter = new CamelCaseNameFormatter(); + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("testResources", resource.EntityName); + } + + [Fact] + public void Attrs_Without_Names_Specified_Will_Use_Default_Formatter() + { + // arrange + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("compound-attribute", resource.Attributes.Single().PublicAttributeName); + } + + [Fact] + public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() + { + // arrange + JsonApiOptions.ResourceNameFormatter = new CamelCaseNameFormatter(); + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("compoundAttribute", resource.Attributes.Single().PublicAttributeName); + } + + [Fact] + public void Relationships_Without_Names_Specified_Will_Use_Default_Formatter() + { + // arrange + var builder = new ContextGraphBuilder(); + builder.AddResource(); + + // act + var graph = builder.Build(); + + // assert + var resource = graph.GetContextEntity(typeof(TestResource)); + Assert.Equal("related-resource", resource.Relationships.Single(r => r.IsHasOne).PublicRelationshipName); + Assert.Equal("related-resources", resource.Relationships.Single(r => r.IsHasMany).PublicRelationshipName); + } + + public class TestResource : Identifiable { + [Attr] public string CompoundAttribute { get; set; } + [HasOne] public RelatedResource RelatedResource { get; set; } + [HasMany] public List RelatedResources { get; set; } + } + + public class RelatedResource : Identifiable { } + + public class CamelCaseNameFormatter : IResourceNameFormatter + { + public string FormatPropertyName(PropertyInfo property) => ToCamelCase(property.Name); + + public string FormatResourceName(Type resourceType) => ToCamelCase(resourceType.Name.Pluralize()); + + private string ToCamelCase(string str) => Char.ToLowerInvariant(str[0]) + str.Substring(1); + } } } diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index 1b00c5aaa1..f48821e756 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -13,6 +13,10 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; using Microsoft.EntityFrameworkCore; +using JsonApiDotNetCore.Models; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; namespace UnitTests.Extensions { @@ -51,5 +55,88 @@ public void AddJsonApiInternals_Adds_All_Required_Services() Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService(typeof(GenericProcessor))); } + + [Fact] + public void AddResourceService_Registers_All_Shorthand_Service_Interfaces() + { + // arrange + var services = new ServiceCollection(); + + // act + services.AddResourceService(); + + // assert + var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetService(typeof(IResourceService))); + Assert.IsType(provider.GetService(typeof(IResourceCmdService))); + Assert.IsType(provider.GetService(typeof(IResourceQueryService))); + Assert.IsType(provider.GetService(typeof(IGetAllService))); + Assert.IsType(provider.GetService(typeof(IGetByIdService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipsService))); + Assert.IsType(provider.GetService(typeof(ICreateService))); + Assert.IsType(provider.GetService(typeof(IUpdateService))); + Assert.IsType(provider.GetService(typeof(IDeleteService))); + } + + [Fact] + public void AddResourceService_Registers_All_LongForm_Service_Interfaces() + { + // arrange + var services = new ServiceCollection(); + + // act + services.AddResourceService(); + + // assert + var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetService(typeof(IResourceService))); + Assert.IsType(provider.GetService(typeof(IResourceCmdService))); + Assert.IsType(provider.GetService(typeof(IResourceQueryService))); + Assert.IsType(provider.GetService(typeof(IGetAllService))); + Assert.IsType(provider.GetService(typeof(IGetByIdService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipService))); + Assert.IsType(provider.GetService(typeof(IGetRelationshipsService))); + Assert.IsType(provider.GetService(typeof(ICreateService))); + Assert.IsType(provider.GetService(typeof(IUpdateService))); + Assert.IsType(provider.GetService(typeof(IDeleteService))); + } + + [Fact] + public void AddResourceService_Throws_If_Type_Does_Not_Implement_Any_Interfaces() + { + // arrange + var services = new ServiceCollection(); + + // act, assert + Assert.Throws(() => services.AddResourceService()); + } + + private class IntResource : Identifiable { } + private class GuidResource : Identifiable { } + + private class IntResourceService : IResourceService + { + public Task CreateAsync(IntResource entity) => throw new NotImplementedException(); + public Task DeleteAsync(int id) => throw new NotImplementedException(); + public Task> GetAsync() => throw new NotImplementedException(); + public Task GetAsync(int id) => throw new NotImplementedException(); + public Task GetRelationshipAsync(int id, string relationshipName) => throw new NotImplementedException(); + public Task GetRelationshipsAsync(int id, string relationshipName) => throw new NotImplementedException(); + public Task UpdateAsync(int id, IntResource entity) => throw new NotImplementedException(); + public Task UpdateRelationshipsAsync(int id, string relationshipName, List relationships) => throw new NotImplementedException(); + } + + private class GuidResourceService : IResourceService + { + public Task CreateAsync(GuidResource entity) => throw new NotImplementedException(); + public Task DeleteAsync(Guid id) => throw new NotImplementedException(); + public Task> GetAsync() => throw new NotImplementedException(); + public Task GetAsync(Guid id) => throw new NotImplementedException(); + public Task GetRelationshipAsync(Guid id, string relationshipName) => throw new NotImplementedException(); + public Task GetRelationshipsAsync(Guid id, string relationshipName) => throw new NotImplementedException(); + public Task UpdateAsync(Guid id, GuidResource entity) => throw new NotImplementedException(); + public Task UpdateRelationshipsAsync(Guid id, string relationshipName, List relationships) => throw new NotImplementedException(); + } } } diff --git a/test/UnitTests/Graph/TypeLocator_Tests.cs b/test/UnitTests/Graph/TypeLocator_Tests.cs index f0d1ce04ad..890994c340 100644 --- a/test/UnitTests/Graph/TypeLocator_Tests.cs +++ b/test/UnitTests/Graph/TypeLocator_Tests.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using JsonApiDotNetCore.Graph; using JsonApiDotNetCore.Models; using Xunit; @@ -85,6 +86,61 @@ public void GetIdType_Correctly_Identifies_NonJsonApiResource() Assert.False(result.isJsonApiResource); Assert.Equal(exextedIdType, result.idType); } + + [Fact] + public void GetIdentifableTypes_Locates_Identifiable_Resource() + { + // arrange + var resourceType = typeof(Model); + + // act + var results = TypeLocator.GetIdentifableTypes(resourceType.Assembly); + + // assert + Assert.Contains(results, r => r.ResourceType == resourceType); + } + + [Fact] + public void GetIdentifableTypes__Only_Contains_IIdentifiable_Types() + { + // arrange + var resourceType = typeof(Model); + + // act + var resourceDescriptors = TypeLocator.GetIdentifableTypes(resourceType.Assembly); + + // assert + foreach(var resourceDescriptor in resourceDescriptors) + Assert.True(typeof(IIdentifiable).IsAssignableFrom(resourceDescriptor.ResourceType)); + } + + [Fact] + public void TryGetResourceDescriptor_Returns_True_If_Type_Is_IIdentfiable() + { + // arrange + var resourceType = typeof(Model); + + // act + var isJsonApiResource = TypeLocator.TryGetResourceDescriptor(resourceType, out var descriptor); + + // assert + Assert.True(isJsonApiResource); + Assert.Equal(resourceType, descriptor.ResourceType); + Assert.Equal(typeof(int), descriptor.IdType); + } + + [Fact] + public void TryGetResourceDescriptor_Returns_False_If_Type_Is_IIdentfiable() + { + // arrange + var resourceType = typeof(String); + + // act + var isJsonApiResource = TypeLocator.TryGetResourceDescriptor(resourceType, out var descriptor); + + // assert + Assert.False(isJsonApiResource); + } } diff --git a/test/UnitTests/Models/ResourceDefinitionTests.cs b/test/UnitTests/Models/ResourceDefinitionTests.cs index 2112a49447..e7a1e75dcf 100644 --- a/test/UnitTests/Models/ResourceDefinitionTests.cs +++ b/test/UnitTests/Models/ResourceDefinitionTests.cs @@ -1,7 +1,9 @@ using JsonApiDotNetCore.Builders; using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Query; using JsonApiDotNetCore.Models; using System.Collections.Generic; +using System.Linq; using Xunit; namespace UnitTests.Models @@ -27,8 +29,7 @@ public void Request_Filter_Uses_Member_Expression() var attrs = resource.GetOutputAttrs(null); // assert - Assert.Single(attrs); - Assert.Equal(nameof(Model.Password), attrs[0].InternalAttributeName); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); } [Fact] @@ -41,7 +42,8 @@ public void Request_Filter_Uses_NewExpression() var attrs = resource.GetOutputAttrs(null); // assert - Assert.Empty(attrs); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.Password)); } [Fact] @@ -55,8 +57,7 @@ public void Instance_Filter_Uses_Member_Expression() var attrs = resource.GetOutputAttrs(model); // assert - Assert.Single(attrs); - Assert.Equal(nameof(Model.Password), attrs[0].InternalAttributeName); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); } [Fact] @@ -70,7 +71,8 @@ public void Instance_Filter_Uses_NewExpression() var attrs = resource.GetOutputAttrs(model); // assert - Assert.Empty(attrs); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.AlwaysExcluded)); + Assert.DoesNotContain(attrs, a => a.InternalAttributeName == nameof(Model.Password)); } [Fact] @@ -98,6 +100,7 @@ public class Model : Identifiable { [Attr("name")] public string AlwaysExcluded { get; set; } [Attr("password")] public string Password { get; set; } + [Attr("prop")] public string Prop { get; set; } } public class RequestFilteredResource : ResourceDefinition @@ -116,6 +119,16 @@ protected override List OutputAttrs() => _isAdmin ? Remove(m => m.AlwaysExcluded) : Remove(m => new { m.AlwaysExcluded, m.Password }, from: base.OutputAttrs()); + + public override QueryFilters GetQueryFilters() + => new QueryFilters { + { "is-active", (query, value) => query.Select(x => x) } + }; + + protected override PropertySortOrder GetDefaultSortOrder() + => new PropertySortOrder { + (t => t.Prop, SortDirection.Ascending) + }; } public class InstanceFilteredResource : ResourceDefinition