ライブラリを使わずにJSON形式のWebAPIクライアントを実装してみたメモ
概要
大阪オフィスの山田です。
私は、Alamofire
をよく使うのですが、よくよく考えてみるとURLSession
を自分で使ってWeb APIクライアントを実装したことがなかったので、今回チャレンジして理解を深めてみます。
開発環境
- macOS: 10.15.4
- Xcode: 11.5
URLSessionを使った最小のリクエストを実装する
以下のようにリクエストを送り、値が取得できることを確認します。
{user_name}
には、GitHubアカウント名を入れます。
class APIClient { func request() { guard let url = URL(string: "https://api.github.com/users/{user_name}") else { return } var request = URLRequest(url: url) request.httpMethod = "GET" let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in dump(data) dump(response) dump(error) }) task.resume() } }
data
、response
の内容がコンソールに表示されます。
JSONをパースし、モデルを生成する
モデルを定義する
今回使用しているAPIでは、様々な情報が取得できますが、簡略化のためにname
とbio
のみを定義したModelを作ります。作成したモデルはCodable
を継承しています。(Codableについては説明を省略します)
class APIClient { func request() { guard let url = URL(string: "https://api.github.com/users/{user_name}") else { return } var request = URLRequest(url: url) request.httpMethod = "GET" let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in if let data = data { let account = try! JSONDecoder().decode(GitHubAccount.self, from: data) dump(account) } }) task.resume() } } struct GitHubAccount: Codable { let name: String let bio: String }
request
メソッドを走らせると、コンソールに以下のように表示されます。
APIRequestSample.GitHubAccount - name: "RyoYamada" - bio: "I love backend, frontend, MobileApplication(iOS, Android), .NET, AWS, Docker. I love various technologies. I hope to meet up something I have never seen before."
他のAPIもコールできるようにする
requestメソッドが他のリクエストも受けられるようにする
先ほど実装したrequest
メソッドはURLが固定されています。そこで外部からリクエストの情報を受け取り、APIをコールし、レスポンスがあれば対応するモデルに変換するようにします。
まず、リクエストのprotocolを定義します
protocol Requestable { var url: String { get } var httpMethod: String { get } var headers: [String: String] { get } } extension Requestable { var urlRequest: URLRequest? { guard let url = URL(string: url) else { return nil } var request = URLRequest(url: url) request.httpMethod = httpMethod headers.forEach { key, value in request.addValue(value, forHTTPHeaderField: key) } return request } }
url
とhttpMethod
とheaders
プロパティを設定した後、urlRequest
プロパティでURLRequest
のインスタンスが取得できるようにしています。このprotocolを継承した構造体を各APIごとに作成していきます。GitHubAccountAPIRequest
はGitHubアカウントの情報を取得するAPIリクエストです。GitHubSearchRepositoriesAPIRequest
は、リポジトリを検索するAPIリクエストです。
struct GitHubAccountAPIRequest: Requestable { var url: String { return "https://api.github.com/users/{user_name}" } var httpMethod: String { return "GET" } var headers: [String : String] { return [:] } } struct GitHubSearchRepositoriesAPIRequest: Requestable { var url: String { return "https://api.github.com/search/repositories?q=swift+api" } var httpMethod: String { return "GET" } var headers: [String : String] { return [:] } }
request
メソッドは以下のようにします。
func request(_ requestable: Requestable) { guard let request = requestable.urlRequest else { return } let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in if let data = data { let account = try! JSONDecoder().decode(GitHubAccount.self, from: data) dump(account) } }) task.resume() }
これで、他のリクエストも引数として受け付けられるようになりました。しかし、これでは以下のことが足りません。
- パースして生成したモデルを呼び出し元が受け取れない
- Requestごとに違うモデルが生成できない
次は上記の問題を解決する実装をします。
レスポンスに応じたモデルを返却できるようにする
まず、以下のモデルを追加します。
struct GitHubRepositories: Codable { let totalCount: Int let incompleteResults: Bool let items: [GitHubRepository]? } struct GitHubRepository: Codable { let name: String let htmlUrl: String }
レスポンスに応じて、JSONからパースしてモデルを生成するための実装をします。
protocol Requestable { associatedtype Model // ...省略 func decode(from data: Data) throws -> Model } struct GitHubAccountAPIRequest: Requestable { typealias Model = GitHubAccount // ...省略 func decode(from data: Data) throws -> GitHubAccount { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(GitHubAccount.self, from: data) } } struct GitHubSearchRepositoriesAPIRequest: Requestable { typealias Model = GitHubRepositories // ...省略 func decode(from data: Data) throws -> GitHubRepositories { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return try decoder.decode(GitHubRepositories.self, from: data) } }
Requestable
にassociatedtyp
としてModel
を定義しています。継承先でtypealias
を使ってModel
のクラスを設定します。decode
メソッドの戻り値は設定されたクラスとなります。
decode
メソッドはRequestable
のメソッドとして記述しても良いかと思いましたが、decoderのオプションを変えたい場合もあることを想定して、個別に定義するようにしました。
今回、JSONはスネークケース、モデル定義はキャメルケースなので、decoderの.keyDecodingStrategy
に.convertFromSnakeCase
を指定しています。
次はrequest
メソッドを修正していきます。
まず、引数にcompletion
を追加して、呼び出し元にパースしたモデルを返せるようにします。
また、Requestableに応じたモデルでパースするようにしています。
func request<T: Requestable>(_ requestable: T, completion: @escaping(T.Model?) -> Void) { guard let request = requestable.urlRequest else { return } let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in if let data = data { let model = try? requestable.decode(from: data) completion(model) } }) task.resume() }
genericsを使ってRequestable
を受け付け、completion
でT.Model
とし、typealias
でModel
に設定されたクラスを返却するようにしています。
以下のように実装すると、リクエストすることができます。
let request = GitHubAccountAPIRequest() APIClient().request(request, completion: { model in dump(model) }) let request2 = GitHubSearchRepositoriesAPIRequest() APIClient().request(request2, completion: { model in dump(model) })
実行結果はこちらです。
Optional(APIRequestSample.GitHubAccount(name: "RyoYamada", bio: "I love backend, frontend, MobileApplication(iOS, Android), .NET, AWS, Docker. I love various technologies. I hope to meet up something I have never seen before.")) ▿ some: APIRequestSample.GitHubAccount - name: "RyoYamada" - bio: "I love backend, frontend, MobileApplication(iOS, Android), .NET, AWS, Docker. I love various technologies. I hope to meet up something I have never seen before." ▿ Optional(APIRequestSample.GitHubRepositories(totalCount: 4936, incompleteResults: false, items: Optional([APIRequestSample.GitHubRepository(name: "SwiftyUserDefaults", htmlUrl: "https://github.com/sunshinejr/SwiftyUserDefaults"), APIRequestSample.GitHubRepository(name: "SwiftGen", htmlUrl: "https://github.com/SwiftGen/Swift // ...省略...
レスポンスがモデルになって取得できるところまで確認できました。しかし、これではエラーが発生したことがわかりません。次はエラーハンドリングを実装していきます。
エラーハンドリングの実装
エラーハンドリングにResult
を使います。Result
はSwift5で追加されました。Proposalはこちらを参照してください。
まず、APIに関するエラーを定義しておきます。
enum APIError: Error { case server(Int) case decode(Error) case noResponse case unknown(Error) }
次に、request
メソッドの定義を変更します。
func request<T: Requestable>(_ requestable: T, completion: @escaping(Result<T.Model?, APIError>) -> Void)
completion
の引数にResult
を使いました。Result
はsuccess
とfailure
を持つenum
で、成功時は値を、失敗時は関連するError
を持たせることができます。以下のようにcompletion
を使います。
// 成功時 completion(.success(model)) // 失敗時 completion(.failure(error))
実際にrequest
メソッドを修正します。
func request<T: Requestable>(_ requestable: T, completion: @escaping(Result<T.Model?, APIError>) -> Void) { guard let request = requestable.urlRequest else { return } let task = URLSession.shared.dataTask(with: request, completionHandler: { (data, response, error) in if let error = error { completion(.failure(APIError.unknown(error))) return } guard let data = data, let response = response as? HTTPURLResponse else { completion(.failure(APIError.noResponse)) return } if case 200..<300 = response.statusCode { do { let model = try requestable.decode(from: data) completion(.success(model)) } catch let decodeError { completion(.failure(APIError.decode(decodeError))) } } else { completion(.failure(APIError.server(response.statusCode))) } }) task.resume() }
Httpステータスコードが200番台で、JSONのパースに成功した時に、completion
にsuccess
と共にモデルのインスタンスを渡しています。200番以外のHttpステータスコードが返ってきたり、JSONのパースに失敗した場合は、completion
にfailure
を渡しています。
request
メソッドの呼び出し側はこのように実装します。
APIClient().request(request, completion: { result in switch(result) { case let .success(model): dump(model) case let .failure(error): switch error { case let .server(status): print("Error!! StatusCode: \(status)") case .noResponse: print("Error!! No Response") case let .unknown(e): print("Error!! Unknown: \(e)") default: print("Error!! \(error)") } } })
result
をsuccess
、failure
でまず判断し、failure
の時はエラーの種類によって処理を分けられるようにしています。
POSTに対応する
ここまではGETメソッドのAPIのみだったので、次はGistに記事を追加できるAPI(POSTメソッド)をコールしてみたいと思います。
POSTメソッドに対応するために、bodyパラメータとHttpヘッダをセットします。
まず、Requestable
プロトコルにbody
プロパティを追加します。(headers
プロパティはすでに追加済みです)
protocol Requestable { ... var headers: [String: String] { get } var body: Data? { get } ... }
次にURLRequest
を取得するプロパティを少し変更します。
URLRequest
のhttpBody
に値をセットします。body
プロパティがnilだった場合は値はセットしません。
var urlRequest: URLRequest? { guard let url = URL(string: url) else { return nil } var request = URLRequest(url: url) request.httpMethod = httpMethod // ここから追加 if let body = body { request.httpBody = body } // ここまで追加 headers.forEach { key, value in request.addValue(value, forHTTPHeaderField: key) } return request }
次に、PostするRequestable
を継承したAPIRequestの構造体とGistの構造体を定義します。
Gistを投稿するAPIの仕様については公式ドキュメントを参照してください。
struct PostGist { let `public`: Bool let filename: String let content: String } struct CreateGistAPIRequest: Requestable { typealias Model = Void var gist: PostGist? var token = "{Your Personal access token}" var url: String { return "https://api.github.com/gists" } var httpMethod: String { return "POST" } var headers: [String: String] { return [ "Content-type": "application/json; charset=utf-8", "Authorization": "token \(token)", "Accept": "application/vnd.github.v3+json" ] } var body: Data? { guard let gist = gist else { return nil } let body: [String: Any] = [ "public": gist.public, "files": [ gist.filename: [ "content": gist.content ] ] ] return try! JSONSerialization.data(withJSONObject: body, options: []) } func decode(from data: Data) throws -> Void { return } }
ここで、ヘッダとしてContent-type
とAuthorization
を追加しています。
Authorization
にはPersonal access tokenを設定します。(間違って公開しないようにお願いします)
Personal access tokenの作り方は公式ドキュメントを参照してください。
body部分は、Encodable
なモデルを定義して、モデルからencodeする方法がありますが、今回使うGistへ投稿するAPIで必要なbodyパラメータはキーの部分が動的に変わります。その場合、Encodable
を使うには少しテクニックが必要なのですが、それは今回は解説しません(やり方は分かったので別記事で解説するかもしれません)。今回は[String:Any]
からData
を生成します。
今回、レスポンスは特に使っていないので、特にパースしていません。パースする場合は、decode
メソッドに実装します。
呼び出し側の実装をします。
var request3 = CreateGistAPIRequest() request3.gist = PostGist(public: false, filename: "Post test", content: "Dekitara Yeah!!(´・ω・`)v") APIClient().request(request3, completion: { result in dump(result) })
実際に動かすと、投稿されているのが確認できます。
URLSessionConfiguration
タイムアウトやキャッシュ、クッキーの取り扱い方を設定することができます。
今までの実装ではシングルトンのURLSession
インスタンス(shared
)を使っていました。基本的なリクエストはこのshared
を使えばデータの取得等できますが、URLSessionConfiguration
による設定の変更はできません。そこで次はdefault
セッションを使って、実際にタイムアウトの値を短く設定してみます。
func request<T: Requestable>(_ requestable: T, completion: @escaping(Result<T.Model?, APIError>) -> Void) { guard let request = requestable.urlRequest else { return } // ここから変更 let config: URLSessionConfiguration = URLSessionConfiguration.default config.timeoutIntervalForResource = 1 let session: URLSession = URLSession(configuration: config) let task = session.dataTask(with: request, completionHandler: { (data, response, error) in // ここまで変更 if let error = error { completion(.failure(APIError.unknown(error))) return } guard let data = data, let response = response as? HTTPURLResponse else { completion(.failure(APIError.noResponse)) return } if case 200..<300 = response.statusCode { do { let model = try requestable.decode(from: data) completion(.success(model)) } catch let decodeError { completion(.failure(APIError.decode(decodeError))) } } else { completion(.failure(APIError.server(response.statusCode))) } }) task.resume() }
1秒でタイムアウトするようにしてみました。 コンソールに以下のように出力されて、タイムアウトしていることがわかりました。
Error!! Unknown: Error Domain=NSURLErrorDomain Code=-1001 "The request timed out." UserInfo={NSErrorFailingURLStringKey=https://api.github.com/search/repositories?q=swift+api, NSErrorFailingURLKey=https://api.github.com/search/repositories?q=swift+api, _kCFStreamErrorDomainKey=4, _kCFStreamErrorCodeKey=-2103, NSLocalizedDescription=The request timed out.}
URLSession
とURLSessionConfiguration
の公式ドキュメントを記載しておきます。
おわりに
車輪の再開発、勉強になるなー。ちょっとだけSwiftと仲良くなれました。たのしー。
参考
- URLSession: Apple Documentation
- Swift の HTTP ライブラリで苦しまないための自作 API クライアント設計: Qiita
- そのリクエストパラメータ、クエリストリングに入れますか、それともボディに入れますか: Qiita
- Alamofire: GitHub
- Swift HTTP通信部分にCombineを使ってみる #WWDC19: Developers.IO
- 【Swift】URLSessionまとめ: Qiita
- It's time to break up with your networking library for URLSession
- Swift 4.0 エラー処理入門
- Swift 5 のResultに備える: Qiita
- Swift 5's Result Type
- Swiftで HTTP StatusCode が400台だったら、みたいなif文を switch case 400..<500 みたいに簡潔に書きたかった時の話