SwiftUI(Combine)✖️LaravelでAPI通信②（Combineで統一したインターフェースでAPIクライアントを作成）

SwiftUI(Combine)、Laravel初心者。完全自分用の学習備忘録として残します。
詳細な解説はコード内に記述してあります。

前回の続きとして、laravel側から返されたカスタムエラーレスポンスのハンドリング処理を実装していきます。また、アプリ側自体のエラー（例えば、ネットワーク接続エラーや不正なURLの生成エラー、レスポンスのシリアライズエラーなど）はAlamofireエラー`（AFError）` としてハンドリングさせていきます。

前回の記事 ↓

**「SwiftUI(Combine)✖️LaravelでAPI通信①（Laravel エラーレスポンスの統一化）」**

### APIリクエストの作成を一元化したプロトコルを作成

- CommonHttpRouter.swift
    
    ```swift
    //
    //  APIRequestConfiguration.swift
    //  SwiftUIApp
    //
    //  Created by 鈴木 健太 on 2024/08/13.
    //
    
    import Foundation
    import Combine
    import Alamofire
    
    protocol CommonHttpRouter: URLRequestConvertible {
        
        // HTTPリクエストした結果のレスポンスの型（Model）を指定する
        // 指定した型（Model）はDecodableに準拠している必要がある
        associatedtype Response: Decodable
        
        // 固定の基本URL（"http://localhost:8000"や本番環境用URLなど）
        var baseUrlString: String { get }
        
        // 各APIエンドポイントのパス
        var path: String { get }
        
        // POST, GET, PUT, DELETE
        var method: HTTPMethod { get }
        
        // ヘッダー
        var headers: HTTPHeaders { get }
        
        // パスパラメーター
        var pathParameters: [String] { get }
        
        // クエリパラメーター
        var queryParameters: Parameters { get }
        
        // リクエストボディ
        func body() throws -> Data?
    }
    
    extension CommonHttpRouter {
        
        var baseUrlString: String { return ApiUrl.baseUrl }
        var headers: HTTPHeaders {
            return [
                "Content-Type": "application/json",
                "Accept": "application/json",
                "Authorization": "Bearer \(apiToken)" // LaravelSanctumを使用しているため、APITokenを含める
            ]
        }
        var pathParameters: [String] { return [] }
        var queryParameters: Parameters { return [:] }
        func body() throws -> Data? { return nil }
        
        // 端末に保存したAPITokenを取得
        private var apiToken: String {
            return KeyChainManager().loadCredentials(service: .apiTokenService)
        }
        
        // リクエストを組み立てる
        func asURLRequest() throws -> URLRequest {
            
            var url = try baseUrlString.asURL()
            url = url.appendingPathComponent(path)
            
            // パスパラメーターを追加
            for component in pathParameters {
                url = url.appendingPathComponent(component)
            }
            
            // クエリパラメーターを追加
            if !queryParameters.isEmpty {
                let queryString = queryParameters.map { "\($0.key)=\($0.value)" }
                    .joined(separator: "&")
                let separator = url.absoluteString.contains("?") ? "&" : "?"
                url = URL(string: url.absoluteString + separator + queryString)!
            }
            
            // 組み立てたurl, メソッド、ヘッダーを追加
            var request = try URLRequest(url: url, method: method, headers: headers)
            
            // ボディを追加
            if method != .get { request.httpBody = try body() }
            
            return request
        }
    }
    
    ```
    

### APIリクエストとレスポンスのハンドリング

- APIService.swift
    
    ```swift
    import Alamofire
    import Combine
    
    protocol APIServiceType {
        // CommonHttpRouterに準拠したリクエストを受け取る
        // 成功時(Output) → Decodable型（デコードしたレスポンスデータ）を流す
        // 失敗時(Failure) → MyAppError型（アプリ内で扱うカスタムエラー）を流す
        func request<Request>(with request: Request) -> AnyPublisher<Decodable, MyAppError> where Request: CommonHttpRouter
    }
    
    final class APIService: APIServiceType {
        
        private let decoder: JSONDecoder = JSONDecoder()
        
        func request<Request>(with request: Request) -> AnyPublisher<Decodable, MyAppError> where Request: CommonHttpRouter {
            
            // DeferredしてFutureする（暗記）
            Deferred {
                // Future<Output → 成功, Failure → 失敗>
                // クロージャに「(Result<(any Decodable, MyAppError)>) -> Void」型であるpromiseを受け取る
                Future<Decodable, MyAppError> { promise in
                    do {
                        // リクエストを組み立てる
                        let urlRequest = try request.asURLRequest()
                        
                        // AlamofireでHTTPリクエスト
                        AF.request(urlRequest)
                            .validate()
                            .responseData(completionHandler:  { [weak self] response in
                                guard let self = self else { return }
                                // リクエスト結果を場合分け
                                switch response.result {
                                case .success(let data):
                                    // 成功時
                                    self.handleSuccessResponse(data, promise: promise, responseType: Request.Response.self)
                                case .failure(let afError):
                                    // 失敗時
                                    promise(.failure(self.handleHttpErrorResponse(afError, response.data)))
                                }
                            })
                    } catch {
                        promise(.failure(.invalidRequest))
                    }
                }
            }
            .eraseToAnyPublisher()
        }
        
        // 成功時のレスポンス処理
        private func handleSuccessResponse<T: Decodable>(_ data: Data, promise: @escaping (Result<Decodable, MyAppError>) -> Void, responseType: T.Type) where T: Decodable {
            do {
                // レスポンスデータを、CommonHttpRouterで任意に指定したレスポンス（Model）の型にデコード
                let decodedModel = try decoder.decode(responseType, from: data)
                // デコード結果を.successとして流す
                promise(.success(decodedModel))
            } catch {
                // デコード失敗時は、.failureとしてデコードエラーを流す
                promise(.failure(.responseSerializationFailed(.decodingFailed(error: error))))
            }
        }
        
        // エラーレスポンス処理
        private func handleHttpErrorResponse(_ afError: AFError, _ responseData: Data?) -> MyAppError {
            // 発生したAFErrorをアプリ独自のAFErrorType型に変換
            let afErrorType = AFErrorType(afError: afError)
            // さらに、アプリ内の全てのエラーをまとめているMyAppError型に変換しエラーハンドリングを扱いやすくする
            if let myAppError = afErrorType.toMyAppError {
                return myAppError
            }
            
            guard let responseData = responseData else {
                return .invalidResponse
            }
            
            do {
                // laravel側から送られてきた（statusCode, message, detailを持つ）カスタムエラーをデコード
                let httpErrorModel = try decoder.decode(HttpErrorModel.self, from: responseData)
                // statusCodeを元に、該当するHTTPエラー(HttpErrorType型)を取得
                let httpErrorType = HttpErrorType(code: httpErrorModel.statusCode)
                // さらに、アプリ内の全てのエラーをまとめているMyAppError型に変換しエラーハンドリングを扱いやすくする
                return httpErrorType.toMyAppError(with: httpErrorModel)
            } catch {
                // デコード失敗時は、.failureとしてデコードエラーを流す
                return .responseSerializationFailed(.decodingFailed(error: error))
            }
        }
    }
    
    ```
    
    laravel側でエラーが発生した場合は、「SwiftUI(Combine)✖️LaravelでAPI通信①（Laravel エラーレスポンスの統一化）」の記事で扱ったように、以下のような統一したjsonフォーマットでエラーレスポンスを返しています。
    
    ```php
      // レスポンスマクロを用いて、Responseに'error'というカスタムメソッドを追加し、一貫したjson形式でエラーレスポンスを返す
            Response::macro('error', function ($status, $message, $detail = '') {
                return response()->json([
                    'code' => $status, // ステータスコード
                    'message' => $message, // エラーメッセージ
                    'detail' => $detail, // 開発者向け詳細メッセージ（SwiftUI側で本番ではなく、Develop環境時にUIAlertとして文言表示）
                ], $status);
            });
    ```
    
    そして、以下の処理にて、HttpErrorModelへのマッピン処理を行っています。
    
    ```php
    // laravel側から送られてきた（statusCode, message, detailを持つ）カスタムエラーをデコード
    let httpErrorModel = try decoder.decode(HttpErrorModel.self, from: responseData)
    // statusCodeを元に、該当するHTTPエラー(HttpErrorType型)を取得
    let httpErrorType = HttpErrorType(code: httpErrorModel.statusCode)
    // さらに、アプリ内の全てのエラーをまとめているMyAppError型に変換しエラーハンドリングを扱いやすくする
    return httpErrorType.getHttpError(with: httpErrorModel)
    ```
    
- HttpErrorModel
    
    ```swift
    struct HttpErrorModel: Codable {
        let statusCode: Int // ステータスコード
        let message: String // エラーメッセージ
        let detail: String // 開発者用エラーメッセージ
        
        // デフォルト値を設定しておく
        init(
            statusCode: Int = 999,
            message: String = "不明なエラーが発生しました。",
            detail: String = "Unknown Error Ocurred. Detail error message is empty"
        ) {
            self.statusCode = statusCode
            self.message = message
            self.detail = detail
        }
        
        enum CodingKeys: String, CodingKey {
            case statusCode = "code"
            case message
            case detail
        }
    }
    
    ```
    

### AFError(Alamofireエラー)のハンドリング

この列挙型では、アプリに必要最低限のAlamofireエラーのみを扱うようにしています。
`Alamofire`が提供するAFErrorをアプリ固有のエラー型`AFErrorType`に変換します。

そして、`var toMyAppError: MyAppError? { ... }`でアプリ内で発生するAFError以外のエラーも含めて、すべてを一つにまとめているMyAppError型に変換します。

注意点として、`.responseValidationFailed` では、ステータスコード`4xx`, `5xx`を捕捉してしまい、`APIService.swift` クラスのエラーレスポンス処理にて後続処理の、

`let httpErrorModel = try decoder.decode(HttpErrorModel.self, from: responseData)`

に進まなくなってしまうため、`nil` を返しています。

- AFErrorType
    
    ```swift
    import Alamofire
    
    enum AFErrorType: Error {
        // アプリに必要最低限のAlamofireエラーのみを扱う
        // Alamofireが提供するAFErrorをアプリ固有のエラー型AFErrorTypeに変換し扱いやすくする
        case createURLRequestFailed(Error)
        case invalidURL(URL)
        case parameterEncoderFailed(AFError.ParameterEncoderFailureReason)
        case responseValidationFailed(AFError.ResponseValidationFailureReason)
        case responseSerializationFailed(AFError.ResponseSerializationFailureReason)
        case sessionInvalidated(Error?)
        case sessionTaskFailed(Error)
        case other
        
        init(afError: AFError) {
            // 発生したAFErrorの種類に応じて適切なケースに設定
            switch afError {
            case .createURLRequestFailed(let error):
                self = .createURLRequestFailed(error)
            case .invalidURL(let url):
                self = .invalidURL(url as! URL)
            case .parameterEncoderFailed(let reason):
                self = .parameterEncoderFailed(reason)
            case .responseValidationFailed(let reason):
                self = .responseValidationFailed(reason)
            case .responseSerializationFailed(let reason):
                self = .responseSerializationFailed(reason)
            case .sessionInvalidated(let error):
                self = .sessionInvalidated(error)
            case .sessionTaskFailed(let error):
                self = .sessionTaskFailed(error)
            default:
                self = .other
            }
        }
        
        // アプリ内で発生するAFError以外のエラーも含めて、すべてを一つにまとめているMyAppError型に変換する
        var toMyAppError: MyAppError? {
            switch self {
                
            // URLリクエストの作成に失敗した場合
            case .createURLRequestFailed(let error):
                return .createURLRequestFailed(error)
                
            // 無効なURLが指定された場合
            case .invalidURL(let url):
                return .invalidURL(url)
                
            // パラメータエンコーディング失敗
            case .parameterEncoderFailed(let reason):
                return .parameterEncoderFailed(reason)
                
            // 4xx, 5xxを捕捉 laravel側のカスタムエラーを受け取れないため、nilを返し後続の処理に進む
            case .responseValidationFailed:
                return nil
                
            // データ変換失敗
            case .responseSerializationFailed(let reason):
                return .responseSerializationFailed(reason)
                
            // セッションタイムアウト（タイムアウトエラー）
            case .sessionInvalidated(let error):
                return .sessionInvalidated(error)
                
            // ネットワークエラー
            case .sessionTaskFailed(let error):
                return .sessionTaskFailed(error)
                
            // その他必要最低限以外のエラーは捕捉する必要がない種類のエラーのためnilを返し後続の処理に進む
            case .other:
                return nil
            }
        }
    }
    
    ```
    

### HttpErrorのハンドリング

この列挙型では、エラー発生時にLaravel側から返される主要なHTTPエラー（ステータスコード）に合わせて、必要なHTTPエラーを定義しています。Laravel側でthrowされたカスタム例外クラスは全て、ステータスコード500`internalServerError` として返しています。

 AFErrorのハンドリングと同様、`func toMyAppError(with httpErrorModel: HttpErrorModel) -> MyAppError { ... }`でHTTPエラー以外のエラーも含めて、すべてを一つにまとめているMyAppError型に変換します。

- HttpError.swift
    
    ```swift
    import Foundation
    
    // 主要なHTTPエラーのみ扱う
    enum HttpErrorType: Int {
        
        // MARK: - Client Error
        case badRequest = 400
        case unauthorized = 401
        case forbidden = 403
        case notFound = 404
        case methodNotAllowed = 405
        case notAcceptable = 406
        case requestTimeOut = 408
        
        // MARK: - Server Error
        case internalServerError = 500
      
        // MARK: - unknown
        case unknown = 999
    }
    
    extension HttpErrorType {
              
        // MARK: - Initializer, parameter code: The HTTP response code
        init(code: Int) {
            // 引数rawValueに指定した値に対応する要素がない場合はnil(.unknown)
            // statusCode(code)を元に、該当するHTTPエラーを取得
            if let validStatus = HttpErrorType(rawValue: code){
                self = validStatus
            } else {
                self = .unknown
            }
        }
        
        // MARK: - Public, get a kind of HTTP error
        func toMyAppError(with httpErrorModel: HttpErrorModel) -> MyAppError {
            switch self {
            case .badRequest:
                return .badRequest(httpErrorModel)
            case .unauthorized:
                return .unauthorized(httpErrorModel)
            case .forbidden:
                return .forbidden(httpErrorModel)
            case .notFound:
                return .notFound(httpErrorModel)
            case .methodNotAllowed:
                return .methodNotAllowed(httpErrorModel)
            case .notAcceptable:
                return .notAcceptable(httpErrorModel)
            case .requestTimeOut:
                return .requestTimeOut(httpErrorModel)
            case .internalServerError:
                return .serverError(httpErrorModel)
            case .unknown:
                return .unknown(HttpErrorModel())
            }
        }
    }
    
    ```
    

### 発生したAFError, HttpError, その他エラーをMyAppErrorに集結させる

これはAPIリクエスト時の戻り値が以下のように`AnyPublisher<Decodable, MyAppError>` であるからです。

```swift
func request<Request>(with request: Request) -> AnyPublisher<Decodable, MyAppError> where Request: CommonHttpRouter
```

- MyAppError.swift
    
    ```swift
    
    import Alamofire
    
    // AFError, laravel側から受け取ったカスタムエラー（HTTPError）, その他のエラーの全てのエラーをアプリ固有のMyAppErrorに集結させる
    enum MyAppError: Error, LocalizedError {
        
        // MARK: - AFError Cases
        case createURLRequestFailed(Error)
                            
        case invalidURL(URLConvertible)
                            
        case parameterEncoderFailed(AFError.ParameterEncoderFailureReason)
                
        case responseSerializationFailed(AFError.ResponseSerializationFailureReason)
                            
        case sessionInvalidated(Error?)
            
        case sessionTaskFailed(Error)
        
        // MARK: - HTTP Error Cases
        
        // HTTP error 400
        case badRequest(HttpErrorModel)
        
        // HTTP error 401
        case unauthorized(HttpErrorModel)
        
        // HTTP error 403
        case forbidden(HttpErrorModel)
        
        // HTTP error 404
        case notFound(HttpErrorModel)
        
        // HTTP error 405
        case methodNotAllowed(HttpErrorModel)
        
        // HTTP error 406
        case notAcceptable(HttpErrorModel)
        
        // HTTP error 408
        case requestTimeOut(HttpErrorModel)
        
        // The server error 500 ~ 599
        case serverError(HttpErrorModel)
        
        // MARK: - Other Error Cases
    
        case invalidRequest
        
        case invalidResponse
            
        case unknown(HttpErrorModel)
        
        // MARK: - LocalizedError Implementation
        public var errorDescription: String? {
            #if DEVELOP // 開発
            return debugDescription
            #else // 本番
            return description
            #endif
        }
        
        // MARK: - Localized Descriptions
        var description: String {
            switch self {
            case .createURLRequestFailed(_):
                return NSLocalizedString("URLリクエストの作成に失敗しました。", comment: "Failed to create URL request")
                
            case .invalidURL(_):
                return NSLocalizedString("無効なURLが指定されました。", comment: "Invalid URL provided")
                
            case .parameterEncoderFailed(_):
                return NSLocalizedString("パラメータエンコーダーに失敗しました", comment: "Parameter encoder failed")
                
            case .responseSerializationFailed(_):
                return NSLocalizedString("データの取得に失敗しました。再度、お試しください。", comment: "Response serialization failed")
                
            case .sessionInvalidated(_):
                return NSLocalizedString("セッションが無効化されました。", comment: "Session invalidated")
                
            case .sessionTaskFailed(_):
                return NSLocalizedString("通信エラーが発生しました。", comment: "Session task failed")
                
            case .badRequest(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Bad request")
                
            case .unauthorized(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Unauthorized")
                
            case .forbidden(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Forbidden")
                
            case .notFound(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Not Found")
                
            case .methodNotAllowed(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Method not allowed")
                
            case .notAcceptable(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Not acceptable")
                
            case .requestTimeOut(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Request timeout")
                
            case .serverError(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Server error")
                
            case .invalidRequest:
                return NSLocalizedString("無効なリクエストです。", comment: "Invalid request")
                
            case .invalidResponse:
                return NSLocalizedString("無効なレスポンスです。", comment: "Invalid response")
                
            case .unknown(let HttpErrorModel):
                return NSLocalizedString(HttpErrorModel.message, comment: "Unknown error")
            }
        }
            
            // MARK: - Debug Descriptions
        var debugDescription: String {
            switch self {
                
            case .createURLRequestFailed(let error):
                return NSLocalizedString("URL request creation failed with error: \(error)", comment: "")
                
            case .invalidURL(let urlConvertible):
                return NSLocalizedString("Invalid URL provided: \(urlConvertible)", comment: "")
                
            case .parameterEncoderFailed(let parameterEncoderFailureReason):
                return NSLocalizedString("Parameter encoder failed due to reason: \(parameterEncoderFailureReason)", comment: "")
                
            case .responseSerializationFailed(let responseSerializationFailureReason):
                return NSLocalizedString("Response serialization failed due to reason: \(responseSerializationFailureReason)", comment: "")
                
            case .sessionInvalidated(let error):
                return NSLocalizedString("Session was invalidated with error: \(String(describing: error))", comment: "")
                
            case .sessionTaskFailed(let error):
                return NSLocalizedString("Session task failed with error: \(error)", comment: "")
                
            case .badRequest(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)" , comment: "Bad request")
                
            case .unauthorized(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Unauthorized")
                
            case .forbidden(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Forbidden")
                
            case .notFound(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Not Found")
                
            case .methodNotAllowed(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Method not allowed")
                
            case .notAcceptable(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Not acceptable")
                
            case .requestTimeOut(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Request timeout")
                
            case .serverError(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Server error")
                
            case .invalidRequest:
                return NSLocalizedString("Request is invalid.", comment: "Invalid request")
                
            case .invalidResponse:
                return NSLocalizedString("Response is invalid.", comment: "Invalid response")
                
            case .unknown(let HttpErrorModel):
                return NSLocalizedString("\(HttpErrorModel.message) with detail: \(HttpErrorModel.detail)", comment: "Unknown error")
            }
        }
    }
    
    ```