🍎

RESTFul API のスキーマを定義すると Swift のコードを生成してくれるものを作った

2022/07/23に公開

https://github.com/soil-schema/soil

soil です。ソイルと読みます。

前回書いた Echeveria と違って、公開レベルに至っていませんが、コンセプトの整理を兼ねて記事を書いておきます。

OpenAPI Schema との別れ

私がコードを書いているプロダクトでは OpenAPI Schema を利用しています。OpenAPI Schema は様々な Web API を記述する表現力があり、その表現力は JSON-Schema に由来しています。

JSON 形式の Web API のスキーマを定義するなら、OpenAPI Schema で表現できないものはないでしょう。

現実に存在する API に、あとから OpenAPI Schema を使ってスキーマを定義することも可能です。

しかし最近、OpenAPI Schema でプロダクトの API スキーマを定義することが苦痛になってきました。理由は簡単です。OpenAPI Schema の表現力が高すぎて、Swift や Kotlin におけるモデル構造とミスマッチが発生してきたのです。

ちなみに、OpenAPI Generator は使っていません。生成されたコードを見て、使うのを見送りました。もし使っていたら、結果的に OpenAPI Schema が嫌になることはなかったかもしれません。

GraphQL の検討

OpenAPI Schema をやめるためにまず検討したのは GraphQL です。しかし、GraphQL は結局のところ問題を解決してくれそうにありませんでした。RESTful API か GraphQL API かというのは、本質的な課題ではなかったからです。むしろ、UI ごとに方言化したクエリがアプリの様々な場所に書かれて、ドメインモデルと UI との乖離が発生することを懸念しました。

個人的な考えですが、GitHub のような不特定多数の開発者が利用する公開 API では、GraphQL はうまく機能すると思います。プロダクトによっては、不特定多数のチーム、数個から数十個のクライアントアプリが存在するかもしれません。その場合も、私が感じた GraphQL のデメリットより、メリットが大きくなりそうです。一言でいうなら、GraphQL は固い API に向いていると思います。

実際にプロダクトに GraphQL を導入し、いくつかのリソースを取得できるようにしましたが、本格的に使うことはやめにしました。

OpenAPI Schema と Swift / Kotlin のコード表現の関係

ここではドメインモデルは「サービスの関心事のうち、主体的な構造と他のドメインモデルとの関係をが定義できるもの」の意味で用います。例えば本棚の管理アプリなら「本」や「ユーザー」などをドメインモデルと呼びます。また、「本の読了状況」や「本の感想」などの、無形の(物理的実体を持たない)存在であっても、主体的な構造と他のドメインモデルとの関係を定義できるので、ドメインモデルとします。

OpenAPI Schema を使った私のアプローチを、ドメインモデルとの関係性で整理してみます。私は、OpenAPI Schema によって定義された API のレスポンス構造から、Swift / Kotlin などのクライアントコードを生成していました。実際にコードを生成するスクリプトは自前で実装したのですが、結局の所、自前のスクリプトで OpenAPI Schema の表現力に完全に適応することはできないので、ある程度の制限を設けて妥協することになります。そうしてさえも、いくつもの不合理が発生して API 本体やクライアントコードを修正する必要が出てきました。

例を挙げます。以下のようなエンドポイントを仮定します。

  • GET /books/{id} から特定の「本」のデータを取得できる。
  • GET /users/{id}/reviews から特定のユーザーが書いた本の「感想」のデータを、一覧で取得できる。

サービスとして成立する API にするためには他にもたくさんのエンドポイントが必要ですが、ここではこの2つだけについて検討します。それぞれ、OpenAPI Schema(というより JSON-Schema)でスキーマを定義してみます。

'/books/{id}':
  get:
    summary: Get A Book Endpoint
    parameters:
    - in: path
      name: id
      schema:
        type: integer
      required: true
    responses:
      200:
        content:
	  application/json:
	    schema:
	      $ref: 'components/schemas/Book.yml'
'/users/{id}/reviews':
  get:
    summary: List Reviews by An User
    parameters:
    - in: path
      name: id
      schema:
        type: integer
      required: true
    responses:
      200:
        content:
	  application/json:
	    schema:
	      type: array
	      items:
	        $ref: 'components/schemas/Review.yml'
components/schemas/Book.yml
type: object
properties:
  title:
    type: string
required: [title]
components/schemas/Review.yml
type: object
properties:
  author:
    $ref: 'components/schemas/User.yml'
  body:
    type: string
required: [author, body]

こんな感じになりそうです。

さて、ある時点でこの API を修正する必要が出てきました。「本」のデータを取得したときに、ログイン状態のユーザーがその「本」に「感想」を書いていれば、それをまとめて取得したいということになりました。(それがいい方法かどうかはひとまずおいておきます)

簡単な方法は、Book が review を1つ持つようにするやり方です。もちろん nullable で。

/components/schemas/Book.yml
type: object
properties:
  title:
    type: string
  review:
    $ref: '/components/schemas/Review' # ← えっ!? これじゃあ、nullable にできないよね。
required: [title, review]

はい。OpenAPI Scheme の nullable が壊れているのは有名ですね。他の書き方もいくつかありますが、どれも欠点がありそうです。細かい検証はしていません。する気力も湧きませんでした。

/components/schemas/Book.yml
type: object
properties:
  title:
    type: string
  review:
    oneOf:
      - $ref: '/components/schemas/Review'
      - type: object # ← 事実上なんでもありになる
        nullable: true
required: [title, review]
/components/schemas/Book.yml
type: object
properties:
  title:
    type: string
  review:
    $ref: '/components/schemas/Review'
required: [title] # ← キーを必須にしないことで、null の代わりにキー不在を返す。ドキュメント類の表現が nullable にならない(非 required になる)
components/schemas/Review.yml
type: object
nullable: true # ← 論外。感想リストが Array<Review?> になる。
properties:
  author:
    $ref: 'components/schemas/User.yml'
  body:
    type: string
required: [author, body]

……。

おそらく OpenAPI Schema の Reference Object は、スキーマを分割するための仕組みとして存在しているのでしょう。ドメインモデルごとにファイルを分割するというアプローチのために Reference Object を用いるのは、あまり良くない解法のようです。

私が書いたスクリプトは $ref を解析して具体的なモデルクラスの名前を取得する仕組みになっていました。例えば、 $ref: '#/components/schemas/Book' から Book というクラスを推論していました。このアプローチは直感的だし、簡単でいいのですが、nullable の扱いでは破綻します。Book を保持しているモデルの required を見て、そこにプロパティの名前が含まれていなければ Book? にする、という措置をしました。

まだ問題はあります。API のレスポンスを OpenAPI Schema を使って検証していたので、review キーは null ではなく不在にしなければなりません。他の場所では自然に null を使えるのに、場合によって null ではなくキーの不在で表現しなければならないのは、API だけを見渡したときに明らかに不合理です。すべての null をキー不在で表現すればいいでしょうか。そうすると、レスポンスの JSON だけを見た時に、そこにキーがあることから読み取れる情報が欠落します。(review: null なので、きっとここには Review が入るんだろう)

この問題の根本には、JSON の表現力をすべてカバーするスキーマ定義である JSON-Schema を、 Swift / Kotlin の素直なコード表現に落とし込めないことにあります。表現能力のミスマッチです。

JSON-Schema と Swift の例を上げましょう。

type: object
properties:
  id:
    anyOf:
    - type: string
    - type: integer

この JSON-Schema は完璧です。id は文字列か、もしくは整数です。ではこれを Swift の struct 定義に落とし込むとどうなるでしょう。

struct Model {
    var id: String || Int // <- ???
}

破綻しています。もちろん、頑張れば表現することはできます。

enum ID {
    case string(String)
    case integer(Int)
}
struct Model {
    var id: ID
}

例えばこんな感じはどうでしょう。悪くはないかもしれませんが、やっぱり、API の実装とスキーマの定義に無理があるように感じられます。そっちを治すほうが遥かに簡単で、単純な結果になることは明らかです。

結局の所、OpenAPI Schema で定義された API のスキーマに完全に適合した Swift / Kotlin のコードを書くことが難しいのです。自動化するから難しいのではありません。(もちろん自動化を諦めれば、多少は話が簡単になりますが)

以上が、OpenAPI Schema によって表現された API スキーマでドメインモデルを定義し、それを元に Swift / Kotlin のコードを作るというアプローチの問題です。

ドメインモデルスキーマからすべてを生成する

ここでは、すでに述べたとおり、サービスの関心主体をドメインモデルと呼んでいます。ドメインモデルの定義を OpenAPI Schema に任せるのではなく、もっとやりやすいスキーマで定義することにしました。つまり、ドメインモデルのスキーマから、OpenAPI Schema、 Swift / Kotlin のコード、そしてドキュメント類を生成するアプローチを試すことにしました。

それが冒頭に書いた soil です。先に断っておくと、soil はまだ未完成です。Swift の不完全なコードの生成しかできませんが、ドメインモデルのスキーマからコードを生成するというアプローチの評価くらいはできる段階になったと考えています。

soil では、ドメインモデルを1つのエンティティとして定義します。例えば「本」は以下のように書きます。

entity/Book.yml
name: Book
fields:
  title: String

Swift のコードは以下のようになります。

dist/Book.swift
import Foundation

public final class Book: Codable {

    public var title: String
}

エンドポイントの定義は、エンティティに含めます。endpoints キーを使います。

entity/Book.yml
name: Book
fields:
  id: +Immutable +ReadOnly Integer
  title: String
endpoints:
  '/books/{id}':
    get:
      summary: Get Book
      success:
        schema:
	  book: Book
dist/Book.swift
import Foundation

public final class Book: Decodable {

    public let id: Int

    public var title: String

    public struct Writer: Encodable {

        public var title: String

        /// - Parameters:
        ///   - title: {No Hint}
        public init(title: String) {
            self.title = title
        }
    }

    /// Get Book Endpoint
    public struct GetBookEndpoint {

        /// GetBookEndpoint.path: `/books/{id}`
        public let path: String

        /// GetBookEndpoint.method: `GET`
        public let method: String = "GET"

        /// - Parameters:
        ///   - id: {No Hint}
        public init(id: Int) {
            self.path = "/books/{id}"
                .replacingOccurrences(of: "{id}", with: "\(id)")
        }

        public struct Response: Decodable {

            public let book: Book
        }
    }
}

Book エンティティの定義で、id フィールドを ReadOnly にしました。そのため、POST や PUT 時に用いる書き込み用の Book エンティティの定義が Book.Writer として生成されました。

また、Get Book Endpoint の定義も追加されています。

今の所、だいたいこの辺までの実装です。サブタイプの定義、入れ子構造になったモデルの struct 化、RequestBody や Response の定義、他のエンティティを探してきて型解決を行う、などはあります。

soil では soil 自身によって、エンティティのスキーマの表現が制約されます。JSON の定義から出発せず、API のスキーマ定義よりもエンティティの定義とネイティブコードの表現を中心に考えることで、破綻しないスキーマを設計することができると考えています。

世の中にある大多数の API は soil と適合しない気がします。しかし平易な API であれば、soil を使うことで適切に恩恵を受けられるようにしていきたいです。

  • API への HTTP リクエスト・レスポンスのバリデーション
  • ネイティブコードの自動生成(Swift / Kotlin / dart?)
  • ドキュメントの自動生成

こうした「普通に期待されるメリット」は享受できるようにしていくつもりでいます。ただ、Kotlin のコードをどうするのがいいか、まだ何も検討していないので、興味のある方は Twitter などで声をかけてください。

Discussion