💉

Swift OpenAPI Generator で自動生成コードにリクエストを注入する

2023/07/06に公開
1

最近 Swift OpenAPI Generator をよく使っています。
コードを自動生成してくれるのはとても便利で、既に自分の個人開発アプリには導入・リリース済みです。

ただ WWDC23 のセッションでも「Swift OpenAPI Generator はまだ成熟していません」と言っていたように、自動生成とはいえまだサポートされていないことも多くあります。

この記事ではそのうちの1つに着目し、自動生成のコードを使用しながら、未対応の部分は自分で実装するという方法について紹介します。

やりたいこと

今回は以下2つのことをやってみました。

  1. secutirySchemes に対応する
  2. Swift OpenAPI Generator が行う API リクエストを cUrl で出力する

どちらもやっていることは 「Generator が実際に API を呼ぶ前に、リクエストに任意の処理を注入する」 ということです。

1. secutirySchemes に対応する

まず secutirySchemes とは、API の認証周りを定義する、OpenAPI の項目の一つです。
API の認証には、APIキー、Basic 認証、OAuth など複数の方法がありますが、それらを定義する項目です。

https://swagger.io/docs/specification/authentication/

yaml で見ると、こんな感じです。

components:
  securitySchemes:
    BasicAuth:
      type: http
      scheme: basic

この定義によって、エンドポイントごとの認証方法を定義できます。

ただ自動生成されたコードを見てどうやって認証周りを実装したらいいか調べたのですが、それが分かりませんでした。。

結論、Swift OpenAPI Generator では、secutirySchemes に関する自動生成にはまだ対応していません。🥲
GitHub で Issue が立てられており、対応したいとは思っているようです。

https://github.com/apple/swift-openapi-generator/issues/37

では Swift OpenAPI Generator の自動生成コードの使用は諦めなければいけないのかというとそんなことはなく、幸いにも対応方法があります。

それが 該当の Issue でも紹介されていた、ミドルウェア を使用する方法です。

この Generator におけるミドルウェアは HTTP リクエストに途中で割り込み、処理を割り込ませることができます。

以下のように実装します。独自のミドルウェアを定義するときは、ClientMiddleware というプロトコルに準拠する必要があります。

import Foundation
import OpenAPIRuntime

final class AuthMiddleware: ClientMiddleware {
    func intercept(_ request: OpenAPIRuntime.Request, baseURL: URL, operationID: String, next: (OpenAPIRuntime.Request, URL) async throws -> OpenAPIRuntime.Response) async throws -> OpenAPIRuntime.Response {
        var request = request
        request.headerFields.append(.init(
            name: "Authorization", value: "Basic <token>"
        ))
        return try await next(request, baseURL)
    }
}

そしてClient を初期化するときに引数として渡します。
配列を受け取るので、複数のミドルウェアを実装して初期化するときに渡すことができます。

self.client = Client(
    serverURL: try! Servers.server1(),
    transport: URLSessionTransport(),
    middlewares: [AuthMiddleware()]
)

2. Swift OpenAPI Generator が行う API リクエストを cUrl で出力する

もう一つ、実際に私が個人開発アプリで導入した例を紹介します。

それが API リクエストを cUrl 形式にして出力するという実装です。

大きいプロダクトで API が膨大に存在する場合、実際にどのようなリクエストをクライアントが行なっているか、コードを毎回見て把握するのは大変です。
実際の開発だと、サーバーサイドのチームなど他のチームにクライアントが行なっているリクエスト情報を求められることも多いと思います。

そういった時、cUrl をさっと共有できると何かと便利です。

しかも今回は Generator による自動生成コードで API リクエストを行っているため、実装を理解するのは自分で書いたコードより困難なことも多いと思います。

そういったユースケースのため、自動生成コードが API リクエストを行う直前に、cUrl でリクエスト情報を出力してもらおうと考えました。

前置きが長くなりました。

実装自体は、前述の securitySchemes の対応と同じようにミドルウェアを実装します。

import Foundation
import OpenAPIRuntime

final class CurlMiddleware: ClientMiddleware {
    func intercept(_ request: Request, baseURL: URL, operationID: String, next: (Request, URL) async throws -> Response) async throws -> Response {
        var baseCommand = ""
        if let query = request.query {
            baseCommand = #"curl "\#(baseURL.absoluteString)\#(request.path)"?\#(query)""#
        } else {
            baseCommand = #"curl "\#(baseURL.absoluteString)\#(request.path)""#
        }

        if request.method == .head {
            baseCommand += " --head"
        }

        var command = [baseCommand]

        if request.method != .get && request.method != .head {
            command.append("-X \(request.method.rawValue)")
        }

        for field in request.headerFields {
            if field.name != "Cookie" {
                command.append("-H '\(field.name): \(field.value)'")
            }
        }

        if let data = request.body, let body = String(data: data, encoding: .utf8) {
            command.append("-d '\(body)'")
        }

        print(command.joined(separator: " \\\n\t"))

        return try await next(request, baseURL)
    }
}

実装は以下の gist を参考に行っています。

https://gist.github.com/shaps80/ba6a1e2d477af0383e8f19b87f53661d

この gist では URLRequest 型の extension として実装されていますが、上記では ClientMiddleware プロトコルのメソッドである intercept メソッドの引数から必要な値を受け取って cUrl に出力しています。

一例ですが、こんな感じに出力されておりターミナルで実行しても問題なく動作することが確認できました。

これは OpenAI の /completions の API のリクエスト情報です。

curl "https://api.openai.com/v1/completions" \
	-X POST \
	-H 'accept: application/json' \
	-H 'content-type: application/json; charset=utf-8' \
	-H 'Authorization: Bearer <token>' \
	-H 'Content-Type: application/json' \
	-d '{
  "frequency_penalty" : 0,
  "max_tokens" : 200,
  "model" : "text-davinci-003",
  "presence_penalty" : 1,
  "prompt" : "<prompt>",
  "temperature" : 0.8,
  "top_p" : 1
}'

おわりに

Swift OpenAPI Generator が対応していないコード自動生成に関して、自分で実装したコードをリクエストに注入する方法を説明してきました。

ちなみにドキュメントコードでは、認証の他にもロギング用のミドルウェアを実装する例が取り上げられていました。

Issues にある通り、Swift OpenAPI Generator はまだ対応されていないことも多いです。

コードは確かに自動生成ではあるのですが、それでも必要に応じて生成されたコードの処理を理解し、カスタマイズしていく必要もあるというのが、自分でゼロから実装する方法と比べた自動生成のデメリットだと思います。

今回はミドルウェアという方法で対応することができましたが、実際調べてみたらどうしても対応できなそうだという結論になることもあるのではないでしょうか?そういうとき、自動生成だとかなり困ってしまいますね。。

とはいえやはり自動生成は便利なので、影響範囲が小さい範囲で試しながら上手く使っていけたらいいなと思いました。

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

Discussion

trickarttrickart

CurlMiddlewareとても参考になりました!

swift-openapi-runtime 1.7.0ではAPIが多少変わっていたのでそれに合わせたバージョンを書いてみたので共有いたします。

import Foundation
import HTTPTypes
import OpenAPIRuntime

// https://zenn.dev/kamimi01/articles/1bb2a5f6f2bcf0
struct CurlMiddleware: ClientMiddleware {
    let needsRedactionValues: [String: String]

    func intercept(
        _ request: HTTPRequest,
        body: HTTPBody?,
        baseURL: URL,
        operationID: String,
        next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)
    ) async throws -> (HTTPResponse, HTTPBody?) {
        var baseCommand = #"curl "\#(baseURL.absoluteString)\#(request.path ?? "")""#

        if request.method == .head {
            baseCommand += " --head"
        }

        var command = [baseCommand]

        if request.method != .get && request.method != .head {
            command.append("-X \(request.method.rawValue)")
        }

        for field in request.headerFields {
            if field.name.rawName != "Cookie" {
                let value: String
                if let needRedactionValue = needsRedactionValues[field.name.rawName] {
                    value = needRedactionValue
                } else {
                    value = field.value
                }
                command.append("-H \"\(field.name): \(value)\"")
            }
        }

        if let body {
            command.append("-d '\(body)'")
        }

        print(command.joined(separator: " \\\n\t"))

        return try await next(request, body, baseURL)
    }
}