Explorar o código

Async Function Calling (#13901)

Yakov Manshin hai 1 ano
pai
achega
0ac8bbbe2b

+ 76 - 28
FirebaseFunctions/Sources/Functions.swift

@@ -385,6 +385,30 @@ enum FunctionsConstants {
     return URL(string: "https://\(region)-\(projectID).cloudfunctions.net/\(name)")
   }
 
+  @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *)
+  func callFunction(at url: URL,
+                    withObject data: Any?,
+                    options: HTTPSCallableOptions?,
+                    timeout: TimeInterval) async throws -> HTTPSCallableResult {
+    let context = try await contextProvider.context(options: options)
+    let fetcher = try makeFetcher(
+      url: url,
+      data: data,
+      options: options,
+      timeout: timeout,
+      context: context
+    )
+
+    do {
+      let rawData = try await fetcher.beginFetch()
+      return try callableResultFromResponse(data: rawData, error: nil)
+    } catch {
+      // This method always throws when `error` is not `nil`, but ideally,
+      // it should be refactored so it looks less confusing.
+      return try callableResultFromResponse(data: nil, error: error)
+    }
+  }
+
   func callFunction(at url: URL,
                     withObject data: Any?,
                     options: HTTPSCallableOptions?,
@@ -413,17 +437,15 @@ enum FunctionsConstants {
                             timeout: TimeInterval,
                             context: FunctionsContext,
                             completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) {
-    let request = URLRequest(url: url,
-                             cachePolicy: .useProtocolCachePolicy,
-                             timeoutInterval: timeout)
-    let fetcher = fetcherService.fetcher(with: request)
-
+    let fetcher: GTMSessionFetcher
     do {
-      let data = data ?? NSNull()
-      let encoded = try serializer.encode(data)
-      let body = ["data": encoded]
-      let payload = try JSONSerialization.data(withJSONObject: body)
-      fetcher.bodyData = payload
+      fetcher = try makeFetcher(
+        url: url,
+        data: data,
+        options: options,
+        timeout: timeout,
+        context: context
+      )
     } catch {
       DispatchQueue.main.async {
         completion(.failure(error))
@@ -431,6 +453,38 @@ enum FunctionsConstants {
       return
     }
 
+    fetcher.beginFetch { [self] data, error in
+      let result: Result<HTTPSCallableResult, any Error>
+      do {
+        result = try .success(callableResultFromResponse(data: data, error: error))
+      } catch {
+        result = .failure(error)
+      }
+
+      DispatchQueue.main.async {
+        completion(result)
+      }
+    }
+  }
+
+  private func makeFetcher(url: URL,
+                           data: Any?,
+                           options: HTTPSCallableOptions?,
+                           timeout: TimeInterval,
+                           context: FunctionsContext) throws -> GTMSessionFetcher {
+    let request = URLRequest(
+      url: url,
+      cachePolicy: .useProtocolCachePolicy,
+      timeoutInterval: timeout
+    )
+    let fetcher = fetcherService.fetcher(with: request)
+
+    let data = data ?? NSNull()
+    let encoded = try serializer.encode(data)
+    let body = ["data": encoded]
+    let payload = try JSONSerialization.data(withJSONObject: body)
+    fetcher.bodyData = payload
+
     // Set the headers.
     fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type")
     if let authToken = context.authToken {
@@ -462,33 +516,27 @@ enum FunctionsConstants {
       fetcher.allowedInsecureSchemes = ["http"]
     }
 
-    fetcher.beginFetch { [self] data, error in
-      let result: Result<HTTPSCallableResult, any Error>
-      do {
-        let data = try responseData(data: data, error: error)
-        let json = try responseDataJSON(from: data)
-        // TODO: Refactor `decode(_:)` so it either returns a non-optional object or throws
-        let payload = try serializer.decode(json)
-        // TODO: Remove `as Any` once `decode(_:)` is refactored
-        result = .success(HTTPSCallableResult(data: payload as Any))
-      } catch {
-        result = .failure(error)
-      }
+    return fetcher
+  }
 
-      DispatchQueue.main.async {
-        completion(result)
-      }
-    }
+  private func callableResultFromResponse(data: Data?,
+                                          error: (any Error)?) throws -> HTTPSCallableResult {
+    let processedData = try processedResponseData(from: data, error: error)
+    let json = try responseDataJSON(from: processedData)
+    // TODO: Refactor `decode(_:)` so it either returns a non-optional object or throws
+    let payload = try serializer.decode(json)
+    // TODO: Remove `as Any` once `decode(_:)` is refactored
+    return HTTPSCallableResult(data: payload as Any)
   }
 
-  private func responseData(data: Data?, error: (any Error)?) throws -> Data {
+  private func processedResponseData(from data: Data?, error: (any Error)?) throws -> Data {
     // Case 1: `error` is not `nil` -> always throws
     if let error = error as NSError? {
       let localError: (any Error)?
       if error.domain == kGTMSessionFetcherStatusDomain {
         localError = FunctionsError(
           httpStatusCode: error.code,
-          body: data,
+          body: data ?? error.userInfo["data"] as? Data,
           serializer: serializer
         )
       } else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut {

+ 2 - 10
FirebaseFunctions/Sources/HTTPSCallable.swift

@@ -130,15 +130,7 @@ open class HTTPSCallable: NSObject {
   /// - Returns: The result of the call.
   @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
   open func call(_ data: Any? = nil) async throws -> HTTPSCallableResult {
-    return try await withCheckedThrowingContinuation { continuation in
-      // TODO(bonus): Use task to handle and cancellation.
-      self.call(data) { callableResult, error in
-        if let callableResult {
-          continuation.resume(returning: callableResult)
-        } else {
-          continuation.resume(throwing: error!)
-        }
-      }
-    }
+    try await functions
+      .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval)
   }
 }

+ 32 - 0
FirebaseFunctions/Tests/Unit/FunctionsTests.swift

@@ -293,6 +293,38 @@ class FunctionsTests: XCTestCase {
     waitForExpectations(timeout: 1.5)
   }
 
+  func testAsyncCallFunctionWhenAppCheckIsNotInstalled() async {
+    let networkError = NSError(
+      domain: "testCallFunctionWhenAppCheckIsInstalled",
+      code: -1,
+      userInfo: nil
+    )
+
+    let httpRequestExpectation = expectation(description: "HTTPRequestExpectation")
+    fetcherService.testBlock = { fetcherToTest, testResponse in
+      let appCheckTokenHeader = fetcherToTest.request?
+        .value(forHTTPHeaderField: "X-Firebase-AppCheck")
+      XCTAssertNil(appCheckTokenHeader)
+      testResponse(nil, nil, networkError)
+      httpRequestExpectation.fulfill()
+    }
+
+    do {
+      _ = try await functionsCustomDomain?
+        .callFunction(
+          at: URL(string: "https://example.com/fake_func")!,
+          withObject: nil,
+          options: nil,
+          timeout: 10
+        )
+      XCTFail("Expected an error")
+    } catch {
+      XCTAssertEqual(error as NSError, networkError)
+    }
+
+    await fulfillment(of: [httpRequestExpectation], timeout: 1.5)
+  }
+
   func testCallFunctionWhenAppCheckIsNotInstalled() {
     let networkError = NSError(
       domain: "testCallFunctionWhenAppCheckIsInstalled",