iOS Advent Calendarの13日目を担当します@giginetです。
APIクライアントを作りたいなあと言う気概になったので、APIクライアントをライブラリ化するまでの方法をご紹介します。
なお、この記事は執筆時点の最新の環境で検証しています。
- Xcode7.2
- Swift 2.1.1
- Carthage 0.11.0
今回使用するAPI
今回は、APIクライアントが見当たらなかったので、WakaTimeという、エディタからデータを送り、自分のプログラミングについてのデータを集積してくれるサービスのAPIクライアントを作って、自分の1週間のコーディングを管理できるようにしてみました。
もちろん、題材とするWeb APIはご自分の好きなサービスのものを使うことができます。
練習用であれば、GitHubのAPIなどがオススメです。
APIドキュメントは以下にありますので、こちらを参考にクライアントを作っていきます。
iOSのフレームワークとプロジェクトのひな形を作る
フレームワークのプロジェクトを作る
まずAPIクライアント用のプロジェクトを作成します。
Xcodeの「File > New > Project」から、「iOS > Framework & Library > Cocoa Touch Framework」を選択します。
今回は「 WakaTimeAPIClient 」と名付けます。
その後、外部からフレームワークとして読み込むために、WakaTimeAPIClient.h
というヘッダファイルを作成します。中身は空で構いません。
デモアプリプロジェクトを作る
その後、デモ用のアプリケーションを作成します。
同様に「iOS > Application > Single View Application」からアプリケーションを作成します。今回は「 DemoApplication 」というプロジェクトにします。
ワークスペースを作る
最後に、これらのプロジェクトをまとめるためにワークスペースを作りましょう。
「File > New > Workspace」からWakaTimeAPIClient.xcworkspace
を作成します。
その後、ワークスペースを開き、左側のペインに今まで作成したプロジェクトを追加します。
DemoApplicationの適当な場所からimport WakaTimeAPIClient
などを試してみて、問題なくビルドできれば、APIクライアントのひな形の完成です。
以後、このワークスペースを使って実装を進めていきます。
APIKitとObjectMapper
今回は、簡単にエンドポイントごとのAPIリクエストを作成できるお便利ライブラリの APIKitと、JSONとオブジェクトを簡単に相互変換できる ObjectMapperを使ってAPIクライアントを作ります。
ishkawa/APIKit
Hearst-DD/ObjectMapper
Carthageを使ってパッケージを導入する
これらのライブラリを利用するために、 Carthageと呼ばれるSwift製のパッケージ管理ツールを使います。
CartageはHomebrewから導入することができます。
$ brew install carthage
まず、先ほど作成したWorkspaceと同じディレクトリにCartfile
を作成します。
ここでは以下のように今回使うライブラリを指定します。
github "ishkawa/APIKit" ~> 1.0.0
github "Hearst-DD/ObjectMapper" ~> 1.0.1
その後、以下のコマンドを実行して、ライブラリを導入します。
$ carthage update --use-submodules
このコマンドを実行すると、Carthage/Build
, Carthage/Checkouts
以下にそれぞれ、ビルド済みのライブラリと、それぞれのプロジェクトのリポジトリが作成されます。
--use-submodules
オプションは、Carthage/Checkouts
以下をgitのサブモジュールとして読み込むことができるオプションです。git管理をしている場合はライブラリの管理が簡単になるので、このオプションをつけることをオススメします。
先ほど作成したワークスペースにCarthage/Checkouts
以下のxcodeproj
を追加しましょう。
その後、WakaTimeAPIClient
のターゲットを開き、「General > Linked Frameworks and Libraries」に依存するライブラリを追加します。
今回は以下の3つの.framework
を追加します。これであなたのAPIクライアントからAPIKitとObjectMapperが利用できるようになりました。
モデルを定義する
ここから実際にコードを書いてみましょう。
次に、ObjectMapper
を使ってモデルを定義します。
WakaTimeのAPIでは、StatsというAPIを叩くと、自分が1週間に書いた言語の秒数などを取得することができます。
レスポンスは以下のようになっています。
{
"data": {
"languages": [{
"created_at": "2015-12-12T15:04:18Z",
"id": "xxxxxxxxxxxxxx",
"modified_at": "2015-12-12T15:04:19Z",
"name": "Swift",
"percent": 34.08,
"total_seconds": 22457
}, {
"created_at": "2015-12-12T15:04:17Z",
"id": "xxxxxxxxxxxxxx",
"modified_at": "2015-12-12T15:04:18Z",
"name": "Python",
"percent": 33.8,
"total_seconds": 22276
}]
}
}
これからわかるように、1つのStat
が複数のLanguage
を保持しているという構造になっています。
今回はStat
モデルとLanguage
モデルを実装していきましょう。
ここで利用するのが先ほど導入したObjectMapper
です。
ObjectMapper
に含まれるMappable
というプロトコルを実装し、mapping
メソッドを実装することで、渡されたJSONから簡単にモデルを生成することができます。
import Foundation
import ObjectMapper
public struct Language: Mappable {
public var name: String!
public var totalSeconds: Int!
public var percent: Float!
public init?(_ map: Map) {
}
public mutating func mapping(map: Map) {
self.name <- map["name"]
self.totalSeconds <- map["total_seconds"]
self.percent <- map["persent"]
}
}
まずLanguageモデルは上記のように実装します。
ObjectMapperでは<-
という演算子が定義されており、これによって簡単にJSONの値を取得できます。
import Foundation
import ObjectMapper
public struct Stat: Mappable {
public var languages: [Language] = []
public init?(_ map: Map) {
}
public mutating func mapping(map: Map) {
self.languages <- map["languages"]
}
}
次にlanguages
を複数持つStat
モデルを定義します。
ObjectMapperではネストしたモデルの保持にも対応していて、self.languages
の型を[Language]
にしておくと、
自動的にJSONの辞書を上記で定義したLanguage
モデルにマッピングしてくれて便利です。
詳しくはObjectMapperのREADMEを参照してください。
今回実装したモデルは、Mapper
を使うことで、以下のようにJSONオブジェクトをパースしてオブジェクトを作成できます。
let mapper = Mapper<Stat>()
if let object = mapper.map(jsonObject) {
print(object)
}
Requestを実装する
次にエンドポイントを叩くリクエストを実装してみましょう。
基底となるプロトコルを定義する
public protocol WakaTimeRequestType: RequestType {
var apiKey: String? { get set }
}
APIKitではRequestType
を継承したプロトコルを作り、それをさらに実装することで,
エンドポイントごとのリクエストを作成していきます。
詳しくはAPIKitのREADMEを読むと良いでしょう。
今回は、全てのリクエストが認証用のAPIキーを持つはずなので、基底のプロトコルにapiKey
を定義しています。
プロトコル・エクステンションと型制約を使って基底となる実装を定義する
次にプロトコル・エクステンションと呼ばれるSwiftの機能を使って、基底となるAPIリクエストを実装します。
プロトコル・エクステンションは、プロトコルに対してデフォルトの実装を与えることができる機能です。
public extension WakaTimeRequestType where Self.Response: Mappable {
public var method: HTTPMethod {
return .GET
}
public var baseURL: NSURL {
return NSURL(string: "https://wakatime.com/api/v1/")!
}
public func configureURLRequest(URLRequest: NSMutableURLRequest) throws -> NSMutableURLRequest {
guard let apiKey = self.apiKey else {
throw WakaTimeAPIClientError.APIKeyNotDefined
}
guard let data = apiKey.dataUsingEncoding(NSUTF8StringEncoding) else {
throw WakaTimeAPIClientError.AuthenticationFailure
}
let encryptedKey = data.base64EncodedStringWithOptions(NSDataBase64EncodingOptions.Encoding64CharacterLineLength)
URLRequest.setValue("Basic \(encryptedKey)", forHTTPHeaderField: "Authorization")
return URLRequest
}
public func responseFromObject(object: AnyObject, URLResponse: NSHTTPURLResponse) -> Response? {
guard let dictionary = object as? [String: AnyObject] else {
return nil
}
guard let data = dictionary["data"] as? [String: AnyObject] else {
return nil
}
let mapper = Mapper<Response>()
guard let object = mapper.map(data) else {
return nil
}
return object
}
}
ここで重要なのはresponseFromObject
メソッドです
このメソッドでは、渡されたJSONオブジェクトをパースしてResponse
型のオブジェクトを返しています。
Response
型は、APIKitによって定義された抽象的な型であり、あとでこのプロトコルを実装した構造体で具体型にオーバーロードすることができます。
public struct StatRequest: WakaTimeRequestType {
// 抽象的な戻り値であるResponse型をStat型にしている
typealias Response = Stat
}
この仕組みを使うことで、基底部分の実装を共通化し、様々な型について同じ実装を使い回すことができます。
しかし、このままではResponse
型として全ての型を利用することができてしまいますが、今回はObjectMapper
のMappable
プロトコルに準拠した型のみをResponse
型として受け付け、
responseFromObject
ではMapper
を使って、JSONからオブジェクトを生成できるようにしたいです。
そこで以下の 型制約 を使います。プロトコルの後にwhere
を使って、適応可能な型を指定することで、その型制約を満たす場合のみ、このプロトコル・エクステンションが適応されます。
public extension WakaTimeRequestType where Self.Response: Mappable
このように表記することで、Response
型は必ずMappable
を適応した型であることが保証されるため、以下のようにMapper
でパースすることが可能になります。
let mapper = Mapper<Response>()
guard let object = mapper.map(data) else {
return nil
}
詳しくはAPIKitの作者の方が解説ブログを書いているので参考にすると良いでしょう。
他に特筆すべきとして、configureURLRequest
ではHTTPヘッダにAPIキーを付加して認証を行っています。
認証は、もちろんAPIの仕様によって異なりますが、今回は以下のドキュメントを参考に、HTTPヘッダにBase64で暗号化したAPIキーを付与することにしています。
エンドポイントごとにAPIリクエストを実装する
その後、基底となるResponseType
を実装して、それぞれのエンドポイントごとにリクエストを作成します。
/users/current/stats/last_7_days/
にアクセスすることで、直近7日間の自分のコーディング履歴を取得することができます。
import Foundation
import ObjectMapper
public struct StatRequest: WakaTimeRequestType {
// Statを取得するAPI
public typealias Response = Stat
public var apiKey: String?
public var path: String {
return "/users/current/stats/last_7_days/"
}
}
ここでの肝はtypealias
による型の指定で、ここでMappable
な型をResponse
として指定することで、先ほど定義した基底となるRequestTypeの実装を汎化して利用することができます。
あとはこれを繰り返すだけで簡単にAPIクライアントが量産できます。
同じようなAPIであれば、typealias
でResponse
の型を差し替えるだけでリクエストを実装することができます。
作成したRequestを利用してAPIを叩く
最後に、今作成したAPIリクエストを使用して、ここ1週間に使った言語の情報を表示してみましょう。
DemoApplication
のViewController
などでAPIクライアントを使用するコードを書いてみます。
import UIKit
import CoreFoundation
import WakaTimeAPIClient
import APIKit
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
var request = StatRequest()
request.apiKey = "Your API Key"
Session.sendRequest(request) { result in
switch result {
case .Success(let response):
let stat = response
for language in stat.languages {
print("\(language.name) \(language.totalSeconds)")
}
case .Failure(let error):
print("error: \(error)")
}
}
}
}
作成したリクエストはSession.sendRequest
の引数として渡すことで、クロージャーから結果を受け取ることができます。
Statsのエンドポイントを叩くとこのように、ここ1週間で自分の書いた言語の秒数を取得することができました。
Python 29092
Swift 18638
Other 10287
INI 2832
HTML 1649
Markdown 1643
あとは、同様にして適宜必要なデータが取得できるようにモデルとリクエストを実装していきます。
実装例
実装例は以下になります。
本当はこれを使って、自分の直近のコーディング量をApple Watchなどに表示するアプリを作りたかったのですが、途中で力尽きたので一部のAPIしか実装されていません。
まとめ
今回は型制約を使った抽象的なAPIクライアントの実装サンプルをご紹介したのですが、非常に説明しづらかったので、正しく伝っていれば幸いです。
このような抽象的な型の概念は、最初に基底の実装を用意するのが大変ですが、一度理解してしまうと、あとは最小限のコードを追加することで様々なモデルやエンドポイントに対応させることができ、爆速でAPIクライアントを実装することができます。
実際に作ってみてWakaTimeのAPIはあまり綺麗ではなく、サンプルとしては不適だったなあと言うのが反省点です。
また、時間があればテストやTravis CIなどの設定についても書きたかったのですが、今回は実現できなかったので次回以降の課題にしたいです。
APIKitはシンプルながらもSwiftの特長を生かした美しい実装になっていますので、内部のコードを読むと大変勉強になると思います。
おまけ - コレクションを返すResponseType
ハマったので追記しておきます。
ResponseType
が返すResponse
を配列にしたいとき、Mappable
な要素を含むコレクションという指定をする必要がありますが、このような複雑な型制約も指定することができます。
public extension WakaTimeRequestType
where Self.Response: CollectionType,
Self.Response.Generator.Element: Mappable {
public func responseFromObject(object: AnyObject, URLResponse: NSHTTPURLResponse) -> Response? {
guard let dictionary = object as? [String: AnyObject] else {
return nil
}
// dataという配列が辞書を持っていて、それぞれをparseしてオブジェクトにしたい
guard let data = dictionary["data"] as? [AnyObject] else {
return nil
}
var objects: [Self.Response.Generator.Element] = []
for objectDictionary in data {
let mapper = Mapper<Response.Generator.Element>()
guard let object = mapper.map(objectDictionary) else {
continue
}
objects.append(object)
}
return objects as? Self.Response
}
}
このとき、以下のような型制約がポイントで,
で繋ぐことで複数条件を指定することができます。
public extension WakaTimeRequestType
where Self.Response: CollectionType,
Self.Response.Generator.Element: Mappable
この場合はResponse
が
-
CollectionType
を実装している - かつ子要素が
Mappable
を実装している
という条件であり、戻り値の型を「Mappable
なオブジェクトを含むコレクション」に制約することができます。
struct User: Mappable {
// 略
}
public UserRequestType: BaseRequestType {
typealias = [User]
}