diff --git a/benchmarks/DependencyFactory.cs b/benchmarks/DependencyFactory.cs index 87680c7ba1..8edd9c9b28 100644 --- a/benchmarks/DependencyFactory.cs +++ b/benchmarks/DependencyFactory.cs @@ -10,7 +10,7 @@ internal static class DependencyFactory { public static IResourceGraph CreateResourceGraph(IJsonApiOptions options) { - IResourceGraphBuilder builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + ResourceGraphBuilder builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); builder.Add(BenchmarkResourcePublicNames.Type); return builder.Build(); } diff --git a/docs/usage/extensibility/middleware.md b/docs/usage/extensibility/middleware.md index dc1c7a6e23..2b1c0bce2e 100644 --- a/docs/usage/extensibility/middleware.md +++ b/docs/usage/extensibility/middleware.md @@ -1,14 +1,40 @@ # Middleware -Add the following to your Startup.ConfigureServices method. Replace AppDbContext with your DbContext. -```c3 -services.AddJsonApi(); +It is possible to replace JsonApiDotNetCore middleware components by configuring the IoC container and by configuring `MvcOptions`. + +## Configuring the IoC container + +The following example replaces the internal exception filter with a custom implementation. +```c# +/// In Startup.ConfigureServices +services.AddService() ``` -Add the middleware to the Startup.Configure method. +## Configuring `MvcOptions` + +The following example replaces all internal filters with a custom filter. +```c# +/// In Startup.ConfigureServices +services.AddSingleton(); + +var builder = services.AddMvcCore(); +services.AddJsonApi(mvcBuilder: builder); -```c3 -app.UseRouting(); +// Ensure this call is placed after the AddJsonApi call. +builder.AddMvcOptions(mvcOptions => +{ + _postConfigureMvcOptions?.Invoke(mvcOptions); +}); + +/// In Startup.Configure app.UseJsonApi(); + +// Ensure this call is placed before the UseEndpoints call. +_postConfigureMvcOptions = mvcOptions => +{ + mvcOptions.Filters.Clear(); + mvcOptions.Filters.Insert(0, app.ApplicationServices.GetService()); +}; + app.UseEndpoints(endpoints => endpoints.MapControllers()); ``` diff --git a/docs/usage/options.md b/docs/usage/options.md index fe5751aa60..6bb09865b7 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -90,8 +90,14 @@ options.SerializerSettings.Converters.Add(new StringEnumConverter()); options.SerializerSettings.Formatting = Formatting.Indented; ``` +The default naming convention (as used in the routes and public resources names) is also determined here, and can be changed (default is camel-case): +```c# +options.SerializerSettings.ContractResolver = new DefaultContractResolver { NamingStrategy = new KebabCaseNamingStrategy() }; +``` + Because we copy resource properties into an intermediate object before serialization, Newtonsoft.Json annotations on properties are ignored. + ## Enable ModelState Validation If you would like to use ASP.NET Core ModelState validation into your controllers when creating / updating resources, set `ValidateModelState = true`. By default, no model validation is performed. diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md index 529969d146..d6b1f83d8f 100644 --- a/docs/usage/resource-graph.md +++ b/docs/usage/resource-graph.md @@ -14,10 +14,14 @@ There are three ways the resource graph can be created: 2. Specifying an entire DbContext 3. Manually specifying each resource -### Auto-Discovery +It is also possible to combine the three of them at once. Be aware that some configuration might overlap, +for example one could manually add a resource to the graph which is also auto-discovered. In such a scenario, the configuration +is prioritized by the list above in descending order. + +### Auto-discovery Auto-discovery refers to the process of reflecting on an assembly and -detecting all of the json:api resources and services. +detecting all of the json:api resources, resource definitions, resource services and repositories. The following command will build the resource graph using all `IIdentifiable` implementations. It also injects resource definitions and service layer overrides which we will @@ -34,9 +38,9 @@ public void ConfigureServices(IServiceCollection services) } ``` -### Entity Framework Core DbContext +### Specifying an Entity Framework Core DbContext -If you are using Entity Framework Core as your ORM, you can add an entire `DbContext` with one line. +If you are using Entity Framework Core as your ORM, you can add all the models of a `DbContext` to the resource graph. ```c# // Startup.cs @@ -46,7 +50,7 @@ public void ConfigureServices(IServiceCollection services) } ``` -Be aware that the previous command does not inject resource definitions and service layer overrides. You can combine it with auto-discovery to register them. +Be aware that this does not register resource definitions, resource services and repositories. You can combine it with auto-discovery to achieve this. ```c# // Startup.cs @@ -60,7 +64,7 @@ public void ConfigureServices(IServiceCollection services) ### Manual Specification -You can also manually construct the graph. +You can manually construct the graph. ```c# // Startup.cs @@ -68,23 +72,33 @@ public void ConfigureServices(IServiceCollection services) { services.AddJsonApi(resources: builder => { - builder.AddResource(); + builder.Add(); }); } ``` -### Public Resource Type Name +## Public Resource Name + +The public resource name is exposed through the `type` member in the json:api payload. This can be configured by the following approaches (in order of priority): -The public resource type name is determined by the following criteria (in order of priority): +1. The `publicName` parameter when manually adding a resource to the graph +```c# +services.AddJsonApi(resources: builder => +{ + builder.Add(publicName: "people"); +}); +``` -1. The model is decorated with a `ResourceAttribute` +2. The model is decorated with a `ResourceAttribute` ```c# -[Resource("my-models")] +[Resource("myResources")] public class MyModel : Identifiable { /* ... */ } ``` -2. The configured naming convention (by default this is camel-case). +3. The configured naming convention (by default this is camel-case). ```c# // this will be registered as "myModels" public class MyModel : Identifiable { /* ... */ } ``` + +The default naming convention can be changed in [options](./options.md#custom-serializer-settings). diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index dfbef4aefd..e9850648cf 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -14,13 +14,8 @@ public class Person : Identifiable There are two ways the public attribute name is determined: -1. By convention, specified in @JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_SerializerSettings -```c# -options.SerializerSettings.ContractResolver = new DefaultContractResolver -{ - NamingStrategy = new KebabCaseNamingStrategy() // default: CamelCaseNamingStrategy -}; -``` +1. Using the configured [naming convention](./options.md#custom-serializer-settings). + 2. Individually using the attribute's constructor ```c# diff --git a/docs/usage/routing.md b/docs/usage/routing.md index b4261d2516..442bab4d42 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -1,15 +1,7 @@ # Routing -By default the library will configure routes for each controller. -Based on the [recommendations](https://jsonapi.org/recommendations/) outlined in the json:api spec, routes are camel-cased. - -```http -GET /api/compoundModels HTTP/1.1 -``` - ## Namespacing and Versioning URLs - -You can add a namespace to all URLs by specifying it in ConfigureServices +You can add a namespace to all URLs by specifying it in ConfigureServices. ```c# public void ConfigureServices(IServiceCollection services) @@ -20,40 +12,47 @@ public void ConfigureServices(IServiceCollection services) ``` Which results in URLs like: https://yourdomain.com/api/v1/people -## Disable Convention +## Default Routing Convention -You can disable the default casing convention and specify your own template by using the `DisableRoutingConvention` attribute. +The library will configure routes for all controllers in your project. By default, routes are camel-cased. This is based on the [recommendations](https://jsonapi.org/recommendations/) outlined in the json:api spec. ```c# -[Route("[controller]")] -[DisableRoutingConvention] -public class CamelCasedModelsController : JsonApiController -{ - public CamelCasedModelsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } -} +public class OrderLine : Identifiable { } + +public class OrderLineController : JsonApiController { /* .... */ } +``` + +```http +GET /orderLines HTTP/1.1 ``` -It is important to note that your routes must still end with the model name in the same format as the resource name. This is so that we can build accurate resource links in the json:api document. For example, if you define a resource as MyModels, the controller route must match. +The public name of the resource ([which can be customized](./resource-graph.md#public-resource-name)) is used for the route, instead of the controller name. +### Non-json:api controllers + +If a controller does not inherit from `JsonApiController`, the [configured naming convention](./options.md#custom-serializer-settings) is applied to the name of the controller. ```c# -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(resources: builder => - builder.AddResource("my-models")); // kebab-cased -} +public class OrderLineController : ControllerBase { /* .... */ } +``` +```http +GET /orderLines HTTP/1.1 +``` -// controller definition -[Route("api/my-models"), DisableRoutingConvention] -public class MyModelsController : JsonApiController -{ - //... -} +## Disabling the Default Routing Convention + +It is possible to bypass the default routing convention for a controller. +```c# +[Route("v1/custom/route/orderLines"), DisableRoutingConvention] +public class OrderLineController : JsonApiController { /* ... */ } ``` +It is required to match your custom url with the public name of the associated resource. + +## Advanced Usage: Custom Routing Convention -See [this](~/usage/resource-graph.md#public-resource-type-name) for -more information on how the resource name is determined. +It is possible to replace the built-in routing convention with a [custom routing convention]](https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/application-model?view=aspnetcore-3.1#sample-custom-routing-convention) by registering an implementation of `IJsonApiRoutingConvention`. +```c# +public void ConfigureServices(IServiceCollection services) +{ + services.AddSingleton(); +} +``` \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index c6b0cfd87b..b41e501b54 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -38,7 +38,7 @@ public override void ConfigureServices(IServiceCollection services) }, ServiceLifetime.Transient); services.AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); - + // once all tests have been moved to WebApplicationFactory format we can get rid of this line below services.AddClientSerialization(); } diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index ebff426d1a..0d4d20f59c 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -1,6 +1,7 @@ using System; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Configuration { @@ -22,6 +23,23 @@ public static class ApplicationBuilderExtensions public static void UseJsonApi(this IApplicationBuilder builder) { if (builder == null) throw new ArgumentNullException(nameof(builder)); + + using var scope = builder.ApplicationServices.GetRequiredService().CreateScope(); + var inverseRelationshipResolver = scope.ServiceProvider.GetRequiredService(); + inverseRelationshipResolver.Resolve(); + + var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); + jsonApiApplicationBuilder.ConfigureMvcOptions = options => + { + var inputFormatter = builder.ApplicationServices.GetRequiredService(); + options.InputFormatters.Insert(0, inputFormatter); + + var outputFormatter = builder.ApplicationServices.GetRequiredService(); + options.OutputFormatters.Insert(0, outputFormatter); + + var routingConvention = builder.ApplicationServices.GetRequiredService(); + options.Conventions.Insert(0, routingConvention); + }; builder.UseMiddleware(); } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs new file mode 100644 index 0000000000..53b84e36d1 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs @@ -0,0 +1,10 @@ +using System; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCore.Configuration +{ + internal interface IJsonApiApplicationBuilder + { + public Action ConfigureMvcOptions { set; } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 8c2455caf6..fded94294a 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -176,7 +176,7 @@ public interface IJsonApiOptions /// Specifies the settings that are used by the . /// Note that at some places a few settings are ignored, to ensure json:api spec compliance. /// - /// The next example changes the casing convention to kebab casing. + /// The next example changes the naming convention to kebab casing. /// - /// Constructs the . - /// - IResourceGraph Build(); - /// - /// Adds a json:api resource. - /// - /// The resource model type. - /// - /// The pluralized name, under which the resource is publicly exposed by the API. - /// If nothing is specified, the configured casing convention formatter will be applied. - /// - IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable; - /// - /// Adds a json:api resource. - /// - /// The resource model type. - /// The resource model identifier type. - /// - /// The pluralized name, under which the resource is publicly exposed by the API. - /// If nothing is specified, the configured casing convention formatter will be applied. - /// - IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable; - /// - /// Adds a json:api resource. - /// - /// The resource model type. - /// The resource model identifier type. - /// - /// The pluralized name, under which the resource is publicly exposed by the API. - /// If nothing is specified, the configured casing convention formatter will be applied. - /// - IResourceGraphBuilder Add(Type resourceType, Type idType = null, string pluralizedTypeName = null); - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/IServiceDiscoveryFacade.cs deleted file mode 100644 index 4e952d9f82..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IServiceDiscoveryFacade.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Reflection; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Scans for types like resources, services, repositories and resource definitions in an assembly and registers them to the IoC container. This is part of the resource auto-discovery process. - /// - public interface IServiceDiscoveryFacade - { - /// - /// Scans in the specified assembly. - /// - ServiceDiscoveryFacade AddAssembly(Assembly assembly); - - /// - /// Scans in the calling assembly. - /// - ServiceDiscoveryFacade AddCurrentAssembly(); - } -} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 87222097e0..a550a06e7e 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -14,9 +14,11 @@ using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Configuration { @@ -24,115 +26,101 @@ namespace JsonApiDotNetCore.Configuration /// A utility class that builds a JsonApi application. It registers all required services /// and allows the user to override parts of the startup configuration. /// - internal sealed class JsonApiApplicationBuilder + internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder, IDisposable { private readonly JsonApiOptions _options = new JsonApiOptions(); - private IResourceGraphBuilder _resourceGraphBuilder; - private Type _dbContextType; private readonly IServiceCollection _services; - private IServiceDiscoveryFacade _serviceDiscoveryFacade; private readonly IMvcCoreBuilder _mvcBuilder; + private readonly ResourceGraphBuilder _resourceGraphBuilder; + private readonly ServiceDiscoveryFacade _serviceDiscoveryFacade; + private readonly ServiceProvider _intermediateProvider; + + public Action ConfigureMvcOptions { get; set; } public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { _services = services ?? throw new ArgumentNullException(nameof(services)); _mvcBuilder = mvcBuilder ?? throw new ArgumentNullException(nameof(mvcBuilder)); + + _intermediateProvider = services.BuildServiceProvider(); + var loggerFactory = _intermediateProvider.GetService(); + + _resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); + _serviceDiscoveryFacade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, loggerFactory); } - + /// /// Executes the action provided by the user to configure . /// - public void ConfigureJsonApiOptions(Action options) + public void ConfigureJsonApiOptions(Action configureOptions) { - options?.Invoke(_options); + configureOptions?.Invoke(_options); } - + /// - /// Configures built-in ASP.NET Core MVC (things like middleware, routing). Most of this configuration can be adjusted for the developers' need. - /// Before calling .AddJsonApi(), a developer can register their own implementation of the following services to customize startup: - /// , , , - /// and . + /// Executes the action provided by the user to configure . /// - public void ConfigureMvc(Type dbContextType) + public void ConfigureAutoDiscovery(Action configureAutoDiscovery) { - RegisterJsonApiStartupServices(); - - IJsonApiExceptionFilterProvider exceptionFilterProvider; - IJsonApiTypeMatchFilterProvider typeMatchFilterProvider; - IJsonApiRoutingConvention routingConvention; + configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); + } - using (var intermediateProvider = _services.BuildServiceProvider()) + /// + /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. + /// + public void AddResourceGraph(Type dbContextType, Action configureResourceGraph) + { + _serviceDiscoveryFacade.DiscoverResources(); + + if (dbContextType != null) { - _resourceGraphBuilder = intermediateProvider.GetRequiredService(); - _serviceDiscoveryFacade = intermediateProvider.GetRequiredService(); - _dbContextType = dbContextType; - - AddResourceTypesFromDbContext(intermediateProvider); - - exceptionFilterProvider = intermediateProvider.GetRequiredService(); - typeMatchFilterProvider = intermediateProvider.GetRequiredService(); - routingConvention = intermediateProvider.GetRequiredService(); + AddResourcesFromDbContext((DbContext)_intermediateProvider.GetService(dbContextType), _resourceGraphBuilder); } + + configureResourceGraph?.Invoke(_resourceGraphBuilder); + var resourceGraph = _resourceGraphBuilder.Build(); + _services.AddSingleton(resourceGraph); + } + + /// + /// Configures built-in ASP.NET Core MVC components. Most of this configuration can be adjusted for the developers' need. + /// + public void ConfigureMvc() + { _mvcBuilder.AddMvcOptions(options => { options.EnableEndpointRouting = true; - options.Filters.Add(exceptionFilterProvider.Get()); - options.Filters.Add(typeMatchFilterProvider.Get()); - options.Filters.Add(new ConvertEmptyActionResultFilter()); - options.InputFormatters.Insert(0, new JsonApiInputFormatter()); - options.OutputFormatters.Insert(0, new JsonApiOutputFormatter()); - options.Conventions.Insert(0, routingConvention); + options.Filters.AddService(); + options.Filters.AddService(); + options.Filters.AddService(); + options.Filters.AddService(); + ConfigureMvcOptions?.Invoke(options); }); if (_options.ValidateModelState) { _mvcBuilder.AddDataAnnotations(); } - - _services.AddSingleton(routingConvention); - } - - private void AddResourceTypesFromDbContext(ServiceProvider intermediateProvider) - { - if (_dbContextType != null) - { - var dbContext = (DbContext) intermediateProvider.GetRequiredService(_dbContextType); - - foreach (var entityType in dbContext.Model.GetEntityTypes()) - { - _resourceGraphBuilder.Add(entityType.ClrType); - } - } } /// - /// Executes auto-discovery of JADNC services. + /// Discovers DI registrable services in the assemblies marked for discovery. /// - public void AutoDiscover(Action autoDiscover) + public void DiscoverInjectables() { - autoDiscover?.Invoke(_serviceDiscoveryFacade); - } - - /// - /// Executes the action provided by the user to configure the resources using . - /// - public void ConfigureResources(Action resources) - { - resources?.Invoke(_resourceGraphBuilder); + _serviceDiscoveryFacade.DiscoverInjectables(); } /// /// Registers the remaining internals. /// - public void ConfigureServices() + public void ConfigureServices(Type dbContextType) { - var resourceGraph = _resourceGraphBuilder.Build(); - - if (_dbContextType != null) + if (dbContextType != null) { - var contextResolverType = typeof(DbContextResolver<>).MakeGenericType(_dbContextType); - _services.AddScoped(typeof(IDbContextResolver), contextResolverType); + var contextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.TryAddScoped(typeof(IDbContextResolver), contextResolverType); } else { @@ -140,12 +128,64 @@ public void ConfigureServices() _services.AddSingleton(new DbContextOptionsBuilder().Options); } + AddRepositoryLayer(); + AddServiceLayer(); + AddMiddlewareLayer(); + + _services.AddSingleton(sp => sp.GetRequiredService()); + + _services.AddScoped(); + _services.AddScoped(typeof(RepositoryRelationshipUpdateHelper<>)); + _services.AddScoped(); + _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + + AddServerSerialization(); + AddQueryStringParameterServices(); + + if (_options.EnableResourceHooks) + { + AddResourceHooks(); + } + + _services.TryAddScoped(); + } + + private void AddMiddlewareLayer() + { + _services.AddSingleton(_options); + _services.AddSingleton(this); + _services.TryAddSingleton(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.AddSingleton(sp => sp.GetService()); + _services.AddSingleton(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); + } + + private void AddRepositoryLayer() + { _services.AddScoped(typeof(IResourceRepository<>), typeof(EntityFrameworkCoreRepository<>)); _services.AddScoped(typeof(IResourceRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); _services.AddScoped(typeof(IResourceReadRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); _services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(EntityFrameworkCoreRepository<,>)); + } + private void AddServiceLayer() + { _services.AddScoped(typeof(ICreateService<>), typeof(JsonApiResourceService<>)); _services.AddScoped(typeof(ICreateService<,>), typeof(JsonApiResourceService<,>)); @@ -172,32 +212,6 @@ public void ConfigureServices() _services.AddScoped(typeof(IResourceQueryService<,>), typeof(JsonApiResourceService<,>)); _services.AddScoped(typeof(IResourceCommandService<,>), typeof(JsonApiResourceService<,>)); - - _services.AddSingleton(resourceGraph); - _services.AddSingleton(); - _services.AddSingleton(resourceGraph); - _services.AddSingleton(); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(typeof(RepositoryRelationshipUpdateHelper<>)); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - - AddServerSerialization(); - AddQueryStringParameterServices(); - if (_options.EnableResourceHooks) - AddResourceHooks(); - - _services.AddScoped(); } private void AddQueryStringParameterServices() @@ -227,7 +241,6 @@ private void AddQueryStringParameterServices() _services.AddScoped(sp => sp.GetService()); _services.AddScoped(sp => sp.GetService()); - _services.AddScoped(); _services.AddScoped(); _services.AddSingleton(); } @@ -254,14 +267,17 @@ private void AddServerSerialization() _services.AddScoped(); } - private void RegisterJsonApiStartupServices() + private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) { - _services.AddSingleton(_options); - _services.TryAddSingleton(); - _services.TryAddSingleton(); - _services.TryAddSingleton(sp => new ServiceDiscoveryFacade(_services, sp.GetRequiredService())); - _services.TryAddScoped(); - _services.TryAddScoped(); + foreach (var entityType in dbContext.Model.GetEntityTypes()) + { + builder.Add(entityType.ClrType); + } + } + + public void Dispose() + { + _intermediateProvider.Dispose(); } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceContext.cs b/src/JsonApiDotNetCore/Configuration/ResourceContext.cs index eb5af30a56..b1c4803b30 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceContext.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceContext.cs @@ -14,7 +14,7 @@ public class ResourceContext /// /// The publicly exposed resource name. /// - public string ResourceName { get; set; } + public string PublicName { get; set; } /// /// The CLR type of the resource. @@ -84,7 +84,7 @@ public class ResourceContext public override string ToString() { - return ResourceName; + return PublicName; } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index f8367af7f5..a1acc938d8 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -26,7 +26,7 @@ public ResourceContext GetResourceContext(string resourceName) { if (resourceName == null) throw new ArgumentNullException(nameof(resourceName)); - return _resources.SingleOrDefault(e => e.ResourceName == resourceName); + return _resources.SingleOrDefault(e => e.PublicName == resourceName); } /// diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 50d635ea13..cd08d5a222 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -9,8 +9,10 @@ namespace JsonApiDotNetCore.Configuration { - /// - public class ResourceGraphBuilder : IResourceGraphBuilder + /// + /// Builds and configures the . + /// + public class ResourceGraphBuilder { private readonly IJsonApiOptions _options; private readonly ILogger _logger; @@ -19,12 +21,14 @@ public class ResourceGraphBuilder : IResourceGraphBuilder public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); - + _options = options ?? throw new ArgumentNullException(nameof(options)); _logger = loggerFactory.CreateLogger(); } - /// + /// + /// Constructs the . + /// public IResourceGraph Build() { _resources.ForEach(SetResourceLinksOptions); @@ -41,17 +45,40 @@ private void SetResourceLinksOptions(ResourceContext resourceContext) resourceContext.TopLevelLinks = attribute.TopLevelLinks; } } - - /// - public IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable - => Add(pluralizedTypeName); - - /// - public IResourceGraphBuilder Add(string pluralizedTypeName = null) where TResource : class, IIdentifiable - => Add(typeof(TResource), typeof(TId), pluralizedTypeName); - - /// - public IResourceGraphBuilder Add(Type resourceType, Type idType = null, string pluralizedTypeName = null) + + /// + /// Adds a json:api resource with int as the identifier type. + /// + /// The resource model type. + /// + /// The name under which the resource is publicly exposed by the API. + /// If nothing is specified, the configured naming convention formatter will be applied. + /// + public ResourceGraphBuilder Add(string publicName = null) where TResource : class, IIdentifiable + => Add(publicName); + + /// + /// Adds a json:api resource. + /// + /// The resource model type. + /// The resource model identifier type. + /// + /// The name under which the resource is publicly exposed by the API. + /// If nothing is specified, the configured naming convention formatter will be applied. + /// + public ResourceGraphBuilder Add(string publicName = null) where TResource : class, IIdentifiable + => Add(typeof(TResource), typeof(TId), publicName); + + /// + /// Adds a json:api resource. + /// + /// The resource model type. + /// The resource model identifier type. + /// + /// The name under which the resource is publicly exposed by the API. + /// If nothing is specified, the configured naming convention formatter will be applied. + /// + public ResourceGraphBuilder Add(Type resourceType, Type idType = null, string publicName = null) { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); @@ -59,9 +86,9 @@ public IResourceGraphBuilder Add(Type resourceType, Type idType = null, string p { if (TypeHelper.IsOrImplementsInterface(resourceType, typeof(IIdentifiable))) { - pluralizedTypeName ??= FormatResourceName(resourceType); + publicName ??= FormatResourceName(resourceType); idType ??= TypeLocator.GetIdType(resourceType); - var resourceContext = CreateResourceContext(pluralizedTypeName, resourceType, idType); + var resourceContext = CreateResourceContext(publicName, resourceType, idType); _resources.Add(resourceContext); } else @@ -73,9 +100,9 @@ public IResourceGraphBuilder Add(Type resourceType, Type idType = null, string p return this; } - private ResourceContext CreateResourceContext(string pluralizedTypeName, Type resourceType, Type idType) => new ResourceContext + private ResourceContext CreateResourceContext(string publicName, Type resourceType, Type idType) => new ResourceContext { - ResourceName = pluralizedTypeName, + PublicName = publicName, ResourceType = resourceType, IdentityType = idType, Attributes = GetAttributes(resourceType), @@ -84,7 +111,7 @@ public IResourceGraphBuilder Add(Type resourceType, Type idType = null, string p ResourceDefinitionType = GetResourceDefinitionType(resourceType) }; - protected virtual IReadOnlyCollection GetAttributes(Type resourceType) + private IReadOnlyCollection GetAttributes(Type resourceType) { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); @@ -125,7 +152,7 @@ protected virtual IReadOnlyCollection GetAttributes(Type resource return attributes; } - protected virtual IReadOnlyCollection GetRelationships(Type resourceType) + private IReadOnlyCollection GetRelationships(Type resourceType) { if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); @@ -183,7 +210,7 @@ protected virtual IReadOnlyCollection GetRelationships(Ty return attributes; } - private static Type TryGetThroughType(PropertyInfo throughProperty) + private Type TryGetThroughType(PropertyInfo throughProperty) { if (throughProperty.PropertyType.IsGenericType) { @@ -201,7 +228,7 @@ private static Type TryGetThroughType(PropertyInfo throughProperty) return null; } - protected virtual Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) + private Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) { if (relationship == null) throw new ArgumentNullException(nameof(relationship)); if (property == null) throw new ArgumentNullException(nameof(property)); @@ -234,7 +261,7 @@ private IReadOnlyCollection GetEagerLoads(Type resourceType, return attributes; } - private static Type TypeOrElementType(Type type) + private Type TypeOrElementType(Type type) { var interfaces = type.GetInterfaces() .Where(t => t.IsGenericType && t.GetGenericTypeDefinition() == typeof(IEnumerable<>)).ToArray(); diff --git a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs index e58816186f..85bf857a9a 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -16,7 +16,7 @@ public ResourceNameFormatter(IJsonApiOptions options) } /// - /// Gets the publicly visible resource name for the internal type name using the configured casing convention. + /// Gets the publicly visible resource name for the internal type name using the configured naming convention. /// public string FormatResourceName(Type resourceType) { diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 199937ed4c..5b5b7a652a 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Client.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Configuration { @@ -17,14 +18,13 @@ public static class ServiceCollectionExtensions /// public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, - Action resources = null, + Action discovery = null, + Action resources = null, IMvcCoreBuilder mvcBuilder = null) { if (services == null) throw new ArgumentNullException(nameof(services)); SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, null); - ResolveInverseRelationships(services); return services; } @@ -34,41 +34,32 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, /// public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, - Action resources = null, + Action discovery = null, + Action resources = null, IMvcCoreBuilder mvcBuilder = null) where TDbContext : DbContext { if (services == null) throw new ArgumentNullException(nameof(services)); SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, typeof(TDbContext)); - ResolveInverseRelationships(services); return services; } - private static void SetupApplicationBuilder(IServiceCollection services, Action options, - Action discovery, - Action resources, IMvcCoreBuilder mvcBuilder, Type dbContextType) + private static void SetupApplicationBuilder(IServiceCollection services, Action configureOptions, + Action configureAutoDiscovery, + Action configureResourceGraph, IMvcCoreBuilder mvcBuilder, Type dbContextType) { - var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); - - applicationBuilder.ConfigureJsonApiOptions(options); - applicationBuilder.ConfigureMvc(dbContextType); - applicationBuilder.AutoDiscover(discovery); - applicationBuilder.ConfigureResources(resources); - applicationBuilder.ConfigureServices(); - } - - private static void ResolveInverseRelationships(IServiceCollection services) - { - using var intermediateProvider = services.BuildServiceProvider(); - using var scope = intermediateProvider.CreateScope(); - - var inverseRelationshipResolver = scope.ServiceProvider.GetService(); - inverseRelationshipResolver?.Resolve(); + using var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); + + applicationBuilder.ConfigureJsonApiOptions(configureOptions); + applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); + applicationBuilder.AddResourceGraph(dbContextType, configureResourceGraph); + applicationBuilder.ConfigureMvc(); + applicationBuilder.DiscoverInjectables(); + applicationBuilder.ConfigureServices(dbContextType); } - + /// /// Enables client serializers for sending requests and receiving responses /// in json:api format. Internally only used for testing. diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index d20c4dcc44..278f0a104d 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -8,11 +8,14 @@ using JsonApiDotNetCore.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Configuration { - /// - public class ServiceDiscoveryFacade : IServiceDiscoveryFacade + /// + /// Scans for types like resources, services, repositories and resource definitions in an assembly and registers them to the IoC container. + /// + public class ServiceDiscoveryFacade { internal static readonly HashSet ServiceInterfaces = new HashSet { typeof(IResourceService<>), @@ -46,36 +49,75 @@ public class ServiceDiscoveryFacade : IServiceDiscoveryFacade typeof(IResourceReadRepository<,>) }; + private readonly ILogger _logger; private readonly IServiceCollection _services; - private readonly IResourceGraphBuilder _resourceGraphBuilder; + private readonly ResourceGraphBuilder _resourceGraphBuilder; private readonly IdentifiableTypeCache _typeCache = new IdentifiableTypeCache(); + private readonly Dictionary> _resourceDescriptorsPerAssemblyCache = new Dictionary>(); - public ServiceDiscoveryFacade(IServiceCollection services, IResourceGraphBuilder resourceGraphBuilder) + public ServiceDiscoveryFacade(IServiceCollection services, ResourceGraphBuilder resourceGraphBuilder, ILoggerFactory loggerFactory) { + if (loggerFactory == null) + { + throw new ArgumentNullException(nameof(loggerFactory)); + } + + _logger = loggerFactory.CreateLogger(); _services = services ?? throw new ArgumentNullException(nameof(services)); _resourceGraphBuilder = resourceGraphBuilder ?? throw new ArgumentNullException(nameof(resourceGraphBuilder)); } - /// + /// + /// Mark the calling assembly for scanning of resources and injectables. + /// public ServiceDiscoveryFacade AddCurrentAssembly() => AddAssembly(Assembly.GetCallingAssembly()); - /// + /// + /// Mark the specified assembly for scanning of resources and injectables. + /// public ServiceDiscoveryFacade AddAssembly(Assembly assembly) { - if (assembly == null) throw new ArgumentNullException(nameof(assembly)); - - AddDbContextResolvers(assembly); - - var resourceDescriptors = _typeCache.GetIdentifiableTypes(assembly); - foreach (var resourceDescriptor in resourceDescriptors) + if (assembly == null) { - AddResource(assembly, resourceDescriptor); - AddServices(assembly, resourceDescriptor); - AddRepositories(assembly, resourceDescriptor); + throw new ArgumentNullException(nameof(assembly)); } + + _resourceDescriptorsPerAssemblyCache.Add(assembly, null); + _logger.LogDebug($"Registering assembly '{assembly.FullName}' for discovery of resources and injectables."); + return this; } + + internal void DiscoverResources() + { + foreach (var (assembly, discoveredResourceDescriptors) in _resourceDescriptorsPerAssemblyCache.ToArray()) + { + var resourceDescriptors = GetResourceDescriptorsFromCache(discoveredResourceDescriptors, assembly); + + foreach (var descriptor in resourceDescriptors) + { + AddResource(assembly, descriptor); + } + } + } + + internal void DiscoverInjectables() + { + foreach (var (assembly, discoveredResourceDescriptors) in _resourceDescriptorsPerAssemblyCache.ToArray()) + { + AddDbContextResolvers(assembly); + var resourceDescriptors = GetResourceDescriptorsFromCache(discoveredResourceDescriptors, assembly); + + foreach (var descriptor in resourceDescriptors) + { + AddResourceDefinition(assembly, descriptor); + AddServices(assembly, descriptor); + AddRepositories(assembly, descriptor); + } + } + } + private void AddDbContextResolvers(Assembly assembly) { var dbContextTypes = TypeLocator.GetDerivedTypes(assembly, typeof(DbContext)); @@ -85,15 +127,13 @@ private void AddDbContextResolvers(Assembly assembly) _services.AddScoped(typeof(IDbContextResolver), resolverType); } } - + private void AddResource(Assembly assembly, ResourceDescriptor resourceDescriptor) { - RegisterResourceDefinition(assembly, resourceDescriptor); - _resourceGraphBuilder.Add(resourceDescriptor.ResourceType, resourceDescriptor.IdType); } - private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) + private void AddResourceDefinition(Assembly assembly, ResourceDescriptor identifiable) { try { @@ -101,7 +141,9 @@ private void RegisterResourceDefinition(Assembly assembly, ResourceDescriptor id .SingleOrDefault(); if (resourceDefinition != null) + { _services.AddScoped(typeof(ResourceDefinition<>).MakeGenericType(identifiable.ResourceType), resourceDefinition); + } } catch (InvalidOperationException e) { @@ -132,12 +174,28 @@ private void RegisterServiceImplementations(Assembly assembly, Type interfaceTyp return; } var genericArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 ? new[] { resourceDescriptor.ResourceType, resourceDescriptor.IdType } : new[] { resourceDescriptor.ResourceType }; - var service = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); + var (implementation, registrationInterface) = TypeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); - if (service.implementation != null) + if (implementation != null) + { + _services.AddScoped(registrationInterface, implementation); + } + } + + private IList GetResourceDescriptorsFromCache(IList discoveredResourceDescriptors, Assembly assembly) + { + IList resourceDescriptors; + if (discoveredResourceDescriptors == null) { - _services.AddScoped(service.registrationInterface, service.implementation); + resourceDescriptors = (IList)_typeCache.GetIdentifiableTypes(assembly); + _resourceDescriptorsPerAssemblyCache[assembly] = resourceDescriptors; } + else + { + resourceDescriptors = discoveredResourceDescriptors; + } + + return resourceDescriptors; } } } diff --git a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 16685e7143..2c36df54bc 100644 --- a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -9,7 +9,6 @@ namespace JsonApiDotNetCore.Controllers /// /// Provides helper methods to raise json:api compliant errors from controller actions. /// - [ServiceFilter(typeof(IQueryStringActionFilter))] public abstract class CoreJsonApiController : ControllerBase { protected IActionResult Error(Error error) diff --git a/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs b/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs index 42159c9214..5edec07bb3 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs @@ -14,7 +14,7 @@ public ResourceTypeMismatchException(HttpMethod method, string requestPath, Reso : base(new Error(HttpStatusCode.Conflict) { Title = "Resource type mismatch between request body and endpoint URL.", - Detail = $"Expected resource of type '{expected.ResourceName}' in {method} request body at endpoint '{requestPath}', instead of '{actual?.ResourceName}'." + Detail = $"Expected resource of type '{expected.PublicName}' in {method} request body at endpoint '{requestPath}', instead of '{actual?.PublicName}'." }) { } diff --git a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs new file mode 100644 index 0000000000..d7c78a94a8 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; + +namespace JsonApiDotNetCore.Middleware +{ + /// + public sealed class AsyncConvertEmptyActionResultFilter : IAsyncConvertEmptyActionResultFilter + { + /// + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (next == null) throw new ArgumentNullException(nameof(next)); + + if (context.HttpContext.IsJsonApiRequest()) + { + if (!(context.Result is ObjectResult objectResult) || objectResult.Value == null) + { + if (context.Result is IStatusCodeActionResult statusCodeResult) + { + context.Result = new ObjectResult(null) {StatusCode = statusCodeResult.StatusCode}; + } + } + } + + await next(); + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs similarity index 69% rename from src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs rename to src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs index f389f2de69..0334f3a9eb 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs @@ -1,22 +1,22 @@ using System; +using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; namespace JsonApiDotNetCore.Middleware { - /// - /// Global exception filter that wraps any thrown error with a JsonApiException. - /// - public sealed class JsonApiExceptionFilter : ActionFilterAttribute, IExceptionFilter + /// + public class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter { private readonly IExceptionHandler _exceptionHandler; - public JsonApiExceptionFilter(IExceptionHandler exceptionHandler) + public AsyncJsonApiExceptionFilter(IExceptionHandler exceptionHandler) { _exceptionHandler = exceptionHandler ?? throw new ArgumentNullException(nameof(exceptionHandler)); } - public void OnException(ExceptionContext context) + /// + public Task OnExceptionAsync(ExceptionContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); @@ -29,6 +29,8 @@ public void OnException(ExceptionContext context) StatusCode = (int) errorDocument.GetErrorStatusCode() }; } + + return Task.CompletedTask; } } } diff --git a/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs similarity index 60% rename from src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs rename to src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs index 5ed52517f3..4bb9f88c32 100644 --- a/src/JsonApiDotNetCore/Middleware/QueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs @@ -7,23 +7,28 @@ namespace JsonApiDotNetCore.Middleware { - public sealed class QueryStringActionFilter : IAsyncActionFilter, IQueryStringActionFilter + /// + public sealed class AsyncQueryStringActionFilter : IAsyncQueryStringActionFilter { private readonly IQueryStringReader _queryStringReader; - - public QueryStringActionFilter(IQueryStringReader queryStringReader) + + public AsyncQueryStringActionFilter(IQueryStringReader queryStringReader) { _queryStringReader = queryStringReader ?? throw new ArgumentNullException(nameof(queryStringReader)); } + /// public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { if (context == null) throw new ArgumentNullException(nameof(context)); if (next == null) throw new ArgumentNullException(nameof(next)); - DisableQueryStringAttribute disableQueryStringAttribute = context.Controller.GetType().GetCustomAttribute(); + if (context.HttpContext.IsJsonApiRequest()) + { + var disableQueryStringAttribute = context.Controller.GetType().GetCustomAttribute(); + _queryStringReader.ReadAll(disableQueryStringAttribute); + } - _queryStringReader.ReadAll(disableQueryStringAttribute); await next(); } } diff --git a/src/JsonApiDotNetCore/Middleware/AsyncResourceTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncResourceTypeMatchFilter.cs new file mode 100644 index 0000000000..b42b0ef087 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/AsyncResourceTypeMatchFilter.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + public sealed class AsyncResourceTypeMatchFilter : IAsyncResourceTypeMatchFilter + { + private readonly IResourceContextProvider _provider; + + public AsyncResourceTypeMatchFilter(IResourceContextProvider provider) + { + _provider = provider; + } + + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (context == null) throw new ArgumentNullException(nameof(context)); + if (next == null) throw new ArgumentNullException(nameof(next)); + + if (context.HttpContext.IsJsonApiRequest() && IsPatchOrPostRequest(context.HttpContext.Request)) + { + var deserializedType = context.ActionArguments.FirstOrDefault().Value?.GetType(); + var targetType = context.ActionDescriptor.Parameters.FirstOrDefault()?.ParameterType; + + if (deserializedType != null && targetType != null && deserializedType != targetType) + { + var resourceFromEndpoint = _provider.GetResourceContext(targetType); + var resourceFromBody = _provider.GetResourceContext(deserializedType); + + throw new ResourceTypeMismatchException(new HttpMethod(context.HttpContext.Request.Method), context.HttpContext.Request.Path, + resourceFromEndpoint, resourceFromBody); + } + } + + await next(); + } + + private static bool IsPatchOrPostRequest(HttpRequest request) + { + return request.Method == "PATCH" || request.Method == "POST"; + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs deleted file mode 100644 index 4fbb3a644c..0000000000 --- a/src/JsonApiDotNetCore/Middleware/ConvertEmptyActionResultFilter.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.AspNetCore.Mvc.Infrastructure; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Transforms s without parameters for correct internal handling. - /// For example: return NotFound() -> return NotFound(null) - /// - public sealed class ConvertEmptyActionResultFilter : IAlwaysRunResultFilter - { - public void OnResultExecuted(ResultExecutedContext context) - { - } - - public void OnResultExecuting(ResultExecutingContext context) - { - if (context == null) throw new ArgumentNullException(nameof(context)); - - if (!context.HttpContext.IsJsonApiRequest()) - { - return; - } - - if (context.Result is ObjectResult objectResult && objectResult.Value != null) - { - return; - } - - // Convert action result without parameters into action result with null parameter. - // For example: return NotFound() -> return NotFound(null) - // This ensures our formatter is invoked, where we'll build a json:api compliant response. - // For details, see: https://github.com/dotnet/aspnetcore/issues/16969 - if (context.Result is IStatusCodeActionResult statusCodeResult) - { - context.Result = new ObjectResult(null) {StatusCode = statusCodeResult.StatusCode}; - } - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs new file mode 100644 index 0000000000..01466cf05e --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Converts action result without parameters into action result with null parameter. + /// For example: return NotFound() -> return NotFound(null) + /// This ensures our formatter is invoked, where we'll build a json:api compliant response. + /// For details, see: https://github.com/dotnet/aspnetcore/issues/16969 + /// + public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter { } +} diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs new file mode 100644 index 0000000000..dc78f44c4e --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Application-wide exception filter that invokes for json:api requests. + /// + public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter { } +} diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs new file mode 100644 index 0000000000..ecc94db010 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Application-wide entry point for processing json:api request query strings. + /// + public interface IAsyncQueryStringActionFilter : IAsyncActionFilter { } +} diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncResourceTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncResourceTypeMatchFilter.cs new file mode 100644 index 0000000000..405c7b30a1 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IAsyncResourceTypeMatchFilter.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Verifies the incoming resource type in json:api request body matches the resource type at the current endpoint URL. + /// + public interface IAsyncResourceTypeMatchFilter : IAsyncActionFilter { } +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs deleted file mode 100644 index 3343c9084e..0000000000 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiExceptionFilterProvider.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Provides the type of the global exception filter that is configured in MVC during startup. - /// This can be overridden to let JADNC use your own exception filter. The default exception filter used - /// is . - /// - public interface IJsonApiExceptionFilterProvider - { - Type Get(); - } -} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs new file mode 100644 index 0000000000..73027c3d2a --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Application-wide entry point for reading json:api request bodies. + /// + public interface IJsonApiInputFormatter : IInputFormatter { } +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs new file mode 100644 index 0000000000..0f2ed7c65d --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Application-wide entry point for writing json:api response bodies. + /// + public interface IJsonApiOutputFormatter : IOutputFormatter { } +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs deleted file mode 100644 index e889ad65d1..0000000000 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiTypeMatchFilterProvider.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Provides the type of the global action filter that is configured in MVC during startup. - /// This can be overridden to let JADNC use your own action filter. The default action filter used - /// is . - /// - public interface IJsonApiTypeMatchFilterProvider - { - Type Get(); - } -} diff --git a/src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs deleted file mode 100644 index d591165542..0000000000 --- a/src/JsonApiDotNetCore/Middleware/IQueryStringActionFilter.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Extensibility point for processing request query strings. - /// - public interface IQueryStringActionFilter - { - Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next); - } -} diff --git a/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs b/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs deleted file mode 100644 index 8ee36e7e6c..0000000000 --- a/src/JsonApiDotNetCore/Middleware/IncomingTypeMatchFilter.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq; -using System.Net.Http; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Action filter used to verify the incoming resource type matches the target type, else return a 409. - /// - public sealed class IncomingTypeMatchFilter : IActionFilter - { - private readonly IResourceContextProvider _provider; - - public IncomingTypeMatchFilter(IResourceContextProvider provider) - { - _provider = provider; - } - - public void OnActionExecuting(ActionExecutingContext context) - { - if (context == null) throw new ArgumentNullException(nameof(context)); - - if (!context.HttpContext.IsJsonApiRequest()) - { - return; - } - - var request = context.HttpContext.Request; - if (request.Method == "PATCH" || request.Method == "POST") - { - var deserializedType = context.ActionArguments.FirstOrDefault().Value?.GetType(); - var targetType = context.ActionDescriptor.Parameters.FirstOrDefault()?.ParameterType; - - if (deserializedType != null && targetType != null && deserializedType != targetType) - { - ResourceContext resourceFromEndpoint = _provider.GetResourceContext(targetType); - ResourceContext resourceFromBody = _provider.GetResourceContext(deserializedType); - - throw new ResourceTypeMismatchException(new HttpMethod(request.Method), request.Path, resourceFromEndpoint, resourceFromBody); - } - } - } - - public void OnActionExecuted(ActionExecutedContext context) { /* noop */ } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilterProvider.cs deleted file mode 100644 index 6289718f69..0000000000 --- a/src/JsonApiDotNetCore/Middleware/JsonApiExceptionFilterProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Middleware -{ - /// - public sealed class JsonApiExceptionFilterProvider : IJsonApiExceptionFilterProvider - { - public Type Get() => typeof(JsonApiExceptionFilter); - } -} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index 4db839cc19..044431b8a5 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -6,11 +6,10 @@ namespace JsonApiDotNetCore.Middleware { - /// - /// Extensibility point for reading incoming HTTP request. - /// - public sealed class JsonApiInputFormatter : IInputFormatter + /// + public sealed class JsonApiInputFormatter : IJsonApiInputFormatter { + /// public bool CanRead(InputFormatterContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); @@ -18,6 +17,7 @@ public bool CanRead(InputFormatterContext context) return context.HttpContext.IsJsonApiRequest(); } + /// public async Task ReadAsync(InputFormatterContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 853386a7e8..3cfca33693 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -165,7 +165,7 @@ private static void SetupRequest(JsonApiRequest request, ResourceContext primary request.Kind = EndpointKind.Primary; request.PrimaryResource = primaryResourceContext; request.PrimaryId = GetPrimaryRequestId(routeValues); - request.BasePath = GetBasePath(primaryResourceContext.ResourceName, options, httpRequest); + request.BasePath = GetBasePath(primaryResourceContext.PublicName, options, httpRequest); var relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); if (relationshipName != null) diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index 89ab8da53b..644e829538 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -6,11 +6,10 @@ namespace JsonApiDotNetCore.Middleware { - /// - /// Extensibility point for writing outgoing HTTP response. - /// - public sealed class JsonApiOutputFormatter : IOutputFormatter + /// + public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter { + /// public bool CanWriteResult(OutputFormatterCanWriteContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); @@ -18,6 +17,7 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) return context.HttpContext.IsJsonApiRequest(); } + /// public async Task WriteAsync(OutputFormatterWriteContext context) { if (context == null) throw new ArgumentNullException(nameof(context)); diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 0264c15797..81192b1c29 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -14,7 +14,7 @@ namespace JsonApiDotNetCore.Middleware { /// /// The default routing convention registers the name of the resource as the route - /// using the serializer casing convention. The default for this is + /// using the serializer naming convention. The default for this is /// a camel case formatter. If the controller directly inherits from and there is no /// resource directly associated, it uses the name of the controller instead of the name of the type. /// @@ -23,7 +23,7 @@ namespace JsonApiDotNetCore.Middleware /// /// public class RandomNameController : JsonApiController { } // => /someResources/relationship/relatedResource /// - /// // when using kebab-case casing convention: + /// // when using kebab-case naming convention: /// public class SomeResourceController : JsonApiController { } // => /some-resources/relationship/related-resource /// /// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource @@ -31,23 +31,29 @@ namespace JsonApiDotNetCore.Middleware public class JsonApiRoutingConvention : IJsonApiRoutingConvention { private readonly IJsonApiOptions _options; - private readonly ResourceNameFormatter _formatter; + private readonly IResourceGraph _resourceGraph; private readonly HashSet _registeredTemplates = new HashSet(); - private readonly Dictionary _registeredResources = new Dictionary(); - - public JsonApiRoutingConvention(IJsonApiOptions options) + + private readonly Dictionary _registeredResources = + new Dictionary(); + + public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph) { _options = options ?? throw new ArgumentNullException(nameof(options)); - _formatter = new ResourceNameFormatter(options); + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); } /// public Type GetAssociatedResource(string controllerName) { if (controllerName == null) throw new ArgumentNullException(nameof(controllerName)); - - _registeredResources.TryGetValue(controllerName, out Type type); - return type; + + if (_registeredResources.TryGetValue(controllerName, out var resourceContext)) + { + return resourceContext.ResourceType; + } + + return null; } /// @@ -57,24 +63,36 @@ public void Apply(ApplicationModel application) foreach (var controller in application.Controllers) { - var resourceType = GetResourceTypeFromController(controller.ControllerType); - + var resourceType = ExtractResourceTypeFromController(controller.ControllerType); + if (resourceType != null) - _registeredResources.Add(controller.ControllerName, resourceType); + { + var resourceContext = _resourceGraph.GetResourceContext(resourceType); + + if (resourceContext != null) + { + _registeredResources.Add(controller.ControllerName, resourceContext); + } + } if (!RoutingConventionDisabled(controller)) + { continue; + } var template = TemplateFromResource(controller) ?? TemplateFromController(controller); if (template == null) - throw new InvalidConfigurationException($"Controllers with overlapping route templates detected: {controller.ControllerType.FullName}"); + { + throw new InvalidConfigurationException( + $"Controllers with overlapping route templates detected: {controller.ControllerType.FullName}"); + } controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel { Template = template }; } } /// - /// Verifies if routing convention should be enabled for this controller + /// Verifies if routing convention should be enabled for this controller. /// private bool RoutingConventionDisabled(ControllerModel controller) { @@ -88,14 +106,15 @@ private bool RoutingConventionDisabled(ControllerModel controller) /// private string TemplateFromResource(ControllerModel model) { - if (_registeredResources.TryGetValue(model.ControllerName, out Type resourceType)) + if (_registeredResources.TryGetValue(model.ControllerName, out var resourceContext)) { - var template = $"{_options.Namespace}/{_formatter.FormatResourceName(resourceType)}"; + var template = $"{_options.Namespace}/{resourceContext.PublicName}"; if (_registeredTemplates.Add(template)) { return template; } } + return null; } @@ -104,23 +123,23 @@ private string TemplateFromResource(ControllerModel model) /// private string TemplateFromController(ControllerModel model) { - string controllerName = _options.SerializerContractResolver.NamingStrategy.GetPropertyName(model.ControllerName, false); + string controllerName = + _options.SerializerContractResolver.NamingStrategy.GetPropertyName(model.ControllerName, false); var template = $"{_options.Namespace}/{controllerName}"; + if (_registeredTemplates.Add(template)) { return template; } - else - { - return null; - } + + return null; } /// - /// Determines the resource associated to a controller by inspecting generic arguments. + /// Determines the resource associated to a controller by inspecting generic arguments in its inheritance tree. /// - private Type GetResourceTypeFromController(Type type) + private Type ExtractResourceTypeFromController(Type type) { var aspNetControllerType = typeof(ControllerBase); var coreControllerType = typeof(CoreJsonApiController); @@ -130,9 +149,11 @@ private Type GetResourceTypeFromController(Type type) { var nextBaseType = currentType.BaseType; - if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) + if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && + currentType.IsGenericType) { - var resourceType = currentType.GetGenericArguments().FirstOrDefault(t => TypeHelper.IsOrImplementsInterface(t, typeof(IIdentifiable))); + var resourceType = currentType.GetGenericArguments() + .FirstOrDefault(t => TypeHelper.IsOrImplementsInterface(t, typeof(IIdentifiable))); if (resourceType != null) { return resourceType; @@ -145,6 +166,7 @@ private Type GetResourceTypeFromController(Type type) break; } } + return currentType?.GetGenericArguments().First(); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiTypeMatchFilterProvider.cs b/src/JsonApiDotNetCore/Middleware/JsonApiTypeMatchFilterProvider.cs deleted file mode 100644 index f6cbb7a743..0000000000 --- a/src/JsonApiDotNetCore/Middleware/JsonApiTypeMatchFilterProvider.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Middleware -{ - /// - public class JsonApiTypeMatchFilterProvider : IJsonApiTypeMatchFilterProvider - { - public Type Get() => typeof(IncomingTypeMatchFilter); - } -} diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index aea73b3126..2d032b98d6 100644 --- a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; [assembly:InternalsVisibleTo("Benchmarks")] -[assembly: InternalsVisibleTo("IntegrationTests")] +[assembly:InternalsVisibleTo("IntegrationTests")] [assembly:InternalsVisibleTo("JsonApiDotNetCoreExampleTests")] [assembly:InternalsVisibleTo("UnitTests")] +[assembly:InternalsVisibleTo("DiscoveryTests")] diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs index a28236f0e1..863cc26b8c 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs @@ -180,8 +180,8 @@ public IReadOnlyCollection ResolveToOneChainEndingInAttr if (lastField is HasManyAttribute) { throw new QueryParseException(path == lastName - ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource '{resourceContext.ResourceName}'." - : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource '{resourceContext.ResourceName}'."); + ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource '{resourceContext.PublicName}'." + : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource '{resourceContext.PublicName}'."); } validateCallback?.Invoke(lastField, resourceContext, path); @@ -197,8 +197,8 @@ public RelationshipAttribute GetRelationship(string publicName, ResourceContext if (relationship == null) { throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' does not exist on resource '{resourceContext.ResourceName}'." - : $"Relationship '{publicName}' in '{path}' does not exist on resource '{resourceContext.ResourceName}'."); + ? $"Relationship '{publicName}' does not exist on resource '{resourceContext.PublicName}'." + : $"Relationship '{publicName}' in '{path}' does not exist on resource '{resourceContext.PublicName}'."); } return relationship; @@ -211,8 +211,8 @@ public RelationshipAttribute GetToManyRelationship(string publicName, ResourceCo if (!(relationship is HasManyAttribute)) { throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-many relationship on resource '{resourceContext.ResourceName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource '{resourceContext.ResourceName}'."); + ? $"Relationship '{publicName}' must be a to-many relationship on resource '{resourceContext.PublicName}'." + : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource '{resourceContext.PublicName}'."); } return relationship; @@ -225,8 +225,8 @@ public RelationshipAttribute GetToOneRelationship(string publicName, ResourceCon if (!(relationship is HasOneAttribute)) { throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-one relationship on resource '{resourceContext.ResourceName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource '{resourceContext.ResourceName}'."); + ? $"Relationship '{publicName}' must be a to-one relationship on resource '{resourceContext.PublicName}'." + : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource '{resourceContext.PublicName}'."); } return relationship; @@ -239,8 +239,8 @@ public AttrAttribute GetAttribute(string publicName, ResourceContext resourceCon if (attribute == null) { throw new QueryParseException(path == publicName - ? $"Attribute '{publicName}' does not exist on resource '{resourceContext.ResourceName}'." - : $"Attribute '{publicName}' in '{path}' does not exist on resource '{resourceContext.ResourceName}'."); + ? $"Attribute '{publicName}' does not exist on resource '{resourceContext.PublicName}'." + : $"Attribute '{publicName}' in '{path}' does not exist on resource '{resourceContext.PublicName}'."); } return attribute; @@ -253,8 +253,8 @@ public ResourceFieldAttribute GetField(string publicName, ResourceContext resour if (field == null) { throw new QueryParseException(path == publicName - ? $"Field '{publicName}' does not exist on resource '{resourceContext.ResourceName}'." - : $"Field '{publicName}' in '{path}' does not exist on resource '{resourceContext.ResourceName}'."); + ? $"Field '{publicName}' does not exist on resource '{resourceContext.PublicName}'." + : $"Field '{publicName}' in '{path}' does not exist on resource '{resourceContext.PublicName}'."); } return field; diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs index c6d2767ad6..d79b24b223 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs @@ -34,8 +34,8 @@ protected void ValidateSingleRelationship(RelationshipAttribute relationship, Re throw new InvalidQueryStringParameterException(_lastParameterName, "Including the requested relationship is not allowed.", path == relationship.PublicName - ? $"Including the relationship '{relationship.PublicName}' on '{resourceContext.ResourceName}' is not allowed." - : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceContext.ResourceName}' is not allowed."); + ? $"Including the relationship '{relationship.PublicName}' on '{resourceContext.PublicName}' is not allowed." + : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceContext.PublicName}' is not allowed."); } } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs index 7b8a3a6ad0..f3d92fd2a1 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs @@ -10,7 +10,7 @@ public sealed class ResourceAttribute : Attribute { /// /// The publicly exposed name of this resource type. - /// When not explicitly assigned, the configured casing convention is applied on the pluralized resource class name. + /// When not explicitly assigned, the configured naming convention is applied on the pluralized resource class name. /// public string PublicName { get; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs index d9688ae52e..2a5bafe980 100644 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs @@ -13,7 +13,7 @@ public abstract class ResourceFieldAttribute : Attribute /// /// The publicly exposed name of this json:api field. - /// When not explicitly assigned, the configured casing convention is applied on the property name. + /// When not explicitly assigned, the configured naming convention is applied on the property name. /// public string PublicName { diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index fe25ee0550..8ff303c40c 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -122,7 +122,7 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r private ResourceObject GetOrBuildResourceObject(IIdentifiable parent, RelationshipAttribute relationship) { var type = parent.GetType(); - var resourceName = ResourceContextProvider.GetResourceContext(type).ResourceName; + var resourceName = ResourceContextProvider.GetResourceContext(type).PublicName; var entry = _included.SingleOrDefault(ro => ro.Type == resourceName && ro.Id == parent.StringId); if (entry == null) { diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs index b3d58fe84c..02321695b8 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs @@ -93,7 +93,7 @@ private string GetSelfTopLevelLink(ResourceContext resourceContext) var builder = new StringBuilder(); builder.Append(_request.BasePath); builder.Append("/"); - builder.Append(resourceContext.ResourceName); + builder.Append(resourceContext.PublicName); string resourceId = _request.PrimaryId; if (resourceId != null) @@ -121,7 +121,7 @@ private string GetPageLink(ResourceContext resourceContext, int pageOffset, Page parameters["page[number]"] = pageOffset.ToString(); }); - return $"{_request.BasePath}/{resourceContext.ResourceName}" + queryString; + return $"{_request.BasePath}/{resourceContext.PublicName}" + queryString; } private string BuildQueryString(Action> updateAction) @@ -164,13 +164,13 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship RelationshipLinks links = null; if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Related)) { - links = new RelationshipLinks { Related = GetRelatedRelationshipLink(parentResourceContext.ResourceName, parent.StringId, childNavigation) }; + links = new RelationshipLinks { Related = GetRelatedRelationshipLink(parentResourceContext.PublicName, parent.StringId, childNavigation) }; } if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Self)) { links ??= new RelationshipLinks(); - links.Self = GetSelfRelationshipLink(parentResourceContext.ResourceName, parent.StringId, childNavigation); + links.Self = GetSelfRelationshipLink(parentResourceContext.PublicName, parent.StringId, childNavigation); } return links; diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index 31c9f2555d..bb4453d64e 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -30,7 +30,7 @@ public ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attr.Property.Name != nameof(Identifiable.Id)).ToArray()).Any()) @@ -105,7 +105,7 @@ private List GetRelatedResourceLinkageForHasMany(HasMa /// private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) { - var resourceName = ResourceContextProvider.GetResourceContext(resource.GetType()).ResourceName; + var resourceName = ResourceContextProvider.GetResourceContext(resource.GetType()).PublicName; return new ResourceIdentifierObject { Type = resourceName, diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 2a101f22c9..41e61b8452 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -353,7 +353,7 @@ private void AssertPrimaryResourceExists(TResource resource) { if (resource == null) { - throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.ResourceName); + throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.PublicName); } } @@ -362,7 +362,7 @@ private void AssertRelationshipExists(string relationshipName) var relationship = _request.Relationship; if (relationship == null) { - throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.ResourceName); + throw new RelationshipNotFoundException(relationshipName, _request.PrimaryResource.PublicName); } } diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 2d38877853..23063e4932 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -47,29 +47,34 @@ public ServiceDiscoveryFacadeTests() _resourceGraphBuilder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); } - private ServiceDiscoveryFacade Facade => new ServiceDiscoveryFacade(_services, _resourceGraphBuilder); - [Fact] - public void AddAssembly_Adds_All_Resources_To_Graph() + public void DiscoverResources_Adds_Resources_From_Added_Assembly_To_Graph() { - // Arrange, act - Facade.AddAssembly(typeof(Person).Assembly); + // Arrange + ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, NullLoggerFactory.Instance); + facade.AddAssembly(typeof(Person).Assembly); + // Act + facade.DiscoverResources(); + // Assert var resourceGraph = _resourceGraphBuilder.Build(); var personResource = resourceGraph.GetResourceContext(typeof(Person)); var articleResource = resourceGraph.GetResourceContext(typeof(Article)); - Assert.NotNull(personResource); Assert.NotNull(articleResource); } [Fact] - public void AddCurrentAssembly_Adds_Resources_To_Graph() + public void DiscoverResources_Adds_Resources_From_Current_Assembly_To_Graph() { - // Arrange, act - Facade.AddCurrentAssembly(); - + // Arrange + ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, NullLoggerFactory.Instance); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverResources(); + // Assert var resourceGraph = _resourceGraphBuilder.Build(); var testModelResource = resourceGraph.GetResourceContext(typeof(TestModel)); @@ -77,11 +82,15 @@ public void AddCurrentAssembly_Adds_Resources_To_Graph() } [Fact] - public void AddCurrentAssembly_Adds_Services_To_Container() + public void DiscoverInjectables_Adds_Resource_Services_From_Current_Assembly_To_Container() { - // Arrange, act - Facade.AddCurrentAssembly(); - + // Arrange + ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, NullLoggerFactory.Instance); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverInjectables(); + // Assert var services = _services.BuildServiceProvider(); var service = services.GetService>(); @@ -89,16 +98,34 @@ public void AddCurrentAssembly_Adds_Services_To_Container() } [Fact] - public void AddCurrentAssembly_Adds_Repositories_To_Container() + public void DiscoverInjectables_Adds_Resource_Repositories_From_Current_Assembly_To_Container() { - // Arrange, act - Facade.AddCurrentAssembly(); + // Arrange + ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, NullLoggerFactory.Instance); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverInjectables(); // Assert var services = _services.BuildServiceProvider(); Assert.IsType(services.GetService>()); } + [Fact] + public void AddCurrentAssembly_Adds_Resource_Definitions_From_Current_Assembly_To_Container() + { + // Arrange + ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, NullLoggerFactory.Instance); + facade.AddCurrentAssembly(); + + // Act + facade.DiscoverInjectables(); + + // Assert + var services = _services.BuildServiceProvider(); + Assert.IsType(services.GetService>()); + } public sealed class TestModel : Identifiable { } public class TestModelService : JsonApiResourceService @@ -133,5 +160,10 @@ public TestModelRepository( : base(targetedFields, _dbContextResolver, resourceGraph, genericServiceFactory, resourceFactory, constraintProviders, loggerFactory) { } } + + public class TestModelResourceDefinition : ResourceDefinition + { + public TestModelResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs index 11553edb90..397914ba7b 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs @@ -92,7 +92,7 @@ public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Delete // Act var response = await client.SendAsync(request); - + // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs index 870a2af9fa..18fdf77398 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs @@ -74,7 +74,7 @@ protected IResponseDeserializer GetDeserializer() { continue; } - builder.Add(rc.ResourceType, rc.IdentityType, rc.ResourceName); + builder.Add(rc.ResourceType, rc.IdentityType, rc.PublicName); } builder.Add(formatter.FormatResourceName(typeof(TodoItem))); builder.Add(formatter.FormatResourceName(typeof(TodoItemCollection))); diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index 22c9b9e927..fb28bf4227 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -61,7 +61,7 @@ public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() // Assert var resource = resourceGraph.GetResourceContext(typeof(TestResource)); - Assert.Equal("testResources", resource.ResourceName); + Assert.Equal("testResources", resource.PublicName); } [Fact] diff --git a/test/UnitTests/Builders/LinkBuilderTests.cs b/test/UnitTests/Builders/LinkBuilderTests.cs index 21f90d062b..8396a5c595 100644 --- a/test/UnitTests/Builders/LinkBuilderTests.cs +++ b/test/UnitTests/Builders/LinkBuilderTests.cs @@ -222,7 +222,7 @@ private ResourceContext GetArticleResourceContext(LinkTypes resourceLinks = Link ResourceLinks = resourceLinks, TopLevelLinks = topLevelLinks, RelationshipLinks = relationshipLinks, - ResourceName = "articles" + PublicName = "articles" }; } diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index c8dd510bf6..ccddd24f1f 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -76,7 +76,7 @@ public void RegisterResource_DeviatingDbContextPropertyName_RegistersCorrectly() var resourceContext = graph.GetResourceContext(); // Assert - Assert.Equal("authors", resourceContext.ResourceName); + Assert.Equal("authors", resourceContext.PublicName); } [Fact] @@ -152,7 +152,7 @@ public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified( var provider = services.BuildServiceProvider(); var resourceGraph = provider.GetService(); var resource = resourceGraph.GetResourceContext(typeof(IntResource)); - Assert.Equal("intResources", resource.ResourceName); + Assert.Equal("intResources", resource.PublicName); } public sealed class IntResource : Identifiable { } diff --git a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs index 75130f1b3f..5ab2fcd1e0 100644 --- a/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -152,7 +152,7 @@ private Mock CreateMockResourceGraph( string resourceName, bool var mockGraph = new Mock(); var resourceContext = new ResourceContext { - ResourceName = resourceName, + PublicName = resourceName, IdentityType = typeof(string) }; var seq = mockGraph.SetupSequence(d => d.GetResourceContext(It.IsAny())).Returns(resourceContext); @@ -160,7 +160,7 @@ private Mock CreateMockResourceGraph( string resourceName, bool { var relResourceContext = new ResourceContext { - ResourceName = "todoItems", + PublicName = "todoItems", IdentityType = typeof(string) }; seq.Returns(relResourceContext);