LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


LINE Creators Studioに広がる美しい型の世界(後篇)

LINE Creators Studio担当の@ukitakaです。タイトルのハードルの高さに驚きつつ、前篇に引き続きLINE Creators Studio iOSアプリでのAPI周りの実装の話をします。主に APIKit というライブラリを中心とした話になります。

Proxyパターンで必要なパラメータを後から設定する

リクエストヘッダに認可トークンを埋め込んで〜のような処理はよくあると思いますが、みなさんどう設定していますか?
APIKitではRequestheaderFieldsというプロパティがあり、これを設定することでHTTPヘッダの設定ができます。

struct MyRequest: Request {
   var headerFields: [String: String] { ... }
}

トークンのような場合、ほとんどすべてのRequestの実装にこの設定を書く必要がでてきてしまいます。

struct MyRequest: Request {
   let token: String
   var headerFields: [String: String] {
       ["Authorization" : "Bearer " + token]
   }
}

もちろんどこかに保存されているtokenをグローバルに参照すればRequestのProtocol Extensionとして書くことはできなくはないですが、テストの時に毎回グローバルな設定を書き換える必要があったり、リクエストによって他のヘッダーを設定したい時などを考えると少し微妙な実装です。

extension Request {
   var headerFields: [String: String] {
       ["Authorization" : "Bearer " + token] // 別のところにあるtokenを参照
   }
}

いくつか方法はあると思いますが、今回は一番実装が綺麗で他にも応用がしやすいProxy パターンを使った方法を採用して実装を行いました。
まずはRequestProxyというprotocolを用意します。

protocol RequestProxy: APIKit.Request {
   associatedtype Request: APIKit.Request
   var request: Request { get }
}
extension RequestProxy {
   var baseURL: URL {
       return request.baseURL
   }
   var headerFields: [String : String] {
       return request.headerFields
   }
   // 以下省略。
   // すべてのpropertyとメソッドについて
   // 同様にrequestへと処理を渡す
}

RequestProxy自身がRequestとして振舞うことができ、また元のrequestをpropertyとして保持していて、デフォルトでは元のrequestの処理を呼び出すだけです。
そして、これを実装した AuthorizedRequest という型を作ります。

struct AuthorizedRequest<R: APIKit.Request>: RequestProxy {
   typealias Request = R
   typealias Response = R.Response
   let request: R
   let token: String
   var headerFields: [String : String] {
       var h = request.headerFields
       h["Authorization"] = "Bearer " + token
       return h
   }
}

こうすることで例えばAPIClientみたいなリクエストを担当するクラスがあったとして、そこでヘッダーにtokenを差し込むことができるようになります。

final class APIClient {
   let token: String
   init(token: String) {
       self.token = token
   }
   func send<Request: APIKit.Request>(request: Request) -> Observable<Request.Response> {
       // tokenを設定
       let request = AuthorizedRequest(request: request, token: token)
       // requestを使ってリクエストを投げる
       // (略)
   }
}

こんな感じです。

また、LINE Creators Studioではサーバーを切り替えたりテストを書いたりするために、baseURLを後から差し替えたいという話があったので、同様の方法で対応しています。

struct BaseURLInjectableRequest<R: APIKit.Request>: RequestProxy {
   typealias Request = R
   typealias Response = R.Response

   let request: Request
   let baseURL: URL

   init(request: Request, baseURL: URL) {
       self.request = request
       self.baseURL = baseURL
   }
}

もちろん上記のAuthorizedRequestと組み合わせることも容易です。

APIKit.RequestとHimotoki.Decodableをうまく組み合わせる

APIKitのRequestの実装では、JSONオブジェクトからResponse 型への変換をするために func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Responseを実装する必要がありますが、うまく記述すればこの実装を毎回書く必要がなくなります。
APIKit + Himotokiという組み合わせは(特にSwift3までの日本のiOS開発界隈では) よく見かけますし、APIKit作者のIshkawa氏のBlogでも紹介されているので、すでに同じことをやってる方も多いのではないかなと思います。
要はこういうことです。

extension Request where Response: Decodable {
   func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
       return try decodeValue(object)
   }
}

LINE Creators Studioではこれに Array への対応と Void への対応を加えて少しだけ便利にしてあります。

// EがDecodableで、ResponseがArray<E>のとき
// (正確にはArrayだけでなくRangeReplaceableCollectionのサブタイプであればなんでも)
extension Request where Response: RangeReplaceableCollection, Response.Iterator.Element: Decodable {
   func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
       let array: [Response.Iterator.Element] = try decodeArray(object)
   return Response(array)
   }
}
// ResponseがVoidのとき
// == での制約はSwift3.1から
extension Request where Response == Void {
   func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
       return ()
   }
}

( Conditional conformancesが入ればもう少し簡単に記述できますが、Swift4ではまだ入りませんでしたね。。)

ページネーションを型で表現する

LINE Creators Studioの一部のAPIでは、ページネーションに対応しています。
何ページ目を読むのか、次のページがあるのか、などいろいろ考慮すべきことがあって面倒なページネーションの処理ですが、今回は適切に型を定義して実装を行いました。
長くなってしまうので、ここでは簡単な紹介にとどめておきます。
まず、ページネーションに対応したAPIのレスポンスの抽象化として以下のような型を作りました。

indirect enum PaginatedResponse<Request: APIKit.Request> {
   case hasNext(Request.Response, AnyRequest<PaginatedResponse<Request>>)
   case done(Request.Response)
}

TypeErasureが入っているのでちょっとだけ分かりづらいですが、簡単に言うと 「次がある場合は、いまのレスポンス結果と次のページのリクエスト」「これで終わりの場合はレスポンスのみ」を表現する型です。
次にページ付きリクエストのためのPaginatedRequestという protocol を作ります。

protocol PaginatedRequest: RequestProxy {
   // レスポンスから次のページのリクエストを作る
   func nextRequest(from response: Request.Response, urlResponse: HTTPURLResponse) throws -> Self?
}

そしてこれの具体的な実装を用意します。
今回はページの情報はヘッダーに埋め込むことにしたので、headerFieldsを実装してあげます。

struct LCSPaginatedRequest<R: APIKit.Request>: PaginatedRequest {
...
   // ペジネーションリクエスト時に必要なページの設定
   var headerFields: [String : String] {
       var h = request.headerFields
       h["page"] = "(page)"
   return h
   }
   // 次のリクエストの作成
   func nextRequest(from response: Request.Response, urlResponse: HTTPURLResponse) throws -> LCSPaginatedRequest? {
       guard let page = urlResponse.allHeaderFields["next_page"] as? Int else {
           return nil
       }
       return LCSPaginatedRequest(request: request, page: page)
   }
}

あとはページングに対応したRequestLCSPaginatedRequestにした上で使ってあげれば、良い感じに動きます。

LCSPaginatedRequest(request: request)

まとめ

型の話というよりかはAPIKit周辺のテクニックの紹介になってしまいました。
他にもRxSwiftと組み合わせるとか、Error型をもたせてステータスコードが4xxとか5xxのときにはその型にマッピングするとか、紹介しきれなかったものもいくつかありますがLINE Creators Studioではこんな感じで実装を行なっているという雰囲気が掴んでもらえたかなと思います。