はじめに

本記事ではサービス開発案件で蓄積されたノウハウとして、OpenAPIの定義ファイルからREST APIのバックエンドソースコードを自動生成して開発を行う方法を紹介します。

背景

本開発ではバックエンドはSpring Boot、 フロントエンドはReactを用いており、SPA+REST API構成サービス開発リファレンスに則り、フロントエンド-バックエンド間のAPIの認識差の発生を防止するため、 OpenAPIでAPIを定義するようにしました。

また、本開発では

  • フロントエンドとバックエンドが並行して開発を行う
  • 少人数で開発開始し、後に増員予定がある

という背景があり、その中で効率的な開発、品質の担保、新規参画者への参入障壁を下げる等の観点を満たして進めるために自動生成を活用することにしました。

OpenAPIとは

OpenAPIとはREST APIの定義を記述するためのフォーマットで、OpenAPI定義ファイルではAPIに関する以下のものを記述できます。

  • オペレーションとエンドポイント
  • オペレーションの入出力パラメータ
  • 認証方法
  • 問い合わせ先、ライセンス、利用規約など

使用ツール

OpenAPI Generator
API仕様を定義したYAMLファイルからソースコードを自動生成するツールです。

OpenAPI Generatorを利用した自動生成方法

公式ドキュメントのGetting StartedOpenAPI generatorのGitHubのREADMEを参考に次のように自動生成を行いました。

1.CLIの導入

任意のディレクトリでOpenAPI generatorのGitHubからcloneします。

git clone https://github.com/OpenAPITools/openapi-generator.git

cloneしたopenapi-generatorディレクトリに移動しコンパイル、パッケージ化します。

cd openapi-generator 
mvn clean package

これによりCLIのjarファイルopenapi-generator-cli.jarが作成されます。

2.ソースコード自動生成

1.設定ファイルの作成

自動生成の前にOpenAPI generatorの設定のため下記のような設定ファイルを任意の場所に作成します。

【config.json】

{ 
    "interfaceOnly": "true", 
    "apiPackage": "hoge.piyo.generated.api", 
    "modelPackage": "hoge.piyo.generated.model"
}

各項目の意味は下記の通りです。

interfaceOnly サーバファイルは作成せずスタブ実装がされたAPIインタフェースのみ作成するかどうか
apiPackage apiクラスのパッケージ
modelPackage modelクラスのパッケージ

他の設定項目やデフォルト値に関してはドキュメントを参照してください。

2.ソースコードの自動生成

openapi-generator-cli.jarを実行し、自動生成を行います。

java -jar [openapi-generator-cli.jarのパス] generate -i [OpenAPI定義ファイルのパス] -o [出力先パス] -g spring -c [設定ファイルのパス]

-gオプションではgenerator nameを指定します。generator nameとは生成するフームワークや言語の種類(java, springなど)を指す識別子です。 詳しくはドキュメントを参照してください。本開発ではバックエンドにSpring Bootを採用していましたのでspringを指定しています。

ここからは自動生成の具体的な例を挙げて説明します。
例えば次のような2APIが定義されたOpenAPI定義ファイルから自動生成を行うとします。

  • 商品登録API:POST /product
  • 商品詳細取得API:GET /product/{product_id}

【openapi.yaml(OpenAPI定義ファイル)】

openapi: 3.0.0
info:
  title: openapi
  version: '1.0'
servers:
  - url: 'http://localhost:8080'
paths:
  '/product':
    post:
      operationId: post-product
      tags:
        - Product
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductRequest'
      responses:
        '200':
          description: OK
  '/product/{product_id}':
    parameters:
      - schema:
          type: string
        name: product_id
        in: path
        required: true
    get:
      operationId: get-product-product_id
      tags:
        - Product
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductResponse'
components:
  schemas:
    ProductResponse:
      title: ProductResponse
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        price:
          type: integer
      required:
        - id
        - name
        - price
    ProductRequest:
      title: ProductRequest
      type: object
      properties:
        name:
          type: string
        price:
          type: integer
      required:
        - name
        - price

すると-oで指定した出力先パスに下記ソースコードファイルが出力されます。

  • APIインタフェース
    • 以下のAPIが生成されます
      • GET /product/{product_id}
      • POST /product

【ProductApi.java】

/**
 * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * https://openapi-generator.tech
 * Do not edit the class manually.
 */
package hoge.piyo.generated.api;

import hoge.piyo.generated.model.ProductRequest;
import hoge.piyo.generated.model.ProductResponse;
import ...

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-09-08T11:56:15.267619500+09:00[Asia/Tokyo]")
@Validated
@Tag(name = "product", description = "the product API")
public interface ProductApi {

    default Optional<NativeWebRequest> getRequest() {
        return Optional.empty();
    }

    /**
     * GET /product/{product_id}
     *
     * @param productId  (required)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "getProductProductId",
        tags = { "Product" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", schema = @Schema(implementation =  ProductResponse.class)))
        }
    )
    @RequestMapping(
        method = RequestMethod.GET,
        value = "/product/{product_id}",
        produces = { "application/json" }
    )
    default ResponseEntity<ProductResponse> getProductProductId(
        @Parameter(name = "product_id", description = "", required = true, schema = @Schema(description = "")) @PathVariable("product_id") String productId
    ) {
        getRequest().ifPresent(request -> {
            for (MediaType mediaType: MediaType.parseMediaTypes(request.getHeader("Accept"))) {
                if (mediaType.isCompatibleWith(MediaType.valueOf("application/json"))) {
                    String exampleString = "{ \"price\" : 0, \"name\" : \"name\", \"id\" : \"id\" }";
                    ApiUtil.setExampleResponse(request, "application/json", exampleString);
                    break;
                }
            }
        });
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }


    /**
     * POST /product
     *
     * @param productRequest  (optional)
     * @return OK (status code 200)
     */
    @Operation(
        operationId = "postProduct",
        tags = { "Product" },
        responses = {
            @ApiResponse(responseCode = "200", description = "OK")
        }
    )
    @RequestMapping(
        method = RequestMethod.POST,
        value = "/product",
        consumes = { "application/json" }
    )
    default ResponseEntity<Void> postProduct(
        @Parameter(name = "ProductRequest", description = "", schema = @Schema(description = "")) @Valid @RequestBody(required = false) ProductRequest productRequest
    ) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);

    }

}
  • モデルクラス
    • @JsonPropertyでjsonにした際のマッピング、@NotNullなどでBeanバリデーションが生成されます
    • また、equals()、hashCode()、toString()、toIndentedString()も生成されます

【ProductResponse.java】

package hoge.piyo.generated.model;

import ...

/**
 * ProductResponse
 */

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-09-08T11:56:15.267619500+09:00[Asia/Tokyo]")
public class ProductResponse   {

  @JsonProperty("id")
  private String id;

  @JsonProperty("name")
  private String name;

  @JsonProperty("price")
  private Integer price;

  public ProductResponse id(String id) {
    this.id = id;
    return this;
  }

  /**
   * Get id
   * @return id
  */
  @NotNull 
  @Schema(name = "id", required = true)
  public String getId() {
    return id;
  }

  public void setId(String id) {
    this.id = id;
  }

  // nameidと同様
    
  // priceidと同様

  @Override
  public boolean equals(Object o) {
      // 省略
  }

  @Override
  public int hashCode() {
      // 省略
  }

  @Override
  public String toString() {
      // 省略
  }

  /**
   * Convert the given object to string with each line indented by 4 spaces
   * (except the first line).
   */
  private String toIndentedString(Object o) {
      // 省略
  }
}

【ProductRequest.java】

>package hoge.piyo.generated.model;

import ...

/**
 * ProductRequest
 */

@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2022-09-08T11:56:15.267619500+09:00[Asia/Tokyo]")
public class ProductRequest   {

  @JsonProperty("name")
  private String name;

  @JsonProperty("price")
  private Integer price;

  public ProductRequest name(String name) {
    this.name = name;
    return this;
  }

  /**
   * Get name
   * @return name
  */
  @NotNull 
  @Schema(name = "name", required = true)
  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  // pricenameと同様

  @Override
  public boolean equals(Object o) {
      // 省略
  }

  @Override
  public int hashCode() {
      // 省略
  }

  @Override
  public String toString() {
      // 省略
  }

  /**
   * Convert the given object to string with each line indented by 4 spaces
   * (except the first line).
   */
  private String toIndentedString(Object o) {
      // 省略
  }
}

3.生成コードの利用

APIインタフェース

APIインタフェースをimplementした下記のようなコントローラを作成することで、APIを実装していきます。

@RestController
public class ProductController implements ProductApi {
    ...
}
モデルクラス

モデルクラスについて独自実装を追加したい場合は自動生成したモデルクラスを継承したサブクラスを作成し、そのサブクラスに実装を追加していきます。なぜこのような手法をとるのかというと、自動生成されたコードを直接編集すると仮に仕様に変更等があり自動生成を実施し直した場合に、手で行った編集が上書きされ失われてしまうためです。ちなみに、このように自動生成されたコードを直接編集せず、サブクラスを作成し編集するデザインパターンのことをGeneration Gapパターンと呼びます。

このようにすることで、APIインタフェースとモデルクラスのソースコードを自動生成しAPIを実装することができます。

利用して感じたこと

良かった点

コーディング時間短縮
  • OpenAPI定義ファイルから人手でコントローラクラス、モデルクラスのソースコードを記述する手間が削減できた
  • typoや型誤り等のヒューマンエラーを防ぐことができ、インタフェースが保障されているため、フロントエンドとの接続をスムーズに行うことができた
  • OpenAPI定義ファイルに修正が生じた際にも自動生成を再実施することで素早く修正対応することができた
バリデーションの実装

OpenAPI generatorの設定項目useBeanValidationをtrueにすることで(デフォルト値:true)、OpenAPI定義ファイルで記述したバリデーションをBeanバリデーションとして自動生成でき、フロントエンドとバックエンドで同じ内容のバリデーションを比較的楽に実装することができました。
ただし、Beanバリデーションでエラーが発生した場合はorg.springframework.validation.BindExceptionが、型違いでエラーが発生した場合はorg.springframework.http.converter.HttpMessageNotReadableExceptionが投げられるのでExceptionHandlerでエラーハンドリングを行いました。

不便だった点

APIのルートパス毎にAPIインタフェースコードがまとめられてファイル出力される

OpenAPI generatorでAPIインタフェースコードを自動生成すると、APIパスのルート毎にファイルがまとめられて出力されます。
例えば下記のようなAPIが定義されていた場合は、1つのファイルProductApi.javaに出力されます。

  • 商品登録API:POST /product -> ProductApi.java
  • 商品取得API:GET /product/{product_id} -> ProductApi.java

また、下記のようなAPIが定義されていた場合は、別々のファイルProductsApi.javaShopApi.javaに出力されます。

  • 商品一覧API:GET /products -> ProductsApi.java
  • お店の商品一覧API:GET /shop/{shop_id}/products -> ShopApi.java
  • お店登録API:POST /shop -> ShopApi.java

このように、APIインタフェースコードがAPIパスのルート毎に生成されるため、APIで扱うリソース毎にAPIをまとめたい場合は、APIパスの命名規則に制約がかかります。ソース管理のしやすさを優先した本開発では、操作する主となるリソース名がAPIパスのルートにくるような命名規則を定めました。

  • お店の商品一覧API:GET /products/shop/{shop_id}

REST APIの開発でその他便利だったツール

OpenAPIの自動生成とは直接関係ありませんが、本開発で有用に感じたツールを簡単に紹介します。

Stoplight Studio

Stoplight StudioとはOpenAPI定義ファイルをGUIで記述できるツールであり、YAMLファイルの記述に経験の浅い私でもOpenAPI定義ファイルを作成しやすかったです。また、構文誤りなどチェックできる機能もあり便利でした。

ModelMapper

ModelMapperとはオブジェクト間のBeanマッピングライブラリです。
本開発ではAPIのRequest/Responseモデルクラスの値をドメインモデルクラスへコピーするのに用いました。これによりフィールドの値の数だけ処理を記述しなければいけないところを1行で記述でき、コーディング量の削減とコードの可読性向上ができました。

Mybatis Generator

MybatisとはJavaアプリケーションとRDSとのデータのやりとりを簡略化してくれるフレームワークであり、Mybatis Generatorはそのデータやり取りのソースコードを自動生成するツールです。
本開発ではデータアクセス層のコードの自動生成も行い、単純なCRUDのAPIであればOpenAPI generatorの自動生成と合わせ、ほとんど自動生成コードでAPIが実装でき、コーディングコストの削減ができました。

Swagger UI

Swagger UIとはOpenAPI定義ファイルからHTML形式でドキュメントを生成するツールであり、本記事のopenapi.yamlから下記のようなドキュメントを作成できます。

このドキュメント上からAPIを実行することができ、バックエンドAPI開発時のAPIのデバッグ、動作確認にGUIのような直感的操作で確認でき便利でした。

おわりに

OpenAPI generatorを用いてOpenAPIの定義ファイルからバックエンドAPIのソースコードを自動生成する流れを記述してきました。
インタフェースとモデルクラスを作成する手間を削減できた点、またインタフェースを自動生成することでインタフェースが守られAPIの単体テスト時に初めて発覚するような人的ミスを未然に防ぐことができた点で作業効率向上に寄与してくれ非常に良かったです。今後ともAPI開発時には積極的に利用していきたいと思います。