こんにちは! フィフス・フロアの開発チームリーダーのnotozekiです。
最近、OpenAPIという技術を知りました。
OpenAPIは、いわゆる「Web API」の仕様を形式的に記述するためのフォーマットです。
OpenAPIに関連するツールもいくつか提供されており、たとえばOpenAPIのフォーマットに従って書かれた仕様から、開発用のAPIのスタブサーバを自動生成するツールなども存在します。
Web開発が多い弊社では、Web APIの仕様管理は長年の課題でした。
特に開発メンバーが増えてくるにしたがって、以下のような課題が浮かび上がってきています:
そこで、OpenAPIを導入することで、Web APIを使う開発での上記のような課題を解決できないかと思い、簡単な試作アプリケーションを作って検証することにしました。
この記事では、OpenAPIと、弊社で最近採用事例が増えつつあるTypeScriptを使って、フロントエンド/バックエンドを分業しながらWebアプリケーションを開発するフローを検証していきます。
みなさんのチームでのWeb API開発の参考になれば幸いです🤗
検証するために、「本の一覧を表示する」というだけの簡単なWebアプリケーションをサンプルとして作成しました。
コードの全文はGitHubで公開しています。
このWebアプリケーションの構成は以下のようになっています。
リポジトリはいわゆるmonorepo構成にして、フロントエンド(frontend/
)、バックエンド(backend/
)の両方のコードを単一のリポジトリで管理します。
また、リポジトリにはOpenAPIで記述したAPI仕様のファイル(openapi.yaml
)もコミットして、フロントエンド、バックエンド双方から参照しやすくします。
この記事では、全体の開発フローに着目しているので、実装の解説は最小限にとどめています。
実装の詳細はコードを参照してください。
OpenAPIを活用した開発では、まずAPIの仕様を考えるのが出発点になります。
まずは、OpenAPIで今回作成するアプリケーションのWeb 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ファイルは、リポジトリにコミットして変更を追跡できるようにしておくと良いでしょう。
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で記述した仕様から、様々な言語・フレームワーク向けにソースコードを自動生成できるソフトウェアです。
対応している言語・フレームワークの一覧は以下のページを参照してください。
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とは、Vue.jsベースのWebフロントエンドアプリケーションのフレームワークで、v2.5.0からはTypeScriptを公式サポートしています。
この記事ではNuxt.jsの使い方の説明は省略しますので、詳しくは公式ドキュメントを参照してください。
今回使うNuxt.jsのバージョンはv2.8.1です。
OpenAPIで記述した仕様から、APIクライアントのソースコードを自動生成します。
💡POINT: APIの呼び出しは単なるHTTPリクエストなので、XHRやFetch APIなどを直接使って実装することもできますが、自動生成するクライアントコードを使うことで以下のようなメリットがあります:
fetch('/books', { method: 'POST' })
よりcreateBook()
のほうが何をしているのかわかりやすい。そのため、基本的には自動生成したクライアントコードを利用することをおすすめします。
クライアントコードの生成にも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クライアントを利用して、本の一覧を表示するページを実装してきいます。
ページは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クライアントが利用している言語に合わせてレスポンスを適切に変換しています:
published_at
)Date
にパースされている。上のコードだけではわかりづらいですが、formatDate
の定義も合わせて見てください。このほか、動的にAPIを呼び出す例として「もっと読み込む...」ボタンを付けています。
詳しくはindex.vue
のソースコードを参照してください。
以下のコマンドで開発用サーバを起動します。
$ cd frontend
$ yarn run dev --port 8080
APIスタブサーバも前述の手順で立ち上げた状態で、http://localhost:8080/ にアクセスしましょう。
よさげです。
ここまで見てきたように、バックエンドがまったく実装されていないにもかかわらず、フロントエンドの開発を進めることができました。
OpenAPIを使うことによって分業がより強化され、開発効率の向上・工期の短縮がはかれます。
さらに、APIで渡ってくるデータの構造があらかじめ確定しているため、手戻りが発生しづらく、スムーズに開発できるメリットもあります。
また、TypeScriptを使っている場合、自動生成されたAPIクライアントのコードに付属する型定義を使えるのも魅力的ですね。
普通、JSONのレスポンスはTypeScript上はany
になってしまいますが、自動生成したAPIクライアントを使えば、あらかじめ型が付いた状態でAPIのレスポンスを受け取ることができます。
自分で型定義を用意する必要がなく、さらに仕様を元にしているので間違いのない型定義が使えるのは、TypeScriptとOpenAPIを組み合わせて使うときの大きなメリットです。
バックエンドの開発にも入っていきましょう。
バックエンドでは、API仕様で定義した、本の一覧を返すGET /books
エンドポイントを実装します。
今回は、バックエンドの実装にNestJSを使います。
NestJSとは、Node.jsベースのWebアプリケーションのサーバサイドフレームワークです。
デフォルトでTypeScriptをサポートが充実している特徴があります。
今回使うNestJSのバージョンはv6.3.1です。
なお、NestJSにはOpenAPI (Swagger)の統合機能がありますが、この機能は今回やりたいこととは逆で、実装からOpenAPIの仕様を出力する機能です。
そのため、今回は利用していません。
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を実装していきます。
今回は以下のようなコントローラを作って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で記述した仕様を元に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には型がつくという話もあるので、こちらも期待したいですね。
本当はフロントエンド・バックエンドの開発環境に合わせてNode.jsあたりを使いたかったのですが、Node.jsのスタブサーバのgeneratorはdeprecatedのようでした😢 ↩
この修正が毎回必要になるのは面倒ですが、generatorのオプションなどは見当たらず、生成後に修正するしかないようでした。一連の流れをシェルスクリプトなどで自動化しておくと良いかもしれません。 ↩
メソッド名はoperationId
に指定したものが使われます。 ↩
誌名は気にしないでください😎 ↩