24/7 twenty-four seven

iOS/OS X application programing topics.

OpenAPI (Swagger) のコード生成から通信処理を分離してスキーマ定義だけを利用する

背景

今関わっているプロジェクトではOpenAPIを利用して、APIのスキーマを定義しています。

OpenAPIではスキーマ定義からクライアントコードを生成できます。

しかし、デフォルトのコード生成はスキーマ定義とネットワーク通信のコードが強く結びついており、使いにくい場面があると感じていました。

認証等がなく、単純なGETだけのエンドポイントを相手にしている場合はそうなっているのは便利だと思いますが、今のプロジェクトでは

  • リクエストヘッダに認証トークンおよびアプリの情報を示す情報を追加する
  • (デバッグビルドでは)リクエスト前と後にログ出力をする
  • アクセストークンの期限が切れた場合は自動的にアクセストークンをリフレッシュし、シームレスにリトライする
  • すべてのエンドポイントで発生しうるエラー(サーバーエラーの5xxや、クライアントエラーの4xx、強制アップデートや緊急メンテナンスなど)と、個々のエンドポイントでのみ起こるエラー、認証のエンドポイントでのみ起こるエラーを適切にハンドリングする

というそこそこ複雑な処理をする必要があります。

問題

OpenAPIのコード生成はデフォルトではAlamofireに依存していて、通信処理の大部分はAlamofireが担当しますが、OAuth2のサポートはデフォルトでは無いので認証は別のOAuth2ライブラリと組み合わせることで実現していました。

問題に感じていたことは主にAPIの追加・更新時に挙動がおかしい場合の調査・デバッグです。

前述のようにAPIの通信に関係する処理は、OpenAPIの自動生成コード(スキーマの定義とパラメータの加工とエンコード、レスポンスのデコードなどが含まれる)、Alamofireのコード、OAuth2ライブラリのコード、アプリケーションコード、の4つにまたがり、そのうちの3つは第三者が書いたライブラリのコードです。

複数のライブラリのコードをスイッチして調査することは問題の調査をかなりややこしくしていました。

問題の解決

その問題を解決するために、OpenAPIによるコード生成はAPIのスキーマ定義だけのシンプルなもの(自動生成コードをデバッグするのはつらい)にし、通信処理もシンプルなクライアントを自分で書くことにしました。

そうしてできあがったのがこちらのライブラリです。

github.com

もともとのOpenAPIが生成する通信のコードは、非常に汎用的になっているため、現在のプロジェクトには関係のないコードが数多く存在しました。

このAPIClientでは、一般的なAPIクライアントでは実装すべき処理も、プロジェクトで使用してないものは対応しないことでシンプルに誰でも読めるように書かれています。

OpenAPIのスキーマ定義から生成したコードを利用することが前提であることも、シンプルさに役立っています。

OpenAPIのコード生成は次のように変更しました。通信処理に必要なヘルパークラスはすべて無くして、パラメータとレスポンスに使用されるモデル(変更なし)とエンドポイントの定義のみを生成するようにしました。

エンドポイントのコードは下記のようになります。

open class func addPet(pet: Pet) -> RequestProvider<Void> {
    let path = "/pet"
    let parameters = pet

    return RequestProvider<Void>(endpoint: path, method: "POST", parameters: RequestProvider.Parameters(parameters))
}

open class func findPetsByTags(tags: [String]) -> RequestProvider<[Pet]> {
    let path = "/pet/findByTags"
    
    let parameters: [String: Any?] = [
        "tags": tags
    ]
    return RequestProvider<[Pet]>(endpoint: path, method: "GET", parameters: RequestProvider.Parameters(parameters))
}
...

APIリクエストに必要なエンドポイントの定義は、URL、HTTPメソッドの種類、パラメータの型およびエンコーディング、レスポンスの型、が必要です。

作り直したコード生成のテンプレートは上記の情報をRequestProviderという型にエンコードして表現します。

RequestProvider<Response>にはAPIリクエストに必要な情報がすべて含まれていますので、これだけ受け取ればAPIリクエストを実行できます。

APIクライアントの通信処理はほぼすべてClient.swiftに書かれています。他のファイルはほぼデータ構造やプロトコルを定義しているだけのファイルなので、処理は記述されていないので見る必要がありません。

Client.swiftURLSessionを使った典型的な通信処理が書かれていて、非同期のコールバックによるネストと、シームレスなリトライ処理のためループする構造になっているところがやや複雑に見えますが、基本的に上から下に読んでいけばわかるように単純に記述されています。

APIClientとOpenAPIのスキーマ定義を個別にビルド可能にしたかったので、RequestBuilder<Response>からAPIClientが使うRequest<Response>に型を変換するコードをアプリケーション側に書きます。

extension RequestProvider {
    func request() -> Request<Response> {
        if let parameters = parameters {
            switch parameters {
            case .query(let raw):
                return Request(endpoint: endpoint, method: method, parameters: Request.Parameters(raw))
            case .form(let raw):
                return Request(endpoint: endpoint, method: method, parameters: Request.Parameters(raw))
            case .json(let raw):
                return Request(endpoint: endpoint, method: method, parameters: Request.Parameters(raw))
            }
        }
        return Request(endpoint: endpoint, method: method)
    }
}

ログ出力やエラーハンドリング、トークンリフレッシュを伴う自動リトライなどはOkHttpを参考にしてInterceptorという仕組みでリクエストの前後の処理をフックできるようにしました。iOSのスタイルに沿うように、単なる複数登録できるDelegateにしました。

ここまでで、

  • OpenAPIのコード生成から通信処理をなくす
  • OpenAPIのコード生成をシンプルに
  • 通信処理の簡略化
  • リトライやエラーハンドリングの仕組みを統一

によって、従来のデバッグが困難という問題を解決できました。

おわりに

ここで示したコードとライブラリは、現在のプロジェクトに最適化しているため、他のプロジェクトでそのまま使用することには不向きです。

また、過不足なく機能を実装することでシンプルさを保つ目的のため、Pull Requestも受け付ける予定はありません。

しかし、考え方や実装方法は参考にはなると考え、公開しています。もし、同様のアプローチで問題を解決しようとするなら、フォークするか、コードをコピーして使用することをおすすめします。

下記は認証のリトライと、ログ出力のInterceptorの実装例です。

public class Authenticator: Intercepting, Authenticating {
    private let credentials: Credentials

    public init(credentials: Credentials) {
        self.credentials = credentials
    }

    public func intercept(client: Client, request: URLRequest) -> URLRequest {
        return sign(request: request)
    }

    public func shouldRetry(client: Client, request: URLRequest, response: HTTPURLResponse, data: Data?) -> Bool {
        if response.statusCode == 401, let url = request.url, !url.path.hasSuffix("/login"), credentials.fetch()?.refreshToken != nil {
            return true
        }
        return false
    }

    public func authenticate(client: Client, request: URLRequest, response: HTTPURLResponse, data: Data?, completion: @escaping (AuthenticationResult) -> Void) {
        switch response.statusCode {
        case 401:
            if let url = request.url, !url.path.hasSuffix("/login"), let refreshToken = credentials.fetch()?.refreshToken {
                client.perform(request: AuthenticationAPI.v1LoginPost(username: nil, password: nil, refreshToken: refreshToken).request()) {
                    switch $0 {
                    case .success(let response):
                        self.credentials.login(response.body)
                        completion(.success(self.sign(request: request)))
                    case .failure(let error):
                        switch error {
                        case .networkError, .decodingError:
                            completion(.failure(error))
                        case .responseError(let code, let headers, let data):
                            switch code {
                            case 400, 401:
                                self.credentials.update(token: nil)
                                completion(.failure(.responseError(401, headers, data)))
                            case 400...499:
                                completion(.failure(error))
                            case 500...599:
                                completion(.failure(error))
                            default:
                                completion(.failure(error))
                            }
                        }
                    }
                }
            } else {
                completion(.cancel)
            }
        default:
            completion(.cancel)
        }
    }

    private func sign(request: URLRequest) -> URLRequest {
        var request = request
        if let url = request.url, !url.path.hasSuffix("/login") {
            if let accessToken = credentials.fetch()?.accessToken {
                request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
            }
        }
        return request
    }
}
public struct Logger: Intercepting {
    public init() {}

    public func intercept(client: Client, request: URLRequest) -> URLRequest {
        os_log("⚡️ %@", type: .debug, "\(requestToCurl(client: client, request: request))")
        return request
    }

    // swiftlint:disable large_tuple
    public func intercept(client: Client, request: URLRequest, response: URLResponse?, data: Data?, error: Error?) -> (URLResponse?, Data?, Error?) {
        if let response = response as? HTTPURLResponse {
            let path = request.url?.path ?? ""
            let statusCode = response.statusCode
            var message = "\(statusCode < 400 ? "🆗" : "🆖") [\(statusCode)] \(path)"
            if let data = data, let text = String(data: data, encoding: .utf8) {
                message += " \(text.prefix(100) + (text.count > 100 ? "..." : ""))"
            }
            os_log("⚡️ %@", type: .debug, message)
        } else if let error = error {
            os_log("⚡️ %@", type: .debug, "\(error)\n🚫 \(requestToCurl(client: client, request: request))")
        }
        return (response, data, error)
    }

    private func requestToCurl(client: Client, request: URLRequest) -> String {
        guard let url = request.url else { return "" }

        var baseCommand = "curl \(url.absoluteString)"
        if request.httpMethod == "HEAD" {
            baseCommand += " --head"
        }
        var command = [baseCommand]
        if let method = request.httpMethod, method != "GET" && method != "HEAD" {
            command.append("-X \(method)")
        }
        if let headers = request.allHTTPHeaderFields {
            for (key, value) in client.headers {
                if let key = key as? String, key != "Cookie" {
                    command.append("-H '\(key): \(value)'")
                }
            }
            for (key, value) in headers where key != "Cookie" {
                command.append("-H '\(key): \(value)'")
            }
        }
        if let data = request.httpBody, let body = String(data: data, encoding: .utf8) {
            command.append("-d '\(body.removingPercentEncoding ?? body)'")
        }

        return command.joined(separator: " ")
    }
}