I'm currently porting an application from ASP.NET 4 to ASP.NET Core. I want to use attribute based routing while having the ability to localize the URLs.
The legacy application was using an approach using a custom IDirectRouteProvider. Since I didn't find the corresponding type in ASP.NET Core, I went with a solution inspired by https://www.strathweb.com/2015/11/localized-routes-with-asp-net-5-and-mvc-6/. Here's the implementation using an IApplicationModelConvention
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(o =>
{
o.Conventions.Insert(0, new LocalizedRouteConvention());
})
.SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseRequestLocalization(new RequestLocalizationOptions { ... });
app.UseMvc();
}
}
public class LocalizedRouteConvention : IApplicationModelConvention
{
public void Apply(ApplicationModel application)
{
foreach (var controller in application.Controllers)
{
foreach (var action in controller.Actions)
{
var attributes = action.Attributes.OfType<RouteAttribute>().ToArray();
if (!attributes.Any()) return;
foreach (var attribute in attributes)
{
SelectorModel defaultSelector = action.Selectors.First();
foreach (var localizedVersion in GetLocalized(attribute.Template))
{
if (!action.Selectors.Any(s => s.AttributeRouteModel.Template == localizedVersion.Template))
{
action.Selectors.Insert(0, new SelectorModel(defaultSelector)
{
AttributeRouteModel = localizedVersion,
ActionConstraints =
{
new CultureActionConstraint { Culture = ((LocalizedRouteAttribute) localizedVersion.Attribute).Culture }
}
});
}
}
}
}
}
}
}
public class LocalizedRouteAttribute : RouteAttribute
{
public LocalizedRouteAttribute(string template) : base(template)
{
}
public string Culture { get; set; }
}
public class CultureActionConstraint : IActionConstraint
{
public string Culture { get; set; }
public int Order => 0;
public bool Accept(ActionConstraintContext context)
{
return CultureInfo.CurrentCulture.TwoLetterISOLanguageName == Culture;
}
}
Now, this approach works and the localized routes are only available when the correct request culture is set. However, when I use Html.ActionLink(...) or any other function that uses IUrlHelper.GetVirtualPathData(), the default route is returned instead of the localized one.
As far as I understand, the IUrlHelper will check the IRouteConstraints of a Route but it doesn't seem to respect the IActionConstraint. Unfortunately, I haven't found a way to set custom IRouteConstraints in my IApplicationModelConvention.
Related
We are using ServiceStack for our .NET backend and I am trying to work on getting unit testing into the project. However there are some automated tools within ServiceStack that makes it a bit complicated to isolate the units so I could really use some advice. In the example below I would like to unit test a simple service that basically does the following:
Takes a request DTO
Passes the DTO to the repository
Gets back a domain model
If the model exists, it maps it to a responseDTO using Automapper and returns it as a part of an IHTTPResult
So the problem I have is that it seems like Automapper is automatically added to the ServiceStack application and in the application the mapper are registered by just calling:
AutoMapping.RegisterConverter().
So how could I inject this into the service to be able to do the unittest?
Example test:
using AutoMapper;
using FluentAssertions;
using NSubstitute;
namespace Api.Services.Tests.Unit;
public class OrderApiServiceTests
{
private readonly OrderApiService _sut;
private readonly IOrderApiRepository accountApiRepository = Substitute.For<IOrderApiRepository>();
public OrderApiServiceTests()
{
_sut = new OrderApiRepository(orderApiRepository);
var config = new MapperConfiguration(cfg => ApiDtoMapping.Register());
var mapper = config.CreateMapper();
}
[Fact]
public async Task Get_ShouldReturnAccount_WhenAccountExistsAsync()
{
// Arrange
var order = new Order
{
Name = "MyOrder",
Value = 1000,
};
var expectedResponse = new OrderApiDto
{
Name = "MyOrder",
Value = 1000,
};
orderApiRepository.GetAsync(Arg.Any<GetOrder>()).Returns(order);
// Act
var result = await _sut.Get(new GetOrder());
// Assert
result.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
result.Response.Should().BeEquivalentTo(expectedResponse);
}
}
Added a full example including all files:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
app.UseHttpsRedirection();
}
app.UseServiceStack(new AppHost());
app.Run();
// Configure.AppHost.cs
using Funq;
using ssUnitTests.ServiceInterface;
[assembly: HostingStartup(typeof(ssUnitTests.AppHost))]
namespace ssUnitTests;
public class AppHost : AppHostBase, IHostingStartup
{
public void Configure(IWebHostBuilder builder) => builder
.ConfigureServices(services =>
{
});
public AppHost() : base("ssUnitTests", typeof(MyServices).Assembly) { }
public override void Configure(Container container)
{
container.RegisterAutoWiredAs<OrderRepository, IOrderRepository>().ReusedWithin(ReuseScope.None);
// Configure ServiceStack only IOC, Config & Plugins
SetConfig(new HostConfig
{
UseSameSiteCookies = true,
});
Mappings.RegisterConverters();
}
}
// Mappings.cs
using ssUnitTests.ServiceModel;
namespace ssUnitTests;
public static class Mappings
{
public static void RegisterConverters()
{
AutoMapping.RegisterConverter((Order from) =>
{
var to = from.ConvertTo<OrderDto>();
to.DtoProperty = from.BaseProperty + "Dto";
return to;
});
}
}
// IOrderRepository.cs
using ssUnitTests.ServiceModel;
namespace ssUnitTests.ServiceInterface;
public interface IOrderRepository
{
Order GetOrder();
}
// Order.cs
namespace ssUnitTests.ServiceModel;
public class Order
{
public string Name { get; set; }
public string BaseProperty { get; set; }
}
// OrderDto.cs
namespace ssUnitTests.ServiceModel;
public class OrderDto
{
public string Name { get; set; }
public string DtoProperty { get; set; }
}
// OrderRequest.cs
using ServiceStack;
namespace ssUnitTests.ServiceModel;
[Route("/order")]
public class OrderRequest : IReturn<OrderDto>
{
public int Id { get; set; }
}
// UnitTest.cs
using NSubstitute;
using NUnit.Framework;
using ssUnitTests.ServiceInterface;
using ssUnitTests.ServiceModel;
namespace ssUnitTests.Tests;
public class UnitTest
{
private readonly MyServices _sut;
private readonly IOrderRepository _repository = Substitute.For<IOrderRepository>();
public UnitTest()
{
_sut = new MyServices(_repository);
}
[Test]
public void Get_ShouldReturn_OrderDto()
{
var order = new Order
{
Name = "MyName",
BaseProperty = "MyBaseProperty"
};
_repository.GetOrder().Returns(order);
var response = (OrderDto)_sut.Any(new OrderRequest { Id = 1 });
Assert.That(response.Name.Equals(order.Name));
Assert.That(response.DtoProperty.Equals(order.BaseProperty + "Dto"));
}
}
ServiceStack.dll does not have any dependencies to any 3rd Party Libraries, e.g. it's built-in AutoMapping is a completely different stand-alone implementation to AutoMapper.
If you're using AutoMapper you can ignore ServiceStack's AutoMapping which is completely unrelated.
I have tried the solution here: How to properly set up snake case JSON for dotnet core api? but seems to be this is only for request/response payloads. Is there a way to force snake_case format on my [FromQuery] parameters?
Currently this is what I have as of the moment
What you did is about json serialization in asp.net core but what you want is to change the Swagger UI. They are the quite different things.
The first way, you can add attribute like: [FromQuery(Name = "account_number")].
Or you can custom an attribute to avoid writing snake_case:
public class CustomFromQueryAttribute : FromQueryAttribute
{
public CustomFromQueryAttribute(string name)
{
Name = name.ToSnakeCase();
}
}
Custom ToSnakeCase for string extension:
public static class StringExtensions
{
public static string ToSnakeCase(this string o) =>
Regex.Replace(o, #"(\w)([A-Z])", "$1_$2").ToLower();
}
Usage:
public voidGet([CustomFromQuery("AccountNumber")]string AccountNumber)
Note:
Actually this way equals to using public voidGet(string account_number), so the Swagger UI changed.
The second way, you can custom IOperationFilter like below to change the Swagger UI:
public class SnakecasingParameOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
if (operation.Parameters == null) operation.Parameters = new List<OpenApiParameter>();
else {
foreach(var item in operation.Parameters)
{
item.Name = item.Name.ToSnakeCase();
}
}
}
}
Register the service like below:
services.AddSwaggerGen(c =>
{
c.OperationFilter<SnakecasingParameOperationFilter>();
c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebApi5_0", Version = "v1" });
});
But this way change the request query string to snake case which has an extra _, It does not match the backend parameter. So you also need to custom value provider that looks for snake cased query parameters:
SnakeCaseQueryValueProvider:
public class SnakeCaseQueryValueProvider : QueryStringValueProvider, IValueProvider
{
public SnakeCaseQueryValueProvider(
BindingSource bindingSource,
IQueryCollection values,
CultureInfo culture)
: base(bindingSource, values, culture)
{
}
public override bool ContainsPrefix(string prefix)
{
return base.ContainsPrefix(prefix.ToSnakeCase());
}
public override ValueProviderResult GetValue(string key)
{
return base.GetValue(key.ToSnakeCase());
}
}
We also have to implement a factory for our value provider:
public class SnakeCaseQueryValueProviderFactory : IValueProviderFactory
{
public Task CreateValueProviderAsync(ValueProviderFactoryContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var valueProvider = new SnakeCaseQueryValueProvider(
BindingSource.Query,
context.ActionContext.HttpContext.Request.Query,
CultureInfo.CurrentCulture);
context.ValueProviders.Add(valueProvider);
return Task.CompletedTask;
}
}
Register the services:
services.AddControllers(options =>
{
options.ValueProviderFactories.Add(new SnakeCaseQueryValueProviderFactory()); //add this...
}).AddNewtonsoftJson(options =>
{
options.SerializerSettings.ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
};
});
Summary:
Imagine we are calling the url http://localhost:5600/Student/All/
My goal is that my Asp .NET Core Web Api compiles a Student controller on the fly when that route is called and then uses that controller for answering that request.
Detailed:
I want to compile an Asp .NET Core Controller when analyzing the route. So I'm using a DynamicRouteValueTransfer to analyze the route and modify it when needed.
app.UseEndpoints(endpoints =>
{
//...
endpoints.MapDynamicControllerRoute<SearchValueTransformer>("{controller}/{action}/{**params}");
//...
In the SearchValue Transfer (inherited from DynamicRouteValueTransfer) I can analyze and rewrite the route. There I'm compiling an Controller with a Get method which returns a list of Students (with an OData - [EnableQuery] - attribute because in the end I want to provide any data for OData access).
Then I try to add this newly compiled StudentController to the Controllers to be used in that request. But that is not working.
I tried saving the IMvcBuilder which I get at startup at the ConfigureServices:
//inside ConfigureServices - MvcManager is my static class
MvcManager.Builder = services.AddMvc();
//in my static class
public static IMvcBuilder Builder { get; set; }
After compiling the Controller I tried to add that Assembly by calling
MvcManager.Builder.AddApplicationPart(assembly).AddControllersAsServices();
The problem is that I can't go into that controller. I found some articles about dynamically adding but none is doing it while the request is already started.
After some search I finally found the optimal solution to create controllers at runtime based on the request path coming in.
Here is the solution - you have to use a middleware. In the middleware you can compile according to the request route and add it as AssemblyPart to your current assembly. Example:
The request
http://localhost:56002/Account/getdata
comes in. You can get the "/Account/getdata" in your middleware and compile an account controller with a get method with name getdata inside. The middleware compiles the controller, add it as AssemblyPart to the currently running assembly and after the middleware has finished the controller can directly be used.
First you have to add a middleware. There you can
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
//...
app.UseMiddleware<MyMiddleware>();
The middleware can look like that:
public class MyMiddleware
{
public MyMiddleware(RequestDelegate requestDel, ControllerGenerator generator)
{
RequestDel = requestDel;
Generator = generator;
}
public RequestDelegate RequestDel { get; }
public ControllerGenerator Generator { get; }
public async Task Invoke(HttpContext context)
{
if (context.Request.Path.HasValue)
{
var queryParams = context.Request.Path.Value;
var entityName = queryParams.Split("/").Where(s => !string.IsNullOrEmpty(s)).FirstOrDefault();
var result = Generator.AppendController(entityName);
Console.WriteLine(result + ", " + queryParams);
}
await RequestDel.Invoke(context);
}
}
The controller generator looks like that in my case. The important thing here is the ApplicationPartManager which can be used to add the assembly (part) to the current assembly.
public class ControllerGenerator
{
private readonly ApplicationPartManager _partManager;
private readonly IHostingEnvironment _hostingEnvironment;
public ControllerGenerator(
ApplicationPartManager partManager,
IHostingEnvironment env)
{
_partManager = partManager;
_hostingEnvironment = env;
}
public bool AppendController(string className)
{
var generator = new WebApiGenerator();
Assembly assembly = generator.Exists(className) ?
generator.GetAssembly(className) : generator.CreateDll(className);
if (assembly != null)
{
_partManager.ApplicationParts.Add(new AssemblyPart(assembly));
// Notify change
MyActionDescriptorChangeProvider.Instance.HasChanged = true;
MyActionDescriptorChangeProvider.Instance.TokenSource.Cancel();
return true;
}
return false;
}
}
The ActionDescriptorChangeProvider is used to inform the framework that something has changed and has to be reloaded.
public class MyActionDescriptorChangeProvider : IActionDescriptorChangeProvider
{
public static MyActionDescriptorChangeProvider Instance { get; } = new MyActionDescriptorChangeProvider();
public CancellationTokenSource TokenSource { get; private set; }
public bool HasChanged { get; set; }
public IChangeToken GetChangeToken()
{
TokenSource = new CancellationTokenSource();
return new CancellationChangeToken(TokenSource.Token);
}
}
Don't forget to register that Action Descriptor at startup:
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IActionDescriptorChangeProvider>(MyActionDescriptorChangeProvider.Instance);
services.AddSingleton(MyActionDescriptorChangeProvider.Instance);
My code generator just creates a controller and an entity class. The controller returns a OData enabled list of entities with three properties on it. Nothing special, just here to have a working example for someone who want to try this out.
public class WebApiGenerator
{
public WebApiGenerator()
{
}
private static CSharpCompilation GenerateCode(string sourceCode, string className)
{
var codeString = SourceText.From(sourceCode);
var options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9);
var parsedSyntaxTree = SyntaxFactory.ParseSyntaxTree(codeString, options);
var references = new List<MetadataReference>
{
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Console).Assembly.Location),
MetadataReference.CreateFromFile(typeof(System.Runtime.AssemblyTargetedPatchBandAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly.Location),
};
var referencedAssemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var referenced in referencedAssemblies)
{
string location = null;
try
{
location = referenced.Location;
}
catch
{
}
if (location != null)
{
references.Add(MetadataReference.CreateFromFile(location));
}
}
string outputDll = className + ".dll";
return CSharpCompilation.Create(outputDll,
new[] { parsedSyntaxTree },
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary,
optimizationLevel: OptimizationLevel.Release,
assemblyIdentityComparer: DesktopAssemblyIdentityComparer.Default));
}
public bool Exists(string className)
{
string outputDll = className + ".dll";
return File.Exists(outputDll);
}
public Assembly GetAssembly(string className)
{
string outputDll = className + ".dll";
return Assembly.LoadFrom(outputDll);
}
public Assembly CreateDll(string className)
{
className = className.Replace(" ", string.Empty);
string outputDll = className + ".dll";
if (File.Exists(outputDll)) return Assembly.LoadFrom(outputDll);
var code = new StringBuilder()
.AppendLine("using Microsoft.AspNetCore.Mvc;")
.AppendLine("using Microsoft.Extensions.Logging;")
.AppendLine("using System;")
.AppendLine("using System.Collections.Generic;")
.AppendLine("using System.Linq;")
.AppendLine("using Microsoft.AspNet.OData;")
.AppendLine("")
.AppendLine("namespace ControllerLibrary")
.AppendLine("{")
.AppendLine($"public class {className}")
.AppendLine("{")
.AppendLine(" public string FirstProperty { get; set; }")
.AppendLine(" public string SecondProperty { get; set; }")
.AppendLine(" public int IntValue { get; set; }")
.AppendLine("}")
.AppendLine($"[ApiController]")
.AppendLine($"[Route(\"[controller]\")]")
.AppendLine($"public class {className}Controller : ControllerBase")
.AppendLine(" {")
.AppendLine(" [HttpGet(\"GetData\")]")
.AppendLine(" [EnableQuery]")
.AppendLine($" public IList<{className}> Get()")
.AppendLine(" {")
.AppendLine($" var list = new List<{className}>();");
for (int i = 0; i < 3; i++)
{
code.AppendLine($"list.Add(new {className} {{ FirstProperty = \"First prop of {className}\", SecondProperty = \"asd\", IntValue = {i} }});");
}
code
.AppendLine(" return list;")
.AppendLine(" }")
.AppendLine(" }")
.AppendLine("}");
File.WriteAllText("code.txt", code.ToString());
var result = GenerateCode(code.ToString(), className).Emit(outputDll);
//CompilerResults results = codeProvider.CompileAssemblyFromSource(parameters, code.ToString());
if (!result.Success)
{
Console.WriteLine("Compilation done with error.");
var failures = result.Diagnostics.Where(diagnostic => diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error);
foreach (var diagnostic in failures)
{
Console.Error.WriteLine("{0}: {1}", diagnostic.Id, diagnostic.GetMessage());
}
}
else
{
Console.WriteLine("Build Succeeded");
return Assembly.LoadFrom(outputDll);
}
return null;
}
}
In my ASP.NET core web application, I want to have an action that runs only in development mode. In production mode, maybe a 404 error will be good enough. Is it possible to do that?
This can be achieved by injecting IHostEnvironment into your controller and using its IsDevelopment() method inside of the action itself. Here's a complete example that returns a 404 when running in anything other than the Development environment:
public class SomeController : Controller
{
private readonly IHostEnvironment hostEnvironment;
public SomeController(IHostEnvironment hostEnvironment)
{
this.hostEnvironment = hostEnvironment;
}
public IActionResult SomeAction()
{
if (!hostEnvironment.IsDevelopment())
return NotFound();
// Otherwise, return something else for Development.
}
}
If you want to apply this more globally or perhaps you just want to separate out the concerns, Daboul explains how to do so with an action filter in this answer.
For ASP.NET Core < 3.0, use IHostingEnvironment in place of IHostEnvironment.
One nice way to do it is to create a DevOnlyActionFilter filter https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/filters?view=aspnetcore-2.2
The filter would look like that:
public class DevOnlyActionFilter : ActionFilterAttribute
{
private IHostingEnvironment HostingEnv { get; }
public DevOnlyActionFilter(IHostingEnvironment hostingEnv)
{
HostingEnv = hostingEnv;
}
public override void OnActionExecuting(ActionExecutingContext context)
{
if(!HostingEnv.IsDevelopment())
{
context.Result = new NotFoundResult();
return;
}
base.OnActionExecuting(context);
}
}
And to annotate your controller action with [TypeFilter(typeof(DevOnlyActionFilter))]
#Daboul's answer is pretty good but I didn't like the TypeFilter(typeof(x)) which feels clunky.
Turns out that implementing IFilterFactory allows for attribute filters to have DI while still being clean to use.
public class DevOnlyAttribute : Attribute, IFilterFactory
{
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
{
return new DevOnlyAttributeImpl(serviceProvider.GetRequiredService<IWebHostEnvironment>());
}
public bool IsReusable => true;
private class DevOnlyAttributeImpl : Attribute, IAuthorizationFilter
{
public DevOnlyAttributeImpl(IWebHostEnvironment hostingEnv)
{
HostingEnv = hostingEnv;
}
private IWebHostEnvironment HostingEnv { get; }
public void OnAuthorization(AuthorizationFilterContext context)
{
if (!HostingEnv.IsDevelopment())
{
context.Result = new NotFoundResult();
}
}
}
}
Now the controller/action can be annotated with [DevOnly].
I'm writing a RestFramework and I'm trying to figure out how I can allow the users to create a custom name for a generic controller. I'm registering my generic controllers like so:
public class GenericControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
foreach (var entityConfig in _entityConfigurations)
{
var entityType = entityConfig.Type;
var typeName = entityType.Name + "Controller";
if (!feature.Controllers.Any(t => t.Name == typeName))
{
var controllerType = typeof(GenericController<>)
.MakeGenericType(entityType.AsType())
.GetTypeInfo();
//Normally I would expect there to be an overload to configure the controller name
//feature.Controllers.Add(controllerType, entityConfig.ControllerName);
}
}
}
}
How ever I need to figure out a way that I can override the route for the controllers. The only information about this in the documentation shows how to create a controller convention like so:
public class GenericControllerNameConvention : Attribute, IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
if (controller.ControllerType.GetGenericTypeDefinition() !=
typeof(GenericController<>))
{
return;
}
var entityType = controller.ControllerType.GenericTypeArguments[0];
controller.ControllerName = entityType.Name;
}
}
This will not work since it is done at compile time. I need user to be able to override the controller name on Startup, How can I Achieve this?
Based on your comment and code you were pretty much on par with how you would achieve this. Note I have cut down the example quite a bit so I could setup a test.
Say I have a basic generic controller as:
public class GenericController<T> : Controller
where T: class
{
public IActionResult Get()
{
return Content(typeof(T).FullName);
}
}
I now have a typed controller with Get action. Now most of your code was right on the money. So my Feature Provider as (note i have a static array of types):
public class GenericControllerFeatureProvider : IApplicationFeatureProvider<ControllerFeature>
{
public void PopulateFeature(IEnumerable<ApplicationPart> parts, ControllerFeature feature)
{
foreach (var entityConfig in ControllerEntity.EntityTypes)
{
var entityType = entityConfig;
var typeName = entityType.Name + "Controller";
if (!feature.Controllers.Any(t => t.Name == typeName))
{
var controllerType = typeof(GenericController<>)
.MakeGenericType(entityType)
.GetTypeInfo();
feature.Controllers.Add(controllerType);
}
}
}
}
Next the IControllerModelConvention implementation.
public class GenericControllerModelConvention : IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
if (!controller.ControllerType.IsGenericType || controller.ControllerType.GetGenericTypeDefinition() != typeof(GenericController<>))
{
return;
}
var entityType = controller.ControllerType.GenericTypeArguments[0];
controller.ControllerName = entityType.Name + "Controller";
controller.RouteValues["Controller"] = entityType.Name;
}
}
And finally the startup is where all the magic happens. Basically we register the IControllerModelConvention into the MVC convention options, and then register the FeatureProvider.
public void ConfigureServices(IServiceCollection services)
{
var mvcBuilder = services.AddMvc();
mvcBuilder.AddMvcOptions(o => o.Conventions.Add(new GenericControllerModelConvention()));
mvcBuilder.ConfigureApplicationPartManager(c =>
{
c.FeatureProviders.Add(new GenericControllerFeatureProvider());
});
}
From my review two things struck me.
I am not sure why you have your GenericControllerNameConvention as an attribute?
You should implicitly set the Controller Route Value to your entity type (not the type + name).
Given two entities (EntityA and EntityB) the result of the controllers is
/Entitya/get/ prints WebApplication11.Infrastructure.EntityA
/Entityb/get/ prints WebApplication11.Infrastructure.EntityB