Search code examples
asp.net-coreasp.net-core-webapiasp.net-mvc-routing

ASP.NET CORE - dynamically add a attribute route to an controller with [ApiController]


I'd like to have controllers with default Route URLs and HTTP method generated by custom code and not require any HttpPost|Get|etc attributes (route actions will default to Post). I can do this using Microsoft.AspNetCore.Mvc.ApplicationModels.IActionModelConvention.

However I cannot then use [ApiController] on the controller since there is no explicit attribute routing - I get the error:

Controller Action> does not have an attribute route. Action methods on controllers annotated with ApiControllerAttribute must be attribute routed

Is there a way to dynamically add default routes and http methods to controller actions AND use the [ApiController] attribute?

Related questions

Conventional Routing in ASP.NET Core API - this is close - but I want to have all the niceties/defaults of [ApiController] - i.e. not having to set [FromBody] for params, etc

Core requirement

I'd like to create an OpenAPI backend and for use on an JS SPA client code gen on the front end. Since the API is only consumed by the SPA, and the client API will be auto code gen - I don't really care about hardcoded routes, or even using "correct" HTTP methods (everything could just be a POST). So I'd like to get rid of all that boilerplate in my controllers.

Current thoughts/ideas

The I*Conventions aren't applied until after Microsoft.AspNetCore.Mvc.ApplicationModels.ApiBehaviorApplicationModelProvider.EnsureActionIsAttributeRouted validation is executed. So even though I apply a AttributeRouteModel in the convention, it isn't included in the validation:

actionSelector.AttributeRouteModel = new AttributeRouteModel
                {
                    Template = $"foo/bar/{Guid.NewGuid():N}",
                };

My next idea is to implement some sort of Microsoft.AspNetCore.Mvc.ApplicationModels.IApplicationModelProvider (which is how the [ApiController] seems to be validated - see: Microsoft.AspNetCore.Mvc.ApplicationModels.ApiBehaviorApplicationModelProvider) - and try to make sure it's registered and executed before the ApiBehaviorApplicationModelProvider?

If that doesn't work then it seems the option would be to get even deeper into something like Microsoft.AspNetCore.Mvc.Abstractions.IActionDescriptorProvider - but that seems pretty scary.

Cross Posted: https://github.com/dotnet/aspnetcore/discussions/52526


Solution

  • I ended up using the IApplicationModelProvider strategy. I just had to make sure to set the Order property to run before the ApiBehaviorApplicationModelProvider - so I ended up with:

    /// <summary>
    /// [ApiController] attribute model provider uses <c>-1000 + 100</c> -
    /// this needs to be applied first so that we get attribute routing.
    /// <see cref="AuthorizationApplicationModelProvider"/> uses +10 - so we're trying
    /// to split the difference here.
    /// </summary>
    public int Order => -1000 + 50;
    

    And register it as a service in the app startup:

    ...
    builder.Services.AddTransient<IApplicationModelProvider, MyFancyApplicationModelProvider>();
    ...
    

    And, if anyone is interested, here is how I am adding Routes and HttpMethods to the Actions (it seems to work...):

    public void OnProvidersExecuting(ApplicationModelProviderContext context)
    {
        var actionSelectors = context.Result.Controllers
            .SelectMany(
                c => c.Actions.SelectMany(
                    a => a.Selectors.Select(
                        selector => (
                            controller: c,
                            action: a, selector))));
    
        foreach (var actionSelector in actionSelectors)
        {
            var selectorModel = actionSelector.selector;
            var actionDebugName = $"{actionSelector.controller.ControllerName}:{actionSelector.action.ActionName}";
    
            if (selectorModel.AttributeRouteModel == null)
            {
                var endpointUrl = // ... magic here to create the URL
    
                _logger.LogDebug(
                    "Endpoint has no attribute route - adding default ({Action}): {Url}",
                    actionDebugName,
                    endpointUrl);
    
                selectorModel.AttributeRouteModel = new AttributeRouteModel
                {
                    Template = endpointUrl,
                };
            }
            else
            {
                _logger.LogInformation(
                    "{Endpoint}: Endpoint has existing attribute route - will not add a default",
                    actionDebugName);
            }
    
            /*
             * Add a default HTTP Method to the action (POST) if not specified
             */
            var httpMethodConstraint =
                (HttpMethodActionConstraint?)selectorModel.ActionConstraints.FirstOrDefault(
                    c => c is HttpMethodActionConstraint);
    
            /*
             * Debug out the HTTP Methods
             */
            var httpMethods = httpMethodConstraint == null
                ? "NONE"
                : string.Join(",", httpMethodConstraint.HttpMethods);
    
            _logger.LogDebug("Existing HTTP Methods: {HttpMethods}", httpMethods);
            
            if (httpMethodConstraint == null)
            {
                _logger.LogDebug("{Endpoint}: Adding default http method", actionDebugName);
                selectorModel.ActionConstraints.Add(
                    new HttpMethodActionConstraint(new[] { DefaultHttpMethod }));
            }
            else
            {
                _logger.LogInformation("{Endpoint}: already has http action", actionDebugName);
            }
        }
    }