預設的情況,Web API 想要用相同的 ClassName 不同的 Namespace 來當成版本 URI 是不行的,幸好這件事不難官方也有提供解法
搬到 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 時,可以取得資源
解決步驟
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<string, HttpControllerDescriptor>> _controllers; private readonly HashSet<string> _duplicates; public NamespaceHttpControllerSelector(HttpConfiguration config) { _configuration = config; _duplicates = new HashSet<string>(StringComparer.OrdinalIgnoreCase); _controllers = new Lazy<Dictionary<string, HttpControllerDescriptor>>(InitializeControllerDictionary); } private Dictionary<string, HttpControllerDescriptor> InitializeControllerDictionary() { var dictionary = new Dictionary<string, HttpControllerDescriptor>(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<string, HttpControllerDescriptor> 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
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 => { c.MultipleApiVersions((apiDesc, targetApiVersion) => SwaggerVersionHelper.ResolveVersionSupportByRouteConstraint(apiDesc, targetApiVersion), (vc) => { vc.Version("v2", "第二版"); vc.Version("v1", "第一版"); }); c.CustomProvider((defaultProvider) => new CachingSwaggerProvider(defaultProvider)); }) .EnableSwaggerUi(c => { }); } }
運行,可以切換文件版本,也可以調用 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 => 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<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); } 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