記事一覧

OpenAPIとTypeScriptで作る!チーム開発に適したWebアプリケーションの作り方
(6/26/2019)

はじめに

こんにちは! フィフス・フロアの開発チームリーダーのnotozekiです。

最近、OpenAPIという技術を知りました。
OpenAPIは、いわゆる「Web API」の仕様を形式的に記述するためのフォーマットです。
OpenAPIに関連するツールもいくつか提供されており、たとえばOpenAPIのフォーマットに従って書かれた仕様から、開発用のAPIのスタブサーバを自動生成するツールなども存在します。

Web開発が多い弊社では、Web APIの仕様管理は長年の課題でした。
特に開発メンバーが増えてくるにしたがって、以下のような課題が浮かび上がってきています:

  • 仕様に関する情報源がないため、「実装が仕様」のような状態になってしまっています。また、新しくプロジェクトに加わる人に、都度仕様を説明する必要があります。
  • 手動でのAPIドキュメントの整備も試みましたが、開発に比べて整備が後回しになりがちだったり、更新が止まってしまい、古いドキュメントのまま残り続けてしまうケースがあります。
  • 仕様に不明点がある場合、フロントエンド担当者がバックエンド担当者に逐一確認する必要があったりしてブロッキングしてしまい、せっかく分業していても開発効率を最大化できません。

そこで、OpenAPIを導入することで、Web APIを使う開発での上記のような課題を解決できないかと思い、簡単な試作アプリケーションを作って検証することにしました。

この記事では、OpenAPIと、弊社で最近採用事例が増えつつあるTypeScriptを使って、フロントエンド/バックエンドを分業しながらWebアプリケーションを開発するフローを検証していきます。
みなさんのチームでのWeb API開発の参考になれば幸いです🤗

サンプルアプリケーション

検証するために、「本の一覧を表示する」というだけの簡単なWebアプリケーションをサンプルとして作成しました。
コードの全文はGitHubで公開しています。

このWebアプリケーションの構成は以下のようになっています。

  • フロントエンド: Nuxt.js (TypeScript)/本の一覧ページを実装
  • バックエンド: NestJS (TypeScript)/本のデータを返すAPIを実装
  • Web API仕様: OpenAPI

リポジトリはいわゆるmonorepo構成にして、フロントエンド(frontend/)、バックエンド(backend/)の両方のコードを単一のリポジトリで管理します。
また、リポジトリにはOpenAPIで記述したAPI仕様のファイル(openapi.yaml)もコミットして、フロントエンド、バックエンド双方から参照しやすくします。

この記事では、全体の開発フローに着目しているので、実装の解説は最小限にとどめています。
実装の詳細はコードを参照してください。

OpenAPIでWeb API仕様を記述する

OpenAPIを活用した開発では、まずAPIの仕様を考えるのが出発点になります。

まずは、OpenAPIで今回作成するアプリケーションのWeb APIの仕様を記述していきます。
アプリケーションの要求を明らかにしておき、それを実現するのに必要なAPIを洗い出していく工程になります。
実開発では、リーダーが音頭を取りながら、バックエンド・フロントエンド双方の開発者が互いに相談しながら取り組むことになりそうです。

本の一覧のAPIの仕様を記述する

さて、今回のアプリケーションで必要なのは「本の一覧」を取得できるAPIです。
このAPIの仕様を以下のように決めました。

openapi: 3.0.2
info:
  title: Book API
  version: 1.0.0
servers:
  - url: http://localhost:3000
    description: development
paths:
  /books:
    get:
      description: Get all books
      operationId: getBooks
      responses:
        '200':
          $ref: '#/components/responses/Books'
components:
  schemas:
    Book:
      type: object
      properties:
        id:
          type: integer
          example: 1
        title:
          type: string
          example: にのめちゃんファンブック
        published_at:
          type: string
          format: date
          example: '2015-05-05'
      required:
        - id
        - title
        - published_at
    Books:
      type: object
      properties:
        books:
          type: array
          items:
            $ref: '#/components/schemas/Book'
      required:
        - books
  responses:
    Books:
      description: Array of books
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Books'

OpenAPIの仕様はYAML形式で記述します。
今回は、本の一覧を返すGET /booksというエンドポイントを1つだけ定義しています。

要点は以下です:

  • paths以下にエンドポイントの定義(パス、HTTPメソッド、レスポンスなど)を書きます。
  • components以下は、定義ファイル内で再利用可能なオブジェクトの定義を書きます。今回はレスポンスデータ、およびそこに含まれるデータのスキーマ(JSONスキーマベース)を定義しています。
  • $refで別のオブジェクトを参照することができます。
  • components.schemas.Bookが、一つ一つの本のデータの定義です。object型で、いくつかのプロパティを内包します。それぞれのプロパティの型や必須かどうか、値の例などの情報を定義できます。値の例は、ドキュメントとしての性質を持つほかに、後述するスタブサーバでも利用されます。

💡POINT: 記述した仕様のYAMLファイルは、リポジトリにコミットして変更を追跡できるようにしておくと良いでしょう。

Swagger UIで確認する

OpenAPIで仕様を記述すると、Swagger UIというソフトウェアを使って、Webで見れるきれいなドキュメントを生成することができます。
Swagger UIを使って、今回記述したAPI仕様を確認してみましょう。

Swagger UIを使う方法はいくつかありますが、Dockerイメージとしても公開されています。
Dockerイメージを使うと、自分の環境にSwagger UIをインストールしなくてもすぐに試すことができるので便利です。

仕様のYAMLファイルが置いていあるディレクトリに移動して、以下のコマンドを実行します。

$ docker run --rm -p 8080:8080 -e SWAGGER_JSON=/local/openapi.yaml -v ${PWD}:/local swaggerapi/swagger-ui:v3.20.1

すると、http://localhost:8080 からSwagger UIにアクセスできるようになります。

良さそうですね。

OpenAPI Generatorでスタブサーバを生成する

OpenAPI Generatorは、OpenAPIで記述した仕様から、様々な言語・フレームワーク向けにソースコードを自動生成できるソフトウェアです。
対応している言語・フレームワークの一覧は以下のページを参照してください。
https://openapi-generator.tech/docs/generators

OpenAPI Generatorを使って、このAPIをモックするスタブサーバを生成しましょう。

💡POINT: スタブサーバを使うことで、バックエンドの開発が完了していなくても、フロントエンドの開発を同時並行で進めることができます。

スタブサーバのコードを生成する

OpenAPI GeneratorもDockerイメージが公開されています。これを使ってスタブサーバを生成しましょう。
生成できるスタブサーバの実装は様々な言語・フレームワークから選ぶことができますが、今回はSpring Boot(Java)を選びます[1]

$ docker run --rm -v ${PWD}:/local openapitools/openapi-generator-cli generate -i /local/openapi.yaml -g spring -o /local/spring_stub --additional-properties returnSuccessCode=true

ここで、後ほどのためにCORS (Cross-Origin Resource Sharing)を有効化しておきます。
お好みのエディタでspring_stub/src/main/java/org/openapitools/OpenAPI2SpringBoot.javaを開き、以下のaddCorsMappingsメソッドのコメントアウトを解除します[2]

    public WebMvcConfigurer webConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/**")
                        .allowedOrigins("*")
                        .allowedMethods("*")
                        .allowedHeaders("Content-Type");
            }
        };
    }

次に、このスタブサーバのプロジェクトをビルドします。

$ cd spring_stub
$ docker run --rm -v ${PWD}:/usr/src/mymaven -w /usr/src/mymaven maven mvn package

これでスタブサーバの準備は完了です。
ここまでの手順は、API仕様を修正したら都度行う必要があります。

スタブサーバを起動する

スタブサーバの実行にもDockerイメージを使います。
以下のコマンドで起動できます。

$ docker run --rm -p 3000:3000 -v ${PWD}:/usr/src/myapp -w /usr/src/myapp java java -jar target/openapi-spring-1.0.0.jar

http://localhost:3000 からアクセスすることができます。

$ curl -s localhost:3000/books | jq
{
  "books": [
    {
      "id": 1,
      "title": "にのめちゃんファンブック",
      "published_at": "2000-01-23"
    },
    {
      "id": 1,
      "title": "にのめちゃんファンブック",
      "published_at": "2000-01-23"
    }
  ]
}

良さげです。

Nuxt.jsでフロントエンドを開発する

それでは、フロントエンドの開発に入っていきます。
今回は、トップページ(/)に本の一覧を表示する、という機能をもつアプリケーションを実装していきます。

実装にはNuxt.jsというフレームワークを使います。
Nuxt.jsとは、Vue.jsベースのWebフロントエンドアプリケーションのフレームワークで、v2.5.0からはTypeScriptを公式サポートしています。
この記事ではNuxt.jsの使い方の説明は省略しますので、詳しくは公式ドキュメントを参照してください。

今回使うNuxt.jsのバージョンはv2.8.1です。

OpenAPI GeneratorでAPIクライアントを生成する

OpenAPIで記述した仕様から、APIクライアントのソースコードを自動生成します。

💡POINT: APIの呼び出しは単なるHTTPリクエストなので、XHRやFetch APIなどを直接使って実装することもできますが、自動生成するクライアントコードを使うことで以下のようなメリットがあります:

  • 各API呼び出しにわかりやすいメソッド名がつく。たとえばfetch('/books', { method: 'POST' })よりcreateBook()のほうが何をしているのかわかりやすい。
  • レスポンスデータが利用している言語に合わせて適切に変換される(次の節に具体例)
  • TypeScriptクライアントの場合、各API呼び出しのレスポンスに適切な型がつく

そのため、基本的には自動生成したクライアントコードを利用することをおすすめします。

APIクライアントを生成する

クライアントコードの生成にもOpenAPI Generatorを使います。
今回はTypeScriptとFetch APIを使うクライアントコードを生成します。
リポジトリルートに移動して、以下のコマンドを実行します。

$ docker run --rm -v ${PWD}:/local openapitools/openapi-generator-cli generate -g typescript-fetch -DnpmName='book-api' -i /local/openapi.yaml -o /local/book-api-client

これでbook-api-clientディレクトリ以下に、APIクライアントのコードが生成されます。

なお、このままだとNuxt.js(のSSRモード)で直接使うには少し難があったため、Nuxt.jsのプラグインを書いてギャップを吸収しています。
詳細はプラグインのソースコードを参照してください。

スタブサーバを使って開発を進める

さて、ここからアプリケーションの実装に入っていきます。

💡POINT: 先述の通り、スタブサーバがあるため、バックエンドの開発を待たずにフロントエンドも開発を進めることができます。

APIクライアントを利用する

前節で生成したAPIクライアントを利用して、本の一覧を表示するページを実装してきいます。

ページはfrontend/pages/index.vueを改造しながら実装していきます。
まず、asyncDataでページのレンダリングに必要な初期データを取得します。

  async asyncData({ app }): Promise<Data> {
    const { books } = await app.$bookApi.getBooks()
    return { books }
  },

$bookApiがAPIクライアントのインスタンスです。デフォルトでは、API仕様のserversに定義したlocalhost:3000に繋がるようになっています。
さらに、pathsに定義したエンドポイントを呼び出すメソッドが一通り使えるようになっています。
GET /booksに対応するgetBooks()メソッド[3]も定義されているので、今回はそれを使って本の一覧を取得しています。

VSCodeなどのTypeScriptに対応したエディタで見ると、戻り値の型も適切についているのが確認できます。

本の一覧を表示する

本の一覧を表示する部分のコードは以下のようになっています。
標準的なVueのテンプレートで、特に変わったことはしていません。

<template>
  <div class="container">
    <h1 class="title">本一覧</h1>
    <ul>
      <li
        v-for="(book, i) in books"
        :key="i"
      >
        {{ book.title }} <small>{{ formatDate(book.publishedAt) }}</small>
      </li>
    </ul>
    <a href="#" @click.prevent="load">もっと読み込む...</a>
  </div>
</template>

ここで注目したいのはbook.publishedAtです。
先述の通り、APIクライアントが利用している言語に合わせてレスポンスを適切に変換しています:

  • プロパティ名がTypeScriptの慣習に合わせてキャメルケースになっている(JSON上はpublished_at
  • 日付を表す文字列が自動的にDateにパースされている。上のコードだけではわかりづらいですが、formatDateの定義も合わせて見てください。

このほか、動的にAPIを呼び出す例として「もっと読み込む...」ボタンを付けています。
詳しくはindex.vueのソースコードを参照してください。

表示を確認する

以下のコマンドで開発用サーバを起動します。

$ cd frontend
$ yarn run dev --port 8080

APIスタブサーバも前述の手順で立ち上げた状態で、http://localhost:8080/ にアクセスしましょう。

よさげです。

OpenAPIがもたらすフロントエンド開発のメリット

ここまで見てきたように、バックエンドがまったく実装されていないにもかかわらず、フロントエンドの開発を進めることができました。
OpenAPIを使うことによって分業がより強化され、開発効率の向上・工期の短縮がはかれます。
さらに、APIで渡ってくるデータの構造があらかじめ確定しているため、手戻りが発生しづらく、スムーズに開発できるメリットもあります。

また、TypeScriptを使っている場合、自動生成されたAPIクライアントのコードに付属する型定義を使えるのも魅力的ですね。
普通、JSONのレスポンスはTypeScript上はanyになってしまいますが、自動生成したAPIクライアントを使えば、あらかじめ型が付いた状態でAPIのレスポンスを受け取ることができます。
自分で型定義を用意する必要がなく、さらに仕様を元にしているので間違いのない型定義が使えるのは、TypeScriptとOpenAPIを組み合わせて使うときの大きなメリットです。

NestJSでバックエンドを開発する

バックエンドの開発にも入っていきましょう。
バックエンドでは、API仕様で定義した、本の一覧を返すGET /booksエンドポイントを実装します。

今回は、バックエンドの実装にNestJSを使います。
NestJSとは、Node.jsベースのWebアプリケーションのサーバサイドフレームワークです。
デフォルトでTypeScriptをサポートが充実している特徴があります。

今回使うNestJSのバージョンはv6.3.1です。

なお、NestJSにはOpenAPI (Swagger)の統合機能がありますが、この機能は今回やりたいこととは逆で、実装からOpenAPIの仕様を出力する機能です。
そのため、今回は利用していません。

API仕様からレスポンスのテストを自動生成する

OpenAPIで記述したAPI仕様から、自動的にAPIレスポンスのデータ構造をテストできるようにします。

💡POINT: レスポンスデータについての厳密な仕様があるため、そこからテスト内容を自動生成することができます。

これには以下のNPMパッケージが使えます:

  • swagger-parser OpenAPIで記述した仕様ファイルをパースしてプログラム上で扱えるようにします。$refの解決を行うこともできます。
  • jsonschema JSONスキーマをベースに、スキーマに合致するデータ構造かどうかをチェックすることができます。

backend/test/app.e2e-spec.tsに以下のテストケースを追加します。

  it('/books (GET)', async () => {
    const parser = new SwaggerParser();
    const spec = await parser.dereference(path.resolve(__dirname, '../../openapi.yaml'));
    const schema = spec.paths['/books'].get.responses['200'].content['application/json'].schema;
    const validator = new Validator();

    return request(app.getHttpServer())
      .get('/books')
      .expect(200)
      .expect(({ body }) => {
        const result = validator.validate(body, schema);
        if (!result.valid) {
          throw new Error(result.errors.toString());
        }
      });
  });

swagger-parserでOpenAPIで記述した仕様ファイルをパースし、そこからレスポンスデータのスキーマを取り出します。
取り出したスキーマをjsonschemaのバリデータに渡して、それを元にレスポンスボディの検証を行っています。

今回はサンプルなので、各テストケースに上記の処理をべた書きしていますが、実開発では適宜ヘルパーなどに分割して使い回せるようにしておくとよいでしょう。

まずはテストの実装に問題がないか、現段階でテストを走らせて確かめてみます。

$ yarn run test:e2e
yarn run v1.12.3
$ jest --config ./test/jest-e2e.json
ts-jest[versions] (WARN) Version 23.6.0 of jest installed has not been tested with ts-jest. If you're experiencing issues, consider using a supported versi
on (>=24.0.0 <25.0.0). Please do not report issues in ts-jest if you are using unsupported versions.
 FAIL  test/app.e2e-spec.ts
  AppController (e2e)
    ✓ / (GET) (196ms)
    ✕ /books (GET) (16ms)

  ● AppController (e2e) › /books (GET)

    expected 200 "OK", got 404 "Not Found"

      at Test.Object.<anonymous>.Test._assertStatus (../node_modules/supertest/lib/test.js:268:12)
      at Test.Object.<anonymous>.Test._assertFunction (../node_modules/supertest/lib/test.js:283:11)
      at Test.Object.<anonymous>.Test.assert (../node_modules/supertest/lib/test.js:173:18)
      at Server.localAssert (../node_modules/supertest/lib/test.js:131:12)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.317s, estimated 4s
Ran all test suites.
error Command failed with exit code 1.

テストは失敗していますが、404が返ってきているのは、まだGET /booksを実装していないので期待通りの挙動です。

これでOpenAPIで記述した仕様をベースにして、APIを実装する準備が整いました。

APIを実装する

準備が整ったところで、あとはどんどんAPIを実装していきます。

今回は以下のようなコントローラを作ってGET /booksを実装しています。

@Controller('books')
export class BooksController {
  constructor(private readonly booksService: BooksService) {}

  @UseInterceptors(ClassSerializerInterceptor)
  @Get()
  findAll(): BooksEntity {
    return new BooksEntity({ books: this.booksService.findAll() });
  }
}

findAll()メソッドがGET /booksの実装で、本のデータはBooksServiceから取得しています。

BooksServiceは仮実装で、以下のようにコードに直接書いたダミーデータ[4]を返すようにしています。
実際はここに、(リポジトリを経由して)DBなどからデータを読み込むコードを書きます。

@Injectable()
export class BooksService {
  // Dummy data
  private readonly books: BookEntity[] = [
    new BookEntity({ id: 1, title: 'にのめちゃんファンブック', publishedAt: new Date(2015, 5, 5) }),
    new BookEntity({ id: 2, title: '視界不良 前編', publishedAt: new Date(2015, 8, 30) }),
    new BookEntity({ id: 3, title: '視界不良 後編', publishedAt: new Date(2016, 1, 31) }),
  ];

  findAll(): BookEntity[] {
    return this.books;
  }
}

なお、BookEntityには、今回のAPI仕様に沿ってデータを加工(キーをスネークケースにする、日付を文字列にする、など)する処理が実装されています。
詳しくはBookEntityのソースコードを参照してください。

さて、これで仮データですが実装は完了しました。再びテストを実行してみましょう。

$ yarn run test:e2e
yarn run v1.12.3
$ jest --config ./test/jest-e2e.json
ts-jest[versions] (WARN) Version 23.6.0 of jest installed has not been tested with ts-jest. If you're experiencing issues, consider using a supported versi
on (>=24.0.0 <25.0.0). Please do not report issues in ts-jest if you are using unsupported versions.
 PASS  test/app.e2e-spec.ts
  AppController (e2e)
    ✓ / (GET) (299ms)
    ✓ /books (GET) (37ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        2.072s, estimated 4s
Ran all test suites.
✨  Done in 2.97s.

見事に通りました。

💡POINT: 仕様を元にテストを生成しているため、テストが通ればOpenAPIの仕様に合致したレスポンスであることが保証されます。

OpenAPIがもたらすバックエンド開発のメリット

バックエンド開発では、OpenAPIで記述した仕様を元にAPIのレスポンスをテストできます。
テストを記述する工数が削減されるのに加え、仕様から自動生成されるので本当に仕様に沿ったテストであることが保証されています。

また、バックエンド開発者が用意することが多いAPIドキュメントも、前述のSwagger UIなどを使えば仕様から自動生成できるため、バックエンド開発者はAPIの開発の部分に注力することができます。
単一の仕様からドキュメントもテストも生成されるため、ドキュメントと実装がだんだんと乖離していくということも起きにくくなっています。

フロントエンド/バックエンドを結合する

最後に、今回実装したフロントエンド/バックエンドを結合し、実際にバックエンドのAPIを使いながらフロントエンドを動作確認してみます。

といっても、統合にあたって追加でコードを書く必要はありません! これがOpenAPIを使うことの意義です。
すでに仕様に沿ったモックサーバで試しているので、同じ仕様に基づいて作られているAPIサーバと統合しても、そのままで問題なく動くはずです。

試してみましょう。

まずはAPIサーバを起動します。

$ cd backend
$ yarn run start

次にフロントエンド開発サーバの起動します。

$ cd frontend
$ yarn run dev --port 8080

http://localhost:8080 にアクセスします。

問題なく表示されました!

まとめ

OpenAPIを導入することで、Web APIを使ったチーム開発がより円滑に進みそうな手応えを得られました。
もちろん今回試したのは本当に簡単なケースなので、実案件に投入してみないと見えてこないこともあると思います。
実際に採用して新たな知見が得られたら、ぜひまた共有したいと考えています。

あと、じつはNestJSはこの記事を書くために初めて使ってみたのですが、バックエンドもTypeScriptでタイプセーフかつ動的言語の身軽さもありつつ実装できる体験は、なかなか良いものでした。
普段はRailsを使うことが多い弊社ですが、NestJSの採用も検討してみたいと思います。
ところでRuby 3には型がつくという話もあるので、こちらも期待したいですね。

参考文献

  • 『WEB+DB PRESS Vol.108』 p.9-40 「特集1[効率急上昇!]スキーマ駆動Web API開発」

  1. 本当はフロントエンド・バックエンドの開発環境に合わせてNode.jsあたりを使いたかったのですが、Node.jsのスタブサーバのgeneratorはdeprecatedのようでした😢 

  2. この修正が毎回必要になるのは面倒ですが、generatorのオプションなどは見当たらず、生成後に修正するしかないようでした。一連の流れをシェルスクリプトなどで自動化しておくと良いかもしれません。 

  3. メソッド名はoperationIdに指定したものが使われます。 

  4. 誌名は気にしないでください😎