0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

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

前回の記事 ↓

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

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

  • CommonHttpRouter.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

    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フォーマットでエラーレスポンスを返しています。

      // レスポンスマクロを用いて、Responseに'error'というカスタムメソッドを追加し、一貫したjson形式でエラーレスポンスを返す
            Response::macro('error', function ($status, $message, $detail = '') {
                return response()->json([
                    'code' => $status, // ステータスコード
                    'message' => $message, // エラーメッセージ
                    'detail' => $detail, // 開発者向け詳細メッセージ(SwiftUI側で本番ではなく、Develop環境時にUIAlertとして文言表示)
                ], $status);
            });
    

    そして、以下の処理にて、HttpErrorModelへのマッピン処理を行っています。

    // 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

    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

    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されたカスタム例外クラスは全て、ステータスコード500internalServerError として返しています。

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

  • HttpError.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> であるからです。

func request<Request>(with request: Request) -> AnyPublisher<Decodable, MyAppError> where Request: CommonHttpRouter
  • MyAppError.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")
            }
        }
    }
    
    
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?