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") } } }