Skip to content
Merged
25 changes: 17 additions & 8 deletions FirebaseFunctions/Sources/Callable+Codable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import FirebaseSharedSwift
@preconcurrency import FirebaseSharedSwift
import Foundation

/// A `Callable` is a reference to a particular Callable HTTPS trigger in Cloud Functions.
///
/// - Note: If the Callable HTTPS trigger accepts no parameters, ``Never`` can be used for
/// iOS 17.0+. Otherwise, a simple encodable placeholder type (e.g.,
/// `struct EmptyRequest: Encodable {}`) can be used.
public struct Callable<Request: Encodable, Response: Decodable> {
public struct Callable<Request: Encodable, Response: Decodable>: Sendable {
/// The timeout to use when calling the function. Defaults to 70 seconds.
public var timeoutInterval: TimeInterval {
get {
Expand Down Expand Up @@ -61,11 +61,10 @@ public struct Callable<Request: Encodable, Response: Decodable> {
/// - Parameter data: Parameters to pass to the trigger.
/// - Parameter completion: The block to call when the HTTPS request has completed.
public func call(_ data: Request,
completion: @escaping (Result<Response, Error>)
completion: @escaping @MainActor (Result<Response, Error>)
-> Void) {
do {
let encoded = try encoder.encode(data)

callable.call(encoded) { result, error in
do {
if let result {
Expand All @@ -81,7 +80,9 @@ public struct Callable<Request: Encodable, Response: Decodable> {
}
}
} catch {
completion(.failure(error))
DispatchQueue.main.async {
completion(.failure(error))
}
}
}

Expand All @@ -108,7 +109,7 @@ public struct Callable<Request: Encodable, Response: Decodable> {
/// - data: Parameters to pass to the trigger.
/// - completion: The block to call when the HTTPS request has completed.
public func callAsFunction(_ data: Request,
completion: @escaping (Result<Response, Error>)
completion: @escaping @MainActor (Result<Response, Error>)
-> Void) {
call(data, completion: completion)
}
Expand Down Expand Up @@ -265,9 +266,9 @@ public extension Callable where Request: Sendable, Response: Sendable {
/// - Returns: A stream wrapping responses yielded by the streaming callable function or
/// a ``FunctionsError`` if an error occurred.
func stream(_ data: Request? = nil) throws -> AsyncThrowingStream<Response, Error> {
let encoded: Any
let encoded: SendableWrapper
do {
encoded = try encoder.encode(data)
encoded = try SendableWrapper(value: encoder.encode(data))
} catch {
throw FunctionsError(.invalidArgument, userInfo: [NSUnderlyingErrorKey: error])
}
Expand Down Expand Up @@ -336,3 +337,11 @@ enum JSONStreamResponse {
case message([String: Any])
case result([String: Any])
}

// TODO: Remove need for below type by changing `FirebaseDataEncoder` to not returning `Any`.
/// This wrapper is only intended to be used for passing encoded data in the
/// `stream` function's hierarchy. When using, carefully audit that `value` is
/// only ever accessed in one isolation domain.
struct SendableWrapper: @unchecked Sendable {
let value: Any
}
32 changes: 19 additions & 13 deletions FirebaseFunctions/Sources/Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@ import FirebaseMessagingInterop
import FirebaseSharedSwift
import Foundation
#if COCOAPODS
import GTMSessionFetcher
@preconcurrency import GTMSessionFetcher
#else
import GTMSessionFetcherCore
@preconcurrency import GTMSessionFetcherCore
#endif

internal import FirebaseCoreExtension

final class AtomicBox<T> {
private var _value: T
final class AtomicBox<T>: Sendable {
private nonisolated(unsafe) var _value: T
private let lock = NSLock()

public init(_ value: T) {
public init(_ value: T) where T: Sendable {
_value = value
}

Expand Down Expand Up @@ -68,7 +68,7 @@ enum FunctionsConstants {
}

/// `Functions` is the client for Cloud Functions for a Firebase project.
@objc(FIRFunctions) open class Functions: NSObject {
@objc(FIRFunctions) open class Functions: NSObject, @unchecked Sendable {
// MARK: - Private Variables

/// The network client to use for http requests.
Expand All @@ -82,7 +82,7 @@ enum FunctionsConstants {

/// A map of active instances, grouped by app. Keys are FirebaseApp names and values are arrays
/// containing all instances of Functions associated with the given app.
private nonisolated(unsafe) static var instances: AtomicBox<[String: [Functions]]> =
private static let instances: AtomicBox<[String: [Functions]]> =
AtomicBox([:])

/// The custom domain to use for all functions references (optional).
Expand All @@ -91,10 +91,14 @@ enum FunctionsConstants {
/// The region to use for all function references.
let region: String

private let _emulatorOrigin: AtomicBox<String?>

// MARK: - Public APIs

/// The current emulator origin, or `nil` if it is not set.
open private(set) var emulatorOrigin: String?
open var emulatorOrigin: String? {
_emulatorOrigin.value()
}

/// Creates a Cloud Functions client using the default or returns a pre-existing instance if it
/// already exists.
Expand Down Expand Up @@ -318,7 +322,9 @@ enum FunctionsConstants {
@objc open func useEmulator(withHost host: String, port: Int) {
let prefix = host.hasPrefix("http") ? "" : "http://"
let origin = String(format: "\(prefix)\(host):%li", port)
emulatorOrigin = origin
_emulatorOrigin.withLock { emulatorOrigin in
emulatorOrigin = origin
}
}

// MARK: - Private Funcs (or Internal for tests)
Expand Down Expand Up @@ -365,7 +371,7 @@ enum FunctionsConstants {
self.projectID = projectID
self.region = region
self.customDomain = customDomain
emulatorOrigin = nil
_emulatorOrigin = AtomicBox(nil)
contextProvider = FunctionsContextProvider(auth: auth,
messaging: messaging,
appCheck: appCheck)
Expand Down Expand Up @@ -414,7 +420,7 @@ enum FunctionsConstants {
func callFunction(at url: URL,
withObject data: Any?,
options: HTTPSCallableOptions?,
timeout: TimeInterval) async throws -> HTTPSCallableResult {
timeout: TimeInterval) async throws -> sending HTTPSCallableResult {
let context = try await contextProvider.context(options: options)
let fetcher = try makeFetcher(
url: url,
Expand Down Expand Up @@ -501,7 +507,7 @@ enum FunctionsConstants {

@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
func stream(at url: URL,
data: Any?,
data: SendableWrapper?,
options: HTTPSCallableOptions?,
timeout: TimeInterval)
-> AsyncThrowingStream<JSONStreamResponse, Error> {
Expand All @@ -512,7 +518,7 @@ enum FunctionsConstants {
let context = try await contextProvider.context(options: options)
urlRequest = try makeRequestForStreamableContent(
url: url,
data: data,
data: data?.value,
options: options,
timeout: timeout,
context: context
Expand Down
6 changes: 3 additions & 3 deletions FirebaseFunctions/Sources/FunctionsError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,9 @@ struct FunctionsError: CustomNSError {
}

if code == .OK {
// Technically, there's an edge case where a developer could explicitly return an error code
// of
// OK, and we will treat it as success, but that seems reasonable.
// Technically, there's an edge case where a developer could explicitly
// return an error code of OK, and we will treat it as success, but that
// seems reasonable.
return nil
}

Expand Down
150 changes: 99 additions & 51 deletions FirebaseFunctions/Sources/HTTPSCallable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,25 @@ open class HTTPSCallableResult: NSObject {
}
}

/**
* A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions.
*/
/// A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions.
@objc(FIRHTTPSCallable)
open class HTTPSCallable: NSObject {
open class HTTPSCallable: NSObject, @unchecked Sendable {
// MARK: - Private Properties

// The functions client to use for making calls.
private let functions: Functions

private let url: URL

private let options: HTTPSCallableOptions?
/// Until this class can be marked *checked* `Sendable`, it's implementation
/// is delegated to an auxialiary class that is checked Sendable.
private let sendableCallable: SendableHTTPSCallable

// MARK: - Public Properties

/// The timeout to use when calling the function. Defaults to 70 seconds.
@objc open var timeoutInterval: TimeInterval = 70
@objc open var timeoutInterval: TimeInterval {
get { sendableCallable.timeoutInterval }
set { sendableCallable.timeoutInterval = newValue }
}

init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) {
self.functions = functions
self.url = url
self.options = options
sendableCallable = SendableHTTPSCallable(functions: functions, url: url, options: options)
}

/// Executes this Callable HTTPS trigger asynchronously.
Expand Down Expand Up @@ -79,36 +75,7 @@ open class HTTPSCallable: NSObject {
completion: @escaping @MainActor (HTTPSCallableResult?,
Error?)
-> Void) {
if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) {
Task {
do {
let result = try await call(data)
await completion(result, nil)
} catch {
await completion(nil, error)
}
}
} else {
// This isn’t expected to ever be called because Functions
// doesn’t officially support the older platforms.
functions.callFunction(
at: url,
withObject: data,
options: options,
timeout: timeoutInterval
) { result in
switch result {
case let .success(callableResult):
DispatchQueue.main.async {
completion(callableResult, nil)
}
case let .failure(error):
DispatchQueue.main.async {
completion(nil, error)
}
}
}
}
sendableCallable.call(data, completion: completion)
}

/// Executes this Callable HTTPS trigger asynchronously. This API should only be used from
Expand All @@ -124,8 +91,8 @@ open class HTTPSCallable: NSObject {
/// resumes with a new FCM Token the next time you call this method.
///
/// - Parameter completion: The block to call when the HTTPS request has completed.
@objc(callWithCompletion:) public func __call(completion: @escaping (HTTPSCallableResult?,
Error?) -> Void) {
@objc(callWithCompletion:) public func __call(completion: @escaping @MainActor (HTTPSCallableResult?,
Error?) -> Void) {
call(nil, completion: completion)
}

Expand All @@ -144,13 +111,94 @@ open class HTTPSCallable: NSObject {
/// - Throws: An error if the Cloud Functions invocation failed.
/// - 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 {
try await functions
.callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval)
open func call(_ data: Any? = nil) async throws -> sending HTTPSCallableResult {
try await sendableCallable.call(data)
}

@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
func stream(_ data: Any? = nil) -> AsyncThrowingStream<JSONStreamResponse, Error> {
functions.stream(at: url, data: data, options: options, timeout: timeoutInterval)
func stream(_ data: SendableWrapper? = nil) -> AsyncThrowingStream<JSONStreamResponse, Error> {
sendableCallable.stream(data)
}
}

private extension HTTPSCallable {
final class SendableHTTPSCallable: Sendable {
// MARK: - Private Properties

// The functions client to use for making calls.
private let functions: Functions

private let url: URL

private let options: HTTPSCallableOptions?

// MARK: - Public Properties

let _timeoutInterval: AtomicBox<TimeInterval> = .init(70)

/// The timeout to use when calling the function. Defaults to 70 seconds.
var timeoutInterval: TimeInterval {
get { _timeoutInterval.value() }
set {
_timeoutInterval.withLock { timeoutInterval in
timeoutInterval = newValue
}
}
}

init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) {
self.functions = functions
self.url = url
self.options = options
}

func call(_ data: sending Any? = nil,
completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) {
if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) {
Task {
do {
let result = try await call(data)
await completion(result, nil)
} catch {
await completion(nil, error)
}
}
} else {
// This isn’t expected to ever be called because Functions
// doesn’t officially support the older platforms.
functions.callFunction(
at: url,
withObject: data,
options: options,
timeout: timeoutInterval
) { result in
switch result {
case let .success(callableResult):
DispatchQueue.main.async {
completion(callableResult, nil)
}
case let .failure(error):
DispatchQueue.main.async {
completion(nil, error)
}
}
}
}
}

func __call(completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) {
call(nil, completion: completion)
}

@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
func call(_ data: Any? = nil) async throws -> sending HTTPSCallableResult {
try await functions
.callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval)
}

@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
func stream(_ data: SendableWrapper? = nil) -> AsyncThrowingStream<JSONStreamResponse, Error> {
functions.stream(at: url, data: data, options: options, timeout: timeoutInterval)
}
}
}
4 changes: 2 additions & 2 deletions FirebaseFunctions/Tests/CombineUnit/HTTPSCallableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ import XCTest
private let timeoutInterval: TimeInterval = 70.0
private let expectationTimeout: TimeInterval = 2

class MockFunctions: Functions {
class MockFunctions: Functions, @unchecked Sendable {
let mockCallFunction: () throws -> HTTPSCallableResult
var verifyParameters: ((_ url: URL, _ data: Any?, _ timeout: TimeInterval) throws -> Void)?

override func callFunction(at url: URL,
withObject data: Any?,
options: HTTPSCallableOptions?,
timeout: TimeInterval) async throws -> HTTPSCallableResult {
timeout: TimeInterval) async throws -> sending HTTPSCallableResult {
try verifyParameters?(url, data, timeout)
return try mockCallFunction()
}
Expand Down
Loading