ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Swiftを使ったAPIクライアントの実装方法

はじめましてiOSエンジニアのしだです。
おかげさまで、去年12月に るくみーnote という園で利用してもらう連絡帳アプリをリリースしました。(実際に園でご利用いただくのは4月からの予定です)

iOS版の るくみーnote では主に AlamofireObjectMapperRxSwiftSQLite.swift などのライブラリを利用しています。
今回は、そのときに実践した Alamofire + ObjectMapper + RxSwift を使ったAPIクライアント周りの実装方法を紹介したいと思います。

以下のサンプルの実装は Slack WebAPI を使って説明しています。

準備

APIの定義

今回は SlackのWeb APIのemoji.listchat.postMessageの実装例を示します。

  • emoji.list: 設定されている絵文字のリストが取得できます
  • chat.postMessage: 指定したchannelにメッセージを送信できます

まず Slack Web APIのmethodsを Enumeration を使って以下のように定義します。

enum SlackApi: API {

    /// 絵文字リストの取得
    case emojiList

    /// メッセージを送信
    case chatPostMessage(text: String, channel: String, options: [String: String])

    /// :
    /// ファイルのアップロード
    /// case fileUpload(filename: String, file: Data, options: [String: String])

    /// 任意のSlackのTest Tokenを利用してください
    static let token = “xoxp-xxx"

    var buildURL: URL {
        return URL(string: "\(baseURL)\(path)")!
    }

    var baseURL: String {
        return "https://slack.com/api"
    }

    var path: String {
        switch self {
        case .emojiList:                                        return "/emoji.list"
        case .chatPostMessage(text: _, channel: _, options: _): return "/chat.postMessage"
        }
    }

    var parameters: Parameters {
        var params = ["token": SlackApi.token]
        switch self {
        case .emojiList:
            return params
        case .chatPostMessage(text: let text, channel: let channel, options: let options):
            params["text"] = text
            params["channel"] = channel
            options.forEach { (k, v) in params[k] = v }
            return params
        }
    }
}

protocol API {
    /// リクエストURL(e.g. https://slack.com/api/emoji.list)
    var buildURL: URL { get }

    /// APIのベースURL(e.g. https://slack.com/api)
    var baseURL: String { get }

    /// APIのパス(e.g. /emoji.list)
    var path: String { get }

    /// リクエストのパラメーター(e.g. token=xoxp-xxx)
    var parameters: [String: Any] { get }
}

ポイントとしては、各APIを列挙型の要素で定義することと、 Associated Values を使ってリクエストに必要なパラメータがわかるようにしてあります。
欠点としては、APIの数が多くなれば、 enum SlackApi のコード量が多くなりますがAPIを一覧できるほうが利点かなと思います。

モデルの定義

続いて、レスポンスのJSON形式からオブジェクトに変換するために、 ObjectMapper を使ってモデルの定義をします。

/// 絵文字
struct Emoji: Mappable {
    var ok: Bool = false
    var list: [String: String] = [:]

    init?(map: Map) { }

    mutating func mapping(map: Map) {
        ok   <- map["ok"]
        list <- map["emoji"]
    }
}

/// メッセージ送信レスポンス
struct PostMessage: Mappable {
    var ok: Bool = false
    var ts: String = ""
    var channel: String = ""
    var message: AnyObject?

    init?(map: Map) { }

    mutating func mapping(map: Map) {
        ok      <- map["ok"]
        ts      <- map["ts"]
        channel <- map["channel"]
        message <- map["message"]
    }
}

HTTPクライアントの定義

最後に、 AlamofireRxSwfitObjectMapper を使って、HTTPのクライアントを定義します。

import Alamofire
import ObjectMapper
import RxSwift

struct Client {
    static let manager = Alamofire.SessionManager.default
    
    private static func json(method: HTTPMethod = .get, api: API, encoding: ParameterEncoding = URLEncoding.default, headers: [String: String]? = nil) -> Observable<Any> {
        return dataRequest(method: method, api: api, encoding: encoding, headers: headers)
            .flatMap { (request) -> Observable<Any> in
                return manager.rx.json(request: request)
        }
    }
    
    private static func dataRequest(method: HTTPMethod = .get, api: API, encoding: ParameterEncoding = URLEncoding.default, headers: [String: String]? = nil) -> Observable<DataRequest> {
        return Observable<DataRequest>
            .create { (observer) -> Disposable in
                let url = api.buildURL
                let request = manager.request(url, method: method, parameters: api.parameters, encoding: encoding, headers: headers)
                observer.onNext(request)
                observer.onCompleted()
                return Disposables.create()
        }
    }
    
    /// レスポンスのJSON形式がDictionary(JSONObject)の場合
    static func get<T: Mappable>(api: API, encoding: ParameterEncoding = URLEncoding.default, headers: [String: String]? = nil) -> Observable<T> {
        return Client.json(method: .get, api: api, encoding: encoding, headers: headers).map(mapping())
    }
    
    /// レスポンスのJSON形式がArray(JSONArray)の場合
    static func get<T: Mappable>(api: API, encoding: ParameterEncoding = URLEncoding.default, headers: [String: String]? = nil) -> Observable<[T]> {
        return Client.json(method: .get, api: api, encoding: encoding, headers: headers).map(mappingToArray())
    }
    
    static func post<T: Mappable>(api: API, encoding: ParameterEncoding = URLEncoding.default, headers: [String: String]? = nil) -> Observable<T> {
        return Client.json(method: .post, api: api, encoding: encoding, headers: headers).map(mapping())
    }
    
    /// JSONからオブジェクトへのマッピング
    static func mapping<T: Mappable>() -> ((Any) -> T) {
        return { (json) -> T in
            let item: T = Mapper<T>().map(JSONObject: json)!
            return item
        }
    }
    
    /// JSONからArrayタイプのオブジェクトへマッピング
    static func mappingToArray<T: Mappable>() -> ((Any) -> [T]) {
        return { (json) -> [T] in
            let item: [T] = Mapper<T>().mapArray(JSONObject: json) ?? []
            return item
        }
    }
}

Genericsを使って、 ObjectMapper でマッピングしたオブジェクトを返す getpost メソッドを用意しておくと、使うときにClient.get(api: SlackApi.emojiList)と書けるので可読性がいいかなと考えます。

また、レスポンスがJSON形式であれば、 Clientクラスはそのまま流用可能です。
iOS版 るくみーnoteの実装も、認証処理や putpatch が追加されてるなどの違いはありますがほとんど一緒です。

あと、ちょっと無駄のようにも思えますが、 Observable<DateRequest> を返すメソッドも用意しておくと、アクセストークンの有効期限切れなどリトライする場合に役に立ちます。

使い方

絵文字リストの取得: emoji.list

/// 絵文字のリストを取得する
let request: Observable<Emoji> = Client.get(api: SlackApi.emojiList)
request.subscribe(
    onNext: { (emoji) in
        print(emoji)
    },
    onError: { (error) in
        print(error)
    })
    .addDisposableTo(disposeBag)

メッセージの送信: chat.postMessage

/// #generalにメッセージを送信する
let api = SlackApi.chatPostMessage(
    text: "てすと",
    channel: "general",
    options: ["username": "Bot", "icon_emoji": ":robot_face:"]
)
let request: Observable<PostMessage> = Client.post(api: api)
request.subscribe(
    onNext: { (message) in
        print(message)
    },
    onError: { (error) in
        print(error)
    })
    .addDisposableTo(disposeBag)

まとめ

今回はSlack Web APIを例に、iOSのAPI周りの実装方法をこんな感じで書いてますという紹介でした。
(コールバックを何個も用意したり、NSURLSessionを自前で書いたり、JSONからオブジェクトへ変換するのにif letをたくさん書いたり、、なにもかもみな懐かしいです。)

僕個人もはじめて Alamofire + ObjectMapper + RxSwift をプロダクトで使ってみましたがとてもよかったです。
さらにSQLite.swiftも一緒に使いましたので今後そのあたりも共有できたらいいなと思います。

iOSアプリ開発の参考になれば幸いです。