diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs index c46a9aa094..c4ce48fc75 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/CamelCasedModelsController.cs @@ -3,13 +3,10 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - [Route("[controller]")] - [DisableRoutingConvention] public class CamelCasedModelsController : JsonApiController { public CamelCasedModelsController( diff --git a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs index 5422cea8a1..21b3058233 100644 --- a/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Builders/ResourceGraphBuilder.cs @@ -26,7 +26,7 @@ public class ResourceGraphBuilder : IResourceGraphBuilder public ResourceGraphBuilder(IResourceNameFormatter formatter = null) { - _resourceNameFormatter = formatter ?? new DefaultResourceNameFormatter(); + _resourceNameFormatter = formatter ?? new KebabCaseFormatter(); } /// @@ -35,7 +35,7 @@ public IResourceGraph Build() _entities.ForEach(SetResourceLinksOptions); List controllerContexts = new List() { }; - foreach(var cm in _controllerMapper) + foreach (var cm in _controllerMapper) { var model = cm.Key; foreach (var controller in cm.Value) @@ -180,7 +180,7 @@ protected virtual List GetRelationships(Type entityType) // Article → ArticleTag.Tag hasManyThroughAttribute.RightProperty = throughProperties.SingleOrDefault(x => x.PropertyType == hasManyThroughAttribute.DependentType) ?? throw new JsonApiSetupException($"{hasManyThroughAttribute.ThroughType} does not contain a navigation property to type {hasManyThroughAttribute.DependentType}"); - + // ArticleTag.TagId var rightIdPropertyName = JsonApiOptions.RelatedIdMapper.GetRelatedIdPropertyName(hasManyThroughAttribute.RightProperty.Name); hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(x => x.Name == rightIdPropertyName) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 2b3e9bae21..631f7e2b63 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -10,7 +10,6 @@ namespace JsonApiDotNetCore.Configuration { - /// /// Global options /// @@ -29,12 +28,6 @@ public class JsonApiOptions : IJsonApiOptions /// public Link RelationshipLinks { get; set; } = Link.All; - - /// - /// Provides an interface for formatting resource names by convention - /// - public static IResourceNameFormatter ResourceNameFormatter { get; set; } = new DefaultResourceNameFormatter(); - /// /// Provides an interface for formatting relationship id properties given the navigation property name /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index e538f1cff9..b960e94a39 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -9,8 +9,6 @@ namespace JsonApiDotNetCore.Controllers { - - public class JsonApiController : BaseJsonApiController where T : class, IIdentifiable { /// diff --git a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs index 3ebe17467d..e7cbcce43d 100644 --- a/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -24,9 +24,7 @@ public static IApplicationBuilder UseJsonApi(this IApplicationBuilder app, bool app.UseMiddleware(); if (useMvc) - { app.UseMvc(); - } using (var scope = app.ApplicationServices.CreateScope()) { diff --git a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs index 4466e1ae30..85d82abb32 100644 --- a/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Extensions/IServiceCollectionExtensions.cs @@ -24,7 +24,7 @@ using JsonApiDotNetCore.Serialization.Server.Builders; using JsonApiDotNetCore.Serialization.Server; using JsonApiDotNetCore.Serialization.Client; -using JsonApiDotNetCore.Controllers; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace JsonApiDotNetCore.Extensions { @@ -32,7 +32,6 @@ namespace JsonApiDotNetCore.Extensions public static class IServiceCollectionExtensions { static private readonly Action _noopConfig = opt => { }; - static private JsonApiOptions _options { get { return new JsonApiOptions(); } } public static IServiceCollection AddJsonApi(this IServiceCollection services, IMvcCoreBuilder mvcBuilder = null) where TContext : DbContext @@ -48,25 +47,20 @@ public static IServiceCollection AddJsonApi(this IServiceCollection se /// /// public static IServiceCollection AddJsonApi(this IServiceCollection services, - Action configureAction, + Action configureOptions, IMvcCoreBuilder mvcBuilder = null) where TContext : DbContext { - var options = _options; + var options = new JsonApiOptions(); // add basic Mvc functionality mvcBuilder = mvcBuilder ?? services.AddMvcCore(); - // set standard options - configureAction(options); - + // configures JsonApiOptions; + configureOptions(options); // ResourceGraphBuilder should not be exposed on JsonApiOptions. // Instead, ResourceGraphBuilder should consume JsonApiOptions - // build the resource graph using ef core DbContext options.BuildResourceGraph(builder => builder.AddDbContext()); - - // add JsonApi fitlers and serializer - mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, options)); - + ConfigureMvc(services, mvcBuilder, options); // register services AddJsonApiInternals(services, options); return services; @@ -83,13 +77,12 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Action configureOptions, IMvcCoreBuilder mvcBuilder = null) { - var options = _options; + var options = new JsonApiOptions(); + // add basic Mvc functionality mvcBuilder = mvcBuilder ?? services.AddMvcCore(); + // configures JsonApiOptions; configureOptions(options); - - // add JsonApi fitlers and serializer - mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, options)); - + ConfigureMvc(services, mvcBuilder, options); // register services AddJsonApiInternals(services, options); return services; @@ -107,22 +100,29 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Action autoDiscover, IMvcCoreBuilder mvcBuilder = null) { - var options = _options; + var options = new JsonApiOptions(); + // add basic Mvc functionality mvcBuilder = mvcBuilder ?? services.AddMvcCore(); + // configures JsonApiOptions; configureOptions(options); - // build the resource graph using auto discovery. var facade = new ServiceDiscoveryFacade(services, options.ResourceGraphBuilder); autoDiscover(facade); - - // add JsonApi fitlers and serializer - mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, options)); - + ConfigureMvc(services, mvcBuilder, options); // register services AddJsonApiInternals(services, options); return services; } + private static void ConfigureMvc(IServiceCollection services, IMvcCoreBuilder mvcBuilder, JsonApiOptions options) + { + // add JsonApi filters and serializers + mvcBuilder.AddMvcOptions(opt => AddMvcOptions(opt, options)); + // register services that allow user to override behaviour that is configured on startup, like routing conventions + AddStartupConfigurationServices(services, options); + var intermediateProvider = services.BuildServiceProvider(); + mvcBuilder.AddMvcOptions(opt => opt.Conventions.Insert(0, intermediateProvider.GetRequiredService())); + } private static void AddMvcOptions(MvcOptions options, JsonApiOptions config) { @@ -143,11 +143,20 @@ public static void AddJsonApiInternals( AddJsonApiInternals(services, jsonApiOptions); } + /// + /// Adds services to the container that need to be retrieved with an intermediate provider during Startup. + /// + private static void AddStartupConfigurationServices(this IServiceCollection services, JsonApiOptions jsonApiOptions) + { + services.AddSingleton(jsonApiOptions); + services.TryAddSingleton(new KebabCaseFormatter()); + services.TryAddSingleton(); + } + public static void AddJsonApiInternals( this IServiceCollection services, JsonApiOptions jsonApiOptions) { - var graph = jsonApiOptions.ResourceGraph ?? jsonApiOptions.ResourceGraphBuilder.Build(); if (graph.UsesDbContext == false) @@ -183,14 +192,12 @@ public static void AddJsonApiInternals( services.AddScoped(typeof(IResourceService<>), typeof(EntityResourceService<>)); services.AddScoped(typeof(IResourceService<,>), typeof(EntityResourceService<,>)); - services.AddSingleton(jsonApiOptions); services.AddSingleton(jsonApiOptions); services.AddSingleton(graph); services.AddSingleton(); services.AddSingleton(graph); services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -273,7 +280,6 @@ public static void SerializeAsJsonApi(this MvcOptions options, JsonApiOptions js { options.InputFormatters.Insert(0, new JsonApiInputFormatter()); options.OutputFormatters.Insert(0, new JsonApiOutputFormatter()); - options.Conventions.Insert(0, new DasherizedRoutingConvention(jsonApiOptions.Namespace)); } /// diff --git a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs deleted file mode 100644 index 6feaab949d..0000000000 --- a/src/JsonApiDotNetCore/Graph/IResourceNameFormatter.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Linq; -using System.Reflection; -using Humanizer; -using JsonApiDotNetCore.Models; -using str = JsonApiDotNetCore.Extensions.StringExtensions; - - -namespace JsonApiDotNetCore.Graph -{ - /// - /// Provides an interface for formatting resource names by convention - /// - 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); - - /// - /// Aoplies the desired casing convention to the internal string. - /// This is generally applied to the type name after pluralization. - /// - string ApplyCasingConvention(string properName); - } - - public class DefaultResourceNameFormatter : IResourceNameFormatter - { - /// - /// Uses the internal type name to determine the external resource name. - /// By default we us Humanizer for pluralization and then we dasherize the name. - /// - /// - /// - /// _default.FormatResourceName(typeof(TodoItem)).Dump(); - /// // > "todo-items" - /// - /// - public string FormatResourceName(Type type) - { - try - { - // check the class definition first - // [Resource("models"] public class Model : Identifiable { /* ... */ } - if (type.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) - return attribute.ResourceName; - - return ApplyCasingConvention(type.Name.Pluralize()); - } - catch (InvalidOperationException e) - { - throw new InvalidOperationException($"Cannot define multiple {nameof(ResourceAttribute)}s on type '{type}'.", e); - } - } - - /// - /// Aoplies the desired casing convention to the internal string. - /// This is generally applied to the type name after pluralization. - /// - /// - /// - /// - /// _default.ApplyCasingConvention("TodoItems"); - /// // > "todo-items" - /// - /// _default.ApplyCasingConvention("TodoItem"); - /// // > "todo-item" - /// - /// - public string ApplyCasingConvention(string properName) => str.Dasherize(properName); - - /// - /// 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/ResourceNameFormatters/CamelCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs new file mode 100644 index 0000000000..7e9a4d1e27 --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/CamelCaseFormatter.cs @@ -0,0 +1,40 @@ +using str = JsonApiDotNetCore.Extensions.StringExtensions; + +namespace JsonApiDotNetCore.Graph +{ + /// + /// Uses kebab-case as formatting options in the route and request/response body. + /// + /// + /// + /// _default.FormatResourceName(typeof(TodoItem)).Dump(); + /// // > "todoItems" + /// + /// + /// + /// Given the following property: + /// + /// public string CompoundProperty { get; set; } + /// + /// The public attribute will be formatted like so: + /// + /// _default.FormatPropertyName(compoundProperty).Dump(); + /// // > "compoundProperty" + /// + /// + /// + /// + /// _default.ApplyCasingConvention("TodoItems"); + /// // > "todoItems" + /// + /// _default.ApplyCasingConvention("TodoItem"); + /// // > "todoItem" + /// + /// + public class CamelCaseFormatter: BaseResourceNameFormatter + { + /// + public override string ApplyCasingConvention(string properName) => str.Camelize(properName); + } +} + diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/IResourceNameFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/IResourceNameFormatter.cs new file mode 100644 index 0000000000..9de1a7c6a6 --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/IResourceNameFormatter.cs @@ -0,0 +1,27 @@ +using System; +using System.Reflection; + +namespace JsonApiDotNetCore.Graph +{ + /// + /// Provides an interface for formatting resource names by convention + /// + 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); + + /// + /// Aoplies the desired casing convention to the internal string. + /// This is generally applied to the type name after pluralization. + /// + string ApplyCasingConvention(string properName); + } +} diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs new file mode 100644 index 0000000000..22144a4769 --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/KebabCaseFormatter.cs @@ -0,0 +1,39 @@ +using str = JsonApiDotNetCore.Extensions.StringExtensions; + +namespace JsonApiDotNetCore.Graph +{ + /// + /// Uses kebab-case as formatting options in the route and request/response body. + /// + /// + /// + /// _default.FormatResourceName(typeof(TodoItem)).Dump(); + /// // > "todo-items" + /// + /// + /// + /// Given the following property: + /// + /// public string CompoundProperty { get; set; } + /// + /// The public attribute will be formatted like so: + /// + /// _default.FormatPropertyName(compoundProperty).Dump(); + /// // > "compound-property" + /// + /// + /// + /// + /// _default.ApplyCasingConvention("TodoItems"); + /// // > "todo-items" + /// + /// _default.ApplyCasingConvention("TodoItem"); + /// // > "todo-item" + /// + /// + public class KebabCaseFormatter : BaseResourceNameFormatter + { + /// + public override string ApplyCasingConvention(string properName) => str.Dasherize(properName); + } +} diff --git a/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/ResourceNameFormatterBase.cs b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/ResourceNameFormatterBase.cs new file mode 100644 index 0000000000..319824041d --- /dev/null +++ b/src/JsonApiDotNetCore/Graph/ResourceNameFormatters/ResourceNameFormatterBase.cs @@ -0,0 +1,42 @@ +using System; +using System.Reflection; +using Humanizer; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCore.Graph +{ + public abstract class BaseResourceNameFormatter : IResourceNameFormatter + { + /// + /// Uses the internal type name to determine the external resource name. + /// + public string FormatResourceName(Type type) + { + try + { + // check the class definition first + // [Resource("models"] public class Model : Identifiable { /* ... */ } + if (type.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute) + return attribute.ResourceName; + + return ApplyCasingConvention(type.Name.Pluralize()); + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException($"Cannot define multiple {nameof(ResourceAttribute)}s on type '{type}'.", e); + } + } + + /// + /// Aoplies the desired casing convention to the internal string. + /// This is generally applied to the type name after pluralization. + /// + public abstract string ApplyCasingConvention(string properName); + + /// + /// Uses the internal PropertyInfo to determine the external resource name. + /// By default the name will be formatted to kebab-case. + /// + public string FormatPropertyName(PropertyInfo property) => ApplyCasingConvention(property.Name); + } +} diff --git a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs index e12d01909b..468537ec1a 100644 --- a/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Graph/ServiceDiscoveryFacade.cs @@ -179,7 +179,7 @@ private void AddResourceToGraph(ResourceDescriptor identifiable) } private string FormatResourceName(Type resourceType) - => JsonApiOptions.ResourceNameFormatter.FormatResourceName(resourceType); + => new KebabCaseFormatter().FormatResourceName(resourceType); /// /// Add implementations to container. diff --git a/src/JsonApiDotNetCore/Internal/CamelizedRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/CamelizedRoutingConvention.cs deleted file mode 100644 index edb7e2444a..0000000000 --- a/src/JsonApiDotNetCore/Internal/CamelizedRoutingConvention.cs +++ /dev/null @@ -1,40 +0,0 @@ -// REF: https://github.com/aspnet/Entropy/blob/dev/samples/Mvc.CustomRoutingConvention/NameSpaceRoutingConvention.cs -// REF: https://github.com/aspnet/Mvc/issues/5691 -using System.Reflection; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Extensions; -using Microsoft.AspNetCore.Mvc.ApplicationModels; - -namespace JsonApiDotNetCore.Internal -{ - public class DasherizedRoutingConvention : IApplicationModelConvention - { - private readonly string _namespace; - public DasherizedRoutingConvention(string nspace) - { - _namespace = nspace; - } - - public void Apply(ApplicationModel application) - { - foreach (var controller in application.Controllers) - { - if (IsDasherizedJsonApiController(controller) == false) - continue; - - var template = $"{_namespace}/{controller.ControllerName.Dasherize()}"; - controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel - { - Template = template - }; - } - } - - private bool IsDasherizedJsonApiController(ControllerModel controller) - { - var type = controller.ControllerType; - var notDisabled = type.GetCustomAttribute() == null; - return notDisabled && type.IsSubclassOf(typeof(JsonApiControllerMixin)); - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/DasherizedRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/DasherizedRoutingConvention.cs deleted file mode 100644 index 21033838d4..0000000000 --- a/src/JsonApiDotNetCore/Internal/DasherizedRoutingConvention.cs +++ /dev/null @@ -1,40 +0,0 @@ -// REF: https://github.com/aspnet/Entropy/blob/dev/samples/Mvc.CustomRoutingConvention/NameSpaceRoutingConvention.cs -// REF: https://github.com/aspnet/Mvc/issues/5691 -using System.Reflection; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Extensions; -using Microsoft.AspNetCore.Mvc.ApplicationModels; - -namespace JsonApiDotNetCore.Internal -{ - public class CamelizedRoutingConvention : IApplicationModelConvention - { - private readonly string _namespace; - public CamelizedRoutingConvention(string nspace) - { - _namespace = nspace; - } - - public void Apply(ApplicationModel application) - { - foreach (var controller in application.Controllers) - { - if (IsCamelizedJsonApiController(controller) == false) - continue; - - var template = $"{_namespace}/{controller.ControllerName.Camelize()}"; - controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel - { - Template = template - }; - } - } - - private bool IsCamelizedJsonApiController(ControllerModel controller) - { - var type = controller.ControllerType; - var notDisabled = type.GetCustomAttribute() == null; - return notDisabled && type.IsSubclassOf(typeof(JsonApiControllerMixin)); - } - } -} diff --git a/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs new file mode 100644 index 0000000000..3335ca87aa --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/DefaultRoutingConvention.cs @@ -0,0 +1,113 @@ +// REF: https://github.com/aspnet/Entropy/blob/dev/samples/Mvc.CustomRoutingConvention/NameSpaceRoutingConvention.cs +// REF: https://github.com/aspnet/Mvc/issues/5691 +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Graph; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace JsonApiDotNetCore.Internal +{ + /// + /// The default routing convention registers the name of the resource as the route + /// using the that is registered. The default for this is + /// a kebab-case formatter. If the controller directly inherits from JsonApiMixin and there is no + /// resource directly associated, it used the name of the controller instead of the name of the type. + /// + /// + /// public class SomeResourceController: JsonApiController{SomeResource} { } + /// // => /some-resources/relationship/related-resource + /// + /// public class RandomNameController{SomeResource} : JsonApiController{SomeResource} { } + /// // => /some-resources/relationship/related-resource + /// + /// // when using the camelCase formatter: + /// public class SomeResourceController{SomeResource} : JsonApiController{SomeResource} { } + /// // => /someResources/relationship/relatedResource + /// + /// // when inheriting from JsonApiMixin formatter: + /// public class SomeVeryCustomController{SomeResource} : JsonApiMixin { } + /// // => /some-very-customs/relationship/related-resource + /// + public class DefaultRoutingConvention : IJsonApiRoutingConvention + { + private readonly string _namespace; + private readonly IResourceNameFormatter _formatter; + private readonly HashSet _registeredTemplates = new HashSet(); + public DefaultRoutingConvention(IJsonApiOptions options, IResourceNameFormatter formatter) + { + _namespace = options.Namespace; + _formatter = formatter; + } + + /// + public void Apply(ApplicationModel application) + { + foreach (var controller in application.Controllers) + { + if (RoutingConventionDisabled(controller) == false) + continue; + + var template = TemplateFromResource(controller) ?? TemplateFromController(controller); + if (template == null) + throw new JsonApiSetupException($"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 + /// + private bool RoutingConventionDisabled(ControllerModel controller) + { + var type = controller.ControllerType; + var notDisabled = type.GetCustomAttribute() == null; + return notDisabled && type.IsSubclassOf(typeof(JsonApiControllerMixin)); + } + + /// + /// Derives a template from the resource type, and checks if this template was already registered. + /// + private string TemplateFromResource(ControllerModel model) + { + var resourceType = GetResourceTypeFromController(model.ControllerType); + if (resourceType != null) + { + var template = $"{_namespace}/{_formatter.FormatResourceName(resourceType)}"; + if (_registeredTemplates.Add(template)) + return template; + } + return null; + } + + /// + /// Derives a template from the controller name, and checks if this template was already registered. + /// + private string TemplateFromController(ControllerModel model) + { + var template = $"{_namespace}/{_formatter.ApplyCasingConvention(model.ControllerName)}"; + if (_registeredTemplates.Add(template)) + return template; + return null; + } + + /// + /// Determines the resource associated to a controller by inspecting generic arguments. + /// + private Type GetResourceTypeFromController(Type type) + { + var target = typeof(BaseJsonApiController<,>); + var currentBaseType = type.BaseType; + while (!currentBaseType.IsGenericType || currentBaseType.GetGenericTypeDefinition() != target) + { + currentBaseType = currentBaseType.BaseType; + if (currentBaseType == null) break; + } + return currentBaseType?.GetGenericArguments().First(); + } + } +} diff --git a/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs new file mode 100644 index 0000000000..aba03b806b --- /dev/null +++ b/src/JsonApiDotNetCore/Internal/IJsonApiRoutingConvention.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace JsonApiDotNetCore.Internal +{ + /// + /// Service for specifying which routing convention to use. This can be overriden to customize + /// the relation between controllers and mapped routes. + /// + public interface IJsonApiRoutingConvention : IApplicationModelConvention { } +} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 1ae5427196..00ecf71759 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -44,4 +44,7 @@ + + + diff --git a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs index 8d271d5327..fe2badc807 100644 --- a/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Server/Builders/LinkBuilder.cs @@ -10,9 +10,9 @@ namespace JsonApiDotNetCore.Serialization.Server.Builders { public class LinkBuilder : ILinkBuilder { - private readonly ICurrentRequest _currentRequest; - private readonly ILinksConfiguration _options; private readonly IContextEntityProvider _provider; + private readonly ILinksConfiguration _options; + private readonly ICurrentRequest _currentRequest; private readonly IPageService _pageService; public LinkBuilder(ILinksConfiguration options, diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/CamelCasedModelsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/CamelCasedModelsControllerTests.cs index 6adb3bce07..8399ff36ed 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/CamelCasedModelsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/CamelCasedModelsControllerTests.cs @@ -38,7 +38,7 @@ public async Task Can_Get_CamelCasedModels() _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); - var route = "/camelCasedModels"; + var route = "api/v1/camelCasedModels"; var builder = new WebHostBuilder() .UseStartup(); var server = new TestServer(builder); @@ -65,7 +65,7 @@ public async Task Can_Get_CamelCasedModels_ById() _context.SaveChanges(); var httpMethod = new HttpMethod("GET"); - var route = $"/camelCasedModels/{model.Id}"; + var route = $"api/v1/camelCasedModels/{model.Id}"; var builder = new WebHostBuilder() .UseStartup(); var server = new TestServer(builder); @@ -100,7 +100,7 @@ public async Task Can_Post_CamelCasedModels() } }; var httpMethod = new HttpMethod("POST"); - var route = $"/camelCasedModels"; + var route = $"api/v1/camelCasedModels"; var builder = new WebHostBuilder() .UseStartup(); var server = new TestServer(builder); @@ -144,7 +144,7 @@ public async Task Can_Patch_CamelCasedModels() } }; var httpMethod = new HttpMethod("PATCH"); - var route = $"/camelCasedModels/{model.Id}"; + var route = $"api/v1/camelCasedModels/{model.Id}"; var builder = new WebHostBuilder().UseStartup(); var server = new TestServer(builder); var client = server.CreateClient(); diff --git a/test/JsonApiDotNetCoreExampleTests/CamelCaseTestStartup.cs b/test/JsonApiDotNetCoreExampleTests/CamelCaseTestStartup.cs new file mode 100644 index 0000000000..3a092e5e1a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/CamelCaseTestStartup.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Graph; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using System; +using UnitTests; + +namespace JsonApiDotNetCoreExampleTests +{ + public class CamelCaseTestStartup : Startup + { + public CamelCaseTestStartup(IHostingEnvironment env) : base(env) + { } + + public override IServiceProvider ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + base.ConfigureServices(services); + services.AddClientSerialization(); + services.AddScoped(); + return services.BuildServiceProvider(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/TestStartup.cs b/test/JsonApiDotNetCoreExampleTests/TestStartup.cs index 730d5f653b..1ea3c03f55 100644 --- a/test/JsonApiDotNetCoreExampleTests/TestStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/TestStartup.cs @@ -21,4 +21,4 @@ public override IServiceProvider ConfigureServices(IServiceCollection services) return services.BuildServiceProvider(); } } -} +} \ No newline at end of file diff --git a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs index 186530197a..bb2e2173fc 100644 --- a/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs +++ b/test/UnitTests/Builders/ContextGraphBuilder_Tests.cs @@ -4,10 +4,8 @@ using System.Reflection; using Humanizer; using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Extensions; using JsonApiDotNetCore.Graph; -using JsonApiDotNetCore.Internal; using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Models; using Microsoft.EntityFrameworkCore; @@ -18,15 +16,11 @@ namespace UnitTests { public class ResourceGraphBuilder_Tests { - class NonDbResource : Identifiable {} - class DbResource : Identifiable {} - class TestContext : DbContext { - public DbSet DbResources { get; set; } - } - - public ResourceGraphBuilder_Tests() + class NonDbResource : Identifiable { } + class DbResource : Identifiable { } + class TestContext : DbContext { - JsonApiOptions.ResourceNameFormatter = new DefaultResourceNameFormatter(); + public DbSet DbResources { get; set; } } [Fact] @@ -34,8 +28,10 @@ public void Can_Build_ResourceGraph_Using_Builder() { // arrange var services = new ServiceCollection(); - services.AddJsonApi(opt => { - opt.BuildResourceGraph(b => { + services.AddJsonApi(opt => + { + opt.BuildResourceGraph(b => + { b.AddResource("non-db-resources"); }); }); @@ -128,7 +124,8 @@ public void Relationships_Without_Names_Specified_Will_Use_Default_Formatter() Assert.Equal("related-resources", resource.Relationships.Single(r => r.IsHasMany).PublicRelationshipName); } - public class TestResource : Identifiable { + public class TestResource : Identifiable + { [Attr] public string CompoundAttribute { get; set; } [HasOne] public RelatedResource RelatedResource { get; set; } [HasMany] public List RelatedResources { get; set; } diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index a3d8801293..c0bfc9aee0 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -30,6 +30,7 @@ public void AddJsonApiInternals_Adds_All_Required_Services() // arrange var services = new ServiceCollection(); var jsonApiOptions = new JsonApiOptions(); + services.AddSingleton(jsonApiOptions); services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb"), ServiceLifetime.Transient); services.AddScoped>(); @@ -49,7 +50,6 @@ public void AddJsonApiInternals_Adds_All_Required_Services() Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService(typeof(IEntityRepository))); - Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService()); Assert.NotNull(provider.GetService>());