Moyaを使ってApiクライアント作成する

github.com

環境

  • Swift 4.1.2
  • Moya 11.0.2

Carthageでインストール

// Cartfile
github "Moya/Moya"
carthage update --platform iOS

ビルドが終わったらプロジェクトに追加します f:id:y-hryk:20180810185316p:plain

使い方

GithubのApiを使って以下の情報を取得してみます

  • ユーザー情報 (/users/:username")
  • ユーザーのリポジトリ情報 (/users/:username/repos")
モデルを作成

Codableを使用します。
※全部は大変なのでそれっぽいフィールドだけパースします。

//  UserProfile.swift
// ユーザー情報
struct UserProfile: Codable {

    let id: Int
    let name: String
    let email: String?
    let avatarUrl: String
    
    private enum CodingKeys: String, CodingKey {
        case id
        case name
        case email
        case avatarUrl = "avatar_url"
    }
}

//  Repository.swift
// ユーザーのリポジトリ情報
struct Repository: Codable {

    let id: Int
    let name: String
    let url: String
    
    private enum CodingKeys: String, CodingKey {
        case id
        case name
        case url = "html_url"
    }
}
リクエストを作成

enumにエンドポイントを定義。TargetTypeに準拠してリクエスト内容を記述します。

//  GitHubAPI.swift
enum GitHub {
    case profile(name: String)
    case repository(name: String, type: String)
}

extension GitHub: TargetType {
    var baseURL: URL { return URL(string: "https://api.github.com")! }
    
    var path: String {
        
        switch self {
        case let .profile(name):
            return "/users/\((name))"
        case let.repository(name, _):
            return "/users/\(name)/repos"
        }
    }
    
    var method: Moya.Method { return .get }
    var sampleData: Data {
        var path = ""
        switch self {
        case .profile:
            path = Bundle.main.path(forResource: "profile", ofType: "json")!
        case .repository:
            path = Bundle.main.path(forResource: "repository", ofType: "json")!
        }
        return FileHandle(forReadingAtPath: path)!.readDataToEndOfFile()
    }
    
    var task: Task {
        switch self {
        case .profile:
            return .requestPlain
        case let .repository(_, type):
            return .requestParameters(parameters: ["type" : type], encoding: URLEncoding.default)
        }
    }
    
    var headers: [String : String]? { return ["Content-Type": "application/json"] }
}
リクエス
// ユーザー情報
let provider = MoyaProvider<GitHub>()
provider.request(.profile(name: "y-hryk")) { (result) in
    switch result {
    case let .success(response):
         let decoder = JSONDecoder()
         let profile = try! decoder.decode(UserProfile.self, from: response.data)
        print(profile)
    case  let .failure(error):
        print(error)
        break
    }
}

// ユーザーのリポジトリ情報
let provider = MoyaProvider<GitHub>()
provider.request(.repository(name: "y-hryk", type: "all")) { (result) in
    switch result {
    case let .success(response):
        
        let decoder = JSONDecoder()
        let data = try! decoder.decode([Repository].self, from: response.data)
        print(data)
        
    case let .failure(error):
        print(error)
        break
    }
}

使い方2

使い方1の例だとAPIが増えるたびにリクエストの処理に分岐が増えていきます。 なのでenumを使用しない記述方法もみていきます。

TargetTypeに準拠したプロトコルを定義

TargetTypeに準拠したプロトコルを定義して共通となる処理を記述します

// GitHubAPIService.swift

class GitHubAPIService {}

protocol GitHubAPITargetType: TargetType {
}

extension GitHubAPITargetType {
    var baseURL: URL { return URL(string: "https://api.github.com")! }
    var headers: [String : String]? { return ["Content-Type": "application/json"] }
}
リクエストを作成

拡張したプロトコルGitHubAPITargetTypeに準拠してリクエストを作成していきます。

// UserProfileRequest.swift

extension GitHubAPIService {
    struct UserProfileRequest: GitHubAPITargetType {
        
        var method: Moya.Method { return .get }
        var path: String { return "/users/\(self.name)" }
        
        var task: Task { return .requestPlain }
        var sampleData: Data { return Data() }
        
        let name: String
        
        init(name: String) {
            self.name = name
        }
    }
}
//  RepositoryRequest.swift
extension GitHubAPIService {
    struct RepositoryRequest: GitHubAPITargetType {
        
        var method: Moya.Method { return .get }
        var path: String { return "/users/\(self.name)/repos" }
        
        var sampleData: Data {
            let path = Bundle.main.path(forResource: "repository", ofType: "json")!
            return FileHandle(forReadingAtPath: path)!.readDataToEndOfFile()
        }
        
        var task: Task { return .requestParameters(parameters: ["type" : self.type], encoding: URLEncoding.default) }
        
        let name: String
        let type: String
        
        init(name: String, type: String) {
            self.name = name
            self.type = type
        }
    }
}
リクエス

enumを使用した方法とほとんど変わらずリクエストできます。

// ユーザー情報
let provider = MoyaProvider<GitHubAPIService.UserProfileRequest>()
provider.request(GitHubAPIService.UserProfileRequest(name: "y-hryk")) { (result) in
    switch result {
    case let .success(response):
        let decoder = JSONDecoder()
        let data = try! decoder.decode(UserProfile.self, from: response.data)
        print(data)
    case let .failure(error):
        print(error)
    }
}

// ユーザーのリポジトリ情報
let provider = MoyaProvider<GitHubAPIService.RepositoryRequest>()
provider.request(GitHubAPIService.RepositoryRequest(name: "y-hryk", type: "all")) { (result) in
    switch result {
    case let .success(response):
        let decoder = JSONDecoder()
        let data = try! decoder.decode([Repository].self, from: response.data)
        print(data)
    case let .failure(error):
        print(error)
    }
}

MoyaProviderの生成 + パース処理を共通化

MoyaProviderの生成とパース部分を隠蔽してシンプルな呼び出しに変更していきます

参考
- Moya/MultiTarget.md at master · Moya/Moya · GitHub

以下みたいに呼び出せるように修正します

GitHubAPIService().send(GitHubAPIService.UserProfileRequest(name: "y-hryk")) { (result) in
    switch result {
    case let .success(response):
        print(response)
    case let .failure(error):
        print(error)
    }
}
TargetTypeに準拠したプロトコルを修正
// GitHubAPIService.swift
protocol GitHubAPITargetType: TargetType {
    // レスポンスの型を定義できるように変更
    // 追加
    associatedtype Response: Codable
}

extension GitHubAPITargetType {
    var baseURL: URL { return URL(string: "https://api.github.com")! }
    var headers: [String : String]? { return ["Content-Type": "application/json"] }
}
モデルを修正
// UserProfileRequest.swift
extension GitHubAPIService {
    struct UserProfileRequest: GitHubAPITargetType {
        // 追加
        typealias Response = UserProfile
        
        var method: Moya.Method { return .get }
        var path: String { return "/users/\(self.name)" }
        
        var sampleData: Data {
            let path = Bundle.main.path(forResource: "profile", ofType: "json")!
            return FileHandle(forReadingAtPath: path)!.readDataToEndOfFile()
        }
        
        var task: Task { return .requestPlain }
        
        let name: String
        init(name: String) {
            self.name = name
        }
    }
}

// RepositoryRequest.swift
extension GitHubAPIService {
    struct RepositoryRequest: GitHubAPITargetType {
        // 追加
        typealias Response = [Repository]
        
        var method: Moya.Method { return .get }
        var path: String { return "/users/\(self.name)/repos" }
        
        var sampleData: Data {
            let path = Bundle.main.path(forResource: "repository", ofType: "json")!
            return FileHandle(forReadingAtPath: path)!.readDataToEndOfFile()
        }
        
        var task: Task { return .requestParameters(parameters: ["type" : self.type], encoding: URLEncoding.default) }
        
        let name: String
        let type: String
        
        init(name: String, type: String) {
            self.name = name
            self.type = type
        }
    }
}

リクエスト処理をラップする

これで最初に示したような呼び出し方が可能になります

// GitHubAPIService.swift
protocol GitHubAPI {
    func send<T: GitHubAPITargetType>(_ request: T, completion: @escaping (Result<T.Response, Moya.MoyaError>) -> Void)
}

class GitHubAPIService: GitHubAPI {
    func send<T>(_ request: T, completion: @escaping (Result<T.Response, MoyaError>) -> Void) where T : GitHubAPITargetType {
        let provider = MoyaProvider<T>()
        provider.request(request) { (result) in
            switch result {
            case let .success(response):
                
                let decoder = JSONDecoder()
                if let model = try? decoder.decode(T.Response.self, from: response.data) {
                    completion(.success(model))
                    print(try! response.mapJSON())
                } else {
                    completion(.failure(.jsonMapping(response)))
                }
            case let .failure(error):
                completion(.failure(error))
            }
        }
    }
}

スタブを利用する

GitHubAPIService,GitHubAPIStub共にGitHubAPIに準拠しているので DIを用いてスタブに差し替えられる。

// GitHubAPIStub.swift
class GitHubAPIStub: GitHubAPI {
    func send<T>(_ request: T, completion: @escaping (Result<T.Response, MoyaError>) -> Void) where T : GitHubAPITargetType {
        
        // スタブを返すように設定。このすることでリクエストに定義した`sampleData`が替えるようになります。
        let provider = MoyaProvider<T>(stubClosure: MoyaProvider.immediatelyStub)
        provider.request(request) { (result) in
            switch result {
            case let .success(response):
                
                let decoder = JSONDecoder()
                if let model = try? decoder.decode(T.Response.self, from: response.data) {
                    completion(.success(model))
                } else {
                    completion(.failure(.jsonMapping(response)))
                }
            case let .failure(error):
                completion(.failure(error))
            }
        }
    }
}

【GitLab-CI】登録したrunnerが削除できない

以下のコマンドで登録をしたrunnerを削除しようとしたところ以下のエラーが発生した

現象

gitlab-ci-multi-runner unregister

// 以下エラー
WARNING: Running in user-mode.
WARNING: Use sudo for system-mode:
WARNING: $ sudo gitlab-runner...

ERROR: Unregistering runner from GitLab error       status=only http or https scheme supported
FATAL: Failed to unregister runner

解決方法

urltokenをオプションとして付与すれば良い。gitlab-ci-multi-runner register
コマンドを入力した後に情報を入れていく形だったので勘違いしていました。

 gitlab-ci-multi-runner unregister --url https://xxxxxxxxxxxxxxxxxxx --token xxxxxxxxxxxxxxxxxxxxxxxxxx

登録しているrunnerはgitlab-ci-multi-runner listで確認できるのでurl,tokenを調べられる。

すでにGitLabの設定画面からrunnerを削除している場合は上記のコマンドでもエラーが出るので
その場合は以下のコマンドで稼働していないrunnerを全て削除することができる。

 gitlab-ci-multi-runner verify --delete

※稼働していないrunnerは全て削除されるので注意。以下のコマンドを実行する時は設定ファイルをバックアップしておいた方が良いと思います。 ちなみに設定ファイルを直接編集して削除しても問題なかったです。

設定ファイルのパス

~/.gitlab-runner/config.toml

設定ファイルの中身

以下のような感じ

concurrent = 1
check_interval = 0

[[runners]]
  name = "project1"
  url = "https://xxxxxxxx/"
  token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  executor = "shell"
  [runners.cache]

[[runners]]
  name = "project2"
  url = "https://xxxxxxxx/"
  token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  executor = "shell"
  [runners.cache]

【Codable】特定の値を型変換する

レスポンスデータは文字列("12345")だけど、クライアントでは数値(12345)で扱いたい時

// レスポンスデータ
{
    id = "12345";
    name = "y-hryk";
    email = "test@gmail.com";
}

struct User: Codable {

    var id: Int
    var name: String
    var email: String
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
   
        // ここで明示的に変換する
        id = Int(try values.decode(Int.self, forKey: .id))
        name = try values.decode(String.self, forKey: .name)
        email = try values.decode(String.self, forKey: .email)
    }
}


R.swiftを使ってみる

便利とは聞いていたが、実務での使用経験がなかったため試してみる。

github.com

メリット

  • コード補完でリソースファイルにアクセスできる
  • 文字列による指定をしなくてよくなるのでtypoに気がつける

導入

R.swift(4.0.0)を導入していきます。
ドキュメント通りですが一応

CocoaPodsでインストール

pod 'R.swift'

Run Scriptの設定

Target > Build Phases を選択して Run Script を追加します。

"$PODS_ROOT/R.swift/rswift" generate "$SRCROOT"

追加したRun ScriptはCompile SourcesCheck Pods Manifest.lock間にドラックして動かしましょう。

f:id:y-hryk:20180802150223p:plain

R.generated.swiftをプロジェクトに追加する

上記の作業を終えたら一回ビルドします。
そうするとR.generated.swiftがプロジェクトのルートディレクトリに追加されているので、このファイルをプロジェクトに追加します。

ビルドされるたびにリソースの状態をみてR.generated.swiftが更新されるので .gitignoreに以下を追加します。

*.generated.swift

使用方法

let string = NSLocalizedString("localize_text", comment: "")
let image = UIImage(named: "sample")
let vc = UIStoryboard(name: "main", bundle: nil).instantiateInitialViewController()
tableView.register(UINib(nibName: "CustomCell", bundle: nil), forCellReuseIdentifier: "CustomCell")
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as! CustomCell

// R.swiftを使用
let string = R.string.localizable.localize_text()
let image = R.image.sample()
let vc = R.storyboard.main.instantiateInitialViewController()
tableView.register(R.nib.customCell)
let cell = tableView.dequeueReusableCell(withIdentifier: R.reuseIdentifier.customCell, for: indexPath)!

【RxSwift】Subjectまとめ

PublishSubject

let publishSubject = PublishSubject<Int>()
publishSubject.asObservable().subscribe { (event) in
    print("publishSubject: 1, event:\(event)")
}

publishSubject.onNext(1)
publishSubject.onNext(2)

publishSubject.asObservable().subscribe { (event) in
    print("publishSubject: 2, event:\(event)")
}

publishSubject.onNext(3)
publishSubject.onNext(4)
publishSubject.onCompleted()
// 結果
publishSubject: 1, event:next(1)
publishSubject: 1, event:next(2)
publishSubject: 1, event:next(3)
publishSubject: 2, event:next(3)
publishSubject: 1, event:next(4)
publishSubject: 2, event:next(4)
publishSubject: 1, event:completed
publishSubject: 2, event:completed

ReplaySubject

Subscribeしたタイミングで今の状態から遡って指定数または全て通知する。

let replaySubject = ReplaySubject<Int>.create(bufferSize: 2)

replaySubject.asObservable().subscribe { (event) in
    print("replaySubject: 1, event:\(event)")
}

replaySubject.onNext(1)
replaySubject.onNext(2)
replaySubject.onNext(3)
replaySubject.onNext(4)

replaySubject.asObservable().subscribe { (event) in
    print("replaySubject: 2, event:\(event)")
}

replaySubject.onNext(5)
replaySubject.onNext(6)
replaySubject.onCompleted()
// 結果
replaySubject: 1, event:next(1)
replaySubject: 1, event:next(2)
replaySubject: 1, event:next(3)
replaySubject: 1, event:next(4)
// ※ 2つ目のsubscribeが実行されたタイミングで 指定した値(2)遡って通知する
replaySubject: 2, event:next(3)
replaySubject: 2, event:next(4)
replaySubject: 1, event:next(5)
replaySubject: 2, event:next(5)
replaySubject: 1, event:next(6)
replaySubject: 2, event:next(6)
replaySubject: 1, event:completed
replaySubject: 2, event:completed

BehaviorSubject

今の状態を一回通知した上で変化があった場合それを通知する

let behaviorSubject = BehaviorSubject<Int>(value: 0)

behaviorSubject.asObservable().subscribe { (event) in
    print("behaviorSubject: 1, event:\(event)")
}

behaviorSubject.onNext(1)
behaviorSubject.onNext(2)

behaviorSubject.asObservable().subscribe { (event) in
    print("behaviorSubject: 2, event:\(event)")
}

behaviorSubject.onNext(3)
behaviorSubject.onNext(4)
behaviorSubject.onCompleted()
// 結果
// ※ 1つ目のsubscribeが実行されたタイミングで最後の値(0)が通知される
behaviorSubject: 1, event:next(0)
behaviorSubject: 1, event:next(1)
behaviorSubject: 1, event:next(2)
// ※ 2つ目のsubscribeが実行されたタイミングで最後の値(2)が通知される
behaviorSubject: 2, event:next(2)
behaviorSubject: 1, event:next(3)
behaviorSubject: 2, event:next(3)
behaviorSubject: 1, event:next(4)
behaviorSubject: 2, event:next(4)
behaviorSubject: 1, event:completed
behaviorSubject: 2, event:completed

Variable

BehaviorSubjectのラッパー。Variableは明示的にonCompleted, onErrorになることはない。 解放時に自動的にonCompletedを発行する。
※ RxSwift 4.0.0-rc.0よりdeprecated

let variable = Variable(0)

variable.asObservable().subscribe({ (event) in
    print("variable: 1, event:\(event)")
})

variable.value = 1
variable.value = 2

variable.asObservable().subscribe({ (event) in
    print("variable: 2, event:\(event)")
})

variable.value = 3
variable.value = 4
// 結果
variable: 1, event:next(0)
variable: 1, event:next(1)
variable: 1, event:next(2)
variable: 2, event:next(2)
variable: 1, event:next(3)
variable: 2, event:next(3)
variable: 1, event:next(4)
variable: 2, event:next(4)
// 変数が解放されたタイミングでonCompletedが発行される
variable: 1, event:completed
variable: 2, event:completed

PublishRelay

PublishSubjectのラッパー。onCompleted, onErrorが発行されることがない。

let publishRelay = PublishRelay<Int>()
publishRelay.asObservable().subscribe { (event) in
    print("PublishRelay: 1, event:\(event)")
}

publishRelay.accept(1)
publishRelay.accept(2)

publishRelay.asObservable().subscribe { (event) in
    print("PublishRelay: 2, event:\(event)")
}

publishRelay.accept(3)
publishRelay.accept(4)
// 結果
PublishRelay: 1, event:next(1)
PublishRelay: 1, event:next(2)
PublishRelay: 1, event:next(3)
PublishRelay: 2, event:next(3)
PublishRelay: 1, event:next(4)
PublishRelay: 2, event:next(4)

BehaviorRelay

BehaviorSubjectのラッパー。onCompleted, onErrorが発行されることがない。

let behaviorRelay = BehaviorRelay<Int>(value: 0)
behaviorRelay.asObservable().subscribe { (event) in
    print("behaviorRelay: 1, event:\(event)")
}

behaviorRelay.accept(1)
behaviorRelay.accept(2)

behaviorRelay.asObservable().subscribe { (event) in
    print("behaviorRelay: 2, event:\(event)")
}.disposed(by: disposeBag)

behaviorRelay.accept(3)
behaviorRelay.accept(4)
// 結果
behaviorRelay: 1, event:next(0)
behaviorRelay: 1, event:next(1)
behaviorRelay: 1, event:next(2)
behaviorRelay: 2, event:next(2)
behaviorRelay: 1, event:next(3)
behaviorRelay: 2, event:next(3)
behaviorRelay: 1, event:next(4)
behaviorRelay: 2, event:next(4)

参考

UIDatePickerまとめ

いつも調べている気がするのでまとめておく

端末の設定に関係なく、強制的に24時間表記にする

※以下の設定をすると端末の設定が英語の場合でも日本語 + 24時間表示になるので注意

let datePicker = UIDatePicker()
datePicker.locale = Locale(identifier: "ja_JP")
端末の設定に関係なく、強制的に西暦表示にする
let datePicker = UIDatePicker()
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale.current
datePicker.calendar = calendar

【iOS】DateとStringの相互変換の注意点

今更ですが混乱することがあるのでまとめておく

String->Date

タイムゾーンなどの情報がない場合は現在の端末設定から自動的に タイムゾーンを考慮したDateが返ってくる
※ 地域の設定が日本の場合

let dateString = "2018/01/01 12:00"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy/MM/dd HH:mm"
print(formatter.date(from: dateString)!)
結果
2018-01-01 03:00:00 +0000

基本的にDateFormatterにタイムゾーンを指定しない場合は
日本の場合UTC+9時間なので以下のようになる(DateはそもそもUTC+0)
Date -> String +9時間
String -> Date - 9時間

ちゃんとタイムゾーンが指定されていればそのタイムゾーンを考慮した時刻を得ることができる(RFC3339)

let dateString = "2018-01-01T12:00:00+09:00"

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
print(formatter.date(from: dateString)!)
結果
2018-01-01 03:00:00 +0000
let dateString = "2018-01-01T12:00:00+00:00"

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
print(formatter.date(from: dateString)!)
結果
2018-01-01 12:00:00 +0000

Date->String

DateFormatterのlocaleにen_US_POSIXを指定すること。そうしないと和暦などの設定によっては正しく変換できない

let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX") // これ

まとめ

  • タイムゾーンの指定しないと端末の地域設定から自動的に考慮した結果が返される
  • DateはUTC+0
  • DateFormatterのlocaleにen_US_POSIXを指定しないと Date->String の時に意図しない結果になる