post Image
【Swift】APIのアクセストークンの処理でPhantomTypeを利用してみる

はじめに

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が宣言されています。

NetworkRequest.swift
public protocol NetworkRequest: Request {
    var requiresAuthorization: Bool { get }
}

InjectableToken

次に、トークンを注入するためのInjectableTokenを実装します。
ここでPhantomTypeを利用していきます。

InjectableToken.swift
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を宣言し、それらを採用しているReadyNotReadyの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つにまとめることができます。

RequestProxy.swift
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: TinjectableToken: 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にアクセスするためのクライアントの実装は下記のようになります。

NetworkClient.swift
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を利用してタイプセーフに注入することができます。


『 Swift 』Article List