Swift4.0から標準でCodable(Encodable,Decodable)
というオブジェクトのシリアライズ、デシリアライズが可能になるプロトコルが登場しました。
まだ正式リリースされていないですが早速自分の開発中のアプリで、APIKitとHimotokiを使っていた部分をAPIKitとSwiftのDecodable
に書き換えてみました。ということで、APIKitとSwiftのDecodable
を使ってAPIクライアントを構築していく手順を書かせていただきたいなと思います。
※Decodableというライブラリもあるのでここまでは"Swiftの"
を付けていましたが、以降は全てSwift4.0でのDecodable
プロトコルと思って頂ければと..!
また、今回はGithub API v3のSearch 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を定義します。
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が肝になります
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 v3のSearch 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)
}
}
}
}
Requestを定義する(1)
まずは、次のようにベースとなる 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 {
// ここをあとで書き換える
}
}
一旦、 Response
がDecodable
に準拠している場合の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なるものを作ります。
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 になるので余計なチェックが不要になります。期待ですね。
```swift:(引用)APIKitdevelop/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
}
```swift
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)
}
}
まだまだ変わる可能性はありますが、より便利になりますね!
Responseを定義する
さて、一旦Requestから離れてResponseの定義に移ります。
今回、Search repositories apiを使用するので、以下のようにResponseを定義してみます。
今回はシンプルにするため、パースする要素を最小限にしています。
{
"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
}
]
}
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)
}
}
}
}
ここまででシンプルな形でAPIKitとDecodable
プロトコルを使ったAPIクライアントが設計できました。
応用: Responseに応じてJSONDecoderの各種strategyを変えられるようにする
ただ、このままではJSONDecoderの各種strategyが変更できず固定のままになってしまうので、Responseに応じて変えられるようにします。
(同じサービスのAPIでDateのフォーマットがコロコロ変わったりすることは稀だと思いますが...)
試しに、Repository
にcreatedAt
を追加します。
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フォーマットでデコードするよ!となってしまいます
なので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つの解としてみてもらえればと思います
更に先へ
実際にはRxPaginationのようにPaginationに対応したRequest/Responseを使ったりするケースもあると思いますので、今回のサンプルよりは考えることが複雑になってくると思いますが、同じようにDecodableを使って構築していくことは可能だと思います。
後日サンプル交えて記事書けたら書きたいなと思います
さいごに
サンプルをこちらにあげています。