@dynamicMemberLookup と @dynamicCallable でWeb APIアクセス


概要

Swiftの機能である @dynamicMemberLookup@dynamicCallable を使って、いい感じにWeb APIにアクセスしてみます。
試しにQiitaのAPIにアクセスしようと思います。

@dynamicCallableでパラメータ作成

GET

GETアクセスをするためには、queryパラメータを設定する必要があります。
URLComponentsを作成してqueryItemsにURLQueryItemを設定することでリクエストするためのURLが作成できます。

URLQueryItemを @dynamicCallable で作成する

以下のような形でURLQueryItemsGenerator を定義してみました。

@dynamicCallable
struct URLQueryItemsGenerator {
    func dynamicallyCall(withKeywordArguments pairs: KeyValuePairs<String, String?>) -> [URLQueryItem] {
        pairs.map { (key, value) -> URLQueryItem in
            URLQueryItem(name: key, value: value)
        }
    }
}

GET URLの作成

このURLQueryItemsGeneratorを作成し、(1)
作成したジェネレーターにAPIドキュメント通りにパラメータを与えると、(2)
URLQueryItemが簡単に作れ、URLにqueryパラメータを与えることができます。

var components = URLComponents()
components.scheme = "https"
components.host = "qiita.com"
components.path = "/api/v2/items"
let generator = URLQueryItemsGenerator() // (1)
components.queryItems = generator(page: "2",
                                  per_page: "10",
                                  query: "SwiftUI") // (2)
https://qiita.com/api/v2/items?page=2&per_page=10&query=SwiftUI

POST

QiitaのPOST APIはほとんど Content-Type: application/json なので、POST用のbodyのJSON Dataを生成するジェネレータを作ります。

JSON Dataを @dynamicCallable で作成する

以下のような形でdynamicCallableを定義してみました。
Dictionaryを作成するDictionaryGeneratorとJSONのデータを作成するJSONDataGeneratorです。

@dynamicCallable
struct DictionaryGenerator {
    func dynamicallyCall(withKeywordArguments pairs: KeyValuePairs<String, Any?>) -> [String:Any] {
        var dictionary:[String:Any] = [:]
        pairs.forEach { (key, value) in
            dictionary[key] = value
        }
        return dictionary
    }
}

@dynamicCallable
struct JSONDataGenerator {
    func dynamicallyCall(withKeywordArguments pairs: KeyValuePairs<String, Any?>) -> Data? {
        let generator = DictionaryGenerator()
        let dictionary = generator.dynamicallyCall(withKeywordArguments: pairs)
        guard JSONSerialization.isValidJSONObject(dictionary) else {
            return nil
        }
        return try! JSONSerialization.data(withJSONObject: dictionary, options: [])
    }
}

POST Requestの作成

JSONDataGeneratorを作成し、作成したジェネレーターにAPIドキュメント通りにパラメータを与えると、POST Request用のJSON Dataが作成できます。
/api/v2/itemsのtagsパラメータにはオプジェクトの配列が必要なので、DictionaryGeneratorも使ってhttpBodyにJSON Dataを設定しています。

var components = URLComponents()
components.scheme = "https"
components.host = "qiita.com"
components.path = "/api/v2/items"
var request = URLRequest(url: components.url!)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let jsonDataGenerator = JSONDataGenerator()
let dictionaryDataGenerator = DictionaryGenerator()

request.httpBody = jsonDataGenerator(body:"# Example",
                                     private:"false",
                                     tags:[
                                        dictionaryDataGenerator(name:"Ruby",
                                                                versions:["0.0.1"])
                                     ],
                                     title: "Example title",
                                     tweet: false)
{
  "body": "# Example",
  "private": false,
  "tags": [
    {
      "name": "Ruby",
      "versions": [
        "0.0.1"
      ]
    }
  ],
  "title": "Example title",
  "tweet": false
}

@dynamicMemberLookupでJSON受け取り

リクエストが作成できたので、リクエストをAPIに送信してJSONレスポンスを受け取ります。

JSON dynamicMemberLookup

だいぶ長いですが、以下のように@dynamicMemberLookupを付与したJSON解析用のenumを用意します。

@dynamicMemberLookup 
enum JSON {
    case dictionaryValue(Dictionary<String, JSON>)
    case arrayValue(Array<JSON>)
    case numberValue(NSNumber)
    case stringValue(String)
    case boolValue(Bool)
    case nullValue

    var objectValue: Dictionary<String, JSON>? {
        if case .dictionaryValue(let dictionary) = self {
            return dictionary
        }
        return nil
    }

    var arrayValue: Array<JSON>? {
        if case .arrayValue(let array) = self {
            return array
        }
        return nil
    }

    var stringValue: String? {
        if case .stringValue(let str) = self {
            return str
        }
        return nil
    }

    var numberValue: NSNumber? {
        if case .numberValue(let number) = self {
            return number
        } else if case .boolValue(let b) = self {
            return NSNumber(value: b)
        }
        return nil
    }

    var boolValue: Bool? {
        if case .boolValue(let bool) = self {
            return bool
        }
        return nil
    }

    var nullValue: NSNull? {
        if case .nullValue = self {
            return NSNull()
        }
        return nil
    }

    subscript(index: Int) -> JSON? {
        if case .arrayValue(let array) = self {
            return index < array.count ? array[index] : nil
        }
        return nil
    }

    subscript(dynamicMember member: String) -> JSON? {
        if case .dictionaryValue(let dict) = self {
            return dict[member]
        }
        return nil
    }

    private init(_ object: Any) {
        switch object {
        case let boolValue as Bool: self = .boolValue(boolValue)
        case let numberValue as NSNumber: self = .numberValue(numberValue)
        case let stringValue as String: self = .stringValue(stringValue)
        case let dictionaryValue as Dictionary<String, Any>: self = JSON.dictionaryValue(dictionaryValue.mapValues{ JSON($0) })
        case let arrayValue as Array<Any>: self = .arrayValue(arrayValue.map { JSON($0)} )
        default: self = .nullValue
        }
    }

    init(data: Data) throws {
        let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
        self = .init(jsonObject)
    }
}

APIにアクセスして記事を取得

先ほどのURLQueryItemsGeneratorも組み合わせてAPIにアクセスして記事のタイトルを取得します。
以下のように、indexやドットを利用して、先頭の記事タイトルが取れます。

var components = URLComponents()
components.scheme = "https"
components.host = "qiita.com"
components.path = "/api/v2/items"
let generator = URLQueryItemsGenerator()
components.queryItems = generator(page: "2",
                                  per_page: "10",
                                  query: "SwiftUI")
let task = URLSession.shared.dataTask(with: components.url!) { (data, response, error) in
    let json = try! JSON(data: data!)
    print(String(describing: json[0]?.title?.stringValue)) // 先頭の記事のタイトルが取れる
}
task.resume()

なにがいいのか

API処理でクライアントを用意したり、Codableなどでレスポンスの定義をきっちりと行って処理をすることも多いんじゃないかと思いますが、@dynamicMemberLookup@dynamicCallableを使うと、その辺りのコードが全て削れます。

外部のAPIと連携するようなアプリの場合、API側の仕様変更が行われたら、APIクライアントやCodableのメンテナンスが必要になりますが、@dynamicMemberLookup@dynamicCallableを使うと、リクエスト送信処理やレスポンス受信処理だけAPIのドキュメントとにらめっこしてドットアクセスやパラメータ作成をするだけですむので、そこが気に入ってます。

おまけ

今回のコードをまとめたものをSwift Package Managerとして提供しています。是非使ってください。スターください。
https://github.com/coe/Dynamics