diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index aa9daaa543b..60bb9032f4b 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -509,13 +509,13 @@ enum FunctionsConstants { if let error = error as NSError? { let localError: (any Error)? if error.domain == kGTMSessionFetcherStatusDomain { - localError = FunctionsErrorCode.errorForResponse( - status: error.code, + localError = FunctionsError( + httpStatusCode: error.code, body: data, serializer: serializer ) } else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut { - localError = FunctionsErrorCode.deadlineExceeded.generatedError(userInfo: nil) + localError = FunctionsError(.deadlineExceeded) } else { localError = nil } @@ -525,15 +525,11 @@ enum FunctionsConstants { // Case 2: `data` is `nil` -> always throws guard let data else { - throw FunctionsErrorCode.internal.generatedError(userInfo: nil) + throw FunctionsError(.internal) } // Case 3: `data` is not `nil` but might specify a custom error -> throws conditionally - if let bodyError = FunctionsErrorCode.errorForResponse( - status: 200, - body: data, - serializer: serializer - ) { + if let bodyError = FunctionsError(httpStatusCode: 200, body: data, serializer: serializer) { throw bodyError } @@ -546,13 +542,13 @@ enum FunctionsConstants { guard let responseJSON = responseJSONObject as? NSDictionary else { let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] - throw FunctionsErrorCode.internal.generatedError(userInfo: userInfo) + throw FunctionsError(.internal, userInfo: userInfo) } // `result` is checked for backwards compatibility: guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] else { let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] - throw FunctionsErrorCode.internal.generatedError(userInfo: userInfo) + throw FunctionsError(.internal, userInfo: userInfo) } return dataJSON diff --git a/FirebaseFunctions/Sources/FunctionsError.swift b/FirebaseFunctions/Sources/FunctionsError.swift index 8755b362bd1..f8815b3ce60 100644 --- a/FirebaseFunctions/Sources/FunctionsError.swift +++ b/FirebaseFunctions/Sources/FunctionsError.swift @@ -101,16 +101,16 @@ public let FunctionsErrorDetailsKey: String = "details" case unauthenticated = 16 } -extension FunctionsErrorCode { +private extension FunctionsErrorCode { /// Takes an HTTP status code and returns the corresponding `FIRFunctionsErrorCode` error code. /// /// + This is the standard HTTP status code -> error mapping defined in: /// https://github.com/googleapis/googleapis/blob/master/google/rpc/code.proto /// - /// - Parameter status: An HTTP status code. + /// - Parameter httpStatusCode: An HTTP status code. /// - Returns: A `FunctionsErrorCode`. Falls back to `internal` for unknown status codes. - static func errorCode(forHTTPStatus status: Int) -> Self { - switch status { + init(httpStatusCode: Int) { + self = switch httpStatusCode { case 200: .OK case 400: .invalidArgument case 401: .unauthenticated @@ -127,102 +127,86 @@ extension FunctionsErrorCode { } } - static func errorCode(forName name: String) -> FunctionsErrorCode { - switch name { - case "OK": return .OK - case "CANCELLED": return .cancelled - case "UNKNOWN": return .unknown - case "INVALID_ARGUMENT": return .invalidArgument - case "DEADLINE_EXCEEDED": return .deadlineExceeded - case "NOT_FOUND": return .notFound - case "ALREADY_EXISTS": return .alreadyExists - case "PERMISSION_DENIED": return .permissionDenied - case "RESOURCE_EXHAUSTED": return .resourceExhausted - case "FAILED_PRECONDITION": return .failedPrecondition - case "ABORTED": return .aborted - case "OUT_OF_RANGE": return .outOfRange - case "UNIMPLEMENTED": return .unimplemented - case "INTERNAL": return .internal - case "UNAVAILABLE": return .unavailable - case "DATA_LOSS": return .dataLoss - case "UNAUTHENTICATED": return .unauthenticated - default: return .internal + init(errorName: String) { + self = switch errorName { + case "OK": .OK + case "CANCELLED": .cancelled + case "UNKNOWN": .unknown + case "INVALID_ARGUMENT": .invalidArgument + case "DEADLINE_EXCEEDED": .deadlineExceeded + case "NOT_FOUND": .notFound + case "ALREADY_EXISTS": .alreadyExists + case "PERMISSION_DENIED": .permissionDenied + case "RESOURCE_EXHAUSTED": .resourceExhausted + case "FAILED_PRECONDITION": .failedPrecondition + case "ABORTED": .aborted + case "OUT_OF_RANGE": .outOfRange + case "UNIMPLEMENTED": .unimplemented + case "INTERNAL": .internal + case "UNAVAILABLE": .unavailable + case "DATA_LOSS": .dataLoss + case "UNAUTHENTICATED": .unauthenticated + default: .internal } } +} - var descriptionForErrorCode: String { - switch self { - case .OK: - return "OK" - case .cancelled: - return "CANCELLED" - case .unknown: - return "UNKNOWN" - case .invalidArgument: - return "INVALID ARGUMENT" - case .deadlineExceeded: - return "DEADLINE EXCEEDED" - case .notFound: - return "NOT FOUND" - case .alreadyExists: - return "ALREADY EXISTS" - case .permissionDenied: - return "PERMISSION DENIED" - case .resourceExhausted: - return "RESOURCE EXHAUSTED" - case .failedPrecondition: - return "FAILED PRECONDITION" - case .aborted: - return "ABORTED" - case .outOfRange: - return "OUT OF RANGE" - case .unimplemented: - return "UNIMPLEMENTED" - case .internal: - return "INTERNAL" - case .unavailable: - return "UNAVAILABLE" - case .dataLoss: - return "DATA LOSS" - case .unauthenticated: - return "UNAUTHENTICATED" - } - } +/// The object used to report errors that occur during a function’s execution. +struct FunctionsError: CustomNSError { + static let errorDomain = FunctionsErrorDomain + + let code: FunctionsErrorCode + let errorUserInfo: [String: Any] + var errorCode: FunctionsErrorCode.RawValue { code.rawValue } - func generatedError(userInfo: [String: Any]? = nil) -> NSError { - return NSError(domain: FunctionsErrorDomain, - code: rawValue, - userInfo: userInfo ?? [NSLocalizedDescriptionKey: descriptionForErrorCode]) + init(_ code: FunctionsErrorCode, userInfo: [String: Any]? = nil) { + self.code = code + errorUserInfo = userInfo ?? [NSLocalizedDescriptionKey: Self.errorDescription(from: code)] } - static func errorForResponse(status: Int, - body: Data?, - serializer: FunctionsSerializer) -> NSError? { + /// Initializes a `FunctionsError` from the HTTP status code and response body. + /// + /// - Parameters: + /// - httpStatusCode: The HTTP status code reported during a function’s execution. Only a subset + /// of codes are supported. + /// - body: The optional response data which may contain information about the error. The + /// following schema is expected: + /// ``` + /// { + /// "error": { + /// "status": "PERMISSION_DENIED", + /// "message": "You are not allowed to perform this operation", + /// "details": 123 // Any value supported by `FunctionsSerializer` + /// } + /// ``` + /// - serializer: The `FunctionsSerializer` used to decode `details` in the error body. + init?(httpStatusCode: Int, body: Data?, serializer: FunctionsSerializer) { // Start with reasonable defaults from the status code. - var code = FunctionsErrorCode.errorCode(forHTTPStatus: status) - var description = code.descriptionForErrorCode - var details: AnyObject? + var code = FunctionsErrorCode(httpStatusCode: httpStatusCode) + var description = Self.errorDescription(from: code) + var details: Any? // Then look through the body for explicit details. if let body, - let json = try? JSONSerialization.jsonObject(with: body) as? NSDictionary, - let errorDetails = json["error"] as? NSDictionary { + let json = try? JSONSerialization.jsonObject(with: body) as? [String: Any], + let errorDetails = json["error"] as? [String: Any] { if let status = errorDetails["status"] as? String { - code = .errorCode(forName: status) + code = FunctionsErrorCode(errorName: status) // If the code in the body is invalid, treat the whole response as malformed. guard code != .internal else { - return code.generatedError(userInfo: nil) + self.init(code) + return } } if let message = errorDetails["message"] as? String { description = message } else { - description = code.descriptionForErrorCode + description = Self.errorDescription(from: code) } - details = errorDetails["details"] as AnyObject? + details = errorDetails["details"] as Any? // Update `details` only if decoding succeeds; // otherwise, keep the original object. if let innerDetails = details, @@ -243,6 +227,28 @@ extension FunctionsErrorCode { if let details { userInfo[FunctionsErrorDetailsKey] = details } - return code.generatedError(userInfo: userInfo) + self.init(code, userInfo: userInfo) + } + + private static func errorDescription(from code: FunctionsErrorCode) -> String { + switch code { + case .OK: "OK" + case .cancelled: "CANCELLED" + case .unknown: "UNKNOWN" + case .invalidArgument: "INVALID ARGUMENT" + case .deadlineExceeded: "DEADLINE EXCEEDED" + case .notFound: "NOT FOUND" + case .alreadyExists: "ALREADY EXISTS" + case .permissionDenied: "PERMISSION DENIED" + case .resourceExhausted: "RESOURCE EXHAUSTED" + case .failedPrecondition: "FAILED PRECONDITION" + case .aborted: "ABORTED" + case .outOfRange: "OUT OF RANGE" + case .unimplemented: "UNIMPLEMENTED" + case .internal: "INTERNAL" + case .unavailable: "UNAVAILABLE" + case .dataLoss: "DATA LOSS" + case .unauthenticated: "UNAUTHENTICATED" + } } } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsErrorTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsErrorTests.swift new file mode 100644 index 00000000000..99b4c8334b3 --- /dev/null +++ b/FirebaseFunctions/Tests/Unit/FunctionsErrorTests.swift @@ -0,0 +1,172 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseFunctions + +import XCTest + +final class FunctionsErrorTests: XCTestCase { + func testInitWithCode() { + let error = FunctionsError(.permissionDenied) + + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "com.firebase.functions") + XCTAssertEqual(nsError.code, 7) + XCTAssertEqual(nsError.localizedDescription, "PERMISSION DENIED") + XCTAssertEqual(nsError.userInfo.count, 1) + } + + func testInitWithCodeAndUserInfo() { + let error = FunctionsError(.unimplemented, userInfo: ["TEST_Key": "TEST_Value"]) + + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "com.firebase.functions") + XCTAssertEqual(nsError.code, 12) + XCTAssertEqual( + nsError.localizedDescription, + "The operation couldn’t be completed. (com.firebase.functions error 12.)" + ) + XCTAssertEqual(nsError.userInfo.count, 1) + XCTAssertEqual(nsError.userInfo["TEST_Key"] as? String, "TEST_Value") + } + + func testInitWithOKStatusCodeAndNoErrorBody() { + // The error should be `nil`. + let error = FunctionsError( + httpStatusCode: 200, + body: nil, + serializer: FunctionsSerializer() + ) + + XCTAssertNil(error) + } + + func testInitWithErrorStatusCodeAndNoErrorBody() { + // The error should be inferred from the HTTP status code. + let error = FunctionsError( + httpStatusCode: 429, + body: nil, + serializer: FunctionsSerializer() + ) + + guard let error else { return XCTFail("Unexpected `nil` value") } + + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "com.firebase.functions") + XCTAssertEqual(nsError.code, 8) + XCTAssertEqual(nsError.localizedDescription, "RESOURCE EXHAUSTED") + XCTAssertEqual(nsError.userInfo.count, 1) + } + + func testInitWithOKStatusCodeAndIncompleteErrorBody() { + // The status code in the error body takes precedence over the HTTP status code. + let responseData = #"{ "error": { "status": "OUT_OF_RANGE" } }"#.data(using: .utf8)! + + let error = FunctionsError( + httpStatusCode: 200, + body: responseData, + serializer: FunctionsSerializer() + ) + + guard let error else { return XCTFail("Unexpected `nil` value") } + + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "com.firebase.functions") + XCTAssertEqual(nsError.code, 11) + XCTAssertEqual(nsError.localizedDescription, "OUT OF RANGE") + XCTAssertEqual(nsError.userInfo.count, 1) + } + + func testInitWithErrorStatusCodeAndErrorBody() { + // The status code in the error body takes precedence over the HTTP status code. + let responseData = + #"{ "error": { "status": "OUT_OF_RANGE", "message": "TEST_ErrorMessage", "details": 123 } }"# + .data(using: .utf8)! + + let error = FunctionsError( + httpStatusCode: 499, + body: responseData, + serializer: FunctionsSerializer() + ) + + guard let error else { return XCTFail("Unexpected `nil` value") } + + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "com.firebase.functions") + XCTAssertEqual(nsError.code, 11) + XCTAssertEqual(nsError.localizedDescription, "TEST_ErrorMessage") + XCTAssertEqual(nsError.userInfo.count, 2) + XCTAssertEqual(nsError.userInfo["details"] as? Int, 123) + } + + func testInitWithErrorStatusCodeAndOKErrorBody() { + // When the status code in the error body is `OK`, error should be `nil` regardless of the HTTP + // status code. + let responseData = + #"{ "error": { "status": "OK", "message": "TEST_ErrorMessage", "details": 123 } }"# + .data(using: .utf8)! + + let error = FunctionsError( + httpStatusCode: 401, + body: responseData, + serializer: FunctionsSerializer() + ) + + XCTAssertNil(error) + } + + func testInitWithErrorStatusCodeAndIncompleteErrorBody() { + // The error name is not in the body; it should be inferred from the HTTP status code. + let responseData = #"{ "error": { "message": "TEST_ErrorMessage", "details": null } }"# + .data(using: .utf8)! + + let error = FunctionsError( + httpStatusCode: 403, + body: responseData, + serializer: FunctionsSerializer() + ) + + guard let error else { return XCTFail("Unexpected `nil` value") } + + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "com.firebase.functions") + XCTAssertEqual(nsError.code, 7) // `permissionDenied`, inferred from the HTTP status code + XCTAssertEqual(nsError.localizedDescription, "TEST_ErrorMessage") + XCTAssertEqual(nsError.userInfo.count, 2) + XCTAssertEqual(nsError.userInfo["details"] as? NSNull, NSNull()) + } + + func testInitWithErrorStatusCodeAndInvalidErrorBody() { + // An unsupported status code in the error body should result in the rest of the body ignored. + let responseData = + #"{ "error": { "status": "TEST_UNKNOWN_ERROR", "message": "TEST_ErrorMessage", "details": 123 } }"# + .data(using: .utf8)! + + let error = FunctionsError( + httpStatusCode: 503, + body: responseData, + serializer: FunctionsSerializer() + ) + + guard let error else { return XCTFail("Unexpected `nil` value") } + + let nsError = error as NSError + XCTAssertEqual(nsError.domain, "com.firebase.functions") + // Currently, `internal` is used as the fallback error code. Is this correct? + // Seems like we could get more information from the HTTP status code in such cases. + XCTAssertEqual(nsError.code, 13) + XCTAssertEqual(nsError.localizedDescription, "INTERNAL") + XCTAssertEqual(nsError.userInfo.count, 1) + } +}