Mając rozbudowany system składający się z wielu aplikacji wymieniających dane między sobą, dochodzi do sytuacji kiedy musimy np. zaktualizować wybrane metody. Część aplikacji musi pracować na "starym API", a część na "nowym API". Musimy więc zapewnić obsługę w starej i nowej wersji. Pojawia się wtedy problem jak najlepiej rozwiązać problem utrzymywania kilku wersji API. Niestety w WebAPI 2 nie otrzymujemy gotowego rozwiązania. Należy bowiem modyfikować przy każdej metodzie atrybut Route oraz można ewentualnie dodać atrybut RoutePrefix do kontrolera.

Rozwiązań tego problemu jest wiele. Można dodać do URL dodatkowy parametr, można przekazać informację o wersji API w HTTP header i oczywiście można przekazać wersję API w URL.

Najlepszym rozwiązaniem będzie przekazywanie wersji w adresie URL. Ale jak to zrobić elegancko w kodzie aplikacji WebAPI? Zaraz się przekonamy.

Tworzymy w VS2017 projekt WebApi.

W folderze Controllers tworzymy dwa podfoldery na nasze wersje API v1 oraz v2.

Tworzymy kontroler PersonsController w pierwszym i drugim folderze v1/v2, które posłużom nam do przetestowania wersjonowania API. Możemy dodać dwukrotnie tą samą klasę, gdyż będzie się znajdować w różnych namespace.

Tworzymy klasę AppRouteConstraint, która pozwoli nam sprawdzać numer wersji API w adresie URL.

    public class AppRouteConstraint : IHttpRouteConstraint
    {
        public AppRouteConstraint(string apiVersion)
        {
            ApiVersion = apiVersion.ToLowerInvariant();
        }

        public string ApiVersion { get; private set; }

        public bool Match(HttpRequestMessage request, IHttpRoute route, string parameterName, 
            IDictionary<string, object> values, HttpRouteDirection routeDirection)
        {
            object value;
            if (values.TryGetValue(parameterName, out value) && value != null)
            {
                return ApiVersion.Equals(value.ToString().ToLowerInvariant());
            }
            return false;
        }
    }

Aby w elegancki sposób i bez pomyłek określać ścieżki routingu do naszych metod w różnych wersjach v1/2 utworzymy jeszcze własny RoutePrefix, który będzie zwracać prefix z numerem wesji API dla kontrolerów.

    public class ApiV1Attribute : RoutePrefixAttribute
    {
        private const string RouteBase = "api/{apiVersion:appRouteConstraint(v1)}";

        public ApiV1Attribute(string routePrefix): 
            base(string.IsNullOrWhiteSpace(routePrefix) ? RouteBase : RouteBase + "/" + routePrefix)
        {
        }
    }    
              
    public class ApiV2Attribute : RoutePrefixAttribute
    {
        private const string RouteBase = "api/{apiVersion:appRouteConstraint(v2)}";

        public ApiV2Attribute(string routePrefix): 
            base(string.IsNullOrWhiteSpace(routePrefix) ? RouteBase : RouteBase + "/" + routePrefix)
        {
        }
    }

Użycie nowego atrybutu do wersjonowania metod naszego API wygląda następująco

namespace WebApi.Controllers.v1
{
    [ApiV1("persons")]
    public class PersonsController : ApiController
    {
        [HttpGet, Route("")]
        public IEnumerable<Person> GetAll()
        {
            return SampleData.GetPersons();
        }

        [HttpGet, Route("{id}")]
        public Person GetPerson()
        {
            return SampleData.GetPersons().First();
        }
    }
}
              
namespace WebApi.Controllers.v2
{
    [ApiV2("persons")]
    public class PersonsController : ApiController
    {
        [HttpGet, Route("")]
        public IEnumerable<Person> GetAll()
        {
            return SampleData.GetPersons();
        }

        [HttpGet, Route("{id}")]
        public Person GetPerson()
        {
            return SampleData.GetPersons().First();
        }
    }
}

Przy próbie wywołania metody pojawi się jednak błąd "Multiple types were found that match the controller named 'persons'". Domyślny routing nie będzie wiedział z którego kontrolera ma skorzystać. Musimy więc zaimplementować własny HttpControllerSelector, który przekaże informację o lokalizacji klasy kontrolera z odpowiedniego namespace.

    public class AppHttpControllerSelector : IHttpControllerSelector
    {
        private readonly HttpConfiguration _config;
        private readonly Lazy<Dictionary<string, HttpControllerDescriptor>> _controllers;

        public AppHttpControllerSelector(HttpConfiguration config)
        {
            _config = config;
            _controllers = new Lazy<Dictionary<string, HttpControllerDescriptor>>(InitializeControllerDictionary);
        }

        #region IHttpControllerSelector implementation
        /// <summary>
        /// Zwrócenie kontrolera
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        public HttpControllerDescriptor SelectController(HttpRequestMessage request)
        {
            var routeData = request.GetRouteData();
            if (routeData == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
            var controllerName = GetControllerName(routeData);
            if (controllerName == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
            var namespaceName = GetVersion(routeData);
            if (namespaceName == null)
            {
                throw new HttpResponseException(HttpStatusCode.NotFound);
            }
            var controllerKey = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", namespaceName, controllerName);
            HttpControllerDescriptor controllerDescriptor;
            if (_controllers.Value.TryGetValue(controllerKey, out controllerDescriptor))
            {
                return controllerDescriptor;
            }
            throw new HttpResponseException(HttpStatusCode.NotFound);
        }


        public IDictionary<string, HttpControllerDescriptor> GetControllerMapping()
        {
            return _controllers.Value;
        }
        #endregion

        #region privates
        /// <summary>
        /// Wczytanie dostępnych kontrolerów przy uruchomieniu aplikacji
        /// </summary>
        /// <returns></returns>
        private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary()
        {
            var dictionary = new Dictionary<string, HttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);
            var assembliesResolver = _config.Services.GetAssembliesResolver();
            var controllersResolver = _config.Services.GetHttpControllerTypeResolver();
            var controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);
            foreach (var controllerType in controllerTypes)
            {
                var segments = controllerType.Namespace.Split(Type.Delimiter);
                var controllerName = controllerType.Name.Remove(controllerType.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);
                var controllerKey = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", segments[segments.Length - 1], controllerName);
                if (!dictionary.Keys.Contains(controllerKey))
                {
                    dictionary[controllerKey] = new HttpControllerDescriptor(_config, controllerType.Name, controllerType);
                }
            }
            return dictionary;
        }

        /// <summary>
        /// Pobranie nazwy kontrtolera
        /// </summary>
        /// <param name="routeData"></param>
        /// <returns></returns>
        private string GetControllerName(IHttpRouteData routeData)
        {
            var subRouteData = routeData.GetSubRoutes().FirstOrDefault();
            if (subRouteData == null) return null;
            var dataTokenValue = subRouteData.Route.DataTokens.First().Value;
            if (dataTokenValue == null) return null;
            var controllerName = ((HttpActionDescriptor[])dataTokenValue).First().ControllerDescriptor.ControllerName.Replace("Controller", string.Empty);
            return controllerName;
        }

        /// <summary>
        /// Pobranie wersji v1/v2 z parametru routingu "apiVersion" (zdefiniowanego za pomocą constraint w routingu)
        /// </summary>
        /// <param name="routeData"></param>
        /// <returns></returns>
        private string GetVersion(IHttpRouteData routeData)
        {
            var subRouteData = routeData.GetSubRoutes().FirstOrDefault();
            if (subRouteData == null) return null;

            object result;
            if (subRouteData.Values.TryGetValue("apiVersion", out result))
            {
                return (string)result;
            }
            return string.Empty;
        }
        #endregion
    }

Aby nowy routing zaczął działać należy jeszcze wproawdzić zmiany w konfiguracji routingu

    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            var constraintsResolver = new DefaultInlineConstraintResolver();
            constraintsResolver.ConstraintMap.Add("appRouteConstraint", typeof(AppRouteConstraint));
            config.MapHttpAttributeRoutes(constraintsResolver);
            config.Services.Replace(typeof(IHttpControllerSelector), new AppHttpControllerSelector(config));
        }
    }

Po uruchomieniu można przetestować nowy routing podając w adresie: http://localhost:port/api/v1/persons oraz http://localhost:port/api/v2/persons

Dzięki takiemu podejściu mamy czytelny routing, który obsługuje wersjonowanie :)