はじめに
APIの通信周りの処理をframeworkとして別プロジェクトで管理したりする中で、APIリクエスト時のアクセストークンをアプリ側からタイプセーフに注入するとこができないかなと考えていました。
そんな中で、PhantomTypeを活用すればうまくいくかもしれないと思い立ったので実践してみました。
PhantomTypeやSwiftでの利用法については、「Swift で Phantom Type (幽霊型)」をご覧いただけると幸いです。
PhantomTypeを利用したTokenを保持するオブジェクトの実装
この投稿では、APIKitを利用していきます。
外部からtokenを注入する実装にすると、簡易的な利用例は下記のようになります。
enum UserRequest {
struct GetByID: NetworkRequest {
...
let id: String
let requiresAuthorization = true
...
}
struct GetByIDs: NetworkRequest {
...
let ids: [String]
let requiresAuthorization = true
...
}
}
// 初期化時の引数にInjectableTokenを返すクロージャーを渡す
let client = NetworkClient(injectToken: { InjectableToken(token: "Any Token") })
let request = UserRequest.GetByID(id: "1234ABCD")
client.send(request) { result in
// do something
}
この例だけでは、NetworkClientの引数のinjectTokenで、tokenを引数にしたInjectableToken
を返しているだけの実装にしか見えないと思うので、内部でどのようにPhantomTypeが利用されているかを記述していこうと思います。
NetworkRequest
まずは、APIKit.Request
を採用しているNetworkRequest
protocolを定義します。
NetworkRequest
では、APIにアクセスする際に認証情報が必要かどうかのpropertyが宣言されています。
public protocol NetworkRequest: Request {
var requiresAuthorization: Bool { get }
}
InjectableToken
次に、トークンを注入するためのInjectableToken
を実装します。
ここでPhantomTypeを利用していきます。
public protocol Status {}
public enum Ready: Status {}
public enum NotReady: Status {}
public typealias InjectableToken = InjectableToken_<NotReady>
enum InjectError: Error {
case emptyToken
}
public struct InjectableToken_<T: Status> {
private let _token: String?
}
extension InjectableToken_ where T == NotReady {
// structなので`public convenience init(token: String?)`と記載できない
public init(token: String?) {
self.init(_token: token)
}
func readify<U: NetworkRequest>(with request: U) throws -> InjectableToken_<Ready> {
let token: String?
if request.requiresAuthorization {
guard let _token = _token, !_token.isEmpty else {
throw InjectError.emptyToken
}
token = _token
} else {
token = nil
}
return .init(_token: token)
}
}
extension InjectableToken_ where T == Ready {
var token: String? {
return _token
}
}
まず始めにStatus
protocolを宣言し、それらを採用しているReady
とNotReady
のenumを定義します。これらがトークンの状態を示す型になります。
次にGenericType T
を持つInjectableToken_
を定義します。TはStatusに準拠させます。InjectableToken_はprivateな_token: String?
を持っています。
そして、typealiasでInjectableToken_
をInjectableToken = InjectableToken_<NotReady>とし、InjectableTokenを返すstatic functionを定義します。このよう実装することで、InjectableToken(token: "Any Token")
のように初期化ができるようになります。
function内では、InjectableToken_のMemberwise Initializerを利用して、_tokenを初期化しています。
この時点で返されるのは、InjectableToken_<NotReady>なので、外部から_tokenにアクセスすることはできません。
次にInjectableToken_<Ready>にするために func readify(with:_) -> InjectableToken_<Ready>
を定義します。
上記を定義する際はTがNotReadyである場合にのみ利用したいので、InjectableToken_のextensionに定義します。
readifyの中では、引数でNetworkRequestに準拠したオブジェクトが渡ってるので、requiresAuthorization
がtrueの場合は_tokenが存在し空文字列でないことを確認し、InjectableToken_<Ready>として初期化してから返します。requiresAuthorization
がfalseの場合はトークンが不要なので、_tokenをnilで初期化したInjectableToken_<Ready>を返します。
そして、TがReadyの場合にのみアクセスできるtoken: String?
をInjectableToken_のextensionに定義することで、リクエストに対する認証情報をタイプセーフな状態で保持したオブジェクトを実現することができるようになります。
実際に利用する際は下記のようになります。
let request = UserRequest.GetByID(id: "1234ABCD")
let notReadyToken = InjectableToken(token: "Any Token")
print(notReadyToken.token) // TがNotReadyなのでエラーになる
do {
let readyToken = try notReadyToken.readify(with: request)
print(readyToken.token) // TがReadyなのでtokenにアクセスできる
} catch let error {
// requiresAuthorizationに対してtokenの状態が正しくなかった場合にエラーとなる
}
このような実装にすることによって、下記のようなことが実現できます。
- tokenの状態確認がリクエスト生成時ではなくリクエストを送る直前に行うことができるので、リクエスト自体にtokenの状態に関するエラーハンドリングをする必要がなくなる
- InjectableToken_<Ready>はprivateなinitializerしか持っていないので、外部からtokenを注入させるためにはInjectableToken_<NotReady>からreadifyをしてInjectableToken_<Ready>を取得するという経路をたどらなければいけないという実装にできるので、InjectableToken_<Ready>に直接することはできずtokenの状態が保証される
- InjectableToken_<Ready>でありtokenがnilでない場合はAPIアクセスに必要なトークンが存在していることを明確にでき、逆にInjectableToken_<Ready>でありtokenがnilである場合はAPIアクセスにトークンがそもそも必要でないことも明確にできる
Tokenを注入可能にするための実装
RequestProxy
tokenを外部から注入できるようにするために、RequestProxy
を実装していきます。
RequestProxyを定義することで、NetworkRequestに準拠した各Requestでtokenを持たせる必要がなくなるので、tokenに関する処理を1つにまとめることができます。
public struct RequestProxy<T: NetworkRequest>: Request {
let request: T
let token: String?
public var headerFields: [String : String] {
var headerFields = request.headerFields
if let token = token {
headerFields["Authorization"] = "bearer \(token)"
}
return headerFields
}
init(request: T, injectableToken: InjectableToken_<Ready>) {
self.request = request
self.token = injectableToken.token
}
}
extension RequestProxy {
public typealias Response = T.Response
...
public var method: HTTPMethod {
return request.method
}
...
public func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
return try request.response(from: object, urlResponse: urlResponse)
}
}
RequestProxyはNetworkRequestに準拠したTをGenericTypeとし、RequestProxy自体はAPIKit.Requestに準拠しています。
initializerの引数はrequest: T
とinjectableToken: InjectableToken_<Ready>
で、requestはNetworkRequestをProxyし、injectableTokenは内部のtokenを利用しています。
RequestProxyのtokenをAPIKit.Requestの必須propertyであるheaderFields: [String : String]
を定義し、request.headerFields
に対して["Authorization" : "bearer \(token)"]
を追加しています。
ここで利用しているtokenはInjectableToken_<Ready>
から取得しているのものなので、nil・非nilに関わらず認証情報が必要かどうかとtokenが正しいかどうかをチェック済みのものになっています。
そして、RequestProxyのextensionでAPIKit.Requestで定義されているものを一通りRequestProxyのrequestからProxyしていきます。
NetworkClient
APIにアクセスするためのクライアントの実装は下記のようになります。
public final class NetworkClient {
public struct Error: Swift.Error {
public let value: Swift.Error
}
private let injectToken: () -> InjectableToken
private let session: Session
public init(injectToken: @escaping () -> InjectableToken, configuration: URLSessionConfiguration = .default) {
let adapter = URLSessionAdapter(configuration: .default)
self.session = Session(adapter: adapter)
self.injectToken = injectToken
}
public func send<T: NetworkRequest>(_ request: T, completion: @escaping (Result<T.Response, Error>) -> ()) {
let injectableToken: InjectableToken_<Ready>
do {
injectableToken = try injectToken().readify(with: request)
} catch let error {
completion(.failure(Error(value: error)))
return
}
let proxy = RequestProxy(request: request, injectableToken: injectableToken)
session.send(proxy) { result in
switch result {
case .success(let value):
completion(.success(value))
case .failure(let error):
completion(.failure(Error(value: error)))
}
}
}
}
initializerの引数であるinjectToken: @escaping () -> InjectableToken
は、InjectableTokenがInjectableToken_<NotReady>
としてtypealiasされているものになります。
このクロージャーをリクエストを送信する直前にlet injectableToken = try injectToken().readify(with: request)
として、アプリ側からtokenを取得し、readifyでrequestをもとに認証情報が必要なリクエストなのかとtokenが正しいものなのかをチェックし、InjectableToken_<Ready>なinjectableTokenを取得します。
そして、injectableTokenとrequestをもとにRequestProxyを初期化し、tokenを注入してリクエストを行います。
最後に
ふと思い立って実践してみたので不備がある箇所があるかもしれませんが、このようにしてアクセストークンをPhantomTypeを利用してタイプセーフに注入することができます。