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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
102 changes: 75 additions & 27 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,26 +516,20 @@ 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)?
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)
}
}
53 changes: 47 additions & 6 deletions FirebaseFunctions/Sources/Internal/FunctionsContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,53 @@ struct FunctionsContextProvider {
self.appCheck = appCheck
}

// TODO: Implement async await version
// @available(macOS 10.15.0, *)
// internal func getContext() async throws -> FunctionsContext {
// return FunctionsContext(authToken: nil, fcmToken: nil, appCheckToken: nil)
//
// }
@available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *)
func context(options: HTTPSCallableOptions?) async throws -> FunctionsContext {
async let authToken = auth?.getToken(forcingRefresh: false)
async let appCheckToken = getAppCheckToken(options: options)
async let limitedUseAppCheckToken = getLimitedUseAppCheckToken(options: options)

// Only `authToken` is throwing, but the formatter script removes the `try`
// from `try authToken` and puts it in front of the initializer call.
return try await FunctionsContext(
authToken: authToken,
fcmToken: messaging?.fcmToken,
appCheckToken: appCheckToken,
limitedUseAppCheckToken: limitedUseAppCheckToken
)
}

@available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *)
private func getAppCheckToken(options: HTTPSCallableOptions?) async -> String? {
guard
options?.requireLimitedUseAppCheckTokens != true,
let tokenResult = await appCheck?.getToken(forcingRefresh: false),
tokenResult.error == nil
else { return nil }
return tokenResult.token
}

@available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *)
private func getLimitedUseAppCheckToken(options: HTTPSCallableOptions?) async -> String? {
// At the moment, `await` doesn’t get along with Objective-C’s optional protocol methods.
await withCheckedContinuation { (continuation: CheckedContinuation<String?, Never>) in
guard
options?.requireLimitedUseAppCheckTokens == true,
let appCheck,
// `getLimitedUseToken(completion:)` is an optional protocol method. Optional binding
// is performed to make sure `continuation` is called even if the method’s not implemented.
let limitedUseTokenClosure = appCheck.getLimitedUseToken
else {
return continuation.resume(returning: nil)
}

limitedUseTokenClosure { tokenResult in
// Make sure there’s no error and the token is valid:
guard tokenResult.error == nil else { return continuation.resume(returning: nil) }
continuation.resume(returning: tokenResult.token)
}
}
}

func getContext(options: HTTPSCallableOptions? = nil,
_ completion: @escaping ((FunctionsContext, Error?) -> Void)) {
Expand Down
103 changes: 103 additions & 0 deletions FirebaseFunctions/Tests/Unit/ContextProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ class ContextProviderTests: XCTestCase {
let appCheckTokenSuccess = FIRAppCheckTokenResultFake(token: "valid_token", error: nil)
let messagingFake = FIRMessagingInteropFake()

func testAsyncContextWithAuth() async throws {
let auth = FIRAuthInteropFake(token: "token", userID: "userID", error: nil)
let provider = FunctionsContextProvider(auth: auth, messaging: messagingFake, appCheck: nil)

let context = try await provider.context(options: nil)

XCTAssertNotNil(context)
XCTAssertEqual(context.authToken, "token")
XCTAssertEqual(context.fcmToken, messagingFake.fcmToken)
}

func testContextWithAuth() {
let auth = FIRAuthInteropFake(token: "token", userID: "userID", error: nil)
let provider = FunctionsContextProvider(auth: auth, messaging: messagingFake, appCheck: nil)
Expand All @@ -49,6 +60,19 @@ class ContextProviderTests: XCTestCase {
waitForExpectations(timeout: 0.1)
}

func testAsyncContextWithAuthError() async {
let authError = NSError(domain: "com.functions.tests", code: 4, userInfo: nil)
let auth = FIRAuthInteropFake(token: nil, userID: "userID", error: authError)
let provider = FunctionsContextProvider(auth: auth, messaging: messagingFake, appCheck: nil)

do {
_ = try await provider.context(options: nil)
XCTFail("Expected an error")
} catch {
XCTAssertEqual(error as NSError, authError)
}
}

func testContextWithAuthError() {
let authError = NSError(domain: "com.functions.tests", code: 4, userInfo: nil)
let auth = FIRAuthInteropFake(token: nil, userID: "userID", error: authError)
Expand All @@ -63,6 +87,15 @@ class ContextProviderTests: XCTestCase {
waitForExpectations(timeout: 0.1)
}

func testAsyncContextWithoutAuth() async throws {
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: nil)

let context = try await provider.context(options: nil)

XCTAssertNil(context.authToken)
XCTAssertNil(context.fcmToken)
}

func testContextWithoutAuth() {
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: nil)
let expectation = expectation(description: "Completion handler should succeed without Auth.")
Expand All @@ -76,6 +109,17 @@ class ContextProviderTests: XCTestCase {
waitForExpectations(timeout: 0.1)
}

func testAsyncContextWithAppCheckOnlySuccess() async throws {
appCheckFake.tokenResult = appCheckTokenSuccess
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: appCheckFake)

let context = try await provider.context(options: nil)

XCTAssertNil(context.authToken)
XCTAssertNil(context.fcmToken)
XCTAssertEqual(context.appCheckToken, appCheckTokenSuccess.token)
}

func testContextWithAppCheckOnlySuccess() {
appCheckFake.tokenResult = appCheckTokenSuccess
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: appCheckFake)
Expand All @@ -91,6 +135,18 @@ class ContextProviderTests: XCTestCase {
waitForExpectations(timeout: 0.1)
}

func testAsyncContextWithAppCheckOnlyError() async throws {
appCheckFake.tokenResult = appCheckTokenError
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: appCheckFake)

let context = try await provider.context(options: nil)

XCTAssertNil(context.authToken)
XCTAssertNil(context.fcmToken)
// Don't expect any token in the case of App Check error.
XCTAssertNil(context.appCheckToken)
}

func testContextWithAppCheckOnlyError() {
appCheckFake.tokenResult = appCheckTokenError
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: appCheckFake)
Expand All @@ -107,6 +163,19 @@ class ContextProviderTests: XCTestCase {
waitForExpectations(timeout: 0.1)
}

func testAsyncContextWithAppCheckWithoutOptionalMethods() async throws {
let appCheck = AppCheckFakeWithoutOptionalMethods(tokenResult: appCheckTokenSuccess)
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: appCheck)

let context = try await provider.context(options: .init(requireLimitedUseAppCheckTokens: true))

XCTAssertNil(context.authToken)
XCTAssertNil(context.fcmToken)
XCTAssertNil(context.appCheckToken)
// If the method for limited-use tokens is not implemented, the value should be `nil`:
XCTAssertNil(context.limitedUseAppCheckToken)
}

func testContextWithAppCheckWithoutOptionalMethods() {
let appCheck = AppCheckFakeWithoutOptionalMethods(tokenResult: appCheckTokenSuccess)
let provider = FunctionsContextProvider(auth: nil, messaging: nil, appCheck: appCheck)
Expand All @@ -126,6 +195,22 @@ class ContextProviderTests: XCTestCase {
waitForExpectations(timeout: 0.1)
}

func testAsyncAllContextsAvailableSuccess() async throws {
appCheckFake.tokenResult = appCheckTokenSuccess
let auth = FIRAuthInteropFake(token: "token", userID: "userID", error: nil)
let provider = FunctionsContextProvider(
auth: auth,
messaging: messagingFake,
appCheck: appCheckFake
)

let context = try await provider.context(options: nil)

XCTAssertEqual(context.authToken, "token")
XCTAssertEqual(context.fcmToken, messagingFake.fcmToken)
XCTAssertEqual(context.appCheckToken, appCheckTokenSuccess.token)
}

func testAllContextsAvailableSuccess() {
appCheckFake.tokenResult = appCheckTokenSuccess
let auth = FIRAuthInteropFake(token: "token", userID: "userID", error: nil)
Expand All @@ -146,6 +231,24 @@ class ContextProviderTests: XCTestCase {
waitForExpectations(timeout: 0.1)
}

func testAsyncAllContextsAuthAndAppCheckError() async {
appCheckFake.tokenResult = appCheckTokenError
let authError = NSError(domain: "com.functions.tests", code: 4, userInfo: nil)
let auth = FIRAuthInteropFake(token: nil, userID: "userID", error: authError)
let provider = FunctionsContextProvider(
auth: auth,
messaging: messagingFake,
appCheck: appCheckFake
)

do {
_ = try await provider.context(options: nil)
XCTFail("Expected an error")
} catch {
XCTAssertEqual(error as NSError, authError)
}
}

func testAllContextsAuthAndAppCheckError() {
appCheckFake.tokenResult = appCheckTokenError
let authError = NSError(domain: "com.functions.tests", code: 4, userInfo: nil)
Expand Down
Loading
Loading