ライブラリを使わずにJSON形式のWebAPIクライアントを実装してみたメモ

URLSessionを自分で使ってWeb APIクライアント(JSON形式)を実装したことがなかったので、今回チャレンジしてみました。そのまとめです。
2020.07.13

概要

大阪オフィスの山田です。 私は、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()
    }
}

dataresponseの内容がコンソールに表示されます。

JSONをパースし、モデルを生成する

モデルを定義する

今回使用しているAPIでは、様々な情報が取得できますが、簡略化のためにnamebioのみを定義した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
    }
}

urlhttpMethodheadersプロパティを設定した後、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)
    }
}

Requestableassociatedtypとして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を受け付け、completionT.Modelとし、typealiasModelに設定されたクラスを返却するようにしています。 以下のように実装すると、リクエストすることができます。

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を使いました。Resultsuccessfailureを持つ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のパースに成功した時に、completionsuccessと共にモデルのインスタンスを渡しています。200番以外のHttpステータスコードが返ってきたり、JSONのパースに失敗した場合は、completionfailureを渡しています。

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)")
        }
    }
})

resultsuccessfailureでまず判断し、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を取得するプロパティを少し変更します。 URLRequesthttpBodyに値をセットします。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-typeAuthorizationを追加しています。 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.}

URLSessionURLSessionConfigurationの公式ドキュメントを記載しておきます。

おわりに

車輪の再開発、勉強になるなー。ちょっとだけSwiftと仲良くなれました。たのしー。

参考