Rails 使用 Swagger 自動生成 API 文件

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

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

swagger-sample
Swagger UI demo

安裝 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 demo
Demo 與官方文件

若使用 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 文件可以大幅度減輕後端的痛苦,相當建議嘗試。以上若有任何疑問歡迎留言喔!如果喜歡文章麻煩按讚分享喔!

Share on facebook
Facebook
Share on linkedin
LinkedIn
Share on twitter
Twitter

More to explorer

Close Menu