Skip to main content
すっさんぽ
  1. Posts/

[iOS] URLSessionでAPIクライアントを実装する

APIとの通信周りは、AlamofireやAPIKitなど便利なパッケージがありますが、FoundationにあるURLSessionも、シンプルで使いやすく便利です。 今回は、URLSessionを使ってAPIクライアントを実装する方法をまとめます。

ただし、もっと良い方法がある気もするので、また新しい方法を見つけ次第。加筆や別の投稿にまとめていこうと思います。

設計の方針と目的 #

今回は、以下の設計を試してみます。

  • 通信処理だけを担うクラスを用意する
    • 目的
      • HTTPのステータスコードやエラーを包括してハンドリングするため
      • 通信周りを実現するためのAPIのアップデートに備えて、通信の実装を局所化するため

(概ねこの設計になっているアプリが多いように感じます)

gooラボについて #

https://labs.goo.ne.jp/api/

今回、APIのリクエスト先として、gooラボのAPIを使用しました。 形態素解析やキーワード抽出など、文字列関係の便利なAPIが用意されています。

実装 #

クライアント #

クライアントには、シンプルにfetchメソッドだけ用意します。 パラメータには、リクエストオブジェクトを渡します。

まずは、実装の全体を見てみます。

enum HTTPMethod: String {
    case post = "POST"
}

// リクエストクラスのプロトコル
// リクエストオブジェクトはこれに準拠する
protocol GooRequest {
    associatedtype Response: Decodable    // レスポンスの型を定義する
    associatedtype HTTPBody: Encodable    // リクエストボディの型を定義する

    var path: String { get }    // パスだけ
    var method: HTTPMethod { get }
    var body: HTTPBody { get }
}

// 通信処理を担うクライアントクラス
class GooClient {
    private let apiPath = "https://labs.goo.ne.jp/api/"

    // GooRequestだけを受け付けられるようにしている
    // リクエストクラスで指定したレスポンスクラスを返すようにしている
    func fetch<T: GooRequest>(_ request: T) async throws -> T.Response {
        var urlRequest = URLRequest(url: URL(string: apiPath + request.path)!)

        // リクエストの作成
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase

        urlRequest.httpBody = try! encoder.encode(request.body)
        urlRequest.httpMethod = request.method.rawValue
        urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")

        // ↑ encoderはRequestのextensionにもたせても良さそう
        // ↑ 固定のリクエストヘッダーは、URLSessionConfigurationでセットしても良さそう

        let data: Data
        let response: URLResponse
        do {
            // リクエストを実行
            (data, response) = try await URLSession.shared.data(for: urlRequest)
        } catch {
            // 通信エラー
            let logger = Logger(subsystem: "MyApp", category: "Error")
            logger.error("\(error.localizedDescription, privacy: .public)")
            throw error
        }

        // APIエラー
        if let httpResponse = response as? HTTPURLResponse,
           httpResponse.statusCode != 200 {
            // ステータスが200以外のときを、まとめてハンドリングする
            throw try JSONDecoder().decode(GooError.self, from: data)
        }

        // 正常終了
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try decoder.decode(T.Response.self, from: data)    // Requestで指定したResponseでデコードして返却する
    }
}

// エラーオブジェクト(gooラボ APIのエラーJSONに準拠させている)
struct GooError: Error, Decodable {
    let error: GooErrorMessage
}

// エラーオブジェクト(gooラボ APIのエラーJSONに準拠させている)
struct GooErrorMessage: Decodable {
    let code: String
    let message: String
}

fetch #

fetchメソッドが、GooRequestプロトコルだけを受け付けるようにしていることが重要。

func fetch<T: GooRequest>(_ request: T) async throws -> T.Response {

ジェネリクス型に対してプロトコルで制約をつけていますが、こうしないと実装できないのが言語的な制約。 これを、型消去というそうです。 プロトコルを引数に入れたくなりますが、下のような書き方はできないようです。

func fetch(_ request: GooRequest) async throws -> GooRequest.Response {

この書き方をすると、警告が表示されます。「Associated type ‘Response’ can only be used with a concrete type or generic parameter base」や「Member ‘body’ cannot be used on value of protocol type ‘GooRequest’; use a generic constraint instead」という警告で、コンパイルができません。具体的な型か、ジェネリクスでしか使えないとのことで、今回はジェネリクス型に制約をかけるしか方法がありませんでした。

ちなみに、今後のSwiftのアップデートでは、some/anyが使えるようになるので、おそらくは直感的な以下の書き方で型消去ができるようになると思います。 (理解間違ってるかもしれないけど)

func fetch(_ request: any GooRequest) async throws -> some GooRequest.Response {

Request #

リクエストクラスでは、associatedtypeを使用して、型の制約を定義しています。

// リクエストクラスのプロトコル
// リクエストオブジェクトはこれに準拠する
protocol GooRequest {
    associatedtype Response: Decodable    // レスポンスの型を定義する
    associatedtype HTTPBody: Encodable    // リクエストボディの型を定義する

    var path: String { get }    // パスだけ
    var method: HTTPMethod { get }
    var body: HTTPBody { get }
}

これたとえば、HTTPBodyを associatedtype ではなく protocol で定義してしまうと……

protocol HTTPBody: Encodable {}

「Protocol ‘HTTPBody’ as a type cannot conform to ‘Encodable’」と警告されてしまいます。 結局ここも、具体的な型じゃないといけないということなんでしょうね……。

リクエストオブジェクト #

GooRequestプロトコルに準拠させた、GooHiraganaRequestを実装します。 このリクエストでは、漢字の混ざった文章をひらがなだけに変換してくれる「ひらがな化API」にリクエストします。

struct GooHiraganaRequest: GooRequest {
    typealias Response = GooHiraganaResponseBody
    typealias HTTPBody = GooHiraganaRequestBody

    var path: String {
        "hiragana"
    }

    var method: HTTPMethod {
        .post
    }

    var body: GooHiraganaRequestBody
}

// gooラボ「ひらがな化API」のレスポンスJSONに準拠している
struct GooHiraganaResponseBody: Decodable {
    let converted: String
}

// gooラボ「ひらがな化API」のリクエストJSONに準拠している
struct GooHiraganaRequestBody: Encodable {
    let appId = "ID"
    let outputType = "hiragana"
    let sentence: String
}

リクエストクラスの実装は、かんたんです。 struct GooHiraganaRequest: GooRequest {}のようにプロトコルに準拠させた空の型を作ると、準拠していない旨の警告が表示されます。警告から選べるFixメニューに従うことで、associatedtypeの指定、不足しているプロパティの追加を順に解消してくれます。

GooRequestに準拠させた型を他にも用意することで、「ひらがな化API」以外への対応もかんたんです。 通信処理をクライアントクラスに任せたこと・リクエストのプロトコルを用意したことにより、機能拡張がしやすくなりました。

クライアントの呼び出し #

呼び出しはかんたんです。

private let client = GooClient()

Task {
    let sentence = "漢字仮名交じり文"
    let body = GooHiraganaRequestBody(sentence: sentence)
    let request = GooHiraganaRequest(body: body)
    let response = try await client.fetch(request)
    // ↑GooHiraganaResponseBodyが返る
    // 適宜エラーハンドリングはしたほうがいい
    print(response.converted)  // prints "かんじかなまじりぶん"
}

これでOK。

まとめ #

URLSessionを使ったAPIクライアントの実装をしてみたので、それをまとめました。 通信処理をクライアントクラスに任せることで、通信エラーのハンドリング、APIエラーのハンドリングを包括して担わせることができました。 また、リクエスト・レスポンスを定義するプロトコルを用意することで、クライアントが受けられる型に制約をつけることができ、さらに拡張もしやすくなりました。

この実装を通して、型消去の方法を知ることができました。 プロトコルで型のテンプレートを用意しつつ、ジェネリクスに対してプロトコルで制約をかけるというものでした。プロトコルはあくまでテンプレートのようなもので、具体ではないということなんでしょうか。Interfaceともちょっと違うんだな……と感じました。