Skip to content

Commit 0ac8bbb

Browse files
authored
Async Function Calling (#13901)
1 parent d9a2a35 commit 0ac8bbb

File tree

3 files changed

+110
-38
lines changed

3 files changed

+110
-38
lines changed

FirebaseFunctions/Sources/Functions.swift

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,30 @@ enum FunctionsConstants {
385385
return URL(string: "https://\(region)-\(projectID).cloudfunctions.net/\(name)")
386386
}
387387

388+
@available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *)
389+
func callFunction(at url: URL,
390+
withObject data: Any?,
391+
options: HTTPSCallableOptions?,
392+
timeout: TimeInterval) async throws -> HTTPSCallableResult {
393+
let context = try await contextProvider.context(options: options)
394+
let fetcher = try makeFetcher(
395+
url: url,
396+
data: data,
397+
options: options,
398+
timeout: timeout,
399+
context: context
400+
)
401+
402+
do {
403+
let rawData = try await fetcher.beginFetch()
404+
return try callableResultFromResponse(data: rawData, error: nil)
405+
} catch {
406+
// This method always throws when `error` is not `nil`, but ideally,
407+
// it should be refactored so it looks less confusing.
408+
return try callableResultFromResponse(data: nil, error: error)
409+
}
410+
}
411+
388412
func callFunction(at url: URL,
389413
withObject data: Any?,
390414
options: HTTPSCallableOptions?,
@@ -413,24 +437,54 @@ enum FunctionsConstants {
413437
timeout: TimeInterval,
414438
context: FunctionsContext,
415439
completion: @escaping ((Result<HTTPSCallableResult, Error>) -> Void)) {
416-
let request = URLRequest(url: url,
417-
cachePolicy: .useProtocolCachePolicy,
418-
timeoutInterval: timeout)
419-
let fetcher = fetcherService.fetcher(with: request)
420-
440+
let fetcher: GTMSessionFetcher
421441
do {
422-
let data = data ?? NSNull()
423-
let encoded = try serializer.encode(data)
424-
let body = ["data": encoded]
425-
let payload = try JSONSerialization.data(withJSONObject: body)
426-
fetcher.bodyData = payload
442+
fetcher = try makeFetcher(
443+
url: url,
444+
data: data,
445+
options: options,
446+
timeout: timeout,
447+
context: context
448+
)
427449
} catch {
428450
DispatchQueue.main.async {
429451
completion(.failure(error))
430452
}
431453
return
432454
}
433455

456+
fetcher.beginFetch { [self] data, error in
457+
let result: Result<HTTPSCallableResult, any Error>
458+
do {
459+
result = try .success(callableResultFromResponse(data: data, error: error))
460+
} catch {
461+
result = .failure(error)
462+
}
463+
464+
DispatchQueue.main.async {
465+
completion(result)
466+
}
467+
}
468+
}
469+
470+
private func makeFetcher(url: URL,
471+
data: Any?,
472+
options: HTTPSCallableOptions?,
473+
timeout: TimeInterval,
474+
context: FunctionsContext) throws -> GTMSessionFetcher {
475+
let request = URLRequest(
476+
url: url,
477+
cachePolicy: .useProtocolCachePolicy,
478+
timeoutInterval: timeout
479+
)
480+
let fetcher = fetcherService.fetcher(with: request)
481+
482+
let data = data ?? NSNull()
483+
let encoded = try serializer.encode(data)
484+
let body = ["data": encoded]
485+
let payload = try JSONSerialization.data(withJSONObject: body)
486+
fetcher.bodyData = payload
487+
434488
// Set the headers.
435489
fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type")
436490
if let authToken = context.authToken {
@@ -462,33 +516,27 @@ enum FunctionsConstants {
462516
fetcher.allowedInsecureSchemes = ["http"]
463517
}
464518

465-
fetcher.beginFetch { [self] data, error in
466-
let result: Result<HTTPSCallableResult, any Error>
467-
do {
468-
let data = try responseData(data: data, error: error)
469-
let json = try responseDataJSON(from: data)
470-
// TODO: Refactor `decode(_:)` so it either returns a non-optional object or throws
471-
let payload = try serializer.decode(json)
472-
// TODO: Remove `as Any` once `decode(_:)` is refactored
473-
result = .success(HTTPSCallableResult(data: payload as Any))
474-
} catch {
475-
result = .failure(error)
476-
}
519+
return fetcher
520+
}
477521

478-
DispatchQueue.main.async {
479-
completion(result)
480-
}
481-
}
522+
private func callableResultFromResponse(data: Data?,
523+
error: (any Error)?) throws -> HTTPSCallableResult {
524+
let processedData = try processedResponseData(from: data, error: error)
525+
let json = try responseDataJSON(from: processedData)
526+
// TODO: Refactor `decode(_:)` so it either returns a non-optional object or throws
527+
let payload = try serializer.decode(json)
528+
// TODO: Remove `as Any` once `decode(_:)` is refactored
529+
return HTTPSCallableResult(data: payload as Any)
482530
}
483531

484-
private func responseData(data: Data?, error: (any Error)?) throws -> Data {
532+
private func processedResponseData(from data: Data?, error: (any Error)?) throws -> Data {
485533
// Case 1: `error` is not `nil` -> always throws
486534
if let error = error as NSError? {
487535
let localError: (any Error)?
488536
if error.domain == kGTMSessionFetcherStatusDomain {
489537
localError = FunctionsError(
490538
httpStatusCode: error.code,
491-
body: data,
539+
body: data ?? error.userInfo["data"] as? Data,
492540
serializer: serializer
493541
)
494542
} else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut {

FirebaseFunctions/Sources/HTTPSCallable.swift

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,7 @@ open class HTTPSCallable: NSObject {
130130
/// - Returns: The result of the call.
131131
@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
132132
open func call(_ data: Any? = nil) async throws -> HTTPSCallableResult {
133-
return try await withCheckedThrowingContinuation { continuation in
134-
// TODO(bonus): Use task to handle and cancellation.
135-
self.call(data) { callableResult, error in
136-
if let callableResult {
137-
continuation.resume(returning: callableResult)
138-
} else {
139-
continuation.resume(throwing: error!)
140-
}
141-
}
142-
}
133+
try await functions
134+
.callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval)
143135
}
144136
}

FirebaseFunctions/Tests/Unit/FunctionsTests.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,38 @@ class FunctionsTests: XCTestCase {
293293
waitForExpectations(timeout: 1.5)
294294
}
295295

296+
func testAsyncCallFunctionWhenAppCheckIsNotInstalled() async {
297+
let networkError = NSError(
298+
domain: "testCallFunctionWhenAppCheckIsInstalled",
299+
code: -1,
300+
userInfo: nil
301+
)
302+
303+
let httpRequestExpectation = expectation(description: "HTTPRequestExpectation")
304+
fetcherService.testBlock = { fetcherToTest, testResponse in
305+
let appCheckTokenHeader = fetcherToTest.request?
306+
.value(forHTTPHeaderField: "X-Firebase-AppCheck")
307+
XCTAssertNil(appCheckTokenHeader)
308+
testResponse(nil, nil, networkError)
309+
httpRequestExpectation.fulfill()
310+
}
311+
312+
do {
313+
_ = try await functionsCustomDomain?
314+
.callFunction(
315+
at: URL(string: "https://example.com/fake_func")!,
316+
withObject: nil,
317+
options: nil,
318+
timeout: 10
319+
)
320+
XCTFail("Expected an error")
321+
} catch {
322+
XCTAssertEqual(error as NSError, networkError)
323+
}
324+
325+
await fulfillment(of: [httpRequestExpectation], timeout: 1.5)
326+
}
327+
296328
func testCallFunctionWhenAppCheckIsNotInstalled() {
297329
let networkError = NSError(
298330
domain: "testCallFunctionWhenAppCheckIsInstalled",

0 commit comments

Comments
 (0)