AWS
Swift
APIGateway

AWS API Gateway(SDK version ≧2.6)の自動生成コードを、ちゃんとコンパイルが通るSwiftyなコードで置き換える

More than 1 year has passed since last update.

Serverlessconf Tokyo 2017 に行ってきました。楽しかったです。
これをきっかけに、AWSのAPI Gatewayを使ってみたのですが、サーバ知識が弱い自分でも特に難しいことなくREST APIを作ることができて、今後のiOSアプリ製作に向けての夢が広がりました。
ただ、使っていて一番気になったのは、API Gatewayのジェネレートしてくれるクライアントコードの質があまり良くないことでした。

2017-11-04 09.44.49.png

API Gatewayでは、ステージエディタという画面から、APIの定義に従ってクライアントコードをジェネレートしてくれます。
しかし、いくつか不満があります。

API Gateway × Swift の不満点

コードの対応しているSDK versionが2.5.3

  • 最新SDK(2.6.5)に追従していない
  • SDKには破壊的変更が入っており、そのままコードを使おうとするとビルドエラーになる

ジェネレートしたコードのままだと、APIGatewayと直接やりとりをする層以外の場所にAWSのSDKの型が漏れ出す

  • リクエスト時の引数として class AWSModel を継承した型が必要
  • パースされるオブジェクトが class AWSModel を継承している
    • 数値が NSNumber だったり色々アレ
    • できれば純粋なstructにしたい
    • 今ならCodableも活用できるはず
  • 非同期処理の実現に AWSTask を使っている
    • 外からのインタフェースとしては、クロージャでのコールバックで受けたい
    • もし可能ならば RxSwift.Single に寄せたい

そこで、

コンパイルを通しつつ、さらに既存の設計の不満を解決するため、小さなラッパーを書いてみました。

やること

ジェネレートされたコード( {PREFIX}{API名}Client )のinitを書き替える

そもそもビルド通らないので、以下のように置き換えます。

    init(configuration: AWSServiceConfiguration) {
        super.init()

        var urlString = "https://xxxxxxxxxx.execute-api.{region}.amazonaws.com/{stage名}"
        if urlString.hasSuffix("/") {
            urlString = String(urlString.dropLast())
        }
        self.configuration = AWSServiceConfiguration(
            region: configuration.regionType,
            endpoint: AWSEndpoint(region: configuration.regionType, service: .APIGateway, url: URL(string: urlString)),
            credentialsProvider: configuration.credentialsProvider
        )

        let signer = AWSSignatureV4Signer(credentialsProvider: self.configuration.credentialsProvider, endpoint: self.configuration.endpoint)!
        if let endpoint = self.configuration.endpoint {
            self.configuration.baseURL = endpoint.url
        }
        self.configuration.requestInterceptors = [AWSNetworkingRequestInterceptor(), signer]
    }

{PREFIX}{API名}Client のラッパーを用意する

/// API Gatewayの自動生成コードをラップする構造体
struct APIGateway {
    enum Method: String {
        case GET
        case POST
        case PUT
        case DELETE
        case HEAD
        case PATCH
        case OPTIONS
    }
    enum DecodingError: Swift.Error {
        case unknown
    }
    enum Result<T> {
        case success(T)
        case error(Swift.Error)
    }

    /// 作成したAPI/ステージなどの環境に応じて書き替える
    fileprivate typealias GeneratedClient = {PREFIX}{API名}Client
    private static let clientKey = "適当な文字列"

    /// didFinishLaunchingWithOptionsで呼ぶやつ
    static func register() {
        let credentialProvider = AWSCognitoCredentialsProvider(regionType: .APNortheast1, identityPoolId: "ap-northeast-1:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")
        let configuration = AWSServiceConfiguration(region: .APNortheast1, credentialsProvider: credentialProvider)!
        GeneratedClient.registerClient(withConfiguration: configuration, forKey: clientKey)
    }

    fileprivate static var generated: GeneratedClient {
        return GeneratedClient.client(forKey: clientKey)
    }
}

/// AWSAPIGatewayRequest, AWSTaskをラップしてSwiftyにAPI定義を記述するためのプロトコル
protocol APIGatewayRequest {
    associatedtype Response

    var httpMethod: APIGateway.Method { get }
    var urlString: String { get }
    var queryParameters: [AnyHashable: Any] { get }
    var headerParameters: [AnyHashable: Any] { get }
    var httpBody: Any? { get }
}
extension APIGatewayRequest {
    var headerParameters: [AnyHashable: Any] {
        return [
            "Content-Type": "application/json",
            "Accept": "application/json",
        ]
    }
    var queryParameters: [AnyHashable: Any] { return [:] }
    var httpBody: Any? { return nil }

    private var awsRequest: AWSAPIGatewayRequest {
        return AWSAPIGatewayRequest(
            httpMethod: httpMethod.rawValue,
            urlString: urlString,
            queryParameters: queryParameters,
            headerParameters: headerParameters,
            httpBody: httpBody
        )
    }
}
/// レスポンスが必要なエンドポイントに使う。Codableを活用する。
extension APIGatewayRequest where Response: Decodable {
    func invoke(completion: @escaping (APIGateway.Result<Response>) -> Void) {
        APIGateway.generated.invoke(awsRequest).continueWith(block: {
            if let data = $0.result?.responseData {
                do {
                    let t = try JSONDecoder().decode(Response.self, from: data)
                    completion(.success(t))
                } catch(let e) {
                    completion(.error(e))
                }
            } else {
                completion(.error($0.error ?? APIGateway.DecodingError.unknown))
            }
            return nil
        })
    }
}
/// レスポンスが不要なエンドポイントに使う
extension APIGatewayRequest where Response == Void {
    func invoke(completion: @escaping (APIGateway.Result<Void>) -> Void) {
        APIGateway.generated.invoke(awsRequest).continueWith(block: {
            if $0.result?.responseData != nil {
                completion(.success(Void()))
            } else {
                completion(.error($0.error ?? APIGateway.DecodingError.unknown))
            }
            return nil
        })
    }
}

// MARK: - RxSwift.Singleに寄せたい場合は以下を使う

import RxSwift
extension Single {
    static func from<T: APIGatewayRequest>(_ request: T) -> Single<Element> where T.Response == Element, T.Response: Decodable {
        return Single<Element>.create { emitter in
            request.invoke {
                switch $0 {
                case .success(let t): emitter(.success(t))
                case .error(let t): emitter(.error(t))
                }
            }
            return Disposables.create()
        }
    }
    static func from<T: APIGatewayRequest>(_ request: T) -> Single<Element> where T.Response == Element, T.Response == Void {
        return Single<Element>.create { emitter in
            request.invoke {
                switch $0 {
                case .success(let t): emitter(.success(t))
                case .error(let t): emitter(.error(t))
                }
            }
            return Disposables.create()
        }
    }
}

AppDelegateでregisterする

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        APIGateway.register()

        return true
    }

レスポンスの型はpure Swift な structで定義する

/// Pure Swift struct brought as the response from APIGateway.
struct Hoge: Codable {
    let id: Int
    let userID: Int
    let url: URL

    private enum CodingKeys: String, CodingKey {
        case id
        case userID = "user_id"
        case url
    }
}

APIGatewayRequest プロトコルに適合したエンドポイント定義を書いて、使う

例① URLパラメータを渡し、Codableな型を受け取る

作りたいもの

2017-11-04 11.09.10.png

エンドポイント定義

struct HogeGetRequest: APIGatewayRequest {
    typealias Response = [Hoge]

    let httpMethod: APIGateway.Method = .GET
    let urlString = "/hoges"
    let queryParameters: [AnyHashable: Any]

    init(userID: Int) {
        queryParameters = [
            "user_id": userID
        ]
    }
}

呼び出し方

        // 通信後の処理をコールバックで記述
        HogeGetRequest(userID: 1).invoke {
            switch $0 {
            case .success(let hoges): print(hoges)
            case .error(let error): print(error)
            }
        }
        // RxSwiftを使っているならこうも書ける
        Single.from(HogeGetRequest(userID: 1))
            .subscribe(onSuccess: { hoges in
                print(hoges)
            })
            .disposed(by: disposeBag)

例② application/json をbodyで渡し、200レスポンスが返ってくればそれでいい

作りたいもの

2017-11-04 11.07.04.png

エンドポイント定義

struct HogePostRequest: APIGatewayRequest {
    typealias Response = Void

    let httpMethod: APIGateway.Method = .POST
    let urlString = "/hoges"
    let httpBody: Any?

    struct Parameters: Encodable {
        let url: URL
    }
    init(parameters: Parameters) {
        httpBody = try! JSONEncoder().encode(parameters)
    }
}

呼び出し方

        // 通信後の処理をコールバックで記述
        HogePostRequest(parameters: .init(
            url: URL(string: "https://aws.amazon.com")!
            ))
            .invoke {
                switch $0 {
                case .success: () // Void
                case .error(let error): print(error)
                }
        }
        // RxSwiftを使っているならこうも書ける
        Single.from(HogePostRequest(parameters: .init(
            url: URL(string: "https://aws.amazon.com")!
            )))
            .subscribe()
            .disposed(by: disposeBag)

後始末

メソッドを置き換える際、 {PREFIX}{API名}Client にジェネレートされていたAPIアクセス用のメソッドと、そこで使われている AWSModel を継承した型は、不要なので消してしまいましょう。
これでスッキリしました。

というか

AWSのジェネレートするコードが良くなってくれればワークアラウンドがなくても済む話なので、進化を期待してます。
AWSの中の人の皆様方、よろしくお願いします :pray: