Due to Sitecore’s peculiarities, it doesn’t support Attribute Routing out of the box, and using the MapMvcAttributeRoutes()  extension method in MVC is not recommended.
Sitecore does support MVC Routing, though, and Sitecore’s documentation on MVC routing gave me something to work with to allow me to use Attribute Routing.

As the documentation states, you need to inject a custom pipeline after Sitecore.Pipelines.Loader.EnsureAnonymousUsers. This placement ensures that we can still track logged in users in our attribute routes.
Instead of adding each route manually as in the documentation, I use reflection to get all controllers from my assemblies, loop through all the methods in each controller, and check for the existence of MVC Route attributes. If the method has a route attribute I then create a new route using the information in the attribute and class. I’m guessing that’s really what MapMvcAttributeRoutes() is doing behind the scenes.

At this point it is probably more efficient to show you the code instead of explaining everything at once, so here it is:

public class AttributeRoutingPipeline : InitializeRoutes
{
    public override void Process(PipelineArgs args)
    {
        try
        {
            // Get all loaded assemblies
            // but only those belonging to your solution
            // and only classes that inherit from Controller
            List<Type> types = AppDomain.CurrentDomain.GetAssemblies()
                .Where(a => a.FullName.StartsWith("YOUR ASSEMBLY PREFIX HERE") && !a.IsDynamic)
                .SelectMany(a => a.GetTypes())
                .Where(t => typeof(Controller).IsAssignableFrom(t) && t.IsClass && !t.IsAbstract)
                .ToList();

            foreach (Type type in types)
            {
                // Look for the [RoutePrefix] attribute
                // Use the class name if it can't be found
                string routePrefix = type.Name;
                foreach (Attribute attribute in type.GetCustomAttributes(true))
                {
                    if (attribute is RoutePrefixAttribute routePrefixAttribute)
                    {
                        routePrefix = routePrefixAttribute.Prefix;
                    }
                }

                // Loop through all public instance methods in the class
                foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
                {
                    // Loop through all attributes on the method
                    foreach (Attribute methodAttribute in method.GetCustomAttributes(true))
                    {
                        // Look for the existence of the [Route] attribute
                        if (methodAttribute is RouteAttribute methodRouteAttribute)
                        {
                            // Create a custom route
                            RouteTable.Routes.MapRoute(
                                $"{type.FullName}.{method.Name}",
                                $"api/custom/{routePrefix}/{methodRouteAttribute.Template}",
                                new { controller = type.Name.TrimEnd("Controller"), action = method.Name },
                                new [] { type.Namespace });
                        }
                    }
                }
            }
        }
        catch (ReflectionTypeLoadException e)
        {
            Log.Error("Could not create routes", e, this);
            foreach (Exception loaderException in e.LoaderExceptions)
            {
                Log.Error("Inner LoaderException:", loaderException, this);
            }
        }
        catch (Exception e)
        {
            Log.Error("Could not create routes", e, this);
        }
    }
}

And adding it to the Sitecore pipeline by using a custom config file:

<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set">
    <sitecore>
        <pipelines>
            <initialize>
                <processor type="My.Namespace.AttributeRoutingPipeline, My.Assembly" patch:after="processor[@type='Sitecore.Pipelines.Loader.EnsureAnonymousUsers, Sitecore.Kernel']" />
            </initialize>
        </pipelines>
    </sitecore>
</configuration>

 

When getting all controllers I found it best to only loop through controllers in my own assemblies, as not to accidentally add routes to Sitecore controllers. If you are using Helix, all your assemblies should have the same prefix, so use this name to filter the assemblies.

Also, when .NET loads the assemblies it will also resolve any assembly dependencies, which may result in errors such as this:

Exception: System.Reflection.ReflectionTypeLoadException
Message: Unable to load one or more of the requested types. Retrieve the LoaderExceptions property for more information.
Could not load file or assembly ‘SomeAssembly, Version=1.3.57.0, Culture=neutral, PublicKeyToken=abcd1234ef567890’ or one of its dependencies. The located assembly’s manifest definition does not match the assembly reference.

This is fixed by ensuring that your web.config has the correct version of assemblies:

<configuration>
    <runtime>
        <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
            <dependentAssembly>
                <assemblyIdentity name="SomeAssembly" publicKeyToken="abcd1234ef567890"/>
                <bindingRedirect oldVersion="0.0.0.0-1.3.57.0" newVersion="1.3.57.0"/>
            </dependentAssembly>
         </assemblyBinding>
    </runtime>
<configuration>

This is also the reason for catching ReflectionTypeLoadException and logging it. If you do not catch exceptions in this pipeline and something goes wrong, Sitecore won’t start!

You’ll see that the route starts with api/custom. This is just to put the controllers somewhere familiar (as Sitecore places them in api/sitecore) and also to prevent any conflicts with Sitecore. You can change this to whatever you want, but be careful that you don’t overwrite any existing APIs.

When this is in place you can finally start using Attribute Routing in your controllers. There’s no magic to this, but I’ll show you an example for completeness:

[RoutePrefix("mytest")]
public class MyController : Controller
{
    [Route("data")]
    public JsonResult GetData()
    {
    }

    [HttpPost]
    [Route("data/{dataId}")]
    public HttpStatusCodeResult HandleData(int dataId)
    {
    }
}

These two methods will now be available at api/custom/mytest/data and api/custom/mytest/data/1234.

An added bonus is that Sitecore will still map these in its own way, so you will also be able to find them on api/sitecore/My/GetData and api/sitecore/My/HandleData as well. This means that this Attribute Routing pipeline lives side by side with Sitecore and does not create any conflicts (unless you intentionally create them) with existing functionality. You can then add this pipeline to an already existing solution without having any conflicts and without having to change a single controller, but still have the flexibility to use Attribute Routing on all new controllers!