Edited at

APIのRequestProtocolの実装案

More than 1 year has passed since last update.


概要

可読性の高くかつ冗長にならないEncodableを使ったRequestの設計を考えてみました。


目的

APIのRequestを作る際にパッと見てRequestが何をしているのかがわかるようにしたかったのと、Requestを作る際に毎回QueryParameterをイニシャライザの引数に書いたり、nil時の処理やDictionaryに格納する処理を書いたりするのが面倒なのでより良いRequestProtocolの実装になるように設計しました。


RequestProtocolの実装

(Requestの実装にAlamofireを使っています。)

細かい使い方や説明は後の方に書いています。


Request.swift

protocol Request {

associatedtype Response
associatedtype Serializer: DataResponseSerializerProtocol where Self.Serializer.SerializedObject == Self.Response
associatedtype Query: Encodable

var method: HTTPMethod { get } // HTTPMethodはAlamofireのクラスです
var host: Hosts { get } // Hostsも自前で実装してください(おまけ参照)
var path: String { get }
var query: Query { get }
var httpBody: Data? { get }
var setting: APISetting { get } // Header情報などを付与するための設定クラス(自前で実装してください。おまけ参照)
var serializer: Serializer { get } // Serializerを設定するとDecodable以外のものにも対応できます
}

struct EmptyQuery: Encodable {}
struct EmptyResponse: Decodable {}

extension Request {
var query: EmptyQuery { return EmptyQuery() }
var httpBody: Data? { return nil }

var urlString: String {
let url = URL(string: host.urlString + path)!
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
if method == .get {
do {
var queryItems: [URLQueryItem] = try QueryEncoder().encode(query)
if let items = urlComponents?.queryItems {
queryItems = items + queryItems
}
urlComponents?.queryItems = queryItems
} catch {
}
}
return urlComponents?.url?.absoluteString ?? url.absoluteString
}
}

extension Request where Response: Decodable {
var serializer: DecodableSerializer<Response> {
return DecodableSerializer<Response>()
}
}



QueryEncoderに関して

Encodableを使ってQueryの自動マッピングができるようにQueryEncoderを作成しました。Swift@IBMのKituraを参考にしています。


QueryEncoder.swift

extension CharacterSet {

static let customURLQueryAllowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~=:&")
}

public class QueryEncoder: Encoder {

private func getFieldName(from codingPath: [CodingKey]) -> String {
return codingPath.compactMap({ $0.stringValue }).joined(separator: ".")
}

private var dictionary: [String: String]
public var codingPath: [CodingKey] = []
public var userInfo: [CodingUserInfoKey: Any] = [:]
public let dateFormatter: DateFormatter?
public init(dateFormatter: DateFormatter? = nil) {
self.dictionary = [:]
self.dateFormatter = dateFormatter
}

// こちらで任意の型のqueryを渡し[URLQueryItem]としてqueryを出力します。
public func encode<T: Encodable>(_ value: T) throws -> [URLQueryItem] {
let dict: [String: String] = try encode(value)
return dict.reduce([URLQueryItem]()) { array, element in
var array = array
array.append(URLQueryItem(name: element.key, value: element.value))
return array
}
}

// ここで全ての値に対してパースしてDictionaryで返しています
public func encode<T: Encodable>(_ value: T) throws -> [String: String] {
let fieldName = getFieldName(from: codingPath)

switch value {
/// Ints
case let fieldValue as Int:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as Int8:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as Int16:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as Int32:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as Int64:
self.dictionary[fieldName] = String(fieldValue)
/// Int Arrays
case let fieldValue as [Int]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [Int8]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [Int16]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [Int32]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [Int64]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
/// UInts
case let fieldValue as UInt:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as UInt8:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as UInt16:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as UInt32:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as UInt64:
self.dictionary[fieldName] = String(fieldValue)
/// UInt Arrays
case let fieldValue as [UInt]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [UInt8]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [UInt16]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [UInt32]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
case let fieldValue as [UInt64]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
/// Floats
case let fieldValue as Float:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as [Float]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
/// Doubles
case let fieldValue as Double:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as [Double]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
/// Bools
case let fieldValue as Bool:
self.dictionary[fieldName] = String(fieldValue)
case let fieldValue as [Bool]:
let strs: [String] = fieldValue.map { String($0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
/// Strings
case let fieldValue as String:
self.dictionary[fieldName] = fieldValue
case let fieldValue as [String]:
self.dictionary[fieldName] = fieldValue.joined(separator: ",")
/// Dates
case let fieldValue as Date:
self.dictionary[fieldName] = dateFormatter?.string(from: fieldValue)
case let fieldValue as [Date]:
let strs: [String] = fieldValue.compactMap { dateFormatter?.string(from: $0) }
self.dictionary[fieldName] = strs.joined(separator: ",")
default:
if fieldName.isEmpty {
self.dictionary = [:] // Make encoder instance reusable
try value.encode(to: self)
} else {
do {
try value.encode(to: self)
} catch let error {
throw encodingError(value, underlyingError: error)
}
}
}
return self.dictionary
}
// 以下略(以降はKituraと全く同じ実装)
//・・・
//・・・
}


使い方は以下のようになります。

実際にはQueryEncoderはRequestのProtcolExtensionの中で規定値として実装しているので明示的に実装することはないです

QueryにDate型が含まれている場合Encoderの初期化時にDateFormatterを渡すようにしてください。

(この部分に関しては実際もっと別の実装をしているのですが、長くなるため今回は書きません)

struct Query {

let offset: Int
let limit: Int
}

var query = Query(offset: 0, limit: 10)
var queryItems: [URLQueryItem] = try QueryEncoder(dateFormatter: dateFormatter).encode(query)
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
urlComponents?.queryItems = queryItems


Requestの作成方法


Queryが複雑な時の場合

https://test.jp/test/ranking?totalLimit=48&offset=0&pageLimit=6&field=image,text&type=day,month,year&device_id=aaaaaaaaaaaaaa

Queryの構造体は基本的にRequestの内部に struct Query として定義するようにします。これによりRequestとQueryの一対一関係がわかりやすくなります。

pathに変数がはいる場合などはcomputed propertyにしてinit作成しないようにします。(僕の思想ですがinitをRequest内部に書くとごちゃごちゃしてるように見えるため)

struct TestRankingRequest: Request {

typealias Response = TestRankingData
let method: HTTPMethod = .get
let host: Hosts = .test
let path = "/test/ranking"
let setting: APISetting = .default
var query: Query?

struct Query: Encodable {
let totalLimit: Int
let offset: Int
let pageLimit: Int
let age: Int?
// QueryEncoderのおかげで同じkeyで複数の値を設定する場合もこんな風にかけます。
let type: [String] = [
"day",
"month",
"year"
]
let field: [String] = [
"image",
"text"
]
let deviceID: String = "aaaaaaaaaaaaaa"

// ここは少しめんどくさいですがCodableの場合と同じで変数名とkeyが違う場合codingkeyをかいてください
private enum CodingKeys: String, CodingKey {
case totalLimit
case offset
case pageLimit
case age
case type
case field
case deviceID = "device_id"
}
}
}

ちなみに利用するときはこんな感じ

let request = TestRankingRequest(query: .init(totalLimit: 48, offset: offset, pageLimit: 6, age: age))

APIClient.send(request)

さらにちなむとQueryEncoderがない場合こんな感じの実装になり最低っぽくなります。


  • オプショナルチェックをパラメータにしなくてはならない

  • 初期化時の引数を全て列挙しなければならない

  • Dictionary格納時に全てkeyを列挙しなければならない

  • Dictionary格納時に全てString変換を書かなければならない

struct TestRankingRequest: Request {

typealias Response = TestRankingData

let method: HTTPMethod = .get
let host: Hosts = .test
let path = "/test/ranking"
let query: [String : String]?
let httpBody: Data? = nil
var setting: APISetting = .default

init(totalLimit: Int, offset: Int, limit: Int, age: Int?, type: [String], field: [String]) {
query = [
"offset": "\(offset)",
"pageLimit": "\(limit)",
"totalLimit": "\(totalLimit)",
"device_id": "aaaaaaaaaaaaaa"
]
let typeString = type.joined(separator: ",")
let fieldString = field.joined(separator: ",")
if !type.isEmpty {
query?.append(["type": "\(typeString)"])
}
if !field.isEmpty {
query?.append(["field": "\(typeString)"])
}
if let age = age {
query?.append(["age": "\(age)"])
}
}
}


Queryが複雑でない場合

https://test.jp/test/ranking?limit=20&offset=0

struct TestRankingRequest: Request {

typealias Response = TestRankingData
let method: HTTPMethod = .get
let host: Hosts = .test
var path = "/test/public/ranking"
var query: Query?

struct Query: Encodable {
let limit: Int
let offset: Int
}
}


Queryがない場合

https://test.jp/test/ranking

Queryが一切ない場合に毎回空のQueryを定義するのが冗長だと感じたので初期値として空のQueryを設定しています。(Request.swiftのEmptyQuery参照)

実際に作成する時は下記のようになり、だいぶスッキリしたRequestになりました。

struct TestRankingRequest: Request {

typealias Response = TestRankingData
let method: HTTPMethod = .get
let host: Hosts = .test
let path = "/test/guests/ranking/"
// Queryがない時は省略できる
}


おまけ

今回はRequestProtocolの設計の話なのでAPIClientの内部の実装やHostsの実装については言及しませんが、参考までにいくつかソースを載せておきます。RequestにはAlamofireRxSwiftを使っています。


APIClient.swift

struct APIClient {

private static let manager: Alamofire.SessionManager = {
var configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 15

var headers = Alamofire.SessionManager.defaultHTTPHeaders
headers["User-Agent"] = // ユーザエージェントを入れる
configuration.httpAdditionalHeaders = headers

return Alamofire.SessionManager(configuration: configuration)
}()

static func send<T: Request>(_ request: T) -> Observable<T.Response> {
let urlRequest = URLRequest.instantiate(request.method, urlString: request.urlString, setting: request.setting, httpBody: request.httpBody)
return Single.create(subscribe: { event in
resume(urlRequest, setting: request.setting, serializer: request.serializer) { (response: DataResponse<T.Response>) in
switch response.result {
case .success(let data):
event(.success(data))
case .failure(let error):
event(.error(error))
}
}
return Disposables.create()
}).asObservable()
}

private static func resume<T: DataResponseSerializerProtocol>(_ urlRequest: URLRequest, setting: APISetting, serializer: T, completion: @escaping (DataResponse<T.SerializedObject>) -> ()) {
manager.request(urlRequest).response(responseSerializer: serializer) { (response: DataResponse<T.SerializedObject>) in
// responseのハンドリング処理はここで書く(クライアントエラーなど)
completion(response)
}
}
}



URLRequestExtension.swift

extension URLRequest {

static func instantiate(_ method: Alamofire.HTTPMethod,
urlString: String,
setting: APISetting = APISetting(),
cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy,
httpBody: Data? = nil
) -> URLRequest {
var request = self.init(url: URL(string: urlString), cachePolicy: cachePolicy, timeoutInterval: 60)

request.httpMethod = method.rawValue

/// headers
var finalHeaders: [String: String] = headers ?? [:]
finalHeaders.append(setting.headers)
finalHeaders.forEach { key, value in
request.setValue(value, forHTTPHeaderField: key)
}

/// body
request.httpBody = httpBody

return request
}
}



APISetting.swift

struct APISetting {

let autoLogin: Bool
let needsToken: Bool
let retryCount: Int
let apiType: APIType

init(autoLogin: Bool = false, needsToken: Bool = false, retryCount: Int = 3, apiType: APIType = .test) {
self.autoLogin = autoLogin
self.needsToken = needsToken
self.retryCount = retryCount
self.apiType = apiType
}
}

extension APISetting {
var headers: [String: String] {
// APITypeにheadersを持たせています
return apiType.headers(needsAuthorization: needsToken)
}
}



Hosts.swift

enum Hosts: String {

case test = "test.jp"
case sample = "sample.jp"
case qiita = "qiita.com"

var urlString: String {
return "https://" + self.rawValue
}
}



おわりに

今回APIのRequest周りの設計を考えてみました。

今まで使っていたものより可読性が向上し、チーム全体としての実装速度も向上したように感じます。

実際にチームメンバーからはこのRequestProtocolは好評で僕自身使いやすいので今回設計を考えた甲斐があったなあという感想です。

気をつけた点としては、下記のようなものがあげられます。


  1. コードの書く量を少なくしようと様々なラップ実装をして可読性がさがったり、後の運用コストが大きくなる(独自実装によるブラックボックスが増えたりするため)といったことが起こらないようにしたこと

  2. Requestを作る際に自然な形で実装できる(余計な手間を感じさせない)こと

  3. 後から見た人が理解しやすいようにすること(1と関連してます)

もし、Requestの設計を考えている人がいたらぜひ参考にしていただけると嬉しいです。

またより良い実装があればご意見もいただけると助かります。