Skip to content

[Functions] Complete Swift 6 support #14838

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 5 commits into from
May 13, 2025
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
3 changes: 3 additions & 0 deletions .github/workflows/functions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
strategy:
matrix:
target: [ios, tvos, macos, watchos]
swift_version: [5.9, 6.0]
build-env:
- os: macos-15
xcode: Xcode_16.3
Expand All @@ -44,6 +45,8 @@ jobs:
run: scripts/setup_bundler.sh
- name: Integration Test Server
run: FirebaseFunctions/Backend/start.sh synchronous
- name: Set Swift swift_version
run: sed -i "" "s/s.swift_version[[:space:]]*=[[:space:]]*'5.9'/s.swift_version = '${{ matrix.swift_version }}'/" FirebaseFunctions.podspec
- name: Build and test
run: |
scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseFunctions.podspec \
Expand Down
3 changes: 2 additions & 1 deletion FirebaseFunctions/Sources/Callable+Codable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ private protocol StreamResponseProtocol {}
/// This can be used as the generic `Response` parameter to ``Callable`` to receive both the
/// yielded messages and final return value of the streaming callable function.
@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
public enum StreamResponse<Message: Decodable, Result: Decodable>: Decodable,
public enum StreamResponse<Message: Decodable & Sendable, Result: Decodable & Sendable>: Decodable,
Sendable,
StreamResponseProtocol {
/// The message yielded by the callable function.
case message(Message)
Expand Down
4 changes: 3 additions & 1 deletion FirebaseFunctions/Sources/FunctionsError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,10 @@ private extension FunctionsErrorCode {
}
}

// TODO(ncooke3): Revisit this unchecked Sendable conformance.

/// The object used to report errors that occur during a function’s execution.
struct FunctionsError: CustomNSError {
struct FunctionsError: CustomNSError, @unchecked Sendable {
static let errorDomain = FunctionsErrorDomain

let code: FunctionsErrorCode
Expand Down
30 changes: 30 additions & 0 deletions FirebaseFunctions/Sources/HTTPSCallable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,39 @@ open class HTTPSCallable: NSObject, @unchecked Sendable {
/// - Parameters:
/// - data: Parameters to pass to the trigger.
/// - completion: The block to call when the HTTPS request has completed.
@available(swift 1000.0) // Objective-C only API
@objc(callWithObject:completion:) open func call(_ data: Any? = nil,
completion: @escaping @MainActor (HTTPSCallableResult?,
Error?)
-> Void) {
sendableCallable.call(SendableWrapper(value: data as Any), completion: completion)
}

/// Executes this Callable HTTPS trigger asynchronously.
///
/// The data passed into the trigger can be any of the following types:
/// - `nil` or `NSNull`
/// - `String`
/// - `NSNumber`, or any Swift numeric type bridgeable to `NSNumber`
/// - `[Any]`, where the contained objects are also one of these types.
/// - `[String: Any]` where the values are also one of these types.
///
/// The request to the Cloud Functions backend made by this method automatically includes a
/// Firebase Installations ID token to identify the app instance. If a user is logged in with
/// Firebase Auth, an auth ID token for the user is also automatically included.
///
/// Firebase Cloud Messaging sends data to the Firebase backend periodically to collect
/// information
/// regarding the app instance. To stop this, see `Messaging.deleteData()`. It
/// resumes with a new FCM Token the next time you call this method.
///
/// - Parameters:
/// - data: Parameters to pass to the trigger.
/// - completion: The block to call when the HTTPS request has completed.
@nonobjc open func call(_ data: sending Any? = nil,
completion: @escaping @MainActor (HTTPSCallableResult?,
Error?)
-> Void) {
sendableCallable.call(data, completion: completion)
}

Expand Down Expand Up @@ -154,6 +183,7 @@ private extension HTTPSCallable {

func call(_ data: sending Any? = nil,
completion: @escaping @MainActor (HTTPSCallableResult?, Error?) -> Void) {
let data = (data as? SendableWrapper)?.value ?? data
if #available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) {
Task {
do {
Expand Down
14 changes: 14 additions & 0 deletions FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ extension FunctionsSerializer {
final class FunctionsSerializer: Sendable {
// MARK: - Internal APIs

// This function only supports the following types and will otherwise throw
// an error.
// - NSNull (note: `nil` collection values from a Swift caller will be treated as NSNull)
// - NSNumber
// - NSString
// - NSDicionary
// - NSArray
func encode(_ object: Any) throws -> Any {
if object is NSNull {
return object
Expand All @@ -53,6 +60,13 @@ final class FunctionsSerializer: Sendable {
}
}

// This function only supports the following types and will otherwise throw
// an error.
// - NSNull (note: `nil` collection values from a Swift caller will be treated as NSNull)
// - NSNumber
// - NSString
// - NSDicionary
// - NSArray
func decode(_ object: Any) throws -> Any {
// Return these types as is. PORTING NOTE: Moved from the bottom of the func for readability.
if let dict = object as? NSDictionary {
Expand Down
64 changes: 34 additions & 30 deletions FirebaseFunctions/Tests/Integration/IntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class IntegrationTests: XCTestCase {
return URL(string: "http://localhost:5005/functions-integration-test/us-central1/\(funcName)")!
}

func testData() {
@MainActor func testData() {
let data = DataTestRequest(
bool: true,
int: 2,
Expand Down Expand Up @@ -148,7 +148,7 @@ class IntegrationTests: XCTestCase {
}
}

func testScalar() {
@MainActor func testScalar() {
let byName = functions.httpsCallable(
"scalarTest",
requestAs: Int16.self,
Expand Down Expand Up @@ -203,7 +203,7 @@ class IntegrationTests: XCTestCase {
}
}

func testToken() {
@MainActor func testToken() {
// Recreate functions with a token.
let functions = Functions(
projectID: "functions-integration-test",
Expand Down Expand Up @@ -271,7 +271,7 @@ class IntegrationTests: XCTestCase {
}
}

func testFCMToken() {
@MainActor func testFCMToken() {
let byName = functions.httpsCallable(
"FCMTokenTest",
requestAs: [String: Int].self,
Expand Down Expand Up @@ -316,7 +316,7 @@ class IntegrationTests: XCTestCase {
}
}

func testNull() {
@MainActor func testNull() {
let byName = functions.httpsCallable(
"nullTest",
requestAs: Int?.self,
Expand Down Expand Up @@ -361,7 +361,7 @@ class IntegrationTests: XCTestCase {
}
}

func testMissingResult() {
@MainActor func testMissingResult() {
let byName = functions.httpsCallable(
"missingResultTest",
requestAs: Int?.self,
Expand Down Expand Up @@ -415,7 +415,7 @@ class IntegrationTests: XCTestCase {
}
}

func testUnhandledError() {
@MainActor func testUnhandledError() {
let byName = functions.httpsCallable(
"unhandledErrorTest",
requestAs: [Int].self,
Expand Down Expand Up @@ -469,7 +469,7 @@ class IntegrationTests: XCTestCase {
}
}

func testUnknownError() {
@MainActor func testUnknownError() {
let byName = functions.httpsCallable(
"unknownErrorTest",
requestAs: [Int].self,
Expand Down Expand Up @@ -522,7 +522,7 @@ class IntegrationTests: XCTestCase {
}
}

func testExplicitError() {
@MainActor func testExplicitError() {
let byName = functions.httpsCallable(
"explicitErrorTest",
requestAs: [Int].self,
Expand Down Expand Up @@ -579,7 +579,7 @@ class IntegrationTests: XCTestCase {
}
}

func testHttpError() {
@MainActor func testHttpError() {
let byName = functions.httpsCallable(
"httpErrorTest",
requestAs: [Int].self,
Expand Down Expand Up @@ -631,7 +631,7 @@ class IntegrationTests: XCTestCase {
}
}

func testThrowError() {
@MainActor func testThrowError() {
let byName = functions.httpsCallable(
"throwTest",
requestAs: [Int].self,
Expand Down Expand Up @@ -685,7 +685,7 @@ class IntegrationTests: XCTestCase {
}
}

func testTimeout() {
@MainActor func testTimeout() {
let byName = functions.httpsCallable(
"timeoutTest",
requestAs: [Int].self,
Expand Down Expand Up @@ -743,7 +743,7 @@ class IntegrationTests: XCTestCase {
}
}

func testCallAsFunction() {
@MainActor func testCallAsFunction() {
let data = DataTestRequest(
bool: true,
int: 2,
Expand Down Expand Up @@ -808,7 +808,7 @@ class IntegrationTests: XCTestCase {
}
}

func testInferredTypes() {
@MainActor func testInferredTypes() {
let data = DataTestRequest(
bool: true,
int: 2,
Expand Down Expand Up @@ -868,7 +868,7 @@ class IntegrationTests: XCTestCase {
}
}

func testFunctionsReturnsOnMainThread() {
@MainActor func testFunctionsReturnsOnMainThread() {
let expectation = expectation(description: #function)
functions.httpsCallable(
"scalarTest",
Expand All @@ -884,7 +884,7 @@ class IntegrationTests: XCTestCase {
waitForExpectations(timeout: 5)
}

func testFunctionsThrowsOnMainThread() {
@MainActor func testFunctionsThrowsOnMainThread() {
let expectation = expectation(description: #function)
functions.httpsCallable(
"httpErrorTest",
Expand All @@ -908,7 +908,7 @@ class IntegrationTests: XCTestCase {
///
/// This can be used as the generic `Request` parameter to ``Callable`` to
/// indicate the callable function does not accept parameters.
private struct EmptyRequest: Encodable {}
private struct EmptyRequest: Encodable, Sendable {}

@available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *)
extension IntegrationTests {
Expand Down Expand Up @@ -1100,18 +1100,21 @@ extension IntegrationTests {
)
}

func testStream_Canceled() async throws {
let task = Task.detached { [self] in
let callable: Callable<EmptyRequest, String> = functions.httpsCallable("genStream")
let stream = try callable.stream()
// Since we cancel the call we are expecting an empty array.
return try await stream.reduce([]) { $0 + [$1] } as [String]
// Concurrency rules prevent easily testing this feature.
#if swift(<6)
func testStream_Canceled() async throws {
let task = Task.detached { [self] in
let callable: Callable<EmptyRequest, String> = functions.httpsCallable("genStream")
let stream = try callable.stream()
// Since we cancel the call we are expecting an empty array.
return try await stream.reduce([]) { $0 + [$1] } as [String]
}
// We cancel the task and we expect a null response even if the stream was initiated.
task.cancel()
let respone = try await task.value
XCTAssertEqual(respone, [])
}
// We cancel the task and we expect a null response even if the stream was initiated.
task.cancel()
let respone = try await task.value
XCTAssertEqual(respone, [])
}
#endif

func testStream_NonexistentFunction() async throws {
let callable: Callable<EmptyRequest, String> = functions.httpsCallable(
Expand Down Expand Up @@ -1163,7 +1166,8 @@ extension IntegrationTests {
func testStream_ResultIsOnlyExposedInStreamResponse() async throws {
// The implementation is copied from `StreamResponse`. The only difference is the do-catch is
// removed from the decoding initializer.
enum MyStreamResponse<Message: Decodable, Result: Decodable>: Decodable {
enum MyStreamResponse<Message: Decodable & Sendable, Result: Decodable & Sendable>: Decodable,
Sendable {
/// The message yielded by the callable function.
case message(Message)
/// The final result returned by the callable function.
Expand Down Expand Up @@ -1248,7 +1252,7 @@ extension IntegrationTests {
}

func testStream_ResultOnly_StreamResponse() async throws {
struct EmptyResponse: Decodable {}
struct EmptyResponse: Decodable, Sendable {}
let callable: Callable<EmptyRequest, StreamResponse<EmptyResponse, String>> = functions
.httpsCallable(
"genStreamResultOnly"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ public class FirebaseDataEncoder {
/// - returns: A new `Data` value containing the encoded JSON data.
/// - throws: `EncodingError.invalidValue` if a non-conforming floating-point value is encountered during encoding, and the encoding strategy is `.throw`.
/// - throws: An error if any value throws an error during encoding.
open func encode<T : Encodable>(_ value: T) throws -> Any {
open func encode<T : Encodable>(_ value: T) throws -> sending Any {
let encoder = __JSONEncoder(options: self.options)

guard let topLevel = try encoder.box_(value) else {
Expand Down
Loading