APIKitとCodableでAPIクライアントを作る

  • 24
    いいね
  • 0
    コメント

Swift4.0から標準でCodable(Encodable,Decodable) というオブジェクトのシリアライズ、デシリアライズが可能になるプロトコルが登場しました。

まだ正式リリースされていないですが早速自分の開発中のアプリで、APIKitHimotokiを使っていた部分をAPIKitとSwiftのDecodableに書き換えてみました。ということで、APIKitとSwiftのDecodableを使ってAPIクライアントを構築していく手順を書かせていただきたいなと思います。
Decodableというライブラリもあるのでここまでは"Swiftの"を付けていましたが、以降は全てSwift4.0でのDecodableプロトコルと思って頂ければと..! :pray:

また、今回はGithub API v3Search repositories apiを使って指定のキーワードで検索したリポジトリの一覧を取得してみます。

注意書き

※まだSwift4.0が正式リリースされていませんので一部変更の可能性があります
※APIKitの今後のバージョンアップによって対応の仕方が変わる可能性があります。現状のv3.1.2ベースでのものになります。
※Decodable(Codable)、JSONDecoderの機能や使い方といったものは割愛します。以下が参考になると思います!

動作環境

  • Xcode 9 beta
  • Swift 4.0
  • APIKit 3.1.2
  • (Result)

tl;dr

今回の内容を先に簡単にまとめておきます。詳細は次項からです。
詳細を読みたい方はこちらから。

DataParserを作る

APIKitはデフォルトでDataParserがData→JSONにパースするDataParserなのでこれを変更すべくDecodableDataParserを定義します。

DecodableDataParser
import Foundation
import APIKit

final class DecodableDataParser: DataParser {
    var contentType: String? {
        return "application/json"
    }

    func parse(data: Data) throws -> Any {
        return data
    }
}

実際にはparse関数でdataをそのまま返却するだけです。(参考: ProtobufDataParser)

GitHubRequestを作る

Requestに準拠したGitHubRequestを定義します。Request.ResponseがDecodableに準拠することと、先ほど定義したDecodableDataParserが肝になります

GitHubRequest
import Foundation
import APIKit

protocol GitHubRequest: Request {

}

extension GitHubRequest {
    var baseURL: URL {
        return URL(string: "https://api.github.com")!
    }
}

extension GitHubRequest where Response: Decodable {
    var dataParser: DataParser {
        return DecodableDataParser()
    }

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        guard let data = object as? Data else {
            throw ResponseError.unexpectedObject(object)
        }
        return try JSONDecoder().decode(Response.self, from: data)
    }
}

Responseを定義する

Github API v3Search repositories apiのResponseを以下のように定義します。Decodableに準拠させます。

struct SearchRepositoriesResponse: Decodable {
    let items: [Repository]
}

struct Repository: Decodable {
    let id: Int
    let fullName: String

    private enum CodingKeys: String, CodingKey {
        case id
        case fullName = "full_name"
    }
}

これで、GitHubRequestのResponseがDecodableに準拠する場合はJSONDecoderを用いてDataからResponseの型にデコードされるようになります。

SearchRepositories Requestを定義する

final class GitHubAPI {
    private init() {}

    struct SearchRepositories: GitHubRequest {
        typealias Response = SearchRepositoriesResponse

        let method: HTTPMethod = .get
        let path: String = "/search/repositories"
        var parameters: Any? {
            return ["q": query, "page": 1]
        }

        let query: String
    }
}

GitHubRequestに準拠したSearchRepositoriesリクエストを定義します。
※Paginationに関しては今回は考慮しないです。

実行する

import Foundation
import APIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        Session.send(GitHubAPI.SearchRepositories(query: "rxswift")) { result in
            switch result {
            case .success(let response):
                print(response)
            case .failure(let error):
                print(error)
            }
        }
    }
}

:clap_tone1:



Requestを定義する(1)

まずは、次のようにベースとなる GitHubRequest を定義してみます。

GitHubRequest
import Foundation
import APIKit

protocol GitHubRequest: Request {

}

extension GitHubRequest {
    var baseURL: URL {
        return URL(string: "https://api.github.com")!
    }
}

extension GitHubRequest where Response: Decodable {
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        // ここをあとで書き換える
    }
}

一旦、 ResponseDecodableに準拠している場合のextensionを追加して、現段階では仮の状態ですがresponse(from:, urlResponse:)を定義しておきます。

DataParserを作る

今回の重要なポイントになります。
APIKitは通信のレスポンス(のData)を受け取った時にデフォルトでJSONDataParserを使うようになっています。
なので、そのままだと先で定義したresponse(from:, urlResponse:)の第一引数のAnyにはデシリアライズされたJSONが渡されてきます。なので...

extension GitHubRequest where Response: Decodable {
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        let data = try JSONSerialization.data(withJSONObject: object, options: [])
        return try JSONDecoder().decode(Response.self, from: data)
    }
}

と実装してしまいがちなのですが、これだと一度DataをJSONSerializationでデシリアライズされたものをまたシリアライズしてJSONDecoderでデコードしてとかなり非効率になってしまうので、 DecodableDataParserなるものを作ります。

DecodableDataParser
import Foundation
import APIKit

final class DecodableDataParser: DataParser {
    var contentType: String? {
        return "application/json"
    }

    func parse(data: Data) throws -> Any {
        return data
    }
}

ここでは単純にDataParserプロトコルに準拠するように定義をし、parse(data:)関数ではそのままDataを返却するようにします。
この定義の仕方は、ProtobufDataParserを参考にしました。

この定義したDecodableDataParserを使って、先程までのGitHubRequestを書き換えます

extension GitHubRequest where Response: Decodable {
    var dataParser: DataParser {
        return DecodableDataParser()
    }

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        guard let data = object as? Data else {
            throw ResponseError.unexpectedObject(object)
        }
        return try JSONDecoder().decode(Response.self, from: data)
    }
}

これで、response(from:, urlResponse:)に渡ってくるobjectがDataになるので、それをそのままJSONDecoderにかけてデコードします。(objectの型がAnyになっているものの、Parserで受け取ったDataをそのまま流しているので強気にtry JSONDecoder().decode(Response.self, from: object as! Data) でも良いのですが、念のためguard節を入れています)

ここまでで一旦、ベースとなるGithubRequestが定義できました。あとはDecodableに準拠したResponseを作成し、GithubRequestに準拠したSearchRepositoriesRequestを定義します。

余談(将来できそうなこと)

現在開発が進められているAPIKitのdevelop/4.0ブランチだと、DataParserの定義が変わっており、以下のように今後定義することができるようになった場合は、response(from:, urlResponse:)の第一引数がAnyからDataParser.Parsed、つまりこの場合では Data になるので余計なチェックが不要になります。期待ですね。

(引用)APIKit`develop/4.0`でのDataParser,Request

/// `DataParser` protocol provides inteface to parse HTTP response body and to state Content-Type to accept.
public protocol DataParser {
    associatedtype Parsed

    /// Value for `Accept` header field of HTTP request.
    var contentType: String? { get }

    /// Return `Any` that expresses structure of response such as JSON and XML.
    /// - Throws: `Error` when parser encountered invalid format data.
    func parse(data: Data) throws -> Parsed
}

public protocol Request {
    /// Build `Response` instance from raw response object. This method is called after
    /// `intercept(object:urlResponse:)` if it does not throw any error.
    /// - Throws: `Error`
    func response(from object: DataParser.Parsed, urlResponse: HTTPURLResponse) throws -> Response
}
final class DecodableDataParser: DataParser {
    typealias Parsed = Data
    var contentType: String? {
        return "application/json"
    }

    func parse(data: Data) throws -> Parsed {
        return data
    }
}

extension GitHubRequest where Response: Decodable {
    var dataParser: DataParser {
        return DecodableDataParser()
    }

    func response(from object: DataParser.Parsed, urlResponse: HTTPURLResponse) throws -> Response {
        // 今回のケースでは、DataParser.Parsed == Dataになるので先述のDataへのキャストが不要になる
        return try JSONDecoder().decode(Response.self, from: object) 
    }
}

まだまだ変わる可能性はありますが、より便利になりますね! :blush:

Responseを定義する

さて、一旦Requestから離れてResponseの定義に移ります。
今回、Search repositories apiを使用するので、以下のようにResponseを定義してみます。
今回はシンプルにするため、パースする要素を最小限にしています。

(引用)jsonのexample
{
  "total_count": 40,
  "incomplete_results": false,
  "items": [
    {
      "id": 3081286,
      "name": "Tetris",
      "full_name": "dtrupenn/Tetris",
      "owner": {
        "login": "dtrupenn",
        "id": 872147,
        "avatar_url": "https://secure.gravatar.com/avatar/e7956084e75f239de85d3a31bc172ace?d=https://a248.e.akamai.net/assets.github.com%2Fimages%2Fgravatars%2Fgravatar-user-420.png",
        "gravatar_id": "",
        "url": "https://api.github.com/users/dtrupenn",
        "received_events_url": "https://api.github.com/users/dtrupenn/received_events",
        "type": "User"
      },
      "private": false,
      "html_url": "https://github.com/dtrupenn/Tetris",
      "description": "A C implementation of Tetris using Pennsim through LC4",
      "fork": false,
      "url": "https://api.github.com/repos/dtrupenn/Tetris",
      "created_at": "2012-01-01T00:31:50Z",
      "updated_at": "2013-01-05T17:58:47Z",
      "pushed_at": "2012-01-01T00:37:02Z",
      "homepage": "",
      "size": 524,
      "stargazers_count": 1,
      "watchers_count": 1,
      "language": "Assembly",
      "forks_count": 0,
      "open_issues_count": 0,
      "master_branch": "master",
      "default_branch": "master",
      "score": 10.309712
    }
  ]
}
上記jsonを基に作成したResponse
struct SearchRepositoriesResponse: Decodable {
    let items: [Repository]
}

struct Repository: Decodable {
    let id: Int
    let fullName: String

    private enum CodingKeys: String, CodingKey {
        case id
        case fullName = "full_name"
    }
}

とりあえず最小限の構成でResponseを定義できました。

Requestを定義する(2)

Requestに対応するResponseが定義できたので、SearchRepositories Requestを定義します。
特筆すべき点はないのでスッと定義できると思います。

final class GitHubAPI {
    private init() {}

    struct SearchRepositories: GitHubRequest {
        typealias Response = SearchRepositoriesResponse

        let method: HTTPMethod = .get
        let path: String = "/search/repositories"
        var parameters: Any? {
            return ["q": query, "page": 1]
        }

        let query: String
    }
}

個人的にはいつもGithubAPIみたいなnamespaceで括るのが好きなので括っていますがお好みで!
※尚、今回はPaginationについては一旦考えないものとします。 なので、常に最初のx件だけ取得という形になります。
Paginationを考えるなら、let page: Intも加えてあげて可変になるようにしてあげると良いです!

通信してみる

無事にRequest/Responseを定義できたので、通信してみます。実際には結果を取得してUIに反映させるのが実際の流れになりますが、
ひとまず「rxswift」でリポジトリ検索した結果をprintで出力してみます

import Foundation
import APIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        Session.send(GitHubAPI.SearchRepositories(query: "rxswift")) { result in
            switch result {
            case .success(let response):
                print(response)
            case .failure(let error):
                print(error)
            }
        }
    }
}

ここまででシンプルな形でAPIKitDecodableプロトコルを使ったAPIクライアントが設計できました。 :clap_tone1:

応用: Responseに応じてJSONDecoderの各種strategyを変えられるようにする

ただ、このままではJSONDecoderの各種strategyが変更できず固定のままになってしまうので、Responseに応じて変えられるようにします。
(同じサービスのAPIでDateのフォーマットがコロコロ変わったりすることは稀だと思いますが...)

試しに、RepositorycreatedAtを追加します。

struct Repository: Decodable {
    let id: Int
    let fullName: String
    let createdAt: Date

    private enum CodingKeys: String, CodingKey {
        case id
        case fullName = "full_name"
        case createdAt = "created_at"
    }
}

再度実行してみると、次のようなエラーがでます

responseError(Swift.DecodingError.typeMismatch(Swift.Double, Swift.DecodingError.Context(codingPath: [Optional(xxxx.SearchRepositoriesResponse.(CodingKeys in xxxx).items), nil, Optional(xxxx.Repository.(CodingKeys in xxxx).createdAt)], debugDescription: "Expected to decode Double but found a string/data instead.")))

"Expected to decode Double but found a string/data instead."

GitHub APIではDateの部分は "2012-01-01T00:31:50Z" のように ISO8601規格に則ったフォーマットの文字列で渡ってくるため、JSONDecoderのdateDecodingStrategy.iso8601に変えないとパースできず、DecodingError.typeMismatchエラーが返却されてしまいます。

なので、GitHubRequestのデコード部分を変更します。

extension GitHubRequest where Response: Decodable {
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        guard let data = object as? Data else {
            throw ResponseError.unexpectedObject(object)
        }
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601 // dateDecodingStrategyをISO8601に変更する
        return try decoder.decode(Response.self, from: data)
    }
}

再度実行すると無事に成功しますが、これだと全てのGitHubRequestのResponseに対してデコードする時にDateはISO8601フォーマットでデコードするよ!となってしまいます :thinking:

なのでResponse毎に各種strategyの指定があれば、JSONDecoderの各種strategyを変更できるようにします。
まずはCustomDecodingStrategyなるプロトコルを定義します。

protocol CustomDecodingStrategy {
    typealias Strategies = (
        dateDecodingStrategy: JSONDecoder.DateDecodingStrategy,
        dataDecodingStrategy: JSONDecoder.DataDecodingStrategy,
        nonConformingFloatDecodingStrategy: JSONDecoder.NonConformingFloatDecodingStrategy
    )
    static var decodingStrategies: (Strategies) { get }
}

次に、GitHubRequestに定義を追加していきます。併せて少し書き方も変えていきます。

// protocolの宣言時点で、ResponseがDecodableに準拠するという制約をつける
protocol GitHubRequest: Request where Response: Decodable {
    var decoder: JSONDecoder { get } // 今回追加
}

extension GitHubRequest {
    var baseURL: URL {
        return URL(string: "https://api.github.com")!
    }

    var dataParser: DataParser {
        return DecodableDataParser()
    }

    // 今回追加
    var decoder: JSONDecoder {
        return JSONDecoder()
    }

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        guard let data = object as? Data else {
            throw ResponseError.unexpectedObject(object)
        }
        return try decoder.decode(Response.self, from: data)
    }
}

// 今回追加
extension GitHubRequest where Response: CustomDecodingStrategy {
    var decoder: JSONDecoder {
        let decoder = JSONDecoder()
        let strategies = Response.decodingStrategies
        decoder.dateDecodingStrategy = strategies.dateDecodingStrategy
        decoder.dataDecodingStrategy = strategies.dataDecodingStrategy
        decoder.nonConformingFloatDecodingStrategy = strategies.nonConformingFloatDecodingStrategy
        return decoder
    }
}

var decoder: JSONDecoder { get }というのを加えた関係で、GitHubRequestプロトコルに準拠させるにはResponseがDecodableに準拠するのが必須になりますが、そこまでややこしくはならないと思います。RequestによってHimotokiやObjectMapperを使う...と混ざらない限りは。

更に、Responseが先ほどのCustomDecodingStrategyに準拠していた場合は、JSONDecoderの生成する時にResponseに定義されたstrategyを反映させるようにしました。

あとは、RepositoryをCustomDecodingStrategyに準拠させるだけです。

struct Repository: Decodable, CustomDecodingStrategy {
    let id: Int
    let fullName: String
    let createdAt: Date

    private enum CodingKeys: String, CodingKey {
        case id
        case fullName = "full_name"
        case createdAt = "created_at"
    }

    static var decodingStrategies: Strategies {
        return (.iso8601, .base64Decode, .throw)
    }
}

また、今回はそのRepositoryを配列で受けるSearchRepositoriesResponseをResponseとしているので、こちらも同様にCustomDecodingStrategyに準拠させます。
ややスッキリしないですが、RepositoryのdecodingStrategiesをそのまま返却します。

struct SearchRepositoriesResponse: Decodable, CustomDecodingStrategy {
    let items: [Repository]

    // RepositoryのdecodingStrategiesをそのまま返却する
    static var decodingStrategies: Strategies {
        return Repository.decodingStrategies
    }
}

これでResponse毎にJSONDecoderの各種strategyを変更できるようになりました!

もし1つのResponseモデルの中で複数の要素のDateのフォーマットが異なる場合は、Decodableのinit(from decoder: Decoder)を実装しないといけないですが。。

基本的に、例えば今回のようにGitHub apiを使うとなった時に一貫してJSONDecoderのstrategyを設定できる状況だったら、この項目の最初に示したように直指定でもよいかもしれません。

1つの解としてみてもらえればと思います :pray_tone1:

更に先へ

実際にはRxPaginationのようにPaginationに対応したRequest/Responseを使ったりするケースもあると思いますので、今回のサンプルよりは考えることが複雑になってくると思いますが、同じようにDecodableを使って構築していくことは可能だと思います。
後日サンプル交えて記事書けたら書きたいなと思います :pray_tone1:

さいごに

サンプルをこちらにあげています。