Yakov Manshin 1 rok pred
rodič
commit
9384b9fb8a

+ 7 - 11
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

+ 85 - 79
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"
+    }
   }
 }

+ 172 - 0
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)
+  }
+}