🚔

OpenAPIKit を使って Swift で OpenAPI ドキュメントをバリデーションする

2023/07/05に公開

OpenAPIKit というライブラリを使ってみたので、使い方やできることを紹介します。

OpenAPIKit とは

mattpolzin さんが作成しています。

https://github.com/mattpolzin/OpenAPIKit

メインの機能は OpenAPI ドキュメントとそのコンポーネントへのエンコードとデコードです。また OpenAPI ドキュメントのバリデーションも行うことができます。特徴はそれを Swift で行うことができることです。

OpenAPI のドキュメントは、yaml または json で書きます。
このライブラリを使うと、そのドキュメントをデコードして Swift のオブジェクトに変換することができます。逆も可能で、Swift のオブジェクトから yaml や json の文字列に変換することもできます。

この記事ではバリデーションに焦点を当てます。

ちなみにこのライブラリは、WWDC23 のセッションで発表された Swift OpenAPI Generator の内部で yaml や json のドキュメントをデコードするために使用されています。

Swift OpenAPI Generator のドキュメントに「OpenAPI ドキュメントのバリデーションには、Swift OpenAPI Generator も利用している OpenAPIKit というライブラリも使うのもいいよ」(意訳)と書かれていたので気になって調べてみました。

OpenAPIKit で OpenAPI ドキュメントをバリデーションする

OpenAPI ドキュメントのバリデーションには様々なツールがありますが、今回は OpenAPIKit で OpenAPI ドキュメントをバリデーションしてみました。

といってもステップ自体はシンプルです。
OpenAPIKit でデコードし、用意されているバリデーションメソッドを使うだけです。

ちなみに今回は OpenAPI のドキュメントは yaml ファイルを想定しますが、json ファイルの場合は、json 用のデコードをすれば同じように OpenAPIKit でバリデーションできます。

1. OpenAPI ドキュメントを準備する

OpenAPI ドキュメントをバリデーションするので当然ではありますが、準備してください。
ここでは yaml や json の書き方は説明しません。🙏🏻

2. OpenAPIKit と Yams(yaml の場合のみ) を Dependencies に追加する

まずは今回使用するライブラリを Depedencies に追加します。

yaml ファイルを使用している場合は、Yams も一緒に追加します。これは yaml ファイルをデコードするために使用します。json の場合は Foundation が提供するクラスを使用するので、それ用のライブラリの追加はここでは不要です。

3. OpenAPI のドキュメントを読み込む

OpenAPI ドキュメントを読み込んで、String 型に変換します。

let url = Bundle.main.url(forResource: "openapi", withExtension: "yaml")
guard let fileURL = url,
      let fileContents = try? String(contentsOf: fileURL, encoding: .utf8)
else { fatalError("cannot read file") }

4. OpenAPI ドキュメントを Swift オブジェクトにデコード

次にデコードをします。OpenAPIKit が用意している OpenAPI.Document という型に変換します。
前述したように json ファイルをデコードする場合は、Foundation が提供する JSONDecoder を使用すれば良いのですが、yaml の場合は Yams のライブラリが提供するデコード用のクラスを使用します。

まずは必要なライブラリを全て import します。

import Foundation
import OpenAPIKit
import Yams

yaml のデコードは以下のように実装します。例外を投げるので、do-catchで囲うなどして使用してください。

do {
    let decoder = YAMLDecoder()
    let openAPIDoc = try decoder.decode(OpenAPI.Document.self, from: fileContents)
} catch let error {
    // OpenAPI.Error でラップするとエラーが読みやすくなる
    print(OpenAPI.Error(from: error).localizedDescription)
}

OpenAPI ドキュメントのバリデーションという観点に焦点を当てると、不正な書き方をしている場合はこのデコード時点で検知することができます。

例えば必須の項目である、info が存在しなかった場合、以下のようなデコードエラーが発生します。

dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid YAML.", underlyingError: Optional(Expected to find `info` key in the root Document object but it is missing.)))

ちなみに、OpenAPI.Error でラップすると以下のように少しエラーが読みやすくなります。

Expected to find `info` key in the root Document object but it is missing.

5. OpenAPIKit のバリデーションメソッドを使う

デコード時点でかなりバリデーションはされますが、OpenAPI の定義を超えた独自のバリデーションをしたい場合もあると思います。
そういった場合に、OpenAPIKit が提供するバリデーションメソッドを使うことができます。

詳細なドキュメントは以下です。

https://github.com/mattpolzin/OpenAPIKit/blob/main/documentation/validation.md

ちなみに OpenAPIKit を内部で使用している Swift OpenAPI Generator は、OpenAPIKit のバリデーションメソッドは使用しておらず、主にデコードの目的で使用していました。

バリデーションの実装

実装自体はとても簡単で、validate メソッドを呼び出すだけです。

// 省略・・・
try openAPIDoc.validate()

ではこの validate メソッドが何をしているのか、独自のバリデーションを追加したい場合はどうすれば良いのか見ていきます。

このメソッドについては以下3種類のバリデーションを覚えておくと良いかと思います。ちなみに最初の2つに関しては、Validation+Builtins.swift に全て実装されています。

  1. validate メソッドにデフォルトで組み込まれているバリデーション
  2. デフォルトではないが追加可能なバリデーション
  3. 自分でカスタムして追加できるバリデーション

では一つずつ見ていきます。

1. validate メソッドにデフォルトで組み込まれているバリデーション

まず現時点(2023/7/5)で validate メソッドにデフォルトで組み込まれているバリデーションを紹介します。

バリデーション 概要
operationsContainResponses 全ての Operations が少なくとも一つの response を保持していること
documentTagNamesAreUnique 全ての Tags が一意な名前であること
pathItemParametersAreUnique 全ての Path Items が重複した parameter を保持していないこと
operationParametersAreUnique 全ての Operations が重複した parameter を保持していないこと
operationIdsAreUnique 全ての Operation Id がドキュメント全体で一意であること
schemaReferencesAreValid 全ての JSONScheme の参照がドキュメントの components の辞書の中に存在すること
responseReferencesAreValid 全ての Response の参照がドキュメントの components の辞書の中に存在すること
parameterReferencesAreValid 全ての parameter の参照がドキュメントの components の辞書の中に存在すること
exampleReferencesAreValid 全ての example の参照がドキュメントの components の辞書の中に存在すること
requestReferencesAreValid 全ての request の参照がドキュメントの components の辞書の中に存在すること
headerReferencesAreValid 全ての header の参照がドキュメントの components の辞書の中に存在すること
2. デフォルトではないが追加可能なバリデーション

次にデフォルトでは組み込まれていないですが、追加可能なバリデーションです。

ここでは簡単にしか紹介しませんが、ドキュメントコメントにはなぜこれらがデフォルトのバリデーションに組み込まれていないかの理由も書いてあるので、気になる方は読んでみると OpenAPI 仕様の理解が進むのでおすすめです。

バリデーション 概要
documentContainsPaths PathItem の中に少なくとも一つの path があること
pathsContainOperations 全ての PathItems が少なくとも一つの operation を保持していること
schemaComponentsAreDefined 全ての JSONSchemas が少なくとも一つの特性が定義されていること
pathParametersAreDefined エンドポイントのパスが変数を含む場合("{variable}")、各エンドポイントの PathItem または Operation に対応するパラメータ項目が存在すること
serverVariablesAreDefined 全ての server オブジェクトが、URL テンプレートにあるすべての変数を定義していること

以下のように使用します。チェーンで繋げて実装できるのが便利ですね。

// 省略・・・
try openAPIDoc.validate(using: Validator()
    .validating(.documentContainsPaths)
    .validating(.pathsContainOperations)
    .validating(.operationsContainResponses)
    .validating(.schemaComponentsAreDefined)
    .validating(.pathParametersAreDefined)
    .validating(.serverVariablesAreDefined)
3. 自分でカスタムして追加できるバリデーション

最後に自分で独自のバリデーションを作成してみます。

詳しいドキュメントは以下です。

https://github.com/mattpolzin/OpenAPIKit/blob/main/documentation/validation.md#creating-new-validations

独自のバリデーションを作成するためには、2つの方法があります。

  1. validation() ヘルパーメソッドを使用する
  2. Validation 型の値を作成する

どちらも使い方は似ているところが多いので、今回は後者の実装について説明します。

Validation 構造体には以下の2種類の初期化メソッドが用意されています。

こちらは複数のエラーを返すことができる初期化メソッドです。

init(
    check validate: @escaping (ValidationContext<Subject>) -> [ValidationError],
    when predicate: @escaping (ValidationContext<Subject>) -> Bool = { _ in true }
)

こちらは単一のエラーを作成するための初期化メソッドです。

init(
    description: String,
    check validate: @escaping (ValidationContext<Subject>) -> Bool,
    when predicate: @escaping (ValidationContext<Subject>) -> Bool = { _ in true }
)

それでは後者の初期化メソッドを使用して、「servers にローカル環境向きのエンドポイントが定義されていた場合に、servers に2つ以上定義されていること」をチェックするバリデーションを作成してみます。

ローカル環境向きのエンドポイントがあるならば、当然本番環境や開発環境向きのエンドポイントも定義しておいてほしいというケースを想定します。

OpenAPI ドキュメントで見ると、

理想はこうなっていて欲しい↓

# 省略・・・
servers:
  - url: http://localhost:3000/api
    description: Local server
  - url: https://example.com/api
    description: Prod server
# 省略・・・

ですが、以下のように本番環境向きのエンドポイントを定義し忘れている場合、検知してもらうようにします。

# 省略・・・
servers:
  - url: http://localhost:3000/api
    description: Local server
# 省略・・・

実装は以下のようになります。
引数は以下の3つで、簡単に言うと、「理想の状態は何で」「どんなバリデーションをするか」「どんな条件でするか」です。

  • description: validate メソッドによって記述された正しい状態
  • check: バリデーションする関数。バリデーションが成功した場合は true を返し、失敗した場合は false を返す
  • when: このバリデータが指定した値に対して実行される場合に true を返す関数。
let serverEnvValidation = Validation(
    description: "At least two servers are specified if one of them is the local server.",
    check: \[OpenAPI.Server].count >= 2,
    when: { context in
        for server in context.subject {
            if server.urlTemplate.absoluteString.contains("localhost") {
                return true
            }
        }
        return false
    }
)

try openAPIDoc.validate(using: Validator()
    .validating(serverEnvValidation)
)

ここでは servers の URL の値に localhost が含まれていた場合に、servers の定義が2つ以上あるかをチェックしています。

定義が不足している状態で実際に実行してみると、以下のようなエラーが発生していることが確認できます。

Failed to satisfy: At least two servers are specified if one of them is the test server. at path: .servers

おわりに

OpenAPI ドキュメントは iOS エンジニアだけに必要なドキュメントというわけでないため、Swift でバリデーションをしたいというユースケースは少ないかもしれません。

ですが例えば個人開発アプリのような小さいアプリで公開されている API を使う場合、Swift に慣れているエンジニアが多いなどといった状況のとき(多くのアプリ開発ではそういう状況は少なそうですが)など、役に立つことはあるんじゃないかなと思いました。

私は前者の個人開発アプリで使用しています。

ちなみに私がこのライブラリを調べたきっかけは前述したように Swift OpenAPI Generator の内部で使用されていたからです。

Swift OpenAPI Generator が使用しているのは今回取り上げたバリデーション部分ではなくデコードがメインなのですが、Swift OpenAPI Generator にとっては重要なライブラリなのではないかと思い、ついでにバリデーション機能も使ってみるかと思い本記事を書くに至りました。

以上、ここまで読んでいただきありがとうございました!🤗

Discussion