CREATE 文から OpenAPI を自動生成するCLIを作ってみた

この記事はRecruit Engineers Advent Calendar 2020の19日目の記事です。

CREATE 文から OpenAPI を自動生成する[sql-swagger-generatorというCLI](https://github.com/toshi1127/sql-swagger-generator)を開発しました。

**作ったきっかけ**

アドベントカレンダーのネタに困っていたところ、0-1のフェーズで OpenAPI を書く必要があったため、ちょうどいいかなと思い実装してみました。

OpenAPIの説明をした後に、開発したCLIの紹介をしていきたいと思います！

## OpenAPI とは

OpenAPI(Swagger)は RESTful API を構築するためのオープンソースのフレームワークのことです。
Swagger Spec を書いておけば自動的にドキュメント生成までしてくれ、それだけではなく、ドキュメントから実際のリクエストを投げられる優れものです。

| ツール          | 説明                                                                          |
| --------------- | ----------------------------------------------------------------------------- |
| Swagger Editer  | Swagger Spec の設計書を記載するためのエディタ                                 |
| Swagger UI      | Swagger Spec で記載された設計からドキュメントを HTML 形式で自動生成するツール |
| Swagger Codegen | Swagger Spec で記載された設計から API のスタブを自動生成                      |

## OpenAPI でドキュメントを書くメリット

OpenAPI でドキュメントを書くメリットは、大きく分けて二つの観点があると思います

### 短期的な開発リードタイム短縮

#### FE 開発

- OpenAPI から API モックを作成して、API の実装を待つことなく実装ができる
  - 例: [Prism](https://github.com/stoplightio/prism)
- OpenAPI から API Client と I/F・Entity の型定義を自動生成することで、その実装の工数を省略+型安全な開発をすることができる
  - 例: [openapi-generator/typescript-axios](https://github.com/OpenAPITools/openapi-generator/blob/master/docs/generators/typescript-axios.md)

#### BE開発
- OpenAPIからController(一部)、バリデーションロジック、Request・Responseの型定義を生成できる
  - 個人的に[Nest.js](https://github.com/OpenAPITools/openapi-generator/pull/5518)用のgeneratorが開発されていて楽しみです。

<details><summary>自動生成に対応している言語/フレームワーク一覧</summary><div>

```
- ada-server
- aspnetcore
- cpp-pistache-server
- cpp-qt5-qhttpengine-server
- cpp-restbed-server
- csharp-nancyfx
- erlang-server
- fsharp-functions (beta)
- fsharp-giraffe-server (beta)
- go-gin-server
- go-server
- graphql-nodejs-express-server
- haskell
- java-inflector
- java-msf4j
- java-pkmst
- java-play-framework
- java-undertow-server
- java-vertx
- java-vertx-web (beta)
- jaxrs-cxf
- jaxrs-cxf-cdi
- jaxrs-cxf-extended
- jaxrs-jersey
- jaxrs-resteasy
- jaxrs-resteasy-eap
- jaxrs-spec
- kotlin-server
- kotlin-spring
- kotlin-vertx (beta)
- nodejs-express-server (beta)
- php-laravel
- php-lumen
- php-silex
- php-slim4
- php-symfony
- php-ze-ph
- python-aiohttp
- python-blueplanet
- python-flask
- ruby-on-rails
- ruby-sinatra
- rust-server
- scala-akka-http-server (beta)
- scala-finch
- scala-lagom-server
- scala-play-server
- scalatra
- spring
```
</div></details>


### 中〜長期的な保守観点
  - いい感じのドキュメントを生成してくれる（OpenAPIからI/Fを生成する開発手法させ続けられれば
    - フロントエンドとバックエンドで実装する人が異なる場合や将来的に異なる人が実装する可能性がある場合などに役立ちそうですね 
     ![代替テキスト](https://static1.smartbear.co/swagger/media/images/tools/opensource/swagger_ui.png?ext=.png)
  - リクエストとレスポンス用のモデルは定義ファイルにしたがって自動生成されるため、APIの仕様変更に追従しやすいく、型安全
  - ある程度は書き方に統一性を持たせられるため、レビュー時に確認すべきポイントが明確になりやすい

## sql-swagger-generatorについて

今回実装してみた、SQLのCreate分から各テーブルのCRUDを生成する[sql-swagger-generator](https://github.com/toshi1127/sql-swagger-generator)についてです。

### 使い方
#### インストール方法
```
go get -v -u github.com/toshi1127/sql-swagger-generator
```
#### コードの自動生成に必要なもの(2つ)
1.プロダクトのDBを作成するSQL文

```sql
CREATE TABLE products (
    id INT NOT NULL AUTO_INCREMENT,
    name varchar(255) NOT NULL,
    price INT NOT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE IF NOT EXISTS users(
    user_id varchar(255) PRIMARY KEY,
    nick_name varchar(255) NOT NULL,
    profile_image_uri varchar(255),
    email varchar(255) NOT NULL,
    description varchar(255),
    social_link varchar(255),
    gender enum('male', 'female', 'other'),
    identify_status varchar(255),
    customer_id varchar(255),
    created_at timestamp not null default current_timestamp,
    updated_at timestamp not null default current_timestamp on update current_timestamp,
    deleted_at timestamp
);
```

2.sql-swagger-generatorの設定ファイル

```yaml

service:
  name: TestService # サービス名
  host: 1.1.1.1:1234 # ホスト名
  prefix: /v1 # エンドポイントのprefix

resources: # SQLの中、どのテーブルを対象に自動生成するか
  users:
    title: user
    definition:
      name: User
  products:
    title: product
    definition:
      name: Product
```

#### 実行方法
設定ファイル・生成対象のSQL文のパス、アウトプットの出力先を指定します。

```
sql-swagger-generator conf=./example/conf.yml -sql=./example/queries.sql -outputDir=./example/swagger/swagger.yml
```

#### 実行結果

CLIを実行すると、モデルの定義とAPIドキュメントが生成されます。

**自動生成されたモデルの定義**
`models`は以下に、ymlファイルで指定したテーブルの定義が生成されます。

```models/user.yml

title: User
type: object
properties:
  user_id:
    type: string
  nick_name:
    type: string
  profile_image_uri:
    type: string
  email:
    type: string
  description:
    type: string
  social_link:
    type: string
  gender:
    type: string
    enum:
      - male
      - female
      - other
  identify_status:
    type: string
  customer_id:
    type: string
  created_at:
    type: string
    format: date-time
  updated_at:
    type: string
    format: date-time
  deleted_at:
    type: string
    format: date-time
required:
  - nick_name
  - email
  - created_at
  - updated_at
```

```models/product.yml
title: Product
type: object
properties:
  id:
    type: integer
    format: int64
  name:
    type: string
  price:
    type: integer
    format: int64
required:
  - name
  - price
```

**各モデルのAPIドキュメント**

各モデルのCRED+Bulk Updateに関するドキュメントが生成されます。

<details><summary>show:</summary><div>

```yaml

swagger: '2.0'
info:
  title: TestService
  version: 1.0.0
host: 1.1.1.1:1234
basePath: /v1
schemes:
  - http
consumes:
  - application/json
produces:
  - application/json
paths:
  /health-check:
    get:
      operationId: HealthCheck
      description: Returns 200 if the service is healthy.
      responses:
        200:
          description: Healthy
        500:
          description: Not healthy
  /users:
    get:
      operationId: GetUsers
      summary: Get users
      description: Returns all user resources.
      parameters:
        - in: query
          name: limit
          type: integer
        - in: query
          name: offset
          type: integer
      responses:
        200:
          description: List of user resources.
          schema:
            type: array
            items:
              $ref: './models/User.yml'
        500:
          description: Internal server error
    post:
      operationId: CreateUser
      summary: Create user
      description: Creates a user.
      parameters:
        - name: resource
          in: body
          required: true
          schema:
            $ref: './models/User.yml'
      responses:
        201:
          description: Created
          schema:
            $ref: './models/User.yml'
        400:
          description: Bad request
          schema:
            $ref: '#/definitions/Error'
        422:
          description: Unprocessable entity
          schema:
            $ref: '#/definitions/Error'
        500:
          description: Internal server error
  /users/batch:
    get:
      operationId: GetUsersByID
      summary: Get users by ID
      description: Returns the user resources with the given IDs.
      parameters:
        - in: query
          name: ids
          type: array
          items:
            type: integer
      responses:
        200:
          description: List of user resources
          schema:
            type: array
            items:
              $ref: './models/User.yml'
        500:
          description: Internal server error
  /users/{id}:
    get:
      operationId: GetUser
      summary: Get user by ID
      description: Returns the user with the given ID.
      parameters:
        - in: path
          name: id
          type: integer
          required: true
      responses:
        200:
          description: Single user
          schema:
            $ref: './models/User.yml'
        404:
          description: Not found
        500:
          description: Internal server error
    patch:
      operationId: PatchUser
      summary: Patch user
      description: Patches the user with the given ID.
      parameters:
        - name: id
          in: path
          type: integer
          required: true
        - name: patch
          in: body
          required: true
          schema:
            $ref: '#/definitions/Patch'
      responses:
        200:
          description: Success
          schema:
            $ref: './models/User.yml'
        400:
          description: Bad request
          schema:
            $ref: '#/definitions/Error'
        404:
          description: Not found
        422:
          description: Unprocessable entity
          schema:
            $ref: '#/definitions/Error'
        500:
          description: Internal server error
    put:
      operationId: PutUser
      summary: Put user
      description: Replaces the user with the given ID.
      parameters:
        - name: id
          in: path
          type: integer
          required: true
        - name: resource
          in: body
          required: true
          schema:
            $ref: './models/User.yml'
      responses:
        200:
          description: Success
        400:
          description: Bad request
          schema:
            $ref: '#/definitions/Error'
        404:
          description: Not found
        422:
          description: Unprocessable entity
          schema:
            $ref: '#/definitions/Error'
        500:
          description: Internal server error
    delete:
      operationId: DeleteUser
      summary: Delete user
      description: Deletes the user with the given ID.
      parameters:
        - name: id
          in: path
          type: integer
          required: true
      responses:
        200:
          description: Success
        404:
          description: Not found
        500:
          description: Internal server error
  /products:
    get:
      operationId: GetProducts
      summary: Get products
      description: Returns all product resources.
      parameters:
        - in: query
          name: limit
          type: integer
        - in: query
          name: offset
          type: integer
      responses:
        200:
          description: List of product resources.
          schema:
            type: array
            items:
              $ref: './models/Product.yml'
        500:
          description: Internal server error
    post:
      operationId: CreateProduct
      summary: Create product
      description: Creates a product.
      parameters:
        - name: resource
          in: body
          required: true
          schema:
            $ref: './models/Product.yml'
      responses:
        201:
          description: Created
          schema:
            $ref: './models/Product.yml'
        400:
          description: Bad request
          schema:
            $ref: '#/definitions/Error'
        422:
          description: Unprocessable entity
          schema:
            $ref: '#/definitions/Error'
        500:
          description: Internal server error
  /products/batch:
    get:
      operationId: GetProductsByID
      summary: Get products by ID
      description: Returns the product resources with the given IDs.
      parameters:
        - in: query
          name: ids
          type: array
          items:
            type: integer
      responses:
        200:
          description: List of product resources
          schema:
            type: array
            items:
              $ref: './models/Product.yml'
        500:
          description: Internal server error
  /products/{id}:
    get:
      operationId: GetProduct
      summary: Get product by ID
      description: Returns the product with the given ID.
      parameters:
        - in: path
          name: id
          type: integer
          required: true
      responses:
        200:
          description: Single product
          schema:
            $ref: './models/Product.yml'
        404:
          description: Not found
        500:
          description: Internal server error
    patch:
      operationId: PatchProduct
      summary: Patch product
      description: Patches the product with the given ID.
      parameters:
        - name: id
          in: path
          type: integer
          required: true
        - name: patch
          in: body
          required: true
          schema:
            $ref: '#/definitions/Patch'
      responses:
        200:
          description: Success
          schema:
            $ref: './models/Product.yml'
        400:
          description: Bad request
          schema:
            $ref: '#/definitions/Error'
        404:
          description: Not found
        422:
          description: Unprocessable entity
          schema:
            $ref: '#/definitions/Error'
        500:
          description: Internal server error
    put:
      operationId: PutProduct
      summary: Put product
      description: Replaces the product with the given ID.
      parameters:
        - name: id
          in: path
          type: integer
          required: true
        - name: resource
          in: body
          required: true
          schema:
            $ref: './models/Product.yml'
      responses:
        200:
          description: Success
        400:
          description: Bad request
          schema:
            $ref: '#/definitions/Error'
        404:
          description: Not found
        422:
          description: Unprocessable entity
          schema:
            $ref: '#/definitions/Error'
        500:
          description: Internal server error
    delete:
      operationId: DeleteProduct
      summary: Delete product
      description: Deletes the product with the given ID.
      parameters:
        - name: id
          in: path
          type: integer
          required: true
      responses:
        200:
          description: Success
        404:
          description: Not found
        500:
          description: Internal server error
definitions:
  Patch:
    type: array
    description: Patch instructions
    items:
      type: object
      required:
        - op
        - path
      properties:
        op:
          type: string
          description: Operation
        path:
          type: string
          description: Path to field to operate on
        value:
          $ref: '#/definitions/AnyValue'
  AnyValue:
    description: Any type of value
  Error:
    type: object
    properties:
      code:
        type: integer
        format: int64
        x-nullable: true
      message:
        type: string
  Principal:
    type: object
    description: Security principal for validating that a user is authorized to execute certain actions
    properties:
      userId:
        type: string
      permissions:
        type: array
        items:
          type: string


```
</div></details>

#### 生成されたOpen APIからコードを自動生成する

[sql-swagger-generatorのexample](https://github.com/toshi1127/sql-swagger-generator/tree/main/example)に、簡単なデモを用意しておいたので、それを使ってみましょう。

##### ソースコードをクローン

```
git clone https://github.com/toshi1127/sql-swagger-generator
```

##### exampleディレクトリは以下で、Dockerを使ってopenapi-generatorを使用する

```
cd example
```

```
make openapigen
```

`httpclient`配下にFE向けのAPIクライアントが生成され、`example/internal/restapi/openapi`配下にBE向けのコードが生成されたかと思います！
実際にAPI Clientを使い始めると、いまいちなところもある（query paramsがオブジェクトで渡せないなど）のですが、モデルの型定義を使えるだけでも、型安全に開発速度を上げることができると思います。

生成された各コードは下記の通りです（これは一部分ですが

```ts

/**
 * 
 * @export
 * @interface User
 */
export interface User {
    /**
     * 
     * @type {string}
     * @memberof User
     */
    userId?: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    nickName: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    profileImageUri?: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    email: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    description?: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    socialLink?: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    gender?: UserGenderEnum;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    identifyStatus?: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    customerId?: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    createdAt: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    updatedAt: string;
    /**
     * 
     * @type {string}
     * @memberof User
     */
    deletedAt?: string;
}

/**
    * @export
    * @enum {string}
    */
export enum UserGenderEnum {
    Male = 'male',
    Female = 'female',
    Other = 'other'
}

............

/**
 * Creates a user.
 * @summary Create user
 * @param {User} resource 
 * @param {*} [options] Override http request option.
 * @throws {RequiredError}
 */
async createUser(resource: User, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>> {
    const localVarAxiosArgs = await DefaultApiAxiosParamCreator(configuration).createUser(resource, options);
    return (axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
        const axiosRequestArgs = {...localVarAxiosArgs.options, url: basePath + localVarAxiosArgs.url};
        return axios.request(axiosRequestArgs);
    };
},

```

```go
// User struct for User
type User struct {
	UserId *string `json:"user_id,omitempty"`
	NickName string `json:"nick_name"`
	ProfileImageUri *string `json:"profile_image_uri,omitempty"`
	Email string `json:"email"`
	Description *string `json:"description,omitempty"`
	SocialLink *string `json:"social_link,omitempty"`
	Gender *string `json:"gender,omitempty"`
	IdentifyStatus *string `json:"identify_status,omitempty"`
	CustomerId *string `json:"customer_id,omitempty"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	DeletedAt *time.Time `json:"deleted_at,omitempty"`
}

// NewUser instantiates a new User object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewUser(nickName string, email string, createdAt time.Time, updatedAt time.Time, ) *User {
	this := User{}
	this.NickName = nickName
	this.Email = email
	this.CreatedAt = createdAt
	this.UpdatedAt = updatedAt
	return &this
}

// NewUserWithDefaults instantiates a new User object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewUserWithDefaults() *User {
	this := User{}
	return &this
}

// GetUserId returns the UserId field value if set, zero value otherwise.
func (o *User) GetUserId() string {
	if o == nil || o.UserId == nil {
		var ret string
		return ret
	}
	return *o.UserId
}

// GetUserIdOk returns a tuple with the UserId field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *User) GetUserIdOk() (*string, bool) {
	if o == nil || o.UserId == nil {
		return nil, false
	}
	return o.UserId, true
}
.......
```

## 最後に

便利に感じだら、是非使ってみてください！
多分バグもあるので、PR歓迎です。笑

PS. 久しぶりのGo言語書いたら楽しかった...