[Swagger] 使用 Namespace 作為版本號

預設的情況,Web API 想要用相同的 ClassName 不同的 Namespace 來當成版本 URI 是不行的,幸好這件事不難官方也有提供解法

https://blogs.msdn.microsoft.com/webdev/2013/03/07/asp-net-web-api-using-namespaces-to-version-web-apis/

搬到 Swagger 也不難,只是有一些坑需要踩..
 

開發環境

  • VS 2017 Enterprise 15.9.5
  • Swashbuckle 5.6.0

情境

期望 URI:

  • http://localhost/api/v1/values
  • http://localhost/api/v2/values

期望 Controller:透過命名空間區分版本

問題

當同時存在兩個 ValuesController 時,無法取得任何資源;反之,只有一個 ValuesController 時,可以取得資源

解決步驟

參考 https://blogs.msdn.microsoft.com/webdev/2013/03/07/asp-net-web-api-using-namespaces-to-version-web-apis/

NamespaceHttpControllerSelector 是為了重新處理 Controller,把 namespace 加進來當 key,由於這段代碼本來在 codeplex,現在已經封存了 https://archive.codeplex.com/?p=aspnet#Samples/WebApi/NamespaceControllerSelector/ReadMe.txt

  • InitializeControllerDictionary:_controllers 集合的 key 包含了 namespace
  • SelectController:從 route table 比對 url 的 namespace (version)
public class NamespaceHttpControllerSelector : IHttpControllerSelector
{
    private const string NamespaceKey = "namespace";
    private const string ControllerKey = "controller";
 
    private readonly HttpConfiguration _configuration;
    private readonly Lazy<Dictionary<stringHttpControllerDescriptor>> _controllers;
    private readonly HashSet<string> _duplicates;
 
    public NamespaceHttpControllerSelector(HttpConfiguration config)
    {
        _configuration = config;
        _duplicates = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        _controllers = new Lazy<Dictionary<stringHttpControllerDescriptor>>(InitializeControllerDictionary);
    }
 
    private Dictionary<stringHttpControllerDescriptor> InitializeControllerDictionary()
    {
        var dictionary = new Dictionary<stringHttpControllerDescriptor>(StringComparer.OrdinalIgnoreCase);
 
        // Create a lookup table where key is "namespace.controller". The value of "namespace" is the last
        // segment of the full namespace. For example:
        // MyApplication.Controllers.V1.ProductsController => "V1.Products"
        IAssembliesResolver assembliesResolver = _configuration.Services.GetAssembliesResolver();
        IHttpControllerTypeResolver controllersResolver = _configuration.Services.GetHttpControllerTypeResolver();
 
        ICollection<Type> controllerTypes = controllersResolver.GetControllerTypes(assembliesResolver);
 
        foreach (Type t in controllerTypes)
        {
            var segments = t.Namespace.Split(Type.Delimiter);
 
            // For the dictionary key, strip "Controller" from the end of the type name.
            // This matches the behavior of DefaultHttpControllerSelector.
            var controllerName = t.Name.Remove(t.Name.Length - DefaultHttpControllerSelector.ControllerSuffix.Length);
 
            var key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", segments[segments.Length - 1], controllerName);
 
            // Check for duplicate keys.
            if (dictionary.Keys.Contains(key))
            {
                _duplicates.Add(key);
            }
            else
            {
                dictionary[key] = new HttpControllerDescriptor(_configuration, t.Name, t);  
            }
        }
 
        // Remove any duplicates from the dictionary, because these create ambiguous matches. 
        // For example, "Foo.V1.ProductsController" and "Bar.V1.ProductsController" both map to "v1.products".
        foreach (string s in _duplicates)
        {
            dictionary.Remove(s);
        }
        return dictionary;
    }
 
    // Get a value from the route data, if present.
    private static T GetRouteVariable<T>(IHttpRouteData routeData, string name)
    {
        object result = null;
        if (routeData.Values.TryGetValue(name, out result))
        {
            return (T)result;
        }
        return default(T);
    }
 
    public HttpControllerDescriptor SelectController(HttpRequestMessage request)
    {
        IHttpRouteData routeData = request.GetRouteData();
        if (routeData == null)
        {
            throw new HttpResponseException(HttpStatusCode.NotFound);
        }
 
        // Get the namespace and controller variables from the route data.
        string namespaceName = GetRouteVariable<string>(routeData, NamespaceKey);
        if (namespaceName == null)
        {
            throw new HttpResponseException(HttpStatusCode.NotFound);
        }
 
        string controllerName = GetRouteVariable<string>(routeData, ControllerKey);
        if (controllerName == null)
        {
            throw new HttpResponseException(HttpStatusCode.NotFound);
        }
 
        // Find a matching controller.
        string key = String.Format(CultureInfo.InvariantCulture, "{0}.{1}", namespaceName, controllerName);
 
        HttpControllerDescriptor controllerDescriptor;
        if (_controllers.Value.TryGetValue(key, out controllerDescriptor))
        {
            return controllerDescriptor;
        }
        else if (_duplicates.Contains(key))
        {
            throw new HttpResponseException(
                request.CreateErrorResponse(HttpStatusCode.InternalServerError,
                "Multiple controllers were found that match this request."));
        }
        else
        {
            throw new HttpResponseException(HttpStatusCode.NotFound);
        }
    }
 
    public IDictionary<stringHttpControllerDescriptor> GetControllerMapping()
    {
        return _controllers.Value;
    }

 

然後套用

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services
 
        // Web API routes
        config.MapHttpAttributeRoutes();
 
        config.Routes.MapHttpRoute(
                                   name: "DefaultApi",
                                   routeTemplate: "api/{namespace}/{controller}/{id}",
                                   defaults: new { id = RouteParameter.Optional }
                                  );
        config.Services.Replace(typeof(IHttpControllerSelector)new NamespaceHttpControllerSelector(config));
    }
}

 

執行結果,用 Postman 測試,URI 如我所預期

MS Help Page

看看文檔頁面會長怎樣
For a C# application: Install-Package Microsoft.AspNet.WebApi.HelpPage
For a Visual Basic application: Install-Package Microsoft.AspNet.WebApi.HelpPage.VB
果然 URL 錯了

先針對 Swagget 改天有機會再來解決它

 

Swagger

我猜應該也是一樣...

Install-Package Swashbuckle

果然 XDDD

 

變更 Swagger UI 的網址

由於長出來的 url 不對,所以要把它改掉

  • 預期:http://localhost/api/v1/values
  • 結果:http://localhost/api/v1/v1.values

urls[3] 那個變數用來把 v1.values 替換成 values,如下

public class CachingSwaggerProvider : ISwaggerProvider
{
    private static readonly ConcurrentDictionary<string, SwaggerDocument> s_cache =
        new ConcurrentDictionary<string, SwaggerDocument>();
 
    private readonly ISwaggerProvider _swaggerProvider;
 
    public CachingSwaggerProvider(ISwaggerProvider swaggerProvider)
    {
        this._swaggerProvider = swaggerProvider;
    }
 
    public SwaggerDocument GetSwagger(string rootUrl, string apiVersion)
    {
        var cacheKey = string.Format("{0}_{1}", rootUrl, apiVersion);
 
        SwaggerDocument doc = null;
 
        if (!s_cache.TryGetValue(cacheKey, out doc))
        {
            doc = this._swaggerProvider.GetSwagger(rootUrl, apiVersion);
            var paths = new Dictionary<string, PathItem>();
            foreach (var item in doc.paths)
            {
                var urls = item.Key.Split('/');
                var i = urls[3].LastIndexOf('.') + 1;
                if (i != -1)
                {
                    urls[3] = urls[3].Substring(i);
                }
 
                paths.Add(string.Join("/", urls), item.Value);
            }
 
            doc.paths = paths;
 
            s_cache.TryAdd(cacheKey, doc);
        }
 
        return doc;
    }
 
}

 

VersionRoute 給 Swagger 判斷目前這個是屬於哪一個版本,如下

[VersionRoute("api/version"2)]
public class ValuesController : ApiController
{
    public IHttpActionResult Get()
    {
        return this.Ok("我是第二版");
    }
}

 

[AttributeUsage(AttributeTargets.All)]
public class VersionRoute : Attribute
{
    public VersionedRoute(string name, int version)
    {
        this.Name = name;
        this.Version = version;
    }
 
    public string Name { get; set; }
 
    public int Version { get; set; }
}

 

取得 Controller 的 VersionRoute 版本號,如下

public class SwaggerVersionHelper
{
    public static bool ResolveVersionSupportByRouteConstraint(ApiDescription apiDesc, string targetApiVersion)
    {
        var attr = apiDesc.ActionDescriptor
                          .ControllerDescriptor
                          .GetCustomAttributes<VersionRoute>()
                          .FirstOrDefault();
        return attr.Version == Convert.ToInt32(targetApiVersion.TrimStart('v'));
    }
}

 

套用

public class SwaggerConfig
{
    public static void Register()
    {
        var thisAssembly = typeof(SwaggerConfig).Assembly;
 
        GlobalConfiguration.Configuration
            .EnableSwagger(=>
                {
                    c.MultipleApiVersions((apiDesc, targetApiVersion) => SwaggerVersionHelper.ResolveVersionSupportByRouteConstraint(apiDesc, targetApiVersion),
                                          (vc) =>
                                          {
                                              vc.Version("v2""第二版");
                                              vc.Version("v1""第一版");
                                          });
                    c.CustomProvider((defaultProvider) => new CachingSwaggerProvider(defaultProvider));
                })
            .EnableSwaggerUi(=>
                {
                });
    }
}

 

運行,可以切換文件版本,也可以調用 API

移除 namespace

我覺得版本在必要條件裡面不是很美麗,所以要再改一下

@ Controller

  • 把 VersionRouteAttribute 移掉,希望透過命名規則就能取得正確的版本

@ Swagger

  • 更改 URL,把 {namespace} 移掉
  • 移除 namespace parameter

 

移除 namespace 參數

public class RemoveNamespaceOperationFilter : IOperationFilter
{
    public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription)
    {
        if (operation.parameters == null)
        {
            return;
        }
 
        var parameter = operation.parameters.FirstOrDefault(=> p.name == "namespace");
        if (parameter != null)
        {
            operation.parameters.Remove(parameter);
        }
    }
}

 

@ SwaggerConfig.cs

套用

c.OperationFilter<RemoveNamespaceOperationFilter>();

 

@ CachingSwaggerProvider.cs

urls[2] 本來是 {namespace} 換成 apiVersion

public SwaggerDocument GetSwagger(string rootUrl, string apiVersion)
{
    var cacheKey = string.Format("{0}_{1}", rootUrl, apiVersion);
 
    SwaggerDocument doc = null;
 
    if (!s_cache.TryGetValue(cacheKey, out doc))
    {
        doc = this._swaggerProvider.GetSwagger(rootUrl, apiVersion);
        var paths = new Dictionary<stringPathItem>();
        foreach (var item in doc.paths)
        {
            var urls = item.Key.Split('/');
            var i = urls[3].LastIndexOf('.') + 1;
            if (!= -1)
            {
                urls[3] = urls[3].Substring(i);
            }
            urls[2] = apiVersion;
            paths.Add(string.Join("/", urls), item.Value);
        }
 
        doc.paths = paths;
 
        s_cache.TryAdd(cacheKey, doc);
    }

 

運行結果如下,namespace 順利地被我移掉了

專案位置

https://github.com/yaochangyu/sample.dotblog/tree/master/WebAPI/Swagger/Version%20for%20Namespace

 

參考
https://blogs.msdn.microsoft.com/webdev/2013/03/07/asp-net-web-api-using-namespaces-to-version-web-apis/
https://blog.csdn.net/qq_32109957/article/details/81128805

 

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo