Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Async Function Calling #13901

Merged
merged 2 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 76 additions & 28 deletions FirebaseFunctions/Sources/Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down Expand Up @@ -413,24 +437,54 @@ 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))
}
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 {
Expand Down Expand Up @@ -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,
ncooke3 marked this conversation as resolved.
Show resolved Hide resolved
serializer: serializer
)
} else if error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut {
Expand Down
12 changes: 2 additions & 10 deletions FirebaseFunctions/Sources/HTTPSCallable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 32 additions & 0 deletions FirebaseFunctions/Tests/Unit/FunctionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading