Skip to content

[Functions] More Swift 6 improvements #14788

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

Merged
merged 13 commits into from
May 9, 2025
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