Vironを半年近く使ってみた



Viron(ヴァイロン)ってご存知ですか？

2018年2月に公開されたオープンソースのWebアプリケーションで、Swaggerに対応したAPIを書くだけで管理画面とダッシュボードを構築可能です。

本当にAPIを書くだけでいいので、デザインも考えなくて良いしむしろフロント側のコードを書く必要すらなくなります。

そんなVironを半年近く使ってみたので所感をかきます。

[cam-inc/viron github](https://github.com/cam-inc/viron)
[viron ドキュメント](https://cam-inc.github.io/viron-doc/docs/demo.html)

`v1.3.0` を使いましたが、一部 `rc` について触れます。

# Vironとは?

## フロントレス

![frontend.png](https://cam-inc.github.io/viron-doc/img/frontend.png)

> HTML、CSS、JavaScript等のフロント用ソースコードを記述する必要はありません。APIサーバのみを用意して下さい。

APIサーバのレスポンスからCURDとか検索とか基本的なUIは揃った綺麗な管理画面が構築されます。

ボタンを押すと指定のAPI叩くとかできるので、タスク実行とかCSVダウンロードとかUIに関係ない事はほぼできます。

vironサーバーはAPIサーバについて何も知らなくていいので、github pagesでもAWS S3の静的サイトホスティングでも良いのです。

普通のAPI作る工数からOAS準拠とかviron準拠させるために使った時間を抜き出すとそんなに時間はかかってないです。(規模とか複雑さにもよるけど3~5日くらい)

## レスポンシブデザイン

![responsive.png](https://cam-inc.github.io/viron-doc/img/responsive.png)

> デスクトップ端末とモバイル端末上で画面サイズに応じたレイアウトが設定されます。

何も書かずとも綺麗な管理画面ができるし、開発者は機能の作成だけに集中できます。

サービス毎に統一されたUIになるので利用者も嬉しい。

## OAS駆動

![oas.png](https://cam-inc.github.io/viron-doc/img/oas.png)

> OpenAPI Specification 2.0に準拠。

APIを記述するためのフォーマットで、Open API Initiativeによって推進されてるオープンな規格である[OpenAPI Specification 2.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md)に準拠。

OASに則りAPIを記述するために作成されたドキュメントは「OpenAPIドキュメント」と呼ばれ、JSON/YAMLの2つのフォーマットで記述が可能。

OpenAPI Specificationの旧名はSwagger SpecificationでSwaggerとイコールだと捉えていいと思います。

議論されたルールに乗っ取ったAPIを作れるので勉強になる。

## 無料/オープンソース

![oas.png](https://cam-inc.github.io/viron-doc/img/oss.png)

> GitHub上でオープンソースとして公開されています。誰でも無料で使用できます。

OSSなので機能追加などのアップデートが誰かの貢献のおかげで利用できる。

issues作るなりPR投げるなりみんなで作れる。ドキュメントもOSSなのでtypo見つけたらPR投げよう。



# Viron立ち上げてみる

[公式のクイックスタート](https://cam-inc.github.io/viron-doc/docs/dev_quickstart.html)でHTTPS(8080) にフロント、 HTTPS(3000) にAPIサーバーが立ち上がるので気軽に試せるし、[デモ](https://cam-inc.github.io/viron-doc/docs/demo.html)もあります。

今回はフロント群だけ欲しいのでdockerでビルド用コンテナ作ってビルドしました。

```sh
$ git clone -b v1.3.0 git@github.com:cam-inc/viron.git
$ docker build -t viron-builder:v1.3.0 ./viron
$ mkdir dist
$ docker run --rm -v $(pwd)/dist:/viron/_dist viron-builder:v1.3.0 /bin/bash -c "npm run build && cp -RT /viron/dist /viron/_dist"
$ docker rmi viron-builder:v1.3.0
```

フロント群がdistに作られたのでDockerfileを作って、公開するnginxコンテナを作ります。

```Dockerfile
FROM nginx:1.15.0
COPY ./dist /usr/share/nginx/html
```

```sh
$ docker build -t viron:v1.3.0 .
$ docker run --rm -p 3000:80 viron:v1.3.0
```

ブラウザで [http://localhost:3000](http://localhost:3000) を開きます。

出てきた画面からswagger.jsonを返すAPIエンドポイントを登録すると管理画面が構築されます。


# APIの定義

公式ドキュメントにほぼ書いている事なのですが、[必須API](https://cam-inc.github.io/viron-doc/docs/dev_api.html)が3つあり、それぞれ以下を定義します。

- swaggerの取得(API定義) `/swagger.json`
- 認証方式 `/viron_authtype`
- グローバルメニューの取得 `/viron`

## swaggerの取得(API定義) `/swagger.json`

swaggerの作り方は以下が参考になります

- [swaggerの取得](https://cam-inc.github.io/viron-doc/docs/dev_api_swagger.html)
- [開発効率を上げる！Swaggerの記法まとめ](https://tech.starttoday-tech.com/entry/swagger_yaml)
- [swagger.io 2.0 Basic Structure](https://swagger.io/docs/specification/2-0/basic-structure/)

定義はなかなか長くなるのでまずは全体像から見ていきます。

### 全体像

```js
{
  swagger: "2.0",
  schemes: ["https"],
  host: "api.example.com",
  info: {
    version: "1.0.0",
    title: "XXX 管理",
    description: "XXX 管理 - Example Management Console"
  },
  // vironからAPIのリクエストMIMEタイプ
  //   vironからファイルをAPIで受け取りたいならmultipart/form-dataを追加します
  consumes: ["application/json"],
  // APIからvironのレスポンスMIMEタイプ
  produces: ["application/json"],
  paths: {
    //APIのエンドポイントとリクエスト方法、レスポンスについて定義
    //長いので下のセクションで説明します
  },
  definitions: {
    //レスポンスやパラメータなどを変数定義して使いまわします
    //長いので下のセクションで説明します
  }
}
```

### paths

必須API 3種と利用したいAPIのエンドポイントとリクエスト方法、レスポンスについて定義します。

以下はuserの基本的なrest apiを定義しています。

```js
{
  // 必須API グローバルメニューの取得
  /viron: {
    get: {
      summary: "エンドポイント全体情報取得",
      operationId: "viron#index",
      responses: {
        200: {
          description: "ok",
          schema: {
            // $refはdefinitionsセクションで定義したschemaを利用
            $ref: "#/definitions/Viron"
          }
        }
      }
    }
  }
  // 必須API 認証方式の取得
  /viron_authtype: {
    get: {
      summary: "認証方式一覧",
      operationId: "viron_authtype#index",
      responses: {
        200: {
          description: "ok",
          schema: {
            $ref: "#/definitions/VironAuthtype"
          }
        }
      }
    }
  }
  // 必須API swaggerの取得(API定義)
  /swagger.json: {
    get: {
      summary: "swagger.json取得",
      operationId: "swagger#index",
      responses: {
        200: {
          description: "ok",
            schema: {
              type: "object"
            }
          },
          500: {
            description: "Internal Server Error"
          }
        }
      }
    }
  }
  // 利用したいAPIを定義する
  /api/v1/users: {
    // 一覧画面の定義
    get: {
      x-swagger-router-controller: "users",
      summary: "ユーザー 一覧",
      description: "ユーザー 一覧",
      operationId: "users#index",
      produces: "application/json",
      tags: ["users"],
      // ?limit=1などurlでパラメータを渡したい場合は in: "query"
      // 他にもヘッダに埋め込んだり、bodyにjsonで埋め込んだり、urlに埋め込んだりできる
      parameters: [
        {
          name: "limit",
          in: "query",
          required: false,
          type: "integer"
        },
        {
          name: "offset",
          in: "query",
          required: false,
          type: "integer"
        },
        {
          name: "sort",
          in: "query",
          required: false,
          type: "string"
        },
        {
          name: "id",
          in: "query",
          required: false,
          type: "integer"
        },
        {
          name: "name",
          in: "query",
          required: false,
          type: "string"
        },
      ],
      responses: {
        200: {
          description: "ok",
          schema: {
            // $refはdefinitionsセクションで定義したschemaを利用
            // definitions/UserCollectionはUserの複数系として定義している
            $ref: "#/definitions/UserCollection"
          }
        }
      }
    }
    // 作成画面の定義
    post: {
      x-swagger-router-controller: "users",
      summary: "ユーザー 作成",
      description: "ユーザー 作成",
      operationId: "users#create",
      produces: "application/json",
      tags: ["users"],
      parameters: [
        {
          // bodyに埋め込んだユーザー情報をパラメータとして送る
          name: "payload",
          in: "body",
          required: true,
          schema: {
            // definitions/UserBuildはUserの登録パラメータ
            $ref: "#/definitions/UserBuild"
          }
        }
      ],
      responses: {
        200: {
          description: "ok",
          schema: {
            // レスポンスはid付きUserオブジェクト
            $ref: "#/definitions/User"
          }
        }
      }
    }
  },
  // ユーザーidをurlに埋め込む
  /api/v1/users/{id}: {
    // 編集画面の定義
    put: {
      x-swagger-router-controller: "users",
      summary: "ユーザー 編集",
      description: "ユーザー 編集",
      operationId: "ads#update",
      produces: "application/json",
      tags: "users",
      parameters: [
        {
          // ユーザーidパラメータがpathに埋め込まれてリクエストされる
          name: "id",
          in: "path",
          required: true,
          type: "integer"
        },
        {
          // 他のパラメータはbodyに埋め込まれてる
          name: "payload",
          in: "body",
          required: true,
          schema: {
            $ref: "#/definitions/UserBuild"
          }
        }
      ],
      responses: {
        200: {
          description: "ok",
          schema: {
            $ref: "#/definitions/User"
          }
        }
      }
    },
    // 削除ボタンの定義
    delete: {
      x-swagger-router-controller: "users",
      summary: "ユーザー 削除",
      description: "ユーザー 削除",
      operationId: "users#delete",
      produces: "application/json",
      tags: "users",
      parameters: [
        {
          name: "id",
          in: "path",
          required: true,
          type: "integer"
        }
      ],
      responses: {
        204: {
          description: "no content"
        }
      }
    }
  }
}
```

### definitions

レスポンスやパラメータなどを変数定義して使いまわします。

$refを使って展開します `$ref: "#/definitions/User"`

```js

{
  // 認証方法の取得で使ってる定義
  VironAuthtype: {
    type: "array",
      items: {
        $ref: "#/definitions/AuthType"
      }
    }
  },
  AuthType: {
    required: [
      "type",
      "url",
      "method",
      "provider"
    ],
    additionalProperties: false,
    properties: {
      type: {
        type: "string"
      },
      url: {
        type: "string"
      },
      method: {
        type: "string"
      },
      provider: {
        type: "string"
      }
    }
  },
  // すでに登録されている単数モデル
  User: {
    type: "object",
    required: ["name"],
    additionalProperties: false,
    properties: {
      id: {
        type: "integer",
        description: "ID",
        example: 1
      },
      name: {
        type: "string",
        description: "名前",
        example: "山田"
      }
    }
  },
  // 登録する前の単数モデル
  UserBuild: {
    type: "object",
    required: ["name"],
    additionalProperties: false,
    properties: {
      name: {
        type: "string",
        description: "名前",
        example: "山田"
      }
    }
  },
  // #indexで返すモデルのリスト
  UserCollection: {
    type: "array",
    items: {
      $ref: "#/definitions/User"
    }
  }
}
```

## 認証方式 `/viron_authtype`

[認証方式](https://cam-inc.github.io/viron-doc/docs/dev_api_authtype.html)

独自にメールアドレスとパスワードを保存したユーザーテーブル作るとか、Googleログインとか利用できる。

```js

[
  // メールアドレスとパスワードによる認証。利用しない場合は削除しても良い
  {
    type: 'email', // メールアドレスとパスワードによる独自認証を利用する場合のtype
    provider: 'viron-example',
    url: '/signin', // サインインフォームでsubmitする際のリクエストURL
    method: 'POST', // submitする際のリクエストメソッド
  },
  // GoogleOAuthによる認証。利用しない場合は削除しても良い
  {
    type: 'oauth', // OAuth認証を利用する場合のtype
    provider: 'google', // OAuthを提供するプロバイダ。
    url: '/googlesignin',
    method: 'POST',
  },
  // 認証方式ではありませんが、サインアウト時にコールするAPIを定義するために必須です。
  {
    type: 'signout',
    provider: '',
    url: '/signout',
    method: 'POST',
  },
];
```

## グローバルメニューの取得 `/viron`

[グローバルメニューの取得](https://cam-inc.github.io/viron-doc/docs/dev_api_menu.html)

ほぼほぼ日本語で説明されてるけど一部説明付け足しました。

```js

{
  // エンドポイントに関する情報
  "theme": "standard", // カラーテーマ
  "color": "white", // ドットカラー
  "name": "Viron example - local", // エンドポイントの名称。サービス名や環境など
  "tags": [ // エンドポイントに付与するタグ
    "local",
    "viron",
    "example"
  ],
  "thumbnail": "https://avatars3.githubusercontent.com/u/23251378?v=3&s=200", // サムネイル画像URL
  // グローバルメニュー定義
  "pages": [
    {
      "section": "dashboard" // 大項目。"dashboard" or "manage"
      "group": "", // 中項目。空の場合はsection直下にcomponentsを配置
      "id": "quickview", // ページのID。全ページでユニークになっている必要があります
      "name": "クイックビュー", // ページ名
      "components": [ // メニューからページを選択した際に表示されるコンポーネントの一覧
        {
          "api": { // コンポーネントに表示する値を取得するためのAPI
            "method": "get",
            "path": "/stats/dau"
          },
          "name": "DAU", // コンポーネント名
          "style": "number" // コンポーネントスタイル。数字(number)、テーブル(table)の他に各種グラフ(graph-*)が利用できます
        },
        {
          "api": {
            "method": "get",
            "path": "/users"
          },
          "name": "MAU",
          "style": "table",
          // ヘッダに値を入れることでページングを可能にする(X-Pagination-Limitなど、後述します)
          "pagination": true,
          // 検索パラメータとして送れる値を定義
          "query": [
            {
              key: "id",
              type: "integer"
            },
            {
              key: "name",
              type: "string",
            }
            {
              // 文字列でリクエストするけど、フォームを日付形式にする
              key: "created_at",
              type: "string",
              format: "date-time"
            },
          ],
          // ソート可能にする v1.4.0でリリース予定? 現状rcブランチで使えます
          // APIにはsortパラメータにコロンとカンマで区切った形式で渡される('id:asc,name:desc')
          sort: [
            "id",
            "name"
          ],
        },
      ],
    },
    {
      "section": "manage"
      "group": "user",
      "id": "user",
      "name": "ユーザー",
      "components": [
        {
          "api": {
            "method": "get",
            "path": "/user"
          },
          "name": "ユーザー",
          "style": "table",
          "primary": "id", // テーブルデータの主キーにあたるフィールド
          "pagination": true, // テーブルスタイルのページャーを有効にするフラグ
          "query": [ // テーブルスタイルの検索フィールドを指定
            {
              "key": "name",
              "type": "string"
            }
          ],
          "table_labels": [ // テーブルスタイルの見出しにするフィールドを指定
            "id",
            "name"
          ],
          "actions": [ // 他APIの関連付けが必要な場合は指定。詳細は下記[API間の関連付け]を参照
            "/user/download/csv"
          ]
        }
      ],
    },
  ],
}
```

## ruby on railsでAPI実装する

ruby on railsでAPI実装する場合のだいたいの流れを書きます。

### ルーティング

必須API3つと管理画面構築に必要なAPIをルーティングします。

```rb
# config/routes.rb
Rails.application.routes.draw do

  resource :viron, only: %i[show], format: false
  resource :viron_authtype, only: %i[show], format: false
  resource :swagger, only: %i[show], constraints: lambda { |req| req.format == :json }

  namespace :api, defaults: { format: :json } do
    namespace :v1 do
      resources :users
    end
  end
end
```

### cors設定

CORS(Cross-Origin Resource Sharing)するための設定を書く必要があります。

Webサイトが持つ情報が別の悪意あるWebサイトに悪用されるのを防ぐために、Same-Origin Policy(日本語では同一生成元ポリシー)が適用されます。

クライアント(ブラウザ)から見知らぬドメイン当てにリクエストを行うことになるのでブラウザがブロックする(親切)ので事前に許可する必要がある。

gem rack-corsがよしなに `Access-Control-Allow-xxx` や `Access-Control-Expose-xxx` ヘッダをつけてくれます。

```Gemfile
# Gemfile
gem 'rack-cors'
```

```rb
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ['viron.example.com'] # 許可するhost
    resource '/api/*', headers: :any, methods: [:get, :post, :delete, :put, :options], expose: [
      'Origin', 'Content-Type', 'Content-Disposition', 'Accept', 'Authorization',
      'X-Requested-With', 'X-Authorization',
      'X-Pagination-Limit', 'X-Pagination-Total-Pages', 'X-Pagination-Current-Page',
    ]
    resource '/viron', headers: :any, methods: [:get]
    resource '/viron_authtype', headers: :any, methods: [:get]
    resource '/swagger.json', headers: :any, methods: [:get]
  end
end
```

### swagger定義

[swagger-docs](https://github.com/richhollis/swagger-docs)というgemはルーティング(やたぶんモデル)を見て動的にswagger定義を書いてくれる!!!のですが、

> I don't currently have any plans to add support for v2.0 at this time due to time constraints

v2.0のサポートを追加する予定はありませんとのことなのである程度楽にかける `swagger-blocks` を利用しました。

```Gemfile
gem 'swagger-blocks'
```

あとは以下を参考に定義をもくもくと書いていきます。

[管理画面は設定ファイルぐらいシンプルに作れるべき！『Viron』を使ってみました](https://inside.pixiv.blog/edvakf/3965)

[github RailsでViron対応管理画面を作る](https://gist.github.com/edvakf/37a3e8b4dbbf87c8a7ae6696eb3ae715)


### ページネーション対応

グローバルメニューの取得(`/viron`)でページネーションを有効にしていて、ページ総数や現在のページ数などを指定ヘッダにいれてレスポンスを返すとページネーションしてくれます。

```rb
response.headers['X-Pagination-Limit'] = @limit
response.headers['X-Pagination-Total-Pages'] = (@users.unscope(:limit).unscope(:offset).count / @limit.to_f).ceil
response.headers['X-Pagination-Current-Page'] = @offset / @limit + 1
```

### ソート

v1.4.0でリリース予定?、rcブランチで使える機能ですが、

グローバルメニューの取得(`/viron`)でソートする列を指定していればソート可能になります。

リクエストに `id:asc,name:desc` のような形式で送られてくるのでよしなに処理します。

```rb
def parse_order query
  query
    .split(',')
    .map{|v| v.split(':') }
    .select{|v| v.size === 2 && v[0].present? && v[1].present? && ['desc', 'asc'].include?(v[1]) }
    .map{|v| [v[0], v[1]] }
    .to_h
end

params[:sort]
=> 'id:asc,name:desc'

order_params = parse_order(params[:sort])
=> {"id"=>"asc", "name"=>"desc"}

users.order!(parse_order(params[:sort])) if params[:sort].present?
```

### controller

だいたいこんな感じになりました。

```rb
# 雰囲気users_controller.rb
class Api::V1::UsersController < ApplicationController
  before_action :set_limit_offset, only: [:index]
  before_action :set_users, only: [:index]
  before_action :set_user, only: [:create, :show, :update, :destroy]
  before_action :set_pagination_headers, only: [:index]

  def index
    render json: @users
  end

  def show
    render json: @user
  end

  def create
    if @user.save
      render json: @user, status: :created
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  def update
    if @user.update(update_params)
      render json: @user
    else
      render json: @user.errors, status: :unprocessable_entity
    end
  end

  def destroy
    @user.destroy
  end

  private
    def set_limit_offset
      @limit = params[:limit]&.to_i || 100
      @offset = params[:offset]&.to_i || 0
    end

    def set_users
      @users = User.all.limit(@limit).offset(@offset)
      @users.order!(parse_order(params[:sort])) if params[:sort].present?
      @users.where!(search_params) if search_params.present?
    end

    def set_pagination_headers
      response.headers['X-Pagination-Limit'] = @limit
      response.headers['X-Pagination-Total-Pages'] = (@users.unscope(:limit).unscope(:offset).count / @limit.to_f).ceil
      response.headers['X-Pagination-Current-Page'] = @offset / @limit + 1
    end

    def search_params
      params.permit(:id, :name)
        .select {|key, value| value.present? }
    end

    def update_params
      params.require(:user).permit(:name)
    end
end
```


# 所感

## いいところ

表のみならずグラフ表示やダッシュボードもあるのでBIツールにもなり得そうです。

API書くだけでいいので、ユースケースに合えば(ドラッグしたりUIゴリゴリじゃなければ)かなりの速度で開発出来ます。

actionを定義出来る(パラメータ指定してCRUD以外にもAPI叩ける)のでCSVダウンロードしたりバッチ起動したりとか柔軟に機能を追加出来ます。

swaggerはもともとAPI仕様を作ったり、テストも出来る便利な規格でしたが、Vironで管理画面も自動構築出来てしまうので1石3鳥です。

## とはいえ

日付の入力フォームが(todayやnowなど補完はしてくれるが)文字列なので厳しいです。これに関しては [DatePicker UI Component #269](https://github.com/cam-inc/viron/issues/269) で進んでいるようなので今後に期待します。

ソートはまだ正式リリースされていないです。次のバージョンでリリースされるのかな。[Table filter #341](https://github.com/cam-inc/viron/pull/341)

[オートコンプリート](https://cam-inc.github.io/viron-doc/docs/dev_form_autocomplete.html)に関しては、補完はしてくれるのですが、表示する値(名前など)とパラメータにする値(IDなど)を分ける事が出来ず、これも今後に期待です。

1レコードの編集に2click以上(歯車押して編集押す or レコード押して表示して編集押す)なのも地味にきます。

ほんとに書かなくなるのでたまにjs書きたくなる。

## さいごに

ユースケース確認して要件合えば今後も使いたいと思います。

導入に時間はかからないので試してみてはどうでしょうか？