炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

Swift2 - 画像アップロードと通常のリクエストを分けてユーザー体験を向上させる

リクエスト回数は極力減らした方が良いのでは?といった方針から、画像をアップロードする処理と通常のテキスト情報を送信する処理を一つのリクエストで行っていたのですが、実際に実装してみたところ、リクエストが完了するまで画面にローディングを表示して、操作を中断させる処理を入れていたため、えらく待たされる感が漂ってました。非同期で行われる API の良さも活かしきれていない状況で、リクエスト回数を減らすよりか、より早くユーザーに操作できる環境を提供する方が、操作していて気持ちいいだろうということで、通常のリクエストと画像のリクエストを分けて実装することになったので、その方法について紹介したいと思います。口コミを投稿する機能をサンプルとしたいと思います。

サンプル例

  1. ある宿泊施設に対して口コミを投稿する機能がある
  2. 口コミで投稿する内容は、感想( content ), 評価 ( rating ) , 画像 ※複数可 ( photos ) とする

実装内容

  1. テキスト情報のリクエストを定義する ( /facilities/:facility_id/reviews )
  2. 画像アップロードのリクエストを定義する ( /reviews/:review_id/photos )
  3. 口コミ投稿する際には、画像以外をまずは投稿する。レスポンスに口コミ ID を返すようにし、画像はレスポンスの口コミ ID にアップロードするようにする
  4. 複数枚の画像のアップロードが想定されるので、画像のアップロード中は画面にアップロード中の View を表示する

通常のリクエストはすぐに完了する処理なので、ローディング画面を表示して、操作を中断。画像アップロードは時間がかかるので、ローディング画面ではなく、ページ上部にアップロード中のお知らせを出して、画面を操作は制御しないようにします。画像のアップロードが失敗しても、口コミのテキスト情報の送信は commit されているので、ロールバックはできません。画像のアップロード失敗時は、適切にユーザーに知らせることに注力し、編集から再度画像をアップロードしてもらう形にしようといった割り切り方をしました。

f:id:t-namikata:20160523233117p:plain

1. テキスト情報のリクエストを定義する

(Swift2) Alamofire の upload 用 API クライアントを作成する の記事で作成した API クライアントを利用して、実装したいと思います。リクエストパスは /facilities/:facility_id/reviews とし POST するものとします。

  1. Review 情報のプロパティを定義する
  2. API を call する為の Endpoint を定義する

1. Review 情報のプロパティを定義する

まずは要件に沿って エンティティとなる ReviewEntity クラスを作成します。

  • ReviewEntity.swift
import ObjectMapper

class ReviewEntity: Mappable {
    var id: Int?
    var content = ""
    var rating = 0
    var photoList: [String] = []

    required init?(_ map: Map) {}    
    
    func mapping(map: Map) {
        id <- map["id"]
        content <- map["content"]
        rating <- map["rating"]
        photoList <- map["photos"]
    }
    
    var parameters: [String: AnyObject] {
        return [
            "content": content,
            "rating": rating,
        ]
    }
}

parameters は POST リクエストに利用するプロパティを定義しています。

2. API を call する為の Endpoint を定義する

  • Endpoint.swift
enum Review: RequestProtocol {
    typealias ResponseType = ReviewEntity
    
    case Post(facilityID: Int, parameters: [String: AnyObject])
    
    var method: Alamofire.Method {
        return .POST
    }
    
    var path: String {
        switch self {
        case .Post(let facilityID, _):
            return "/facilities/\(facilityID)/reviews"
        }
    }
    
    var parameters: [String: AnyObject]? {
        switch self {
        case .Post(_, let parameters):
            return parameters
        }
    }        
}

リクエストを投げる際は以下のように記述します。

let facilityID = 1
let parameters = ["content": "楽しかった", "rating": 3]
API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
    switch response {
    case .Success(let result):
        // 成功時の処理を書く
    case .Failure(let error):
        // エラー時の処理を書く
    }
}

2. 画像アップロードのリクエストを定義する ( /reviews/:review_id/photos )

通常のリクエストと同じように、画像のアップロードのリクエストを定義します。

  • Endpoint.swift
class Endpoint {
    class UploadPhoto: UploadProtocol {
        typealias ResponseType = AnyObject
        var id: Int
        var imageData: NSData
        
        init(id: Int, imageData: NSData) {
            self.id = id
            self.imageData = imageData
        }
        
        var path: String {
            return "/reviews/\(self.id)/photos"
        }

        lazy var multipartFormData: (MultipartFormData) -> () = { [weak self](data: MultipartFormData) in
            guard let weakSelf = self else {
                return
            }
            data.appendBodyPart(
                data: weakSelf.imageData,
                name: "photo",
                fileName: "image",
                mimeType: "image/jpeg"
            )
        }
    }
}

リクエストを投げる際は以下のように記述します

let reviewID = 1
let image = UIImage(named: "test")
let humanQuality: CGFloat = 0.8
let imageData = UIImageJPEGRepresentation(image, humanQuality)

API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
    switch response {
    case .Success(let result):
        print("success \(result)")
    case .Failure(let error):
        print("failure \(error)")
    }
}

3. 口コミ投稿する際には、画像以外をまずは投稿する。レスポンスに口コミ ID を返すようにし、画像はレスポンスの口コミ ID にアップロードするようにする

簡易的に ViewController で API のリクエストを行いますが、きちんと実装する場合は Model 等で非同期のリクエストは行うようにしてください。

  1. Endpoint.Review.Post でテキスト情報のリクエストを投げる
  2. Endpoint.Review.Post リクエストの Success 処理内で画像をアップロードする Endpoint.UploadPhoto を呼び出す

これでテキスト情報のリクエストと画像アップロードを一連の流れの中で別処理として行うことができるようになりました。

ViewController.swift

import SVProgressHUD

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        let facilityID = 1
        let parameters = ["content": "楽しかった", "rating": 3]
        // テキスト情報の投稿中はユーザーの画面操作を制御する
        SVProgressHUD.showWithStatus("投稿中", maskType: .Black)
        API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
            // API の結果が返ってきたら、ローディングを解除する。このタイミングでユーザーは画面を操作可能となる。
            SVProgressHUD.dismiss()
            switch response {
            case .Success(let result):
                // Endpoint.Review.Post は Endpoint.swift の ResponseType に ReviewEntity を指定しているので
                // result の型は ReviewEntity となる
                self.upload(result.id)
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }

    private upload(reviewID: Int) {
        let imageList = [UIImage(named: "test"), UIImage(named: "test2"), UIImage(named: "test3")]
        let humanQuality: CGFloat = 0.8
        let imageData = UIImageJPEGRepresentation(image, humanQuality)

        for image in imageList {
            // API.call リクエストは非同期処理の為、一つ一つの処理を待たずに画像のアップロードは 3並列で行われる。
            API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
                switch response {
                case .Success(let result):
                    print("success \(result)")
                case .Failure(let error):
                    print("failure \(error)")
                }
            }        
        }
    }
}
  1. 複数枚の画像のアップロードが想定されるので、画像のアップロード中は画面にアップロード中の View を表示する

最後に、このままでは画像のリクエストの進捗がユーザーに分からないので、進捗が分かるように View を画面に表示します。

f:id:t-namikata:20160523233117p:plain

(Swift2) Alamofire の upload の進捗を通知で受け取ってプログレスバーを表示するの内容を拡張すれば容易に行えるかと思います。ここでは、処理の流れと、変更を加えた箇所だけ説明します。具体的な実装方法については記事を参照してください。

処理の流れ

  1. 画像アップロードの開始に合わせてアップロード中の View を表示する
  2. 画像アップロードの終了に合わせてアップロード終了の View を表示する
  3. アップロードに失敗した画像があればアップロード失敗のトーストを表示する

最終的に ViewController の実装は以下のようになります。

import SVProgressHUD

class ViewController: UIViewController {
    // 画像アップロードの通知用 View
    private let uploadProgressView = UploadProgressUIView()

    override func viewDidLoad() {
        super.viewDidLoad()

        let facilityID = 1
        let parameters = ["content": "楽しかった", "rating": 3]
        SVProgressHUD.showWithStatus("投稿中", maskType: .Black)
        API.call(Endpoint.Review.Post(facilityID: facilityID, parameters: parameters)) { response in
            SVProgressHUD.dismiss()
            switch response {
            case .Success(let result):
                self.upload(result.id)
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }

    private upload(reviewID: Int) {
        let imageList = [UIImage(named: "test"), UIImage(named: "test2"), UIImage(named: "test3")]
        let humanQuality: CGFloat = 0.8
        let imageData = UIImageJPEGRepresentation(image, humanQuality)

        // 画像のリクエストに合わせて View を表示する
        self.addUploadProgressView()
        // アップロード回数を計測する。API のリクエストは並列で行われる為 for 文の index は利用できない
        var uploadPhotoCount = 0
        for image in imageList {
            API.call(Endpoint.UploadPhoto(id: reviewID, imageData: imageData)) { response in
                // リクエストの回数をインクリメントする
                uploadPhotoCount += 1
                switch response {
                case .Success(let result):
                    // No-op
                case .Failure(let error):
                    // トースト表示なので dismiss() しなくても一定時間が経過すると表示は消える
                    SVProgressHUD.showErrorWithStatus("画像のアップロードに失敗しました")
                }
            }
            if uploadPhotoCount == imageList.count {
                self.removeUploadProgressView()
            }
        }
    }

    private func addUploadProgressView() {
        // ...省略
    }
    
    private func removeUploadProgressView() {
        // ...省略
    }

    private func settingProgressView() {
        // ...省略
    }
}

Swift2 - SDWebImage の placeholderImage や失敗時の画像をセットする Utility クラスを作成する

SDWebImage を利用していると、読み込み中に表示する placeholderImage や、取得が失敗したことを表す画像のセットを毎回書くことになるので、アプリ内で共通で利用できる placeholderImage と失敗時の画像を用意して SDWebImage を呼び出すメソッドを定義してあげると便利です。

  • noImage

f:id:t-namikata:20160621000003p:plain

  • logingImage

f:id:t-namikata:20160621000026p:plain

  • errorImage

f:id:t-namikata:20160620235927p:plain

  1. アプリ内で利用するデフォルトイメージ、ダウンロード中イメージ、ダウンロードエラー用イメージの 3 点を準備する
  2. url の指定がなければデフォルトイメージを。 url が無効だったり、タイムアウトエラーなどの場合はエラー用イメージをセットする。
static func setPhoto(imageView: UIImageView, urlString: String) {
    guard urlString != "" else {
        imageView.image = UIImage(named: "noImage")
        return
    }
    let nsUrl = NSURL(string: urlString)
    imageView.sd_setImageWithURL(nsUrl, placeholderImage: UIImage(named: "loadingImage")) { (_, error: NSError?, _, _) in
        if error != nil {
            imageView.image = UIImage(named: "errorImage")
        }
    }
}
let imageView = UIImageView()
let urlString = "https://example.com/image.png"
UIUtility.setPhoto(imageView, urlString: urlString)

Swift2 - 円形( circle )の UIImageView と UIButton の作成

円形の UIImageView や UIButton を利用したくなるケースは多いと思うので、カスタムクラスを利用した方法を紹介したいと思います。この方法では storyboard 上で円形にはならないので注意してください。

UIImageView

UIImageView を継承した CircleImageView を作成する。やっていることは単純で layout の調整を行う layoutSubviews() 内で self.layer.cornerRadius に円の半径となる self.frame.size.width / 2 を設定しているだけです。

class CircleImageView: UIImageView {
    override func layoutSubviews() {
        super.layoutSubviews()
        self.layer.cornerRadius = self.frame.size.width / 2
    }
}

使い方としては円形にしたい imageView に UIImageView ではなく CircleImageView を指定してあげます。 storyboard を利用していて @IBOutlet にも指定しなくてもいい部品に関しては、コードで指定するのと同じように storyboard 上で CircleImageView を指定してあげます。

@IBOutlet weak var imageView: CircleImageView!

UIButton

UIImageView と同じように行けるかと思っていましたが、同じように試したところ画像がひし形のようになってしまい、上手く対応することができませんでした。通常の image ではなく backgroundImage に画像を指定してあげることで、綺麗に円形になることが分かったので、円形の UIButton を利用したくなったら backgroundImage を利用するようにしています。

class CircleButton: UIButton {
    override func layoutSubviews() {
        super.layoutSubviews()
        self.layer.cornerRadius = self.frame.size.width / 2
        // UIButton の場合は clipsToBounds の設定が必要
        self.clipsToBounds = true
    }
}

設定の仕方は UIImageView と同じですが setImage ではなく setBackgroundImage で画像をセットしてあげます。 storyboard の場合も同様に backgroundImage に利用したい画像をセットしてあげます。

@IBOutlet weak var button: CircleButton!
self.button.setBackgroundImage(UIImage(named: "photo"), forState: .Normal)

( Swift2 ) Alamofire + ObjectMapper + Realm でリクエストを Realm に自動的に保存する

Swift2.2, Xcode, Realm1.0.0

Alamofire と ObjectMapper を利用して API 通信を行っている場合、リクエストが成功した際のレスポンスをそのまま Realm に突っ込んで保存したいケースがあったりします。前回作成した API クライアント(Swift2 Alamofire + ObjectMapper で API クライアントを作成する)を拡張して、リクエストが成功したら結果を自動的に Realm に保存する方法を紹介したいと思います。

  1. Realm への保存を行うメソッドを定義した RealmStorable プロトコルを作成する
  2. ObjectMapper の Mappable を採用した Entity に RealmStorable も採用する
  3. API のリクエスト成功時の formJson メソッドを拡張して、レスポンスを Realm に保存する

1. Realm への保存を行うメソッドを定義した RealmStorable プロトコルを作成する

RealmStorable を採用した Entity は API のリクエストを自動的に Realm に保存するといった作戦です。

  • RealmStorable.swift
import RealmSwift

/**
 API のレスポンスを自動的に Realm に保存したいエンティティに設定するプロトコル
*/

protocol RealmStorable {
    static func store(object: Object)
}

extension RealmStorable {
    // store メソッドは APIのリクエスト成功時の fromJson メソッド内で呼び出されます
    static func store(object: Object) {
        let realm = try! Realm()
        try! realm.write {
            realm.add(object, update: true)
        }
    }
}

2. ObjectMapper の Mappable を採用した Entity に RealmStoreble も採用する

今回は、 id で本を検索したら、タイトルと著者をレスポンスで返す API を例にしたいと思います。

  • API.call(Endpoint.Book.Find(1)) といった形でリクエストを行う
  • レスポンスは { id: 1, title: "炊きたてのご飯", author: "山田太郎" } といった形式で返却される

上記の場合、本のデータとなる Entity は ObjectMapper を利用して以下のように定義できます。

  • BookEntity.swift
import ObjectMapper

class BookEntity: Mappable {
    var id: Int?
    var title = ""
    var author = ""

    required init?(_ map: Map) {}

    func mapping(map: Map) {
        self.id <- map["id"]
        self.title <- map["title"]
        self.author <- map["author"]
    }
}

BookEntity を Realm に保存したいので BookEntity に Realm の Object を採用します。ObjectMapper の公式サイトに記載がありますが ObjectMapper と Realm を共存させたい時は以下のように書きます。

import RealmSwift
import ObjectMapper

class BookEntity: Object, Mappable {
    dynamic var id: Int?
    dynamic var title = ""
    dynamic var author = ""

    override static func primaryKey() -> String {
        return "id"
    }

    required convenience init?(_ map: Map) {
        self.init()
    }

    func mapping(map: Map) {
        self.id <- map["id"]
        self.title <- map["title"]
        self.author <- map["author"]
    }
}

これで ObjectMapper でマッピングされた Entity を Realm に保存できるようになりました。後は Realm に保存したい Entity なので RealmStoreble を採用してあげます。

import RealmSwift
import ObjectMapper

class BookEntity: Object, Mappable, RealmStoreble {
    dynamic var id: Int?
    dynamic var title = ""
    dynamic var author = ""

    override static func primaryKey() -> String {
        return "id"
    }

    required convenience init?(_ map: Map) {
        self.init()
    }

    func mapping(map: Map) {
        self.id <- map["id"]
        self.title <- map["title"]
        self.author <- map["author"]
    }
}

3. API のリクエスト成功時の formJson メソッドを拡張して、レスポンスを Realm に保存する

最後に、前回の記事で作成した APIProtocol の fromJson メソッドを RealmStoreble 用に拡張します。

  • APIProtocol.swift
import Alamofire
import ObjectMapper

protocol RequestProtocol: URLRequestConvertible {
    ... 省略
    
    func fromJson(json: AnyObject) -> Result<ResponseType, NSError>
}

extension RequestProtocol {
    ... 省略

    func fromJson(json: AnyObject) -> Result<ResponseType, NSError> {
        ... 省略
    }
}

extension RequestProtocol where ResponseType: Mappable {
    func fromJson(json: AnyObject) -> Result<ResponseType, NSError> {
        ... 省略
    }
}

extension APIUsable where ResponseType: protocol<RealmStorable, Mappable> {
    func fromJson(json: AnyObject) -> Result<ResponseType, NSError> {
        guard let value = Mapper<ResponseType>().map(json) else {
            let errorInfo = [ NSLocalizedDescriptionKey: "Mapping object failed" , NSLocalizedRecoverySuggestionErrorKey: "Rainy days never stay." ]
            let error = NSError(domain: "com.example.app", code: 0, userInfo: errorInfo)
            return .Failure(error)
        }
        guard let object = value as? Object else {
            let errorInfo = [ NSLocalizedDescriptionKey: "Realm object failed" , NSLocalizedRecoverySuggestionErrorKey: "Rainy days never stay." ]
            let error = NSError(domain: "com.example.app", code: 0, userInfo: errorInfo)
            return .Failure(error)
        }
        // ResponseType は RealmStorable を採用しているので store メソッドを呼び出すことができる
        ResponseType.store(object)
        return .Success(value)
    }
}

これで API.call(Endpoint.Book.Find(1)) とリクエストを投げ、処理が正常終了したら ResponseType.store(object) の実行により Realm に自動的に保存されるようになりました。

( Swift2 ) Alamofire で有効期限付きの認証用トークンを利用する( refresh token を利用した token 更新)

Alamofire を利用して有効期限付きの token を利用した API call の実装例を紹介したいと思います。 token を http ヘッダーリクエストに含めるのは簡単ですが、トークン の有効期限が切れた際に、リクエストを保持して、リフレッシュトークン を利用してトークンを更新する方法が中々見当たらなかったので記事にしたいと思います。

前回書いた API クライアントをベースに話を進めたいと思います。

また、サーバー側の API は作成済みで以下の仕様となっていることを前提に書きたいと思います。

前提条件

リクエスト

  • token は httpヘッダーの Authorization に Bearer を指定してリクエストする
  • 有効期限が切れた token は refresh token を利用して更新を行うことができる
    • refresh token を利用した token の更新は API /tokens に POST を行って更新する
    • POST する際のリクエストは ["grant_type": "refresh_token", "refresh_token": "リフレッシュトークン文字列"] とする

レスポンス

  • 有効期限が切れた token でリクエストした場合 API は 有効期限切れのエラーを返す
    • http ステータスは 401 を返却する
    • エラーメッセージは body に { "message": "invalid_token" } を入れて返却する

実装内容

  1. token を API リクエストの http ヘッダー に一律で設定する
  2. token 情報を保持する Entity と refresh token を利用した token の更新処理を行う Endpoint を定義する
  3. リクエストを保持 & token の有効期限が切れていたら refresh token を利用して token の更新を行う Model を作成する
    1. リクエストをキャッシュする仕組みを実装する
    2. 401 , invalid_token のエラーを判別する
    3. invalid_token であれば refresh token を利用して token の更新を行う。

1. token を API リクエストの http ヘッダー に一律で設定する

API をコールする際に http のリクエストヘッダーに都度 token を設定する方法を採用しています。リクエストの度に token を設定する場合は、前回の記事((Swift2) Alamofire の upload 用 API クライアントを作成する)で作成した APIProtocol.swift の extension で headers にデフォルトで token を設定するように変更を加えます。以下のような設定を行うことで、リクエストの度に、アプリ内で保持している token を取得して利用することができるようになります。

protocol APIProtocol {
    ... 省略

    var headers: [String: String]? { get }
    
    ... 省略
}
extension APIProtocol {
    ... 省略

    var headers: [String: String]? {
        // getToken は token を取得するメソッドです。 Realm や CoreData を利用して保管している場合など、ケースに合わせて token を取得する処理を記述します。
        guard let token = self.getToken() else {
            return nil
        }
        return ["Authorization": "Bearer \(token)"]
    }

    ... 省略
}

2. token 情報を保持する Entity と refresh token を利用した token の更新処理を行う Endpoint を定義する

ObjectMapper の Mappable を採用した Entity を作成します。

import ObjectMapper

class LoginEntity: Mappable {
    dynamic var token = ""
    dynamic var refreshToken = ""

    required init?(_ map: Map) {}

    func mapping(map: Map) {
        self.token <- map["token"]
        self.refreshToken <- map["refresh_token"]
    }
}

Swift2 Alamofire + ObjectMapper で API クライアントを作成する で作成した Endpoint.swift に token を更新するリクエストを定義します。

import Alamofire
import ObjectMapper

class Endpoint {
    enum Login: RequestProtocol {
        typealias ResponseType = LoginEntity

        case Refresh(refreshToken: String)
        
        var method: Alamofire.Method {
            return .POST
        }
        
        var path: String {
            return "/tokens"
        }
        
        var parameters: [String : AnyObject]? {
            switch self {
            case .Refresh(let refreshToken):
                return ["grant_type": "refresh_token", "refresh_token": refreshToken]
            }
        }
    }
}

3. リクエストを保持 & token の有効期限が切れていたら refresh token を利用して token の更新を行う Model を作成する

A のリクエストを行って、 token が切れていたら更新を行って、再度 A をリクエストできるように、リクエストを管理する Model を作成します。( AuthorizationModel.swift )

1. リクエストをキャッシュする仕組みを実装する

AuthorizationModel.swift

import Alamofire

class AuthorizationModel {
    static let sharedInstance = AuthorizationModel()
    
    private typealias CachedTask = Void -> Void
    private var cachedTasks = Array<CachedTask>()
    
    func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        let cachedTask: CachedTask = { [weak self] in
            guard let strongSelf = self else {
                return
            }
            strongSelf.call(request, completion: completion)
        }

        API.call(request) { response in
            switch response {
            case .Success(let json):
                completion(.Success(json))
            case .Failure(let error):
                // ここにリフレッシュトークンを更新する処理を書く
                completion(.Failure(error))
            }
        }
    }
    
    func call<T: UploadProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        ... upload メソッドも上記と同様の記述で対応できるので省略
    }
}

cachedTasks に リクエスト request: Tとリクエスト完了後のコールバックの completion completion: (Result<V, NSError>) -> Void を複数保持するようにします。

API.call(request) で一度 API を call して 401 , invalid token だったら case .Failure(let error): に処理が入ってくるので、ここでリフレッシュトークンを利用した token の更新処理を行います。

2. 401 , invalid_token のエラーを判別する

前回の記事(Swift2 Alamofire + ObjectMapper で API クライアントを作成する)では API クラスを以下のように実装していました。

  • API.swift
class API {
    class func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        Alamofire.request(request)
            .validate(statusCode: 200..<300)
            .validate(contentType: ["application/json"])
            .responseJSON { response in
                switch response.result {
                case .Success(let json):
                    completion(request.fromJson(json))
                case .Failure(let error):
                    completion(.Failure(error))
                }
        }
    }
}

.validate(statusCode: 200..<300) で http ステータスコードが 200〜299 までを Success として、それ以外を Failure とするようにしていますが Failure の値は completion: (Result<V, NSError>) なので NSError 型となり、 API サーバーから返却される { "message": "invalid_token" } の body メッセージを解析することができません。そのため .validate を 200...401 まで広げ request.fromJson(json) の処理の中で再度 Success or Failure を処理するように実装を変更します。

  • API.swift
class API {
    class func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        Alamofire.request(request)
            .validate(statusCode: 200...401)
            .validate(contentType: ["application/json"])
            .responseJSON { response in
                switch response.result {
                case .Success(let json):
                    completion(request.fromJson(json, statusCode: response.response?.statusCode)) // 引数に statusCode を追加
                case .Failure(let error):
                    completion(.Failure(error))
                }
        }
    }
}
  • APIProtocol.swift
protocol APIProtocol {
    ... 省略
    
    func fromJson(json: AnyObject, statusCode: Int?) -> Result<ResponseType, NSError>

    ... 省略
}

extension APIProtocol {
    ... 省略

    func fromJson(json: AnyObject, statusCode: Int?) -> Result<ResponseType, NSError> {
        guard let statusCode = statusCode else {
            return .Failure(NSErrorType.System.error)
        }
        switch statusCode {
        case (200..<300):
            guard let value = json as? ResponseType else {
                return .Failure(NSErrorType.System.error)
            }
            return .Success(value)
        case 401:
            return .Failure(NSErrorType.Unauthorization(json).error)
        default:
            return .Failure(NSErrorType.System.error)
        }
    }
}

extension APIProtocol where ResponseType: Mappable {
    func fromJson(json: AnyObject, statusCode: Int?) -> Result<ResponseType, NSError> {
        guard let statusCode = statusCode else {
            return .Failure(NSErrorType.System.error)
        }
        switch statusCode {
        case (200..<300):
            guard let value = Mapper<ResponseType>().map(json) else {
                return .Failure(NSErrorType.System.error)
            }
            return .Success(value)
        case 401:
            return .Failure(NSErrorType.Unauthorization(json).error)
        default:
            return .Failure(NSErrorType.System.error)
        }
    }
}

401 エラーの際に body のメッセージを解析して NSError を返却する為の enum NSErrorType を定義します。

enum NSErrorType {
    case System
    case Unauthorization(AnyObject)
    
    var code: Int {
        switch self {
        case .System:
            return 0
        case .Unauthorization:
            return 401
        }
    }
    
    var descriptionKey: String {
        return "エラー"
    }
    
    var recoverySuggestionErrorKey: String {
        switch self {
        case .System:
            return "接続環境の良いところで再度お試しください"
        case .Unauthorization(let errorJson):
            guard let errorMessage = self.getErrorMessage(errorJson) else {
                return NSErrorType.System.recoverySuggestionErrorKey
            }
            return errorMessage
    }
    
    var error: NSError {
        let errorInfo = [ NSLocalizedDescriptionKey: self.descriptionKey, NSLocalizedRecoverySuggestionErrorKey: self.recoverySuggestionErrorKey ]
        let error = NSError(domain: "com.example.app", code: self.code, userInfo: errorInfo)
        return error
    }
    
    /**
     token の有効期限が切れていた場合のレスポンスは以下のような Json です
     { "message": "invalid_token" }
    */
    private func getErrorMessage(error: AnyObject) -> String? {
        guard let message = dictionary["message"] else {
            return nil
        }
        return message
    }
}

これで 401 エラーの際は NSError の code に 401 , メッセージに invalid_token が含まれる NSError が .Failure として処理されるようになりました。

3. invalid_token であれば refresh token を利用して token の更新を行う。

最後の総仕上げとして、「1」でコメントアウトにしていた refresh token を利用した token の更新処理を実装してみます。

import Alamofire
import SVProgressHUD

/*
 使用例
 AuthorizationModel.sharedInstance.call(Endpoint.User.Find(id: 1)) { response in
    switch response {
    case .Success(let result):
        print("success \(result)")
    case .Failure(let error):
        print("failure \(error)")
    }
 } 
 */

// http://stackoverflow.com/questions/28733256/alamofire-how-to-handle-errors-globally を参考に実装
/**
 AuthorizationModel の call メソッドを利用することで、トークンの有効期限が切れていた場合は
 リフレッシュトークンを利用してトークンを更新したリクエストができるようになります。
*/
class AuthorizationModel {
    static let sharedInstance = AuthorizationModel()
    
    private typealias CachedTask = Void -> Void
    private var cachedTasks = Array<CachedTask>()
    private var isRefreshing = false
    
    func call<T: RequestProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        let cachedTask: CachedTask = { [weak self] in
            guard let strongSelf = self else {
                return
            }
            strongSelf.call(request, completion: completion)
        }

        // token の更新中の場合は、リクエストをキャッシュして終了させる
        // token の更新が完了した際に cachedTasks の中身が実行される
        if self.isRefreshing {
            self.cachedTasks.append(cachedTask)
            return nil
        }

        return API.call(request) { response in
            switch response {
            case .Success(let json):
                completion(.Success(json))
            case .Failure(let error):
                // NSError のメッセージが invalid_token の場合、リフレッシュトークンの処理を行う
                if error.localizedRecoverySuggestion == "invalid_token" {
                    self.cachedTasks.append(cachedTask)
                    self.refreshTokens()
                    return
                }
                completion(.Failure(error))
            }
        }
    }
    
    func call<T: UploadProtocol, V where T.ResponseType == V>(request: T, completion: (Result<V, NSError>) -> Void) {
        ... upload メソッドも上記と同様の記述で対応できるので省略
    }

    
    func refreshTokens() {
        self.isRefreshing = true

        // refresh token を Realm や CoreData や変数などから取得してセットする        
        API.call(Endpoint.Login.Refresh(refreshToken: refreshToken)) { response in
            self.isRefreshing = false
            switch response {
            case .Success(let resutl):
                // result の結果に更新された token が入っているので token を Realm や CoreData, 変数などにセットする
                let cachedTasksCopy = self.cachedTasks
                self.cachedTasks.removeAll()
                cachedTasksCopy.forEach { $0() }
            case .Failure(let error):
                // エラー時の処理を書く
            }
        }
    }    
}

参考にした記事