前言
上一篇博客介紹了使用Nancy框架內部的方法來創建了一個簡單到不能再簡單的Document。但是還有許許多多的不足。
為了能稍微完善一下這個Document,這篇引用了當前流行的Swagger,以及另一個開源的Nancy.Swagger項目來完成今天的任務!
注:Swagger是已經相對成熟的了,但Nancy(2.0.0-clinteastwood)和Nancy.Swagger(2.2.6-alpha)是基於目前的最新版本,但目前的都是沒有發布正式版,所以后續API可能會有些許變化。
下面先來簡單看看什么是Swagger
何為Swagger
The World's Most Popular Framework for APIs.
這是Swagger官方的描述。能說出是世界上最流行的,也是要有一定資本的!
光看這個描述就知道Swagger不會差!畢竟人家敢這樣說。當然個人也認為Swagger確實很不錯。
通過官方文檔,我們都知道要想生成Swagger文檔,可以使用YAML或JSON兩種方式來書寫,由於我們平常寫程序用的比較多的是JSON!
所以本文主要是使用了JSON,順帶說一下YAML的語法也是屬於易懂易學的。
既然是用JSON書寫,那么要怎么寫呢?這個其實是有一套規定、約束,我們只要遵守這些來寫就可以了。詳細內容可以參見OpenAPI Specification
本文后面的內容將默認園友們對Swagger有過了解。
Swagger主要有下面幾個東西,要引用基本的樣式和腳本就不在多說了。
當然,引用樣式和腳本只是最基本的前提,下面這段js(來自swagger-ui項目)才是最為主要的!
<script>
window.onload = function() {
// Build a system
const ui = SwaggerUIBundle({
url: "your url",//返回json數據的url地址
dom_id: '#swagger-ui',//在這個div展示內容
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
window.ui = ui
}
</script>
就是在上面加上注釋的兩個屬性:url指定了我們要展示數據(JSON格式)的來源,dom_id指定了在id為swagger-ui
的容器中展示我們的文檔。
在加載的時候創建了Swagger相關的內容,主要的有下面的兩個,其余的用默認的就可以了。
簡單來說,我們請求了這個url拿到了這些json數據,再根據這些數據在dom_id中構造出我們所看到的頁面。有那么點數據驅動的意思。
當然這些JSON數據是有格式要求的。可以看看下面的簡單示例
{
"swagger": "2.0",
"info": {
"title": "Simple API overview",
"version": "v2"
},
"paths": {
"/": {
"get": {
"operationId": "listVersionsv2",
"summary": "List API versions",
"produces": [
"application/json"
],
"responses": {
"200": {
"description": "200 300 response",
"examples": {
"application/json": "一串json"
}
}
}
}
}
},
"consumes": [
"application/json"
]
}
這也就意味着我們只需要嚴格按照Swagger的定義,就可以生成一個即美觀,又可執行的API文檔了。
更多相關JSON示例可參見
https://github.com/OAI/OpenAPI-Specification/tree/master/examples/v2.0/json
Nancy.Swagger說明
Nancy.Swagger
是我們今天的主角,是一個基於MIT協議的開源項目。Github地址:Nancy.Swagger
當然通過上面關於Swagger的說明,也已經大概明白了這個項目主要為我們做了什么。就是構造Swgger所需要的JSON格式的數據!
它並沒有像Swashbuckle.AspNetCore一樣集成了SwaggerUI的內容到項目中去,只是一個提供數據的項目。
其官方的示例Demo是用跳轉到petstore.swagger.io方式來完成的。但是經常性是要等待很長時間的,應該是網絡的問題。
為了避免這一情況,可以通過下面的操作避免:
- 手動下載swagger-ui相關的內容並添加到我們的新項目中。同時我還將這些設置成嵌入式的資源。
-
添加一個用於顯示的頁面,示例為doc.html,內容可以照搬swagger-ui目錄下面的index.html
-
在Bootstrapper中添加靜態資源的引用
protected override void ConfigureConventions(NancyConventions nancyConventions)
{
base.ConfigureConventions(nancyConventions);
nancyConventions.StaticContentsConventions.Add(StaticContentConventionBuilder.AddDirectory("swagger-ui"));
}
- 在訪問我們API時,將其重定向到doc.html頁面
public class HomeModule : NancyModule
{
public HomeModule()
{
Get("/", _ =>
{
return Response.AsRedirect("/swagger-ui");
});
Get("/swagger-ui",_=>
{
var url = $"{Request.Url.BasePath}/api-docs";
return View["doc", url];
});
}
}
- 修改doc.html的內容,將上述的url,替換成
@Model
window.onload = function() {
// Build a system
const ui = SwaggerUIBundle({
url: "@Model",
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
window.ui = ui
}
完成上面的內容后,就開始構造我們的文檔了。
構造文檔的基本信息
這里主要是設置這個API文檔的概要信息,比如文檔的標題,此api的版本等
需要通過SwaggerMetadataProvider的SetInfo方法來設置這些信息
下面是具體的示例代碼,寫在Bootstrapper中:
protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
{
SwaggerMetadataProvider.SetInfo("Nancy Swagger Example", "v1.0", "Some open api", new Contact()
{
EmailAddress = "catcher_hwq@outlook.com",
Name = "Catcher Wong",
Url = "http://www.cnblogs.com/catcher1994"
}, "http://www.cnblogs.com/catcher1994");
base.ApplicationStartup(container, pipelines);
}
此時對應的大致效果(這個時候是不能正常運行的,只是顯示了這部分的效果)如下:
上面代碼生成的JSON數據是符合規范的,如下所示:
下面要做的就是構造路由相關的信息
不帶任何請求參數
先在Module中定義一個簡單的路由,這個路由不帶任何參數。
Get("/", _ =>
{
var list = new List<Product>
{
new Product{ Name="p1", Price=199 , IsActive = true },
new Product{ Name="p2", Price=299 , IsActive= true }
};
return Negotiate.WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/json"), list);
}, null, "GetProductList");
然后在MetadataModule中添加相應的描述,這里的MetadataModule與上一篇是相似的,這也是為什么我會在上一篇先介紹不使用
第三方組件的來構造的原因,因為這種寫法下面,兩者沒有本質的區別!
Describe["GetProductList"] = desc => desc.AsSwagger(
with => with.Operation(
op => op.OperationId("GetProductList")
.Tag("Products")
.Summary("Get all products")
.Response(r=>r.Schema<IEnumerable<Product>>().Description("OK"))
.Description("This returns a list of products")
));
下面是部分Nancy.Swagger
里面的核心內容,也是上一篇所沒有的特殊之處。
AsSwagger是RouteDescription一個擴展方法,這個方法是返回我們需要的PathItem。
OperationId是這個路由的一個友好名稱,源碼里面的字段定義表明它要唯一。對更加詳盡的描述可能去看Swagger中對這些參數的說明!
Tag可以理解為這個路由屬於那個分組,起分隔符的作用,舉個例子,現在有A,B兩個模塊的API,我們肯定不能把它們交叉排列下去
而是A的放到一個地方,B的一個地方,便於我們的的區分。
Summary是當前路由的精簡描述,要小於120個字符。
Description是當前路由的詳細描述。
Response是期望的運行結果的相關內容,可以有多個,這里沒有標明狀態碼,而是直接寫處理的內容,此時說明這里用的是默認的狀態碼。
Response里面又是一個委托,里面又有部分定義:
Schema
Description是這個響應對應的描述信息
這個時候是會出錯的,因為我們在Respoonse的時候指定了Schema,但是我們並沒有指定它的定義。
我們需要先在MetadataModule中引用ISwaggerModelCatalog這個接口並調用它的AddModel方法把相關的類型添加進去,這樣才能正常運行!
public ProductsMetadataModule(ISwaggerModelCatalog modelCatalog)
{
//添加相應的類型
modelCatalog.AddModels(typeof(Product), typeof(IEnumerable<Product>));
Describe["GetProductList"] = desc => desc.AsSwagger(
with => with.Operation(
op =>
op.OperationId("GetProductList")
.Tag("Products")
.Summary("Get all products")
//在Schema中使用modelCatalog
.Response(r => r.Schema<IEnumerable<Product>>(modelCatalog).Description("OK"))
.Description("This returns a list of products")
));
}
示例結果如下:
先來看看上面設置對應的內容:
點擊Try it out運行的結果
可以看到使用curl 去訪問我們的實際接口拿到服務器的響應信息(結果和頭部)
在終端執行一下這個命令,也是這個結果。
帶Path參數和Query參數
同樣的,先在Module中定義一個路由,這個路由包含了一個Path參數和一個Query參數
Get("/{productid}", _ =>
{
var productId = _.productid;
if (string.IsNullOrWhiteSpace(productId))
{
return HttpStatusCode.NotFound;
}
var isActive = Request.Query.isActive ?? true;
var product = new Product
{
Name = "apple",
Price = 100,
IsActive = isActive
};
return Negotiate.WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/json"), product);
}, null, "GetProductByProductId");
這里作了多一點操作,為的是演示盡可能多的用法。如果傳遞的產品id為空,則直接返回404。如果沒有輸入isActive這個Query參數
返回Productr的IsActive就為false。
然后在MetadataModule中添加相應的描述
Describe["GetProductByProductId"] = desc => desc.AsSwagger(
with => with.Operation(
op => op.OperationId("GetProductByProductId")
.Tag("Products2")
.Summary("Get a product by product's id")
.Description("This returns a product's infomation by the special id")
.Parameter(new Parameter
{
Name = "productid",
In = ParameterIn.Path,//指明該參數是對應路由上面的同名參數
Required = true,//必填
Description = "id of a product"
})
.Parameter(new Parameter
{
Name = "isactive",
In = ParameterIn.Query,//指明該參數是對應QueryString上面的參數
Description = "get the actived product",
Required = false//非必填
})
.Response(r => r.Schema<Product>(modelCatalog).Description("Here is the product"))
.Response(404, r => r.Description("Can't find the product"))
));
這里多了一個Parameter是上面沒有提到的,這個就是我們的請求參數,這里的請求參數包含下面五種:
- Path
- Query
- Body
- Header
- Form
下面是運行的效果圖,分別演示了下面幾種情況
- 不填productid,不能執行,輸入框會變紅
- 填了productid,能執行,但是服務器端返回的isactive是false
- 填了productid和isactive,能執行,服務器返回的isactive是true
當然現在在MetadataModule的參數還有其他的寫法
Describe["GetProductByProductId"] = desc => desc.AsSwagger(
with => with.Operation(
op => op.OperationId("GetProductByProductId")
.Tag("Products2")
.Summary("Get a product by product's id")
.Description("This returns a product's infomation by the special id")
.Parameters(new List<Parameter>
{
new Parameter{Name = "productid",In = ParameterIn.Path,Required = true,Description = "id of a product"},
new Parameter{Name = "isactive",In = ParameterIn.Query,Description = "get the actived product",Required = false}
})
.ProduceMimeType("application/json")
.Response(r => r.Schema<Product>(modelCatalog).Description("Here is the product"))
.Response(404, r => r.Description("Can't find the product"))
));
可以用Parameters直接將所有的參數,組合成一個集合來進行處理。
此時的效果和上面是一樣的。
請求頭參數和請求體參數
在Module中添加一個新增商品的方法,這個方法包含兩種請求參數,一種是正常POST的json格式的數據,一種是請求頭,對於請求頭,只是判斷了一下客戶端發起的請求有沒有包含相應的請求頭就是了,並沒有做嚴格的判斷。同時為了演示多種MIME類型的返回結果,這里兼容了json和xml格式的返回結果。
Post("/", _ =>
{
var product = this.Bind<Product>();
if(!Request.Headers.Any(x=>x.Key=="test"))
{
return HttpStatusCode.BadRequest;
}
return Negotiate
.WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/json"), product)
.WithMediaRangeModel(new Nancy.Responses.Negotiation.MediaRange("application/xml"), product)
;
}, null, "AddProduct");
同樣的,MetadataModule中添加如下的描述:
Describe["AddProduct"] = desc => desc.AsSwagger(
with => with.Operation(
op => op.OperationId("AddProduct")
.Tag("Products")
.Summary("Add a new product to database")
.Description("This returns the added product's infomation")
.BodyParameter(para=>para.Name("para").Schema<Product>().Description("the infomation of the adding product").Build())//Request body
.Parameter(new Parameter()
{
Name = "test",
In = ParameterIn.Header,//http請求頭
Description = "must be not null",
Required = true,
})
.ConsumeMimeType("application/json") //post的參數只允許是json格式
.ProduceMimeTypes(new List<string>{ "application/json","application/xml" })//結果支持json和xml
.Response(r => r.Schema<Product>(modelCatalog).Description("Here is the added product"))
.Response(400, r => r.Description("Some errors occur during the processing"))
));
BodyParameter是我們在POST等操作時用的,它需要指定我們POST的數據格式(Schema那里的類型),為了演示添加請求頭信息,所以這里也加了一個必填的請求頭信息。
ConsumeMimeType表示我們發起請求的數據格式必須是json格式的,當然也可以支持多種不同的數據格式。
ProduceMimeTypes表示服務端響應時支持的數據格式,這里指定了json和Xml也是為了和我們Module中的內容相對應。
演示效果:
標注過時API和一個API屬於多個分組
有時候,API的界限分的不是很清晰或者有交集的時候,可能會出現這樣的情況:一個api會屬於多個分組。
前面我們都是直接指定了一個tag,也就表示上面的只是對應一個tag。
先來定義一個方法,用於演示多分組和過時、廢棄的API
Head("/",_=>
{
return HttpStatusCode.OK;
},null,"HeadOfProduct");
Metadata內容
Describe["HeadOfProduct"] = desc => desc.AsSwagger(
with => with.Operation(
op => op.OperationId("HeadOfProduct")
.Tags(new List<string>() { "Products", "Products2" })//同時屬於兩個分組
.Summary("Something is deprecated")
.Description("This returns only http header")
.IsDeprecated()//過時的,相當於常用的Obsolete,但是還可以用
.Response(r => r.Description("Nothing will return but http headers"))
));
效果如下:
雖說已經標記為過時了,但是本質這個方法還是存在,所以也是能正常調用的。
安全認證問題
Swagger支持3種安全認證折方式:APIKEY、Basic、OAuth2.0,同樣的Nancy.Swagger
也支持,不過有點坑就是了。
使用的話有兩個步驟(這里用最簡單的APIKEY演示):
Step 1: 引用定義,在Bootstrapper中添加驗證相關的內容
protected override void ApplicationStartup(TinyIoCContainer container, IPipelines pipelines)
{
SwaggerMetadataProvider.SetInfo("Nancy Swagger Example", "v1.0", "Some open api", new Contact()
{
EmailAddress = "catcher_hwq@outlook.com",
Name = "Catcher Wong",
Url = "http://www.cnblogs.com/catcher1994",
}, "http://www.cnblogs.com/catcher1994");
var securitySchemeBuilder = new ApiKeySecuritySchemeBuilder();
securitySchemeBuilder.Description("Authentication with apikey");
securitySchemeBuilder.IsInQuery();
securitySchemeBuilder.Name("Item1");
SwaggerMetadataProvider.AddSecuritySchemeBuilder(securitySchemeBuilder, "Item1");
base.ApplicationStartup(container, pipelines);
}
Step 2 : 在MetadataModule中添加描述
Describe["Head"] = description => description.AsSwagger(
with => with.Operation(
op => op.OperationId("Head")
.Tag("Head method")
.SecurityRequirement(SecuritySchemes.ApiKey)
.Summary("an example head method")
.Response(r => r.Description("OK"))));
當然,目前是沒有辦法正常運行的!此時運行效果如下:
單獨打開/api-docs這個路徑時提示如下錯誤:
這個十有八九是Nancy.Swagger
的安全驗證存在bug的,這個項目沒有足夠多的單元測試可能也是導致問題的一部分原因。
發現的主要bug是在MetadataModule中使用SecurityRequirement(SecuritySchemes.ApiKey)
時一直在報錯,報錯內容如下:
Nancy.RequestExecutionException: Oh noes! ---< System.InvalidCastException: Unable to cast object of type 'Swagger.ObjectModel.SecuritySchemes' to type 'System.String'.
at Swagger.ObjectModel.SwaggerModel.SwaggerSerializerStrategy.ToObject(IDictionary source)
於是調試源碼,發現在Swagger.ObjectModel項目下的ToObject方法有問題
private static dynamic ToObject(IDictionary source)
{
var expando = new ExpandoObject();
var expandoCollection = (ICollection<KeyValuePair<string, object>>)expando;
foreach (string key in source.Keys)
{
expandoCollection.Add(new KeyValuePair<string, object>(key, source[key]));
}
return expando;
}
從上面的出錯內容也能清楚的看到,SecuritySchemes不能轉成string的,其中SecuritySchemes是一個枚舉類型。
為了能正常運行,肯定要修改驗證一下!!於是修改成如下 :
private static dynamic ToObject(IDictionary source)
{
var expando = new ExpandoObject();
var expandoCollection = (ICollection<KeyValuePair<string, object>>)expando;
//用了var,在使用的時候強制ToString一下將其轉成string
foreach (var key in source.Keys)
{
expandoCollection.Add(new KeyValuePair<string, object>(key.ToString(), source[key]));
}
return expando;
}
由於在Mac上無法打開這個項目,所以上面的修改是切換回windows完成的。
進行上面的修改后,項目是已經能正常運行了!但是卻少了一個很重要的東西!
在這個方法里面加了APIKEY驗證的,但是小鎖的標記卻沒有出來!
之后對比了Swagger的官方示例http://petstore.swagger.io/
居然有這么坑爹的事情!security是一個數組啊,不是一個對象啊~~
后面就修改了Nancy.Swagger里面的許多代碼(瞎改的,只為了能正常運行),涉及了好幾個類文件,就不一一說明了。
第一個問題已經提了PR到這個項目了,第二個問題還沒找到比較滿意的方案,暫時沒提。
直接上最后的效果圖,分別演示了,沒有驗證,驗證成功和驗證失敗這三種情況!
注:本文只演示了其中Nancy.Swagger的其中一種用法,而且還有部分內容是沒有涉及到的。還有兩種其他用法有時間會拿出來和大家分享。
注意事項
在過程中還有一個需要十分注意的地方(本來這個應該是在上一篇提及的):就是XXModule和XXMetadataModule相對應的位置關系。
Nancy在這里限制的比較死,強制了下面三種情況:
Module所在的位置 | MetadtaModule應該在的位置 |
---|---|
./BlahModule | ./BlahMetadataModule |
./BlahModule | ./Metadata/BlahMetadataModule |
./Modules/BlahModule | ../Metadata/BlahMetadataModule |
這是文件分布所要注意的問題。
還有一個命名應該注意的問題:當我們對一個Module起名為ProductsModule時,它對應的MetadataModule一定要是ProductsMetadataModule。
而不能是其它,有一次由於粗心,忘記把s字母帶上,花了不少時間去找原因~~
上述兩個問題的答案在Nancy.Metadata.Modules
項目的DefaultMetadataModuleConventions類中。
簡單總結
Nancy.Swagger
給我們API文檔化的道路上帶來了不少的便利之處,除了安全驗證這一塊的問題有點坑,其他的算是比較正常,用起來也還算簡單。
對於Swagger來說,通用性很好,只要提供的指定格式的數據就能很好的渲染出讓人舒適的界面,或許這就是它這么流行的一個關鍵點吧。
下面是一張腦圖簡單的概括相關的內容 :
本文已同步到Catcher寫的Nancy匯總博客:Nancy之大雜燴