瀏覽代碼

[Functions] Complete Swift 6 support (#14838)

Nick Cooke 10 月之前
父節點
當前提交
dbecea06dc

+ 3 - 0
.github/workflows/functions.yml

@@ -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
@@ -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 \

+ 2 - 1
FirebaseFunctions/Sources/Callable+Codable.swift

@@ -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)

+ 3 - 1
FirebaseFunctions/Sources/FunctionsError.swift

@@ -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

+ 30 - 0
FirebaseFunctions/Sources/HTTPSCallable.swift

@@ -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)
   }
 
@@ -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 {

+ 14 - 0
FirebaseFunctions/Sources/Internal/FunctionsSerializer.swift

@@ -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
@@ -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 {

+ 34 - 30
FirebaseFunctions/Tests/Integration/IntegrationTests.swift

@@ -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,
@@ -148,7 +148,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testScalar() {
+  @MainActor func testScalar() {
     let byName = functions.httpsCallable(
       "scalarTest",
       requestAs: Int16.self,
@@ -203,7 +203,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testToken() {
+  @MainActor func testToken() {
     // Recreate functions with a token.
     let functions = Functions(
       projectID: "functions-integration-test",
@@ -271,7 +271,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testFCMToken() {
+  @MainActor func testFCMToken() {
     let byName = functions.httpsCallable(
       "FCMTokenTest",
       requestAs: [String: Int].self,
@@ -316,7 +316,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testNull() {
+  @MainActor func testNull() {
     let byName = functions.httpsCallable(
       "nullTest",
       requestAs: Int?.self,
@@ -361,7 +361,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testMissingResult() {
+  @MainActor func testMissingResult() {
     let byName = functions.httpsCallable(
       "missingResultTest",
       requestAs: Int?.self,
@@ -415,7 +415,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testUnhandledError() {
+  @MainActor func testUnhandledError() {
     let byName = functions.httpsCallable(
       "unhandledErrorTest",
       requestAs: [Int].self,
@@ -469,7 +469,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testUnknownError() {
+  @MainActor func testUnknownError() {
     let byName = functions.httpsCallable(
       "unknownErrorTest",
       requestAs: [Int].self,
@@ -522,7 +522,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testExplicitError() {
+  @MainActor func testExplicitError() {
     let byName = functions.httpsCallable(
       "explicitErrorTest",
       requestAs: [Int].self,
@@ -579,7 +579,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testHttpError() {
+  @MainActor func testHttpError() {
     let byName = functions.httpsCallable(
       "httpErrorTest",
       requestAs: [Int].self,
@@ -631,7 +631,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testThrowError() {
+  @MainActor func testThrowError() {
     let byName = functions.httpsCallable(
       "throwTest",
       requestAs: [Int].self,
@@ -685,7 +685,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testTimeout() {
+  @MainActor func testTimeout() {
     let byName = functions.httpsCallable(
       "timeoutTest",
       requestAs: [Int].self,
@@ -743,7 +743,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testCallAsFunction() {
+  @MainActor func testCallAsFunction() {
     let data = DataTestRequest(
       bool: true,
       int: 2,
@@ -808,7 +808,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testInferredTypes() {
+  @MainActor func testInferredTypes() {
     let data = DataTestRequest(
       bool: true,
       int: 2,
@@ -868,7 +868,7 @@ class IntegrationTests: XCTestCase {
     }
   }
 
-  func testFunctionsReturnsOnMainThread() {
+  @MainActor func testFunctionsReturnsOnMainThread() {
     let expectation = expectation(description: #function)
     functions.httpsCallable(
       "scalarTest",
@@ -884,7 +884,7 @@ class IntegrationTests: XCTestCase {
     waitForExpectations(timeout: 5)
   }
 
-  func testFunctionsThrowsOnMainThread() {
+  @MainActor func testFunctionsThrowsOnMainThread() {
     let expectation = expectation(description: #function)
     functions.httpsCallable(
       "httpErrorTest",
@@ -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 {
@@ -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(
@@ -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.
@@ -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"

+ 1 - 1
FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift

@@ -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 {