squlette-bg-white.jpg
Dec 14, 2019

全TSユーザーのためのOpen API Codegen、「Squelette」を作っている話

この記事はCamphor- Advent Calendar 2019 15日目の記事です。

Squelette: Yet another TS codegen ecosystem for Open API.

Embedded content: https://github.com/andoshin11/squelette


本Advent Calendarへの参加も今年で4回を数えます。フロントエンドなAndyです。

ここ数年の間に加速度的にTypeScriptの利用機会が増え、Webブラウザで動くSingle Page Applicationの世界にも静的型付けの考え方が浸透してきました。

Redux, VuexのようなRepositoryレイヤーからUIコンポーネント内のビジネスロジック、ひいてはDOMにいたるまで一気通貫に型の恩恵を感じることのできる開発体験は、今後もしばらくメイントレンドとして受け入れられていくことでしょう。

このように型という「銀の弾丸」によって一見安全な世界が保証されたかに見えるフロントエンド開発ですが、実は無視することのできない大きな課題が残されています。

それは「ネットワーク境界」という壁です。

ネットワーク境界における課題と既知の解

(この章はWebアプリケーション開発を経験したことのある開発者の方には自明の内容かと思うので、読み飛ばしていただいて結構です。)

まず前提としてWebブラウザで動くSPAとバックエンドのAPIサーバーではその実行環境も、デプロイのサイクルもセキュリティモデルも監視の方法も全てが基本的に異なります。

そしてそのようなコンテキストの差異を吸収するほどの抽象的なプログラミングメンタルモデルがまだ(*2019年12月現在)浸透していない以上、この二者は基本的に別個のアプリケーション単位として実装されることとなるでしょう。

アプリケーション単位が分散するということは、実務という具象においてもコードベースそのものや利用されるプログラミング言語・フレームワーク・ライブラリ等に必然的に差異が発生するわけで、まさにそのうちの言語・FWにおける差異が今回の課題の発端となります。

雑にいうならば「TSという銀の弾丸を活用したいフロントエンド開発」と「TSの利用をそもそもの前提としていないバックエンド開発」という二項対立な状況ですね。

そのような言語・FW差異を吸収するために人類は Universal Schema という概念を生み出しました。

Universal Schemaの細かい定義は他に譲るとして、主に有名なのは今回紹介するOpen APIGraphQLProtocol Bufferなどでしょうか。

これらの共通点はドキュメンテーション用途での手軽さだけでなく、Codegenによって実装に組み込むことのできるClient/Serverを自動生成できる点にあります。 仕様として確定したAPI定義を人間の手で実装に落とし込むのではなく、機械に一気通貫でやらせてしまえというわけですね。理にかなっています。

Squeletteが解決したいこと

タイトルからも御察しの通り、今回紹介する「Squelette」はOpen APIというUniversal SchemaからTypeScriptで利用するコードを自動生成するCodegenです。

Squelette: Yet another TS codegen ecosystem for Open API.

Embedded content: https://github.com/andoshin11/squelette

実は同様のCodegenとしては公式のopenapi-generatorが既に存在しているのですが、こちらが生成してくれるAPI Clientは実装としての具象度がやや高めです。 「Specを定義すれば通信部分のコードまで全部自動生成してあげるよ!」という手軽さは嬉しく思う反面、実際のプロダクトに置けるAPI Clientは独自パーサー・req/res interceptor・validator・formatter等を積んで魔改造したくなるケースも多いためむしろ自動生成されたClient codeは使い勝手が悪かったりします。

Squeletteはもう一段階ゆるふわなスタンスで、「自前のAPI Clientを作るのに便利なコードを自動生成するよ!」くらいの温度感で開発しました。

記事執筆時点の段階でSqueletteが提供するツールはこんな感じ。

lernaを使ってmonorepoで管理しています。

パッケージ名 概要
@squelette/core Open APIをパースして抽象構文木に変換するパーザなどを提供
@squelette/ts-gen Request/Response型およびSchema Componentのリソース型を自動生成
@squelette/request-gen URL・HTTP Method・Request/Response型をまとめたRequest Hintを自動生成
@squelette/url-gen URL情報と、動的pathの場合の文字列生成関数を自動生成

公開バージョンを見ていただいても分かるのですが、現在は碌にtestも無いpre-alpha版です(汗 もうしばらく社内でDogFoodingしたら、v1に上げようかと考えています。

各パッケージのご紹介

@squelette/core

  • Open APIの中身をTSとして二次利用するための parse関数を提供してくれるくん
  • monorepo全体での共通型などもexportしています
  • 単体で利用されることは想定していませんが、自前のTS向けCodegenを作成したり独自templateを利用したい際に活用していただければと思います。
import fs from "fs"
import YAML from "js-yaml"
import { parse } from '@squelette/core'

const file = fs.readFileSync(YOUR_FILE_PATH, 'utf-8')
const yaml = YAML.safeLoad(file)

// retrive abstract syntax tree
const parsedAST = parse(yaml)

@squelette/ts-gen

  • 各エンドポイントのRequest/Responseの型を自動生成してくれる一番大事なやつ
  • Open API componentとして定義したリソース型も自動生成してくれる

使い方は簡単。下記のコマンドでspec file、プロジェクト名、出力先を指定して実行します。

$ ts-gen generate swagger.yml --namespace PetStore --dist types

すると下記のように定義したPet Schemaはこのようなinterfaceに、

Pet Schema

export default interface Pet {
  id: number;
  name: string;
  category?: 1 | 2 | 3;
  tag?: string;
  sex?: "male" | "female";
}

下記のエンドポイント定義はこのようなinterfaceに、

List all pets

// pets.ts
import Pet from "./Pet";

type Pets = Pet[];
export default Pets;
export interface listPetsRequest {
  limit?: number;
}

export type listPetsResponse = Pets;

それぞれ変換されます。

@squelette/request-gen

  • interfaceだけでなく、実際にAPI Clientに引数として渡すRequest Objectを生成します
  • Request Object(Class)をnewする際にconstructorで動的Pathの生成処理も行います

こちらも同じく下記のコマンドでspec file、プロジェクト名、出力先を指定して実行します。

$ request-gen generate swagger.yml --namespace PetStore --dist types

これによって生成されるのが下記のようなRequest Object(Class)

Get Pet Operation

export class showPetById
  implements APIRequest<PetStore.pets.showPetByIdResponse> {
  response: PetStore.pets.showPetByIdResponse;
  method = HTTPMethod["get"];
  path: string;
  params?: PetStore.pets.showPetByIdRequest;

  constructor(args: {
    params?: PetStore.pets.showPetByIdRequest;
    pathParameter: PetStore.pets.showPetByIdPathParameter;
  }) {
    const { params, pathParameter } = args;
    this.params = params;
    this.path = `/pets/${pathParameter.petId}`;
  }
}

ちなみに APIRequest<U>は下記のようなシグネチャを持っています。

interface APIRequest<R> {
  response: R;
  path: string;
  method: string;
  params?: any;
  baseURL?: string;
}

そのため、こちらのシグネチャのRequest Objectを引数で受け取って Uをreturnするような任意のAPI Clientを定義してあげれば下記のように利用できるわけです。

# Your API Client
import axios from 'axios'

class APIClient {
  baseURL = 'https//hogehoge.com'

  request<U>(hint: APIRequest<U>): Promise<U> {
    const isRead = request.method === HTTPMethod.GET

    return axios.request({
      url: hint.path,
      method: hint.method,
      params: isRead && request.params,
      data: !isRead && request.params,
      baseURL: request.baseURL || this.baseURL
    })
  }
}

# Call API
import { showPetByID } from './requests'

const showPetByIDRequest = new showPetByID({
  pathParameter: {
    petId: 'hoge'
  }
})

// Open APIで定義したresponse型が取得できる
const result = await new APIClient().request(showPetByIDRequest)

@squelette/url-gen

  • 昨日の業務中に90分くらいで作った出来立てホヤホヤのやつ
  • 上記のRequest Objectを通さずにURL情報だけ必要なときに便利なURL Hintを自動生成してくれるくん
  • Client/Server双方から参照されることを想定にしています

例によって例のごとく、コマンドの実行は下記のシグネチャ

$ url-gen generate swagger.yml --namespace PetStore --dist types

このようなURL群を定義すると下記のようなURL Hint Objectを自動生成してくれます。

PetStore URL

const HTTPMethod = {
  get: "GET",
  post: "POST",
  delete: "DELETE",
  put: "PUT"
};

export const listPets = {
  operationId: "listPets",
  raw: "/pets",
  method: HTTPMethod["get"],
  factory: function() {
    return `/pets`;
  }
};

export const createPets = {
  operationId: "createPets",
  raw: "/pets",
  method: HTTPMethod["post"],
  factory: function() {
    return `/pets`;
  }
};

export const showPetById = {
  operationId: "showPetById",
  raw: "/pets/{petId}",
  method: HTTPMethod["get"],
  factory: function(pathParameter: { petId: string }) {
    return `/pets/${pathParameter.petId}`;
  }
};

factory関数の型を定義してあげてるのがこだわりポイントです。

Squeletteの今後

まずはちゃんとtestとexampleを追加します。。。(この記事の公開までにはやろうと思ってたけど忙しすぎて間に合いませんでした。謝罪)

Client SideからAPIを利用する際のHowとWhatの部分を解決する仕組みは一通り揃った気がするので、今後はServer SideをTSで書くためのCodegenを追加する予定です。例としてはExpressfastifyのmiddlewareやrouterを想定しています。

最近AWS CDKも触る機会があるので、API Gateway Resource用のtemplateを自動生成するのも面白そうかなーなんて考えたり。

これからもOpen APIの定義をするだけで型安全な世界が手に入れられるような仕組みを作っていければ嬉しいです。怠惰なエンジニアとしてあるべき姿を標榜していきたい。

Special Thanks

最後になりましたがSqueletteのために素晴らしいロゴデザインを提供して生命を吹き込んでくれたコウガくん(@_pilecks)に感謝です。

Squelette Concept

個人でデザイナーさんに依頼するのが初めてだったので至らない点も多かったのですが、一緒にSoftware Identityの議論ができてすごく楽しかったなー。本当にありがとうございます!

また、業務時間を使っての本ツールの開発と本番環境への投入を快諾していただいたクライアント様各社にもこの場を借りて謝辞を伝えたいと思います。

明日はkmconnerさんの記事です。お楽しみに!