Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Getting Web API Help Pages to Work with Custom Routing Constraints

In my project I have implemented custom routing constraints to allow for API versioning via a custom header variable (api-version), similar to this sample project on Codeplex, although I modified the constraint to allow for a major.minor convention.

This involves creating two separate controllers whose routes are differentiated via a FullVersionedRoute attribute:

Sample1Controller.cs

/// <summary>
/// v1.0 Controller
/// </summary>
public class Sample1Controller : ApiController
{
    [FullVersionedRoute("api/test", "1.0")]
    public IEnumerable<string> Get()
    {
        return new[] { "This is version 1.0 test!" };
    }
}

Sample2Controller.cs

/// <summary>
/// v2.0 Controller
/// </summary>
public class Sample2Controller : ApiController
{
    [FullVersionedRoute("api/test", "2.0")]
    public IEnumerable<string> Get()
    {
        return new[] { "This is version 2.0 test!" };
    }
}

FullVersionedRoute.cs

    using System.Collections.Generic;
    using System.Web.Http.Routing;

namespace HelperClasses.Versioning
{
    /// <summary>
    /// Provides an attribute route that's restricted to a specific version of the api.
    /// </summary>
    internal class FullVersionedRoute : RouteFactoryAttribute
    {
        public FullVersionedRoute(string template, string allowedVersion) : base(template)
        {
            AllowedVersion = allowedVersion;
        }

        public string AllowedVersion
        {
            get;
            private set;
        }

        public override IDictionary<string, object> Constraints
        {
            get
            {
                var constraints = new HttpRouteValueDictionary();
                constraints.Add("version", new FullVersionConstraint(AllowedVersion));
                return constraints;
            }
        }
    }
}

FullVersionConstraint.cs

using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Web.Http.Routing;

namespace HelperClasses.Versioning
{
    /// <summary>
    /// A Constraint implementation that matches an HTTP header against an expected version value.
    /// </summary>
    internal class FullVersionConstraint : IHttpRouteConstraint
    {
        public const string VersionHeaderName = "api-version";

        private const string DefaultVersion = "1.0";

        public FullVersionConstraint(string allowedVersion)
        {
            AllowedVersion = allowedVersion;
        }

        public string AllowedVersion
        {
            get;
            private set;
        }

        public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, IDictionary<string, object> values, HttpRouteDirection routeDirection)
        {
            if (routeDirection == HttpRouteDirection.UriResolution)
            {
                var version = GetVersionHeader(request) ?? DefaultVersion;
                return (version == AllowedVersion);
            }

            return false;
        }

        private string GetVersionHeader(HttpRequestMessage request)
        {
            IEnumerable<string> headerValues;

            if (request.Headers.TryGetValues(VersionHeaderName, out headerValues))
            {
                // enumerate the list once
                IEnumerable<string> headers = headerValues.ToList();

                // if we find once instance of the target header variable, return it
                if (headers.Count() == 1)
                {
                    return headers.First();
                }
            }

            return null;
        }
    }
}

This works just fine, but the auto-generated help files can't differentiate between the actions in the two controllers as they look like the same route (if you only pay attention to the url route, whic it does by default). As such, the action from Sample2Controller.cs overwrites the action from Sample1Controller.cs so only the Sample2 API is displayed on the help pages.

Is there a way to configure the Web API Help Page package to recognize a Custom Constraint and recognize that there are two, separate APIs, and subsequently display them as separate API groups on Help Pages?

like image 802
bperniciaro Avatar asked Mar 29 '16 20:03

bperniciaro


1 Answers

I found this article which describes how to achieve this by implementing IApiExplorer.

In short, what you'll want to do is add a new VersionedApiExplorer class implementing IApiExplorer like so

using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Web.Http;
using System.Web.Http.Controllers;
using System.Web.Http.Description;
using System.Web.Http.Routing;

namespace HelperClasses.Versioning
{
    public class VersionedApiExplorer<TVersionConstraint> : IApiExplorer
    {
        private IApiExplorer _innerApiExplorer;
        private HttpConfiguration _configuration;
        private Lazy<Collection<ApiDescription>> _apiDescriptions;
        private MethodInfo _apiDescriptionPopulator;

        public VersionedApiExplorer(IApiExplorer apiExplorer, HttpConfiguration configuration)
        {
            _innerApiExplorer = apiExplorer;
            _configuration = configuration;
            _apiDescriptions = new Lazy<Collection<ApiDescription>>(
                new Func<Collection<ApiDescription>>(Init));
        }

        public Collection<ApiDescription> ApiDescriptions
        {
            get { return _apiDescriptions.Value; }
        }

        private Collection<ApiDescription> Init()
        {
            var descriptions = _innerApiExplorer.ApiDescriptions;

            var controllerSelector = _configuration.Services.GetHttpControllerSelector();
            var controllerMappings = controllerSelector.GetControllerMapping();

            var flatRoutes = FlattenRoutes(_configuration.Routes);
            var result = new Collection<ApiDescription>();

            foreach (var description in descriptions)
            {
                result.Add(description);

                if (controllerMappings != null && description.Route.Constraints.Any(c => c.Value is TVersionConstraint))
                {
                    var matchingRoutes = flatRoutes.Where(r => r.RouteTemplate == description.Route.RouteTemplate && r != description.Route);

                    foreach (var route in matchingRoutes)
                        GetRouteDescriptions(route, result);
                }
            }
            return result;
        }

        private void GetRouteDescriptions(IHttpRoute route, Collection<ApiDescription> apiDescriptions)
        {
            var actionDescriptor = route.DataTokens["actions"] as IEnumerable<HttpActionDescriptor>;

            if (actionDescriptor != null && actionDescriptor.Count() > 0)
                GetPopulateMethod().Invoke(_innerApiExplorer, new object[] { actionDescriptor.First(), route, route.RouteTemplate, apiDescriptions });
        }

        private MethodInfo GetPopulateMethod()
        {
            if (_apiDescriptionPopulator == null)
                _apiDescriptionPopulator = _innerApiExplorer.GetType().GetMethods(BindingFlags.NonPublic | BindingFlags.Instance).FirstOrDefault(
                   m => m.Name == "PopulateActionDescriptions" && m.GetParameters().Length == 4);

            return _apiDescriptionPopulator;
        }

        public static IEnumerable<IHttpRoute> FlattenRoutes(IEnumerable<IHttpRoute> routes)
        {
            var flatRoutes = new List<HttpRoute>();

            foreach (var route in routes)
            {
                if (route is HttpRoute)
                    yield return route;

                var subRoutes = route as IReadOnlyCollection<IHttpRoute>;
                if (subRoutes != null)
                    foreach (IHttpRoute subRoute in FlattenRoutes(subRoutes))
                        yield return subRoute;
            }
        }
    }
}

and then add this to your WebAPIConfig

var apiExplorer = config.Services.GetApiExplorer();
config.Services.Replace(typeof(IApiExplorer), new VersionedApiExplorer<FullVersionConstraint>(apiExplorer, config));

You should then see both your Sample1 and Sample2 APIs on your Web API Help Page.

like image 57
ChrisS Avatar answered Oct 17 '22 16:10

ChrisS