Rails 使用 Swagger 自動生成 API 文件

Gary Huang
Traveling Light Taipei
22 min readOct 13, 2020

隨著專案規模增大,前後端分離勢在必行,對於後端來說,撰寫 API 文件是相當重要的工作,一方面讓前端進行開發時不用一直打擾後端詢問 API 格式,另一方面對於未來工作交接也會更順利。 BUT ! 就是這個 BUT ,API 文件撰寫相當花時間,文件呈現美觀與格式也需要設計,對於缺乏美感的後端工程師來說相當痛苦,例如我。這時候就可以使用 Swagger 製作 API 精美的文件!馬上讓我們來試試看~

Ruby 有許多針對 swagger 開發的 gem,許多人有撰寫過 swagger-docs 的教學文件,然而這個 gem 只有支援 v1.2 swagger ,若要使用新版的 v2.0 ,建議使用 swagger-blocks 。兩者我都有實作過,我覺得 swagger-blocks 更好用,推薦大家。

安裝 gem

Gemfile 中寫入 gem 'swagger-blocks' ,執行 bundle install 即可。

Controller 設定 Swagger

以下分別解釋 CRUD API 文範例,只需要在 Controller 檔案中加入 include Swagger::Blocks ,這邊以前後分離的 controller 為例。

module Api::V1
class ArticlesController < ApiController
before_action :set_article, only: %i[show update destroy] include Swagger::Blocks swagger_path '/articles' do
operation :get do
key :summary, '顯示全部文章'
key :description, '顯示全部文章'
key :operationId, 'findArticles'
key :produces, [
'application/json'
]
key :tags, [
'article'
]
parameter do
key :name, :page
key :in, :query
key :description, '頁數'
key :type, :integer
end
response 200 do
key :description, '成功回應'
schema do
property :success, example: true
property :articles do
items do
key :'$ref', :ArticleOutput
end
end
property :pagination, '$ref': :Paging
end
end
end
end
def index
@articles = Article.all
end

swagger_path 代表顯示的 routes 路徑。
operationId 代表 api 文件呈現的標籤,例如: key :operationId, 'findArticles'顯示 #operation/findArticles 。
produces 代表顯示的資料型態。
tags 代表文件中顯示標籤名稱,同一個 controller 裡面都應該顯示相同的 tags
parameter 區塊表示輸入的參數
key :in 可以輸入 :query, :body, :path 等,代表輸入的內容
response 區塊顯示回傳資料內容
schema 區塊中可以輸入不同 property,顯示回傳的資料
property 表示回傳資料名稱
key :'$ref' 後面接著變數會從 model 裡面抓相對應的資料,大幅度簡化撰寫 API 文件的工作量,並且降低出錯的可能性

接著我們查看 Article model 中的 ArticleOutput 怎麼設計。

Model 設定 Swagger

撰寫輸出 API 格式需要在 Model 檔案中加入 include Swagger::Blocks ,以下是範例:

class Article < ApplicationRecord  include Swagger::Blocks  swagger_schema :Article do
property :title do
key :type, :string
key :example, 'Article title'
end
property :content do
key :type, :text
key :example, 'Article content'
end
end
swagger_schema :ArticleOutput do
allOf do
schema do
property :id do
key :type, :integer
key :example, 1
end
property :created_at do
key :type, :datetime
key :example, '2019-04-19T07:07:23.982Z'
end
property :updated_at do
key :type, :datetime
key :example, '2019-04-19T07:07:23.982Z'
end
end
schema do
key :'$ref', :Author
end
end
end

swagger_schema 區塊可以填入 schema table 中 column 名稱跟資料型態
property 代表 column 名稱
key :type 代表資料型態
allOf 可以包裝兩個以上的 schema
key :’$ref’ 可以輸入其他 model 中定義的 swagger_schema

這裡可以看到我們將會區分 Output 與 Input ,輸出與輸入內容原本就不同,例如:產生 article 時並不用指定 id, created_at, updated_at 等資訊,這些都是資料庫自動指定的數值。

設定 Docs Controller

ApidocsController 將內容轉換為 JSON 格式,並且輸出 API 檔案,以下是範例:

module Api::V1
class ApidocsController < ApiController
include Swagger::Blocks
swagger_root do
key :swagger, '2.0'
info do
key :version, '0.1.0'
key :title, '測試 API'
key :description, '測試中 API'
key :name, 'Gary Huang'
end
key :host, ENV.fetch('HOST_DOMAIN')
key :schemes, ['https']
key :basePath, '/api/v1'
key :consumes, ['application/json']
key :produces, ['application/json']
end
SWAGGERED_CLASSES = [
Api::V1::ArticlesController,
Article,
Paging,
self,
]
def index
render json: Swagger::Blocks.build_root_json(SWAGGERED_CLASSES)
end
end
end

SWAGGERED_CLASSES 需要把包含的 controller 寫進去陣列中,需要注意 Apidocscontroller 必須與包含的 ArticlesController 位於同一個資料夾中,舉例來說兩者都位於 app/controller/api/v1 的資料夾中,好處在於可以分別撰寫 v1, v2, v3 等不同版本 api 的文件。
render json: Swagger::Blocks.build_root_json(SWAGGERED_CLASSES) 會將 SWAGGERED_CLASSES 包含的內容輸出成 JSON 格式

設定 Routes

需要設定 ApidocsController 的路由,輸入 /api/v1 將會出入 API 文件的 JSON 檔案

namespace :api, defaults: {format: :json} do
namespace :v1 do
get '/', to: 'apidocs#index'
resources :articles
end
end

設定文件顯示頁面

光 JSON 檔案沒有排版看起來還是非常困難,這裡建議可以用 Swagger 官方的 UI 呈現或是 ReDoc 來呈現文件。

若使用 ReDoc 相當簡單,新增 views/pages/api.html.erb 檔案,並且在 routes.rb 中設定 get ‘/api’, to: ‘pages#api’ ,輸入 api 可以連結到 API 文件。範例文件如下:

<!DOCTYPE html>
<html>
<head>
<title>ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url="/api/v1"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>

ReDoc 使用 React 撰寫,因此可以看到 component <redoc spec-url="/api/v1"></redoc> 需要輸入路由,接受 ApidocsController 產生的 JSON 檔案。

如果要使用 Swagger 官方的 UI 呈現,可以新增 views/pages/swagger.html.erb 檔案,範例如下:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="x-ua-compatible" content="IE=edge">
<title>Swagger UI</title>
<!-- <link rel="icon" type="image/png" href="images/favicon-32x32.png" sizes="32x32" /> -->
<!-- <link rel="icon" type="image/png" href="images/favicon-16x16.png" sizes="16x16" /> -->
<!-- <link href='css/typography.css' media='screen' rel='stylesheet' type='text/css'/> -->
<link href='https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css' media='screen' rel='stylesheet' type='text/css'/>
<link href='https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/2.2.10/css/screen.css' media='screen' rel='stylesheet' type='text/css'/>
<!-- <link href='css/reset.css' media='print' rel='stylesheet' type='text/css'/> -->
<!-- <link href='css/print.css' media='print' rel='stylesheet' type='text/css'/> -->
<script> // src='lib/object-assign-pollyfill.js'
if (typeof Object.assign != 'function') {
(function () {
Object.assign = function (target) {
'use strict';
if (target === undefined || target === null) {
throw new TypeError('Cannot convert undefined or null to object');
}
var output = Object(target);
for (var index = 1; index < arguments.length; index++) {
var source = arguments[index];
if (source !== undefined && source !== null) {
for (var nextKey in source) {
if (Object.prototype.hasOwnProperty.call(source, nextKey)) {
output[nextKey] = source[nextKey];
}
}
}
}
return output;
};
})();
}
</script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/1.8.0/jquery-1.8.0.min.js' type='text/javascript'></script>
<script> // src='lib/jquery.slideto.min.js'
(function(b){b.fn.slideto=function(a){a=b.extend({slide_duration:"slow",highlight_duration:3E3,highlight:true,highlight_color:"#FFFF99"},a);return this.each(function(){obj=b(this);b("body").animate({scrollTop:obj.offset().top},a.slide_duration,function(){a.highlight&&b.ui.version&&obj.effect("highlight",{color:a.highlight_color},a.highlight_duration)})})}})(jQuery);
</script>
<script> // src='lib/jquery.wiggle.min.js'
jQuery.fn.wiggle=function(o){var d={speed:50,wiggles:3,travel:5,callback:null};var o=jQuery.extend(d,o);return this.each(function(){var cache=this;var wrap=jQuery(this).wrap('<div class="wiggle-wrap"></div>').css("position","relative");var calls=0;for(i=1;i<=o.wiggles;i++){jQuery(this).animate({left:"-="+o.travel},o.speed).animate({left:"+="+o.travel*2},o.speed*2).animate({left:"-="+o.travel},o.speed,function(){calls++;if(jQuery(cache).parent().hasClass('wiggle-wrap')){jQuery(cache).parent().replaceWith(cache);}
if(calls==o.wiggles&&jQuery.isFunction(o.callback)){o.callback();}});}});};
</script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery.ba-bbq/1.2.1/jquery.ba-bbq.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.5/handlebars.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/lodash-compat/3.10.1/lodash.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js' type='text/javascript'></script>
<script> // src='lib/backbone-min.js'
// From http://stackoverflow.com/a/19431552
// Compatibility override - Backbone 1.1 got rid of the 'options' binding
// automatically to views in the constructor - we need to keep that.
Backbone.View = (function(View) {
return View.extend({
constructor: function(options) {
this.options = options || {};
View.apply(this, arguments);
}
});
})(Backbone.View);
</script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/2.2.10/swagger-ui.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.10.0/highlight.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.10.0/languages/json.min.js' type='text/javascript'></script>
<!-- <script src='https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.10.0/languages/xml.min.js' type='text/javascript'></script> -->
<!-- <script src='lib/highlight.9.1.0.pack_extended.js' type='text/javascript'></script> -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/json-editor/0.7.28/jsoneditor.min.js' type='text/javascript'></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.6/marked.min.js' type='text/javascript'></script>
<!-- <script src='lib/swagger-oauth.js' type='text/javascript'></script> -->
<!-- Some basic translations -->
<!-- <script src='lang/translator.js' type='text/javascript'></script> -->
<!-- <script src='lang/ru.js' type='text/javascript'></script> -->
<!-- <script src='lang/en.js' type='text/javascript'></script> -->
<script type="text/javascript">
$(function () {
var url = window.location.search.match(/url=([^&]+)/);
if (url && url.length > 1) {
url = decodeURIComponent(url[1]);
} else {
url = "your_api_url";
}
hljs.configure({
highlightSizeThreshold: 5000
});
// // Pre load translate...
// if(window.SwaggerTranslator) {
// window.SwaggerTranslator.translate();
// }
window.swaggerUi = new SwaggerUi({
url: url,
dom_id: "swagger-ui-container",
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch'],
onComplete: function(swaggerApi, swaggerUi){
// if(typeof initOAuth == "function") {
// initOAuth({
// clientId: "your-client-id",
// clientSecret: "your-client-secret-if-required",
// realm: "your-realms",
// appName: "your-app-name",
// scopeSeparator: " ",
// additionalQueryStringParams: {}
// });
// }
//
// if(window.SwaggerTranslator) {
// window.SwaggerTranslator.translate();
// }
},
onFailure: function(data) {
log("Unable to Load SwaggerUI");
},
docExpansion: "none",
jsonEditor: false,
defaultModelRendering: 'schema',
showRequestHeaders: false,
showOperationIds: false
});
window.swaggerUi.load();
function log() {
if ('console' in window) {
console.log.apply(console, arguments);
}
}
});
</script>
</head>
<body class="swagger-section">
<div id='header'>
<div class="swagger-ui-wrap">
<a id="logo" href="http://swagger.io"><img class="logo__img" alt="swagger" height="30" width="30" src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/2.2.10/images/logo_small.png" /><span class="logo__title">swagger</span></a>
<form id='api_selector'>
<div class='input'><input placeholder="http://example.com/api" id="input_baseUrl" name="baseUrl" type="text"/></div>
<div id='auth_container'></div>
<div class='input'><a id="explore" class="header__btn" href="#" data-sw-translate>Explore</a></div>
</form>
</div>
</div>
<div id="message-bar" class="swagger-ui-wrap" data-sw-translate> </div>
<div id="swagger-ui-container" class="swagger-ui-wrap"></div>
</body>
</html>

可以搜尋以上區塊中有段 url = “your_api_url” ,這裡換成你的 URL ,注意此檔案有限定同源,如果在本機開啟伺服器就只能讀取本機產生的 JSON 檔案。

ReDoc 與 Swagger UI 格式比較

ReDoc 的文件比較寬,三個欄位,Swagger UI 則是一個欄位從上到下,因此文件會比較長,我會建議使用 ReDoc 方便閱讀。

小結

使用 Swagger 撰寫 API 文件可以大幅度減輕後端的痛苦,相當建議嘗試。以上若有任何疑問歡迎留言喔!如果喜歡文章麻煩按讚分享喔!

--

--

Gary Huang
Traveling Light Taipei

自學程式,目前爲 React 前端工程師,兼職線上課程業師,協助程式自學者就業。熱愛旅遊,將近 30 個國家。訂閱我的旅行與街舞 YT :https://www.youtube.com/channel/UCEU-bEDl7R-iGyLVZFae33g