URLProtocol を使っていますか? Playground へコピペするだけで動く API スタブの実践的サンプルコード付き

f:id:RAPT:20180723112121p:plain

こんにちは!
iOS アプリエンジニアの山口です。

先日、大阪港にて一般公開された護衛艦かがに乗艦しました。

ウェーブ(WAVE: 女性の海上自衛官)のみなさまが一様にべっぴんさん揃いだったことも驚きでしたが、一番驚いたのが、艦内便所にウォッシュレットがついていたことです。しかも便座足台もついており、手洗いの脇にはハンドドライヤーまでついていました。

この 20 年でここまで進化を遂げたとは思いもしませんでした。

使用後の便器洗浄自体は相変わらずバルブをセルフでしたが。

URLProtocol について

さて、昨今ではスマホアプリといえばサーバー連携ありきのものがほとんどだと思います。

アプリ開発中はサーバーも開発中だったりして、ダミーの応答を返す仕組みをつかってアプリ開発をすすめることが多いと思います。

ダミーの応答を返す仕組みは色々ありますが、今回は iOS 標準 SDK にある URLProtocol を使って実装する例をご紹介したいと思います。

Cloud なサービスを使ったり、CocoaPodsCarthage などを使って外部ライブラリを導入したりすることもあるかと思いますが、標準 SDK だけでもスタブを作ることは可能です。

標準 SDK に入っているので Playground でも自由に使えちゃいます。

今回は Xcode 9.3.1 / Swift 4.1 / Playground で実際に動作させることができるサンプルコードを提示しますが、無論、Objective-C でも同様に使えます。(コードは提示しませんので適宜読み替えてください。)

URLProtocol クラスの概要

URLProtocol クラスを継承したクラスを書いて、必要なメソッドをオーバーライドしていきます。

canInit(with:)true を返すとインスタンス化され、プロキシサーバーのように通信をハンドリングできるようになります。

startLoading で実際の通信処理を置き換えることができます。

なお、今回は通信処理を行う関係上、Playground を実行させたままにしないと処理が中断されるおそれがあるため、先頭の方に下記のように記述しておきます。

import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

URLProtocol を継承したクラスを実装

さて、本題ですが、下記のように有効化するコードを書きます。

final class NetworkCapture: URLProtocol {

    class func setup() {
        // 有効化
        let isOk = URLProtocol.registerClass(NetworkCapture.self)
        assert(isOk, "NetworkCapture 登録失敗")

        URLSessionConfiguration.default.protocolClasses?.insert(NetworkCapture.self, at: 0)

        setupHandlers()
    }

次にフックするデータを登録します。
俯瞰できるように定義はまとめておきたいのでこの構造にしています。
ここでは省略していますが、パラメーターによってはフックする/しないを切り分け可能です。

    // フックの定義
    private static var entryItems = NetworkCaptureEntry()
    private static func setupHandlers() {
        // ★★★ ここに追加したいスタブを定義する ★★★
        entryItems
            .add(urlString: {
                $0.contains("/maintenance")
            }, data: { _ in
                StubData.maintenance.asData()
            })
            .add(urlString: {
                $0.contains("/notice")
            }, data: { _ in
                StubData.notice.asData()
            })
            .add(urlString: {
                $0.contains("/error")
            }, data: { _ in
                StubData.empty.asData()
            }, error: { _ in
                NSError(domain: "test.api.error", code: 1, userInfo: nil) as Error
            })
    }

URLProtocol のメソッドをオーバーライドしていく

まず最初にクラスメソッド canInit(with:) を override します。
通信のロギングだけならここでログ出力して、false を返す、といった使い方もできます。

    // ここで true を返すと、このクラスがインスタンス化され、使用される
    override class func canInit(with request: URLRequest) -> Bool {
        let method = request.httpMethod ?? ""
        let url = request.url?.absoluteString ?? ""
        print("[\(method)] \(url)")

        return entryItems.isWantHook(request: request)
    }

以下、スタブ対応時に必須となる override メソッド群を実装していきます。

    // リクエストの正規化が必要であれば加工して返す。そのままでも OK
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        return request
    }

    // キャッシュのための比較用として使われる。キャッシュ不要なので常に false を返している
    override class func requestIsCacheEquivalent(_ lhs: URLRequest, to rhs: URLRequest) -> Bool {
        return false
    }

    // 実際に通信を開始しようとしたときに呼ばれる
    override func startLoading() {
        guard let client = client, let url = request.url else {
            return
        }

        let result = type(of: self).entryItems.performHook(request: request)
        print("  * hooked")

        // エラー処理
        if let error = result.error {
            client.urlProtocol(self, didFailWithError: error)
            return
        }

        // 成功処理
        let commonHeaders: [String: String] = [
            "Content-Language": "ja",
            "Content-Type": "text/plain; charset=utf-8"
        ]
        if let response = HTTPURLResponse(url: url, statusCode: result.status, httpVersion: "1.0", headerFields: commonHeaders) {
            client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
        }
        if let data = result.data {
            client.urlProtocol(self, didLoad: data)
        }
        client.urlProtocolDidFinishLoading(self)
    }

    // 通信が中断されたときに呼ばれる
    override func stopLoading() {
    }
}

フック定義コンテナーの実装

先ほど登録したフック定義を収める容れ物です。

extension NetworkCapture {
    /// フック定義コンテナー
    final class NetworkCaptureEntry {
        private var stringItems: [StringItem] = []

        /// フック定義を追加
        ///
        /// - Parameter urlString: フックしたい場合は true を返す
        /// - Parameter data: フックしたときに呼ばれる。レスポンスデータを返す。
        /// - Parameter error: フックしたときに呼ばれる。エラーを返したい場合に使用する。
        /// - Returns: メソッドチェーンで記述できるようにしている
        @discardableResult
        func add(urlString: @escaping (String) -> Bool, data: @escaping (String) -> Data?, error: @escaping (String) -> Error? = { _ in nil }) -> NetworkCaptureEntry {
            stringItems.append(StringItem(urlString: urlString, data: data, error: error, status: { 200 }))
            return self
        }

        /// フック判定
        ///
        /// - Returns: フックする場合は true を返す
        func isWantHook(request: URLRequest) -> Bool {
            if let urlString = request.url?.absoluteString {
                return stringItems.contains(where: { $0.urlString(urlString) })
            }
            return false
        }

        /// フック処理連携
        ///
        /// - Parameter request: リクエスト
        /// - Returns: レスポンスとして使用されるべきデータ、HTTP ステータス、エラーを返す
        func performHook(request: URLRequest) -> (data: Data?, status: Int, error: Error?) {
            var data: Data?
            var status = 404
            var error: Error?

            if let urlString = request.url?.absoluteString,
                let item = stringItems.first(where: { $0.urlString(urlString) }) {
                data = item.data(urlString)
                error = item.error(urlString)
                status = item.status()
            }

            return (data: data, status: status, error: error)
        }

        /// フック定義
        private struct StringItem {
            var urlString: (String) -> Bool
            var data: (String) -> Data?
            var error: (String) -> Error?
            var status: () -> Int
        }
    }
}

スタブのレスポンスを記述

JSON レスポンスの定義も一覧にしておきたいため、別に分けています。

private enum StubData: String {

    /// 値を Data? 型に変換
    func asData() -> Data? {
        return rawValue.data(using: .utf8, allowLossyConversion: true)
    }

    // MARK: - スタブデータは以下に書く
    case empty = "{}"
    case maintenance = """
{
    "code": 100,
    "message": "ただいまメンテナンス中です。"
}
"""
    case notice = """
{
    "notice": [
        {
            "code": 100,
            "title": "お知らせその1です。"
        },
        {
            "code": 102,
            "title": "お知らせその2です。"
        }
    ]
}
"""
}

せっかくなので HTTP クライアントも自作で

本記事では Playground でさくっと作ることがゴールなので全部自作していますが、無論、通常の開発であれば Alamofire などお使いのライブラリで通信すれば OK です。

/// シンプルな HTTP クライアント
final class HttpClient {
    enum Error: Swift.Error {
        case invalidParameter(String)
    }

    /// RFC で規定されている URL エンコード対象外文字は大小英数字とハイフン、ピリオド、アンダースコア、チルダ
    ///  https://tools.ietf.org/html/rfc3986#section-2.3
    ///  unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
    ///  ALPHA(%41-%5A and %61-%7A), DIGIT (%30-%39), hyphen (%2D), period (%2E), underscore (%5F), or tilde (%7E)
    ///
    ///  CharacterSet.alphanumerics US-ASCII の範囲を超えて unicode エリアにまで及ぶ一方、記号が含まれていない。
    ///  CharacterSet.urlQueryAllowed [A-Za-z_~] はカバーされているが、「&」や「/」などといった、エンコードが必要な要素まで含まれている。
    static var urlAllowedInRfc3986: CharacterSet = {
        var allows = CharacterSet()
        allows.insert(charactersIn: Unicode.Scalar(0x41)...Unicode.Scalar(0x5a))
        allows.insert(charactersIn: Unicode.Scalar(0x61)...Unicode.Scalar(0x7a))
        allows.insert(charactersIn: Unicode.Scalar(0x30)...Unicode.Scalar(0x39))
        allows.insert(charactersIn: Unicode.Scalar(0x2d)...Unicode.Scalar(0x2d))
        allows.insert(charactersIn: Unicode.Scalar(0x2e)...Unicode.Scalar(0x2e))
        allows.insert(charactersIn: Unicode.Scalar(0x5f)...Unicode.Scalar(0x5f))
        allows.insert(charactersIn: Unicode.Scalar(0x7e)...Unicode.Scalar(0x7e))
        return allows
    }()

    /// GET 通信
    func get(urlString: String, parameters: [String: Any] = [:], handler: @escaping (Any?, Swift.Error?) -> Void) {
        guard var url = URL(string: urlString) else {
            handler(nil, HttpClient.Error.invalidParameter(urlString))
            return
        }
        let escape: (String) -> String = { $0.addingPercentEncoding(withAllowedCharacters: HttpClient.urlAllowedInRfc3986) ?? $0 }

        if !parameters.isEmpty, var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) {
            urlComponents.percentEncodedQuery = parameters
                .compactMap {
                    (escape($0.key), escape("\($0.value)"))
                }
                .map {
                    "\($0.0)=\($0.1)"
                }
                .joined(separator: "&")
            if let newUrl = urlComponents.url {
                url = newUrl
            }
        }

        let task = URLSession.shared.dataTask(with: url) { data, _, error in
            if let data = data, let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) {
                handler(json, nil)
            } else {
                handler(nil, error)
            }
        }
        task.resume()
    }
}

おまけのユーティリティ

本筋とは関係ありませんが、配列要素を順番に実行するようなものがあると検証に便利なので定義しています。

forEach をそのまま使ってしまうと、通信処理が終わる前に並列実行されるおそれがあり、検証の妨げになります。

extension Array {

    /// continuousHandler を呼び出すことで次の element を参照する forEach
    ///
    /// - Parameter body: 次の element の呼び出しを許可する
    func forEachAsync(body: @escaping (_ element: Element, _ continuousHandler: @escaping () -> Void) -> Void) {
        var main: ((Int, Int) -> Void)? = nil
        main = { index, count in
            if index >= count {
                return
            }

            body(self[index]) {
                main?(index + 1, count)
            }
        }
        main?(0, count)
    }
}

実際に使ってみる

2 つの成功パターン (notice, maintenance) と、 1 つのエラーパターン (error) と、 1 つのフック定義がないパターン (notfound) がそれぞれ成功するか見てみます。

NetworkCapture.setup()

let apis: [String] = [
    "http://www.example.com/notice",
    "http://www.example.com/maintenance",
    "http://www.example.com/error",
    "http://www.example.com/notfound",
]
apis.forEachAsync { urlString, next in
    HttpClient().get(urlString: urlString, parameters: ["foo": "bar", "baz": "1"]) { json, error in
        if let error = error {
            print("  * Response Error: \(error)")
        } else if let json = json as? [String: [[String: Any]]] {
            print("  * Response JSON(notice): \(json)")
        } else if let json = json as? [String: Any] {
            print("  * Response JSON(maintenance): \(json)")
        } else {
            print("  * Response Other: \(String(describing: json))")
        }
        next()
    }
}

実行結果

notice でレスポンスを返すスタブ化成功♪

[GET] http://www.example.com/notice?baz=1&foo=bar
  * hooked
  * Response JSON(notice): ["notice": [["code": 100, "title": お知らせその1です。], ["code": 102, "title": お知らせその2です。]]]

maintenance でレスポンスを返すスタブ化成功♪

[GET] http://www.example.com/maintenance?baz=1&foo=bar
  * hooked
  * Response JSON(maintenance): ["code": 100, "message": ただいまメンテナンス中です。]

error でエラーを返すスタブ化成功♪

[GET] http://www.example.com/error?baz=1&foo=bar
  * hooked
  * Response Error: Error Domain=test.api.error Code=1 "(null)"

スタブ登録していない場合はきちんと ハンドルしない こともできています。

[GET] http://www.example.com/notfound?baz=1&foo=bar
  * Response Other: nil

総括

いかがでしたでしょうか。

スタンドアローンでも API スタブは結構簡単に作れます。

ちなみに、Xcode で新規 Playground を作成し、本記事で提示したコード断片をすべて貼り付けると、実行可能なコードになりますので試してみてください。