diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 75091b26815..d4cd4e4f54f 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/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) -> 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 + 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 - 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 { diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 4a196134e3e..2c772bc8c78 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/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) } } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 89cf70fa6a0..42e684cdf1a 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/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",