Skip to content

Commit

Permalink
Async Function Calling
Browse files Browse the repository at this point in the history
* Introduced an async / await version of `callFunction` which takes advantage of `FunctionsContextProvider`’s async `context(options:)`
* Refactored `GTMSessionFetcher` setup to minimize code duplication
* Added tests
  • Loading branch information
yakovmanshin committed Oct 16, 2024
1 parent 9a19e07 commit f7a0de2
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 37 deletions.
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)
}
}
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

0 comments on commit f7a0de2

Please sign in to comment.