From 231d602960727d410dd2d4e1ea4888631e9575c7 Mon Sep 17 00:00:00 2001 From: Eblen Macari Date: Thu, 7 Nov 2024 21:14:52 -0500 Subject: [PATCH 01/38] [Infra] Update functions workflow to use macOS 15 for Xcode 16 jobs (#14051) --- .github/workflows/functions.yml | 19 ++++---- FirebaseFunctions/Sources/Functions.swift | 44 +++++++++++++++++++ .../Tests/Unit/FunctionsTests.swift | 17 +++++++ firebase-database-emulator.log | 2 + firebase-database-emulator.pid | 1 + 5 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 firebase-database-emulator.log create mode 100644 firebase-database-emulator.pid diff --git a/.github/workflows/functions.yml b/.github/workflows/functions.yml index 226583412e0..49d26b29a5c 100644 --- a/.github/workflows/functions.yml +++ b/.github/workflows/functions.yml @@ -30,8 +30,11 @@ jobs: strategy: matrix: target: [ios, tvos, macos, watchos] - os: [macos-14] - xcode: [Xcode_15.2, Xcode_16] + include: + - os: macos-14 + xcode: Xcode_15.2 + - os: macos-15 + xcode: Xcode_16.1 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -116,22 +119,22 @@ jobs: xcode: Xcode_15.4 target: iOS - os: macos-15 - xcode: Xcode_16 + xcode: Xcode_16.1 target: iOS - os: macos-15 - xcode: Xcode_16 + xcode: Xcode_16.1 target: tvOS - os: macos-15 - xcode: Xcode_16 + xcode: Xcode_16.1 target: macOS - os: macos-15 - xcode: Xcode_16 + xcode: Xcode_16.1 target: watchOS - os: macos-15 - xcode: Xcode_16 + xcode: Xcode_16.1 target: catalyst - os: macos-15 - xcode: Xcode_16 + xcode: Xcode_16.1 target: visionOS runs-on: ${{ matrix.os }} steps: diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index d4cd4e4f54f..fd8caf92fa3 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -294,6 +294,50 @@ enum FunctionsConstants { emulatorOrigin = origin } + @available(iOS 13.0, *) + open func stream(_ data: Any? = nil, withURL url: URL) throws -> AsyncStream { + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.httpMethod = "POST" + request.httpBody = try? JSONSerialization.data(withJSONObject: ["data": data], options: []) + let error = NSError() + let stream = AsyncStream(String.self) { continuation in + Task { + do { + let (data, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + if let responseString = String(data: data, encoding: .utf8) { + let lines = responseString.split(separator: "\n") + for line in lines { + continuation.yield(String(line)) + } + continuation.finish() + } else { + print("Error: Couldn't decode response") + throw error + } + default: + print( + "Falling back to default error handler: Error: \(httpResponse.statusCode)" + ) + throw error + } + } else { + print("Error: Couldn't get HTTP response") + throw error + } + } catch { + print("Error: \(error)") + throw error + } + } + } + return stream + } + // MARK: - Private Funcs (or Internal for tests) /// Solely used to have one precondition and one location where we fetch from the container. This diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 42e684cdf1a..45347a5df40 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -358,4 +358,21 @@ class FunctionsTests: XCTestCase { } waitForExpectations(timeout: 1.5) } + + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + func testStreamDownload() async { + let url = URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")! + do { + let stream = try functions?.stream(withURL: url) + if let stream = stream { + for await line in stream { + print(line) + } + } + + } catch { + print("Failed to download stream: \(error)") + } + } } + diff --git a/firebase-database-emulator.log b/firebase-database-emulator.log new file mode 100644 index 00000000000..ec17b5aff71 --- /dev/null +++ b/firebase-database-emulator.log @@ -0,0 +1,2 @@ +18:07:50.544 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started +18:07:50.617 [main] INFO com.firebase.server.forge.App$ - Listening at localhost:9000 diff --git a/firebase-database-emulator.pid b/firebase-database-emulator.pid new file mode 100644 index 00000000000..e5da04baf77 --- /dev/null +++ b/firebase-database-emulator.pid @@ -0,0 +1 @@ +4061 From a14d964e5b2b6b5dfd7de708c792d7e098db02dd Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 20 Dec 2024 10:26:26 -0600 Subject: [PATCH 02/38] Stremable Functions. Add initial support for Streamable functions. --- FirebaseFunctions/Sources/Functions.swift | 124 +++++++++++++++++- FirebaseFunctions/Sources/HTTPSCallable.swift | 10 +- .../Tests/Unit/FunctionsTests.swift | 16 ++- 3 files changed, 145 insertions(+), 5 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index fd8caf92fa3..500c1a65cb9 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -293,7 +293,7 @@ enum FunctionsConstants { let origin = String(format: "\(prefix)\(host):%li", port) emulatorOrigin = origin } - + @available(iOS 13.0, *) open func stream(_ data: Any? = nil, withURL url: URL) throws -> AsyncStream { var request = URLRequest(url: url) @@ -510,6 +510,128 @@ enum FunctionsConstants { } } } + + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) + func stream(at url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) async throws -> AsyncStream { + 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) + } + + } + + @available(iOS 13.0, *) + func callableResultFromResponse(data: Data?, error: Error?) throws -> AsyncStream { + + var result = AsyncStream { continuation in + Task { + do { + let (data, response) = try await URLSession.shared.data(for: request) + if let httpResponse = response as? HTTPURLResponse { + switch httpResponse.statusCode { + case 200: + if let responseString = String(data: data, encoding: .utf8) { + let lines = responseString.split(separator: "\n") + for line in lines { + continuation.yield(String(line)) + } + continuation.finish() + } else { + print("Error: Couldn't decode response") + throw error + } + default: + print( + "Falling back to default error handler: Error: \(httpResponse.statusCode)" + ) + throw error + } + } else { + print("Error: Couldn't get HTTP response") + throw error + } + } catch { + print("Error: \(error)") + throw error + } + } + } + return result + } + + func stream(at url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval, + completion: @escaping ((Result) -> Void)) { + // Get context first. + contextProvider.getContext(options: options) { context, error in + // Note: context is always non-nil since some checks could succeed, we're only failing if + // there's an error. + if let error { + completion(.failure(error)) + } else { + self.callFunction(url: url, + withObject: data, + options: options, + timeout: timeout, + context: context, + completion: completion) + } + } + } + + private func stream(url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval, + context: FunctionsContext, + completion: @escaping ((Result) -> Void)) { + let fetcher: GTMSessionFetcher + do { + 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 + 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?, diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 2c772bc8c78..28eea894665 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -38,16 +38,13 @@ open class HTTPSCallable: NSObject { // The functions client to use for making calls. private let functions: Functions - private let url: URL private let options: HTTPSCallableOptions? - // MARK: - Public Properties /// The timeout to use when calling the function. Defaults to 70 seconds. @objc open var timeoutInterval: TimeInterval = 70 - init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) { self.functions = functions self.url = url @@ -133,4 +130,11 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } + + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + open func stream(_ data: Any? = nil) async throws -> AsyncStream { + try await functions + .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) + } + } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 45347a5df40..8d8c216e76c 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -359,6 +359,7 @@ class FunctionsTests: XCTestCase { waitForExpectations(timeout: 1.5) } + //TODO -- Remove after testing phase finsh. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) func testStreamDownload() async { let url = URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")! @@ -369,7 +370,20 @@ class FunctionsTests: XCTestCase { print(line) } } - + } catch { + print("Failed to download stream: \(error)") + } + } + + func testGenerateStreamContent() async { + do { + let stream = try await functions?.httpsCallable("testStream").stream("GenContent") + if let stream = stream { + for await line in stream { + print(line) + } + } + print(stream as Any) } catch { print("Failed to download stream: \(error)") } From a92d7c2632ca2c46e63261d6f62a5a0a100f07b6 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 20 Dec 2024 15:23:47 -0600 Subject: [PATCH 03/38] Changed return type. Change call to AsyncThrowingStream --- FirebaseFunctions/Sources/Functions.swift | 85 +++++++------------ FirebaseFunctions/Sources/HTTPSCallable.swift | 2 +- .../Tests/Unit/FunctionsTests.swift | 2 +- 3 files changed, 34 insertions(+), 55 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 500c1a65cb9..8a6aac893f2 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -513,67 +513,46 @@ enum FunctionsConstants { @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) func stream(at url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval) async throws -> AsyncStream { - let context = try await contextProvider.context(options: options) - let fetcher = try makeFetcher( - url: url, - data: data, - options: options, - timeout: timeout, - context: context - ) + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) async throws -> AsyncThrowingStream { + 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) + 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) + } + } - - } @available(iOS 13.0, *) - func callableResultFromResponse(data: Data?, error: Error?) throws -> AsyncStream { - - var result = AsyncStream { continuation in - Task { - do { - let (data, response) = try await URLSession.shared.data(for: request) - if let httpResponse = response as? HTTPURLResponse { - switch httpResponse.statusCode { - case 200: - if let responseString = String(data: data, encoding: .utf8) { - let lines = responseString.split(separator: "\n") - for line in lines { - continuation.yield(String(line)) + func callableResultFromResponse(data: Data?, error: Error?) throws -> AsyncThrowingStream { + + return AsyncThrowingStream { continuation in + Task { + do { + let processedData = try processedResponseData(from: data, error: error) + let json = try responseDataJSON(from: processedData) + let payload = try JSONSerialization.jsonObject(with: processedData, options: []) + continuation.yield(HTTPSCallableResult(data: payload as Any)) + continuation.finish() + } catch { + continuation.finish(throwing: error) } - continuation.finish() - } else { - print("Error: Couldn't decode response") - throw error - } - default: - print( - "Falling back to default error handler: Error: \(httpResponse.statusCode)" - ) - throw error } - } else { - print("Error: Couldn't get HTTP response") - throw error - } - } catch { - print("Error: \(error)") - throw error } - } } - return result - } func stream(at url: URL, withObject data: Any?, diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 28eea894665..0b517917e08 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -132,7 +132,7 @@ open class HTTPSCallable: NSObject { } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - open func stream(_ data: Any? = nil) async throws -> AsyncStream { + open func stream(_ data: Any? = nil) async throws -> AsyncThrowingStream { try await functions .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 8d8c216e76c..42221dc033f 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -379,7 +379,7 @@ class FunctionsTests: XCTestCase { do { let stream = try await functions?.httpsCallable("testStream").stream("GenContent") if let stream = stream { - for await line in stream { + for try await line in stream { print(line) } } From 10bec1d4a772d7f97af8942a62ca50ef82f563ce Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 20 Dec 2024 16:07:18 -0600 Subject: [PATCH 04/38] Lint test testing check.sh --- FirebaseFunctions/Sources/Functions.swift | 101 +++++++++--------- FirebaseFunctions/Sources/HTTPSCallable.swift | 7 +- .../Tests/Unit/FunctionsTests.swift | 5 +- 3 files changed, 58 insertions(+), 55 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 8a6aac893f2..95f0a2951a5 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -293,7 +293,7 @@ enum FunctionsConstants { let origin = String(format: "\(prefix)\(host):%li", port) emulatorOrigin = origin } - + @available(iOS 13.0, *) open func stream(_ data: Any? = nil, withURL url: URL) throws -> AsyncStream { var request = URLRequest(url: url) @@ -337,7 +337,7 @@ enum FunctionsConstants { } return stream } - + // MARK: - Private Funcs (or Internal for tests) /// Solely used to have one precondition and one location where we fetch from the container. This @@ -510,55 +510,58 @@ enum FunctionsConstants { } } } - + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) func stream(at url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval) async throws -> AsyncThrowingStream { - let context = try await contextProvider.context(options: options) - let fetcher = try makeFetcher( - url: url, - data: data, - options: options, - timeout: timeout, - context: context - ) + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) async throws + -> AsyncThrowingStream { + 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) - } - + 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) } + } @available(iOS 13.0, *) - func callableResultFromResponse(data: Data?, error: Error?) throws -> AsyncThrowingStream { - - return AsyncThrowingStream { continuation in - Task { - do { - let processedData = try processedResponseData(from: data, error: error) - let json = try responseDataJSON(from: processedData) - let payload = try JSONSerialization.jsonObject(with: processedData, options: []) - continuation.yield(HTTPSCallableResult(data: payload as Any)) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } + func callableResultFromResponse(data: Data?, + error: Error?) throws -> AsyncThrowingStream< + HTTPSCallableResult, + Error + > { + return AsyncThrowingStream { continuation in + Task { + do { + let processedData = try processedResponseData(from: data, error: error) + let json = try responseDataJSON(from: processedData) + let payload = try JSONSerialization.jsonObject(with: processedData, options: []) + continuation.yield(HTTPSCallableResult(data: payload as Any)) + continuation.finish() + } catch { + continuation.finish(throwing: error) } + } } - - func stream(at url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval, - completion: @escaping ((Result) -> Void)) { + } + + func stream(at url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval, + completion: @escaping ((Result) -> Void)) { // Get context first. contextProvider.getContext(options: options) { context, error in // Note: context is always non-nil since some checks could succeed, we're only failing if @@ -576,12 +579,12 @@ enum FunctionsConstants { } } - private func stream(url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval, - context: FunctionsContext, - completion: @escaping ((Result) -> Void)) { + private func stream(url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval, + context: FunctionsContext, + completion: @escaping ((Result) -> Void)) { let fetcher: GTMSessionFetcher do { fetcher = try makeFetcher( diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 0b517917e08..172e09b9d09 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -41,6 +41,7 @@ open class HTTPSCallable: NSObject { private let url: URL private let options: HTTPSCallableOptions? + // MARK: - Public Properties /// The timeout to use when calling the function. Defaults to 70 seconds. @@ -130,11 +131,11 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } - + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - open func stream(_ data: Any? = nil) async throws -> AsyncThrowingStream { + open func stream(_ data: Any? = nil) async throws + -> AsyncThrowingStream { try await functions .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) } - } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 42221dc033f..0c210920368 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -359,7 +359,7 @@ class FunctionsTests: XCTestCase { waitForExpectations(timeout: 1.5) } - //TODO -- Remove after testing phase finsh. + // TODO: -- Remove after testing phase finsh. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) func testStreamDownload() async { let url = URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")! @@ -374,7 +374,7 @@ class FunctionsTests: XCTestCase { print("Failed to download stream: \(error)") } } - + func testGenerateStreamContent() async { do { let stream = try await functions?.httpsCallable("testStream").stream("GenContent") @@ -389,4 +389,3 @@ class FunctionsTests: XCTestCase { } } } - From 53a2aabc102ade1424dcc60b7500dc2fc59d00ef Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 20 Dec 2024 16:30:40 -0600 Subject: [PATCH 05/38] Remove test function Remove old test function. --- FirebaseFunctions/Sources/Functions.swift | 44 ----------------------- 1 file changed, 44 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 95f0a2951a5..0a877cb4784 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -294,50 +294,6 @@ enum FunctionsConstants { emulatorOrigin = origin } - @available(iOS 13.0, *) - open func stream(_ data: Any? = nil, withURL url: URL) throws -> AsyncStream { - var request = URLRequest(url: url) - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("text/event-stream", forHTTPHeaderField: "Accept") - request.httpMethod = "POST" - request.httpBody = try? JSONSerialization.data(withJSONObject: ["data": data], options: []) - let error = NSError() - let stream = AsyncStream(String.self) { continuation in - Task { - do { - let (data, response) = try await URLSession.shared.data(for: request) - if let httpResponse = response as? HTTPURLResponse { - switch httpResponse.statusCode { - case 200: - if let responseString = String(data: data, encoding: .utf8) { - let lines = responseString.split(separator: "\n") - for line in lines { - continuation.yield(String(line)) - } - continuation.finish() - } else { - print("Error: Couldn't decode response") - throw error - } - default: - print( - "Falling back to default error handler: Error: \(httpResponse.statusCode)" - ) - throw error - } - } else { - print("Error: Couldn't get HTTP response") - throw error - } - } catch { - print("Error: \(error)") - throw error - } - } - } - return stream - } - // MARK: - Private Funcs (or Internal for tests) /// Solely used to have one precondition and one location where we fetch from the container. This From 758fbed3d49f6555dc844e3fdd1d8977ba82497c Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 20 Dec 2024 16:35:37 -0600 Subject: [PATCH 06/38] Remove old test. Remove old test --- .../Tests/Unit/FunctionsTests.swift | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 0c210920368..23be4d7bdd8 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -359,22 +359,6 @@ class FunctionsTests: XCTestCase { waitForExpectations(timeout: 1.5) } - // TODO: -- Remove after testing phase finsh. - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - func testStreamDownload() async { - let url = URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")! - do { - let stream = try functions?.stream(withURL: url) - if let stream = stream { - for await line in stream { - print(line) - } - } - } catch { - print("Failed to download stream: \(error)") - } - } - func testGenerateStreamContent() async { do { let stream = try await functions?.httpsCallable("testStream").stream("GenContent") From 93b6c8bc4b5a001ddfb64e47bc5f20ecfa9ad8bc Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 27 Dec 2024 15:52:59 -0600 Subject: [PATCH 07/38] Updated function, add full test. Add a full working test for stremableFunction. Refactor. --- FirebaseFunctions/Sources/Functions.swift | 343 +++++++++--------- FirebaseFunctions/Sources/HTTPSCallable.swift | 13 +- .../Tests/Integration/IntegrationTests.swift | 39 ++ .../Tests/Unit/FunctionsTests.swift | 170 ++++++--- firebase-database-emulator.log | 4 +- firebase-database-emulator.pid | 2 +- 6 files changed, 347 insertions(+), 224 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 0a877cb4784..a696e090e56 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -41,7 +41,7 @@ enum FunctionsConstants { /// `Functions` is the client for Cloud Functions for a Firebase project. @objc(FIRFunctions) open class Functions: NSObject { // MARK: - Private Variables - + /// The network client to use for http requests. private let fetcherService: GTMSessionFetcherService /// The projectID to use for all function references. @@ -50,25 +50,25 @@ enum FunctionsConstants { private let serializer = FunctionsSerializer() /// A factory for getting the metadata to include with function calls. private let contextProvider: FunctionsContextProvider - + /// 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 static var instances: [String: [Functions]] = [:] - + /// Lock to manage access to the instances array to avoid race conditions. private static var instancesLock: os_unfair_lock = .init() - + /// The custom domain to use for all functions references (optional). let customDomain: String? - + /// The region to use for all function references. let region: String - + // MARK: - Public APIs - + /// The current emulator origin, or `nil` if it is not set. open private(set) var emulatorOrigin: String? - + /// Creates a Cloud Functions client using the default or returns a pre-existing instance if it /// already exists. /// - Returns: A shared Functions instance initialized with the default `FirebaseApp`. @@ -79,7 +79,7 @@ enum FunctionsConstants { customDomain: nil ) } - + /// Creates a Cloud Functions client with the given app, or returns a pre-existing /// instance if one already exists. /// - Parameter app: The app for the Firebase project. @@ -87,7 +87,7 @@ enum FunctionsConstants { @objc(functionsForApp:) open class func functions(app: FirebaseApp) -> Functions { return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: nil) } - + /// Creates a Cloud Functions client with the default app and given region. /// - Parameter region: The region for the HTTP trigger, such as `us-central1`. /// - Returns: A shared Functions instance initialized with the default `FirebaseApp` and a @@ -95,7 +95,7 @@ enum FunctionsConstants { @objc(functionsForRegion:) open class func functions(region: String) -> Functions { return functions(app: FirebaseApp.app(), region: region, customDomain: nil) } - + /// Creates a Cloud Functions client with the given custom domain or returns a pre-existing /// instance if one already exists. /// - Parameter customDomain: A custom domain for the HTTP trigger, such as @@ -106,7 +106,7 @@ enum FunctionsConstants { return functions(app: FirebaseApp.app(), region: FunctionsConstants.defaultRegion, customDomain: customDomain) } - + /// Creates a Cloud Functions client with the given app and region, or returns a pre-existing /// instance if one already exists. /// - Parameters: @@ -117,7 +117,7 @@ enum FunctionsConstants { region: String) -> Functions { return functions(app: app, region: region, customDomain: nil) } - + /// Creates a Cloud Functions client with the given app and custom domain, or returns a /// pre-existing /// instance if one already exists. @@ -127,17 +127,17 @@ enum FunctionsConstants { /// - Returns: An instance of `Functions` with a custom app and HTTP trigger domain. @objc(functionsForApp:customDomain:) open class func functions(app: FirebaseApp, customDomain: String) - -> Functions { + -> Functions { return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: customDomain) } - + /// Creates a reference to the Callable HTTPS trigger with the given name. /// - Parameter name: The name of the Callable HTTPS trigger. /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithName:) open func httpsCallable(_ name: String) -> HTTPSCallable { HTTPSCallable(functions: self, url: functionURL(for: name)!) } - + /// Creates a reference to the Callable HTTPS trigger with the given name and configuration /// options. /// - Parameters: @@ -146,17 +146,17 @@ enum FunctionsConstants { /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithName:options:) public func httpsCallable(_ name: String, options: HTTPSCallableOptions) - -> HTTPSCallable { + -> HTTPSCallable { HTTPSCallable(functions: self, url: functionURL(for: name)!, options: options) } - + /// Creates a reference to the Callable HTTPS trigger with the given name. /// - Parameter url: The URL of the Callable HTTPS trigger. /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithURL:) open func httpsCallable(_ url: URL) -> HTTPSCallable { return HTTPSCallable(functions: self, url: url) } - + /// Creates a reference to the Callable HTTPS trigger with the given name and configuration /// options. /// - Parameters: @@ -165,10 +165,10 @@ enum FunctionsConstants { /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithURL:options:) public func httpsCallable(_ url: URL, options: HTTPSCallableOptions) - -> HTTPSCallable { + -> HTTPSCallable { return HTTPSCallable(functions: self, url: url, options: options) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -180,22 +180,23 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. + @available(iOS 13.0, *) open func httpsCallable(_ name: String, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ name: String, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(name), encoder: encoder, decoder: decoder ) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -208,23 +209,24 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. + @available(iOS 13.0, *) open func httpsCallable(_ name: String, - options: HTTPSCallableOptions, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ name: String, + options: HTTPSCallableOptions, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(name, options: options), encoder: encoder, decoder: decoder ) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -236,22 +238,23 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. + @available(iOS 13.0, *) open func httpsCallable(_ url: URL, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ url: URL, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(url), encoder: encoder, decoder: decoder ) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -264,23 +267,24 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. + @available(iOS 13.0, *) open func httpsCallable(_ url: URL, - options: HTTPSCallableOptions, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ url: URL, + options: HTTPSCallableOptions, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(url, options: options), encoder: encoder, decoder: decoder ) } - + /** * Changes this instance to point to a Cloud Functions emulator running locally. * See https://firebase.google.com/docs/functions/local-emulator @@ -293,9 +297,9 @@ enum FunctionsConstants { let origin = String(format: "\(prefix)\(host):%li", port) emulatorOrigin = origin } - + // MARK: - Private Funcs (or Internal for tests) - + /// Solely used to have one precondition and one location where we fetch from the container. This /// previously was avoided due to default arguments but that doesn't work well with Obj-C /// compatibility. @@ -305,10 +309,10 @@ enum FunctionsConstants { fatalError("`FirebaseApp.configure()` needs to be called before using Functions.") } os_unfair_lock_lock(&instancesLock) - + // Unlock before the function returns. defer { os_unfair_lock_unlock(&instancesLock) } - + if let associatedInstances = instances[app.name] { for instance in associatedInstances { // Domains may be nil, so handle with care. @@ -329,7 +333,7 @@ enum FunctionsConstants { instances[app.name] = existingInstances + [newInstance] return newInstance } - + @objc init(projectID: String, region: String, customDomain: String?, @@ -346,7 +350,7 @@ enum FunctionsConstants { appCheck: appCheck) self.fetcherService = fetcherService } - + /// Using the component system for initialization. convenience init(app: FirebaseApp, region: String, @@ -357,7 +361,7 @@ enum FunctionsConstants { in: app.container) let appCheck = ComponentType.instance(for: AppCheckInterop.self, in: app.container) - + guard let projectID = app.options.projectID else { fatalError("Firebase Functions requires the projectID to be set in the App's Options.") } @@ -368,23 +372,23 @@ enum FunctionsConstants { messaging: messaging, appCheck: appCheck) } - + func functionURL(for name: String) -> URL? { assert(!name.isEmpty, "Name cannot be empty") - + // Check if we're using the emulator if let emulatorOrigin { return URL(string: "\(emulatorOrigin)/\(projectID)/\(region)/\(name)") } - + // Check the custom domain. if let customDomain { return URL(string: "\(customDomain)/\(name)") } - + 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?, @@ -398,7 +402,7 @@ enum FunctionsConstants { timeout: timeout, context: context ) - + do { let rawData = try await fetcher.beginFetch() return try callableResultFromResponse(data: rawData, error: nil) @@ -408,7 +412,7 @@ enum FunctionsConstants { return try callableResultFromResponse(data: nil, error: error) } } - + func callFunction(at url: URL, withObject data: Any?, options: HTTPSCallableOptions?, @@ -430,7 +434,7 @@ enum FunctionsConstants { } } } - + private func callFunction(url: URL, withObject data: Any?, options: HTTPSCallableOptions?, @@ -452,7 +456,7 @@ enum FunctionsConstants { } return } - + fetcher.beginFetch { [self] data, error in let result: Result do { @@ -460,117 +464,50 @@ enum FunctionsConstants { } catch { result = .failure(error) } - + DispatchQueue.main.async { completion(result) } } } - + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) func stream(at url: URL, withObject data: Any?, options: HTTPSCallableOptions?, timeout: TimeInterval) async throws - -> AsyncThrowingStream { + -> AsyncThrowingStream { let context = try await contextProvider.context(options: options) - let fetcher = try makeFetcher( + let fetcher = try makeFetcherForStreamableContent( url: url, data: data, options: options, timeout: timeout, context: context ) - + do { let rawData = try await fetcher.beginFetch() - return try callableResultFromResponse(data: rawData, error: nil) + return try callableResultFromResponseAsync(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) + return try callableResultFromResponseAsync(data: nil, error: error) } } - + @available(iOS 13.0, *) - func callableResultFromResponse(data: Data?, - error: Error?) throws -> AsyncThrowingStream< - HTTPSCallableResult, - Error - > { - return AsyncThrowingStream { continuation in - Task { - do { - let processedData = try processedResponseData(from: data, error: error) - let json = try responseDataJSON(from: processedData) - let payload = try JSONSerialization.jsonObject(with: processedData, options: []) - continuation.yield(HTTPSCallableResult(data: payload as Any)) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } - - func stream(at url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval, - completion: @escaping ((Result) -> Void)) { - // Get context first. - contextProvider.getContext(options: options) { context, error in - // Note: context is always non-nil since some checks could succeed, we're only failing if - // there's an error. - if let error { - completion(.failure(error)) - } else { - self.callFunction(url: url, - withObject: data, - options: options, - timeout: timeout, - context: context, - completion: completion) - } - } - } - - private func stream(url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval, - context: FunctionsContext, - completion: @escaping ((Result) -> Void)) { - let fetcher: GTMSessionFetcher - do { - 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 - do { - result = try .success(callableResultFromResponse(data: data, error: error)) - } catch { - result = .failure(error) - } - - DispatchQueue.main.async { - completion(result) - } - } - } - + func callableResultFromResponseAsync(data: Data?, + error: Error?) throws -> AsyncThrowingStream< + HTTPSCallableResult, Error + + > { + + let processedData = try processResponseDataForStreamableContent(from: data, error: error) + + return processedData + } + private func makeFetcher(url: URL, data: Any?, options: HTTPSCallableOptions?, @@ -665,7 +602,89 @@ enum FunctionsConstants { // Case 4: `error` is `nil`; `data` is not `nil`; `data` doesn’t specify an error -> OK return data } - + + + private func makeFetcherForStreamableContent(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 for starting a streaming session. + fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type") + fetcher.setRequestValue("text/event-stream", forHTTPHeaderField: "Accept") + fetcher.request?.httpMethod = "POST" + if let authToken = context.authToken { + let value = "Bearer \(authToken)" + fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization") + } + + if let fcmToken = context.fcmToken { + fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) + } + + if options?.requireLimitedUseAppCheckTokens == true { + if let appCheckToken = context.limitedUseAppCheckToken { + fetcher.setRequestValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + } else if let appCheckToken = context.appCheckToken { + fetcher.setRequestValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + //Remove after genStream is updated on the emulator or deployed +#if DEBUG + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] +#endif + // Override normal security rules if this is a local test. + if emulatorOrigin != nil { + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] + } + + return fetcher + } + + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) + private func processResponseDataForStreamableContent(from data: Data?, error: Error?) throws -> AsyncThrowingStream { + + return AsyncThrowingStream { continuation in + Task { + do { + var processedData = try processedResponseData(from: data, error: error) + var processedString = String(data: processedData, encoding: .utf8) + + processedString = processedString?.replacingOccurrences(of: "data:", with: "") + processedString = processedString?.replacingOccurrences(of: "result:", with: "") + processedData = Data(processedString?.utf8 ?? "".utf8) + + continuation.yield(HTTPSCallableResult(data: (processedString ?? "") as String)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + throw error + } + } + } + } + private func responseDataJSON(from data: Data) throws -> Any { let responseJSONObject = try JSONSerialization.jsonObject(with: data) diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 172e09b9d09..cca7aa0aef9 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -32,6 +32,8 @@ open class HTTPSCallableResult: NSObject { /** * A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions. */ + + @objc(FIRHTTPSCallable) open class HTTPSCallable: NSObject { // MARK: - Private Properties @@ -48,7 +50,12 @@ open class HTTPSCallable: NSObject { @objc open var timeoutInterval: TimeInterval = 70 init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) { self.functions = functions + //Remove after genStream if deployed +#if DEBUG + self.url = URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")! + #else self.url = url +#endif self.options = options } @@ -134,8 +141,8 @@ open class HTTPSCallable: NSObject { @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func stream(_ data: Any? = nil) async throws - -> AsyncThrowingStream { - try await functions - .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) + -> AsyncThrowingStream { + try await functions + .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) } } diff --git a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift index 5260bd10b2b..8a702b5a57a 100644 --- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift +++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift @@ -741,6 +741,45 @@ class IntegrationTests: XCTestCase { } } } + + func testStreamable() async { + var byName = functions.httpsCallable( + "timeoutTest", + requestAs: [Int].self, + responseAs: Int.self + ) + + do { + let stream = try await functions.httpsCallable("Why is the sky blue").stream("genStream") + + for try await line in stream { + print(line) + + } + print(stream as Any) + } catch { + print("Failed to download stream: \(error)") + } + + byName.timeoutInterval = 0.05 + var byURL = functions.httpsCallable( + emulatorURL("timeoutTest"), + requestAs: [Int].self, + responseAs: Int.self + ) + byURL.timeoutInterval = 0.05 + for function in [byName, byURL] { + do { + _ = try await function.call([]) + XCTAssertFalse(true, "Failed to throw error for missing result") + } catch { + let error = error as NSError + XCTAssertEqual(FunctionsErrorCode.deadlineExceeded.rawValue, error.code) + XCTAssertEqual("DEADLINE EXCEEDED", error.localizedDescription) + XCTAssertNil(error.userInfo["details"]) + } + } + } func testCallAsFunction() { let data = DataTestRequest( diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 23be4d7bdd8..8742123302b 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -30,7 +30,7 @@ class FunctionsTests: XCTestCase { var functionsCustomDomain: Functions? let fetcherService = GTMSessionFetcherService() let appCheckFake = FIRAppCheckFake() - + override func setUp() { super.setUp() functions = Functions( @@ -47,13 +47,13 @@ class FunctionsTests: XCTestCase { messaging: nil, appCheck: nil, fetcherService: fetcherService) } - + override func tearDown() { functions = nil functionsCustomDomain = nil super.tearDown() } - + func testFunctionsInstanceIsStablePerApp() throws { let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", gcmSenderID: "00000000000000000-00000000000-000000000") @@ -62,30 +62,30 @@ class FunctionsTests: XCTestCase { var functions1 = Functions.functions() var functions2 = Functions.functions(app: FirebaseApp.app()!) XCTAssertEqual(functions1, functions2) - + FirebaseApp.configure(name: "test", options: options) let app2 = try XCTUnwrap(FirebaseApp.app(name: "test")) functions2 = Functions.functions(app: app2, region: "us-central2") XCTAssertNotEqual(functions1, functions2) - + functions1 = Functions.functions(app: app2, region: "us-central2") XCTAssertEqual(functions1, functions2) - + functions1 = Functions.functions(customDomain: "test_domain") functions2 = Functions.functions(region: "us-central1") XCTAssertNotEqual(functions1, functions2) - + functions2 = Functions.functions(app: FirebaseApp.app()!, customDomain: "test_domain") XCTAssertEqual(functions1, functions2) } - + func testFunctionURLForName() throws { XCTAssertEqual( functions?.functionURL(for: "my-endpoint")?.absoluteString, "https://my-region-my-project.cloudfunctions.net/my-endpoint" ) } - + func testFunctionURLForNameEmulator() throws { functionsCustomDomain?.useEmulator(withHost: "localhost", port: 5005) XCTAssertEqual( @@ -93,7 +93,7 @@ class FunctionsTests: XCTestCase { "http://localhost:5005/my-project/my-region/my-endpoint" ) } - + func testFunctionURLForNameRegionWithEmulatorWithScheme() throws { functionsCustomDomain?.useEmulator(withHost: "http://localhost", port: 5005) XCTAssertEqual( @@ -101,19 +101,19 @@ class FunctionsTests: XCTestCase { "http://localhost:5005/my-project/my-region/my-endpoint" ) } - + func testFunctionURLForNameCustomDomain() throws { XCTAssertEqual( functionsCustomDomain?.functionURL(for: "my-endpoint")?.absoluteString, "https://mydomain.com/my-endpoint" ) } - + func testSetEmulatorSettings() throws { functions?.useEmulator(withHost: "localhost", port: 1000) XCTAssertEqual("http://localhost:1000", functions?.emulatorOrigin) } - + /// Test that Functions instances get deallocated. func testFunctionsLifecycle() throws { weak var weakApp: FirebaseApp? @@ -131,9 +131,9 @@ class FunctionsTests: XCTestCase { XCTAssertNil(weakApp) XCTAssertNil(weakFunctions) } - + // MARK: - App Check Integration - + func testCallFunctionWhenUsingLimitedUseAppCheckTokenThenTokenSuccess() { // Given // Stub returns of two different kinds of App Check tokens. Only the @@ -143,7 +143,7 @@ class FunctionsTests: XCTestCase { token: "limited_use_valid_token", error: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -153,10 +153,10 @@ class FunctionsTests: XCTestCase { testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -165,15 +165,15 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenLimitedUseAppCheckTokenDisabledThenCallWithoutToken() { // Given let limitedUseDummyToken = "limited use dummy token" @@ -181,7 +181,7 @@ class FunctionsTests: XCTestCase { token: limitedUseDummyToken, error: NSError(domain: #function, code: -1) ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in // Assert that header does not contain an AppCheck token. @@ -190,14 +190,14 @@ class FunctionsTests: XCTestCase { XCTAssertNotEqual(value, limitedUseDummyToken) } } - + testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: false) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -206,36 +206,36 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenLimitedUseAppCheckTokenCannotBeGeneratedThenCallWithoutToken() { // Given appCheckFake.limitedUseTokenResult = FIRAppCheckTokenResultFake( token: "dummy token", error: NSError(domain: #function, code: -1) ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in // Assert that header does not contain an AppCheck token. fetcherToTest.request?.allHTTPHeaderFields?.forEach { key, _ in XCTAssertNotEqual(key, "X-Firebase-AppCheck") } - + testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -244,15 +244,15 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenAppCheckIsInstalledAndFACTokenSuccess() { // Stub returns of two different kinds of App Check tokens. Only the // shared use token should be present in Functions's request header. @@ -261,13 +261,13 @@ class FunctionsTests: XCTestCase { token: "limited_use_valid_token", error: nil ) - + let networkError = NSError( domain: "testCallFunctionWhenAppCheckIsInstalled", code: -1, userInfo: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -276,7 +276,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + let completionExpectation = expectation(description: "completionExpectation") functions? .httpsCallable("fake_func") @@ -284,22 +284,22 @@ class FunctionsTests: XCTestCase { guard let error = error else { return XCTFail("Unexpected success: \(result!).") } - + XCTAssertEqual(error as NSError, networkError) - + completionExpectation.fulfill() } - + 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? @@ -308,7 +308,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + do { _ = try await functionsCustomDomain? .callFunction( @@ -321,17 +321,17 @@ class FunctionsTests: XCTestCase { } catch { XCTAssertEqual(error as NSError, networkError) } - + await fulfillment(of: [httpRequestExpectation], timeout: 1.5) } - + func testCallFunctionWhenAppCheckIsNotInstalled() { let networkError = NSError( domain: "testCallFunctionWhenAppCheckIsInstalled", code: -1, userInfo: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -340,7 +340,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + let completionExpectation = expectation(description: "completionExpectation") functionsCustomDomain?.callFunction( at: URL(string: "https://example.com/fake_func")!, @@ -358,18 +358,76 @@ class FunctionsTests: XCTestCase { } waitForExpectations(timeout: 1.5) } - + + func testGenerateStreamContent() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let genStream = "genStream" + let query = "Why is the sky blue" + let errorMessage = "Stream does not contain expected message" + + do { + let stream = try await functions?.httpsCallable(genStream, options: options).stream( + query + ) + if let stream = stream { + for try await line in stream { + + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"hello"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"world"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"this"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"cool"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"result":"hello world this is cool"} + """), errorMessage) + } + } + } catch { + XCTExpectFailure("Failed to download stream: \(error)") + } + } + func testGenerateStreamContentCanceled() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let genStream = "genStream" + let query = "Why is the sky blue" + let errorMessage = "Stream does not contain expected message" + do { - let stream = try await functions?.httpsCallable("testStream").stream("GenContent") + let stream = try await functions?.httpsCallable(genStream, options: options).stream( + query + ) if let stream = stream { for try await line in stream { - print(line) + + + + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"hello"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"world"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"this"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"message":{"chunk":"cool"}} + """), errorMessage) + XCTAssertTrue((line.data as AnyObject).contains(""" + {"result":"hello world this is cool"} + """), errorMessage) } } - print(stream as Any) } catch { - print("Failed to download stream: \(error)") + XCTExpectFailure("Failed to download stream: \(error)") } } } diff --git a/firebase-database-emulator.log b/firebase-database-emulator.log index ec17b5aff71..1313e5caae9 100644 --- a/firebase-database-emulator.log +++ b/firebase-database-emulator.log @@ -1,2 +1,2 @@ -18:07:50.544 [NamespaceSystem-akka.actor.default-dispatcher-5] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started -18:07:50.617 [main] INFO com.firebase.server.forge.App$ - Listening at localhost:9000 +14:49:26.407 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started +14:49:26.470 [main] INFO com.firebase.server.forge.App$ - Listening at localhost:9000 diff --git a/firebase-database-emulator.pid b/firebase-database-emulator.pid index e5da04baf77..42654939753 100644 --- a/firebase-database-emulator.pid +++ b/firebase-database-emulator.pid @@ -1 +1 @@ -4061 +35877 From a7e8fe829318bdcd942a5af6e5d23a9061048edb Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 16:02:54 -0600 Subject: [PATCH 08/38] Update functions Add Json capabilities to parse an HTTP result back and forth. Updating Unit tests. --- FirebaseFunctions/Sources/Functions.swift | 70 +++++++++----- FirebaseFunctions/Sources/HTTPSCallable.swift | 4 - .../Tests/Unit/FunctionsTests.swift | 95 ++++++++----------- 3 files changed, 86 insertions(+), 83 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index a696e090e56..858970cd343 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -619,9 +619,9 @@ enum FunctionsConstants { let data = data ?? NSNull() let encoded = try serializer.encode(data) let body = ["data": encoded] - let payload = try JSONSerialization.data(withJSONObject: body) + let payload = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]) fetcher.bodyData = payload - + // Set the headers for starting a streaming session. fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type") fetcher.setRequestValue("text/event-stream", forHTTPHeaderField: "Accept") @@ -663,27 +663,49 @@ enum FunctionsConstants { } @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) - private func processResponseDataForStreamableContent(from data: Data?, error: Error?) throws -> AsyncThrowingStream { - - return AsyncThrowingStream { continuation in - Task { - do { - var processedData = try processedResponseData(from: data, error: error) - var processedString = String(data: processedData, encoding: .utf8) - - processedString = processedString?.replacingOccurrences(of: "data:", with: "") - processedString = processedString?.replacingOccurrences(of: "result:", with: "") - processedData = Data(processedString?.utf8 ?? "".utf8) - - continuation.yield(HTTPSCallableResult(data: (processedString ?? "") as String)) - continuation.finish() - } catch { - continuation.finish(throwing: error) - throw error - } - } + private func processResponseDataForStreamableContent(from data: Data?, error: Error?) throws -> AsyncThrowingStream { + + return AsyncThrowingStream { continuation in + Task { + do { + if let error = error { + throw error + } + + guard let data = data else { + throw NSError(domain: "Error", code: -1, userInfo: nil) + } + + let dataChunk = String(data: data, encoding: .utf8) + + var resultArray = [String]() + + let dataChunkToJson = dataChunk!.split(separator:"\n").map { + String($0.dropFirst(6)) + } + + resultArray.append(contentsOf: dataChunkToJson) + + for dataChunk in resultArray{ + + let json = try callableResultFromResponse( + data: dataChunk.data(using: .utf8, allowLossyConversion: true), + error: error + ) + continuation.yield(HTTPSCallableResult(data: json.data)) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } } - } + + + + + private func responseDataJSON(from data: Data) throws -> Any { let responseJSONObject = try JSONSerialization.jsonObject(with: data) @@ -694,11 +716,11 @@ enum FunctionsConstants { } // `result` is checked for backwards compatibility: - guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] else { + guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] else { let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] throw FunctionsError(.internal, userInfo: userInfo) } - + return dataJSON } } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index cca7aa0aef9..9841b2b6eaf 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -51,11 +51,7 @@ open class HTTPSCallable: NSObject { init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) { self.functions = functions //Remove after genStream if deployed -#if DEBUG - self.url = URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")! - #else self.url = url -#endif self.options = options } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 8742123302b..fc29ac7d521 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -360,74 +360,59 @@ class FunctionsTests: XCTestCase { } + func testGenerateStreamContent() async { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - let genStream = "genStream" - let query = "Why is the sky blue" - let errorMessage = "Stream does not contain expected message" - - do { - let stream = try await functions?.httpsCallable(genStream, options: options).stream( - query - ) - if let stream = stream { - for try await line in stream { - - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"hello"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"world"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"this"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"cool"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"result":"hello world this is cool"} - """), errorMessage) + // let genStream = "genStream" + // let query = "Why is the sky blue" + // let errorMessage = "Stream does not contain expected message" + let input: [String: Any] = ["data": "Why is the sky blue"] + do { + let stream = try await functions?.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 7.0 + ) + + if let stream = stream { + for try await result in stream { + + //TODO FINISH THIS UNIT TEST + + } } + } catch { + XCTExpectFailure("Failed to download stream: \(error)") } - } catch { - XCTExpectFailure("Failed to download stream: \(error)") - } } + func testGenerateStreamContentCanceled() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - let genStream = "genStream" + let genStream = "genStreamError" let query = "Why is the sky blue" - let errorMessage = "Stream does not contain expected message" - - do { + + let task = Task.detached { [self] in + let stream = try await functions?.httpsCallable(genStream, options: options).stream( query ) - if let stream = stream { - for try await line in stream { - - - - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"hello"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"world"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"this"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"message":{"chunk":"cool"}} - """), errorMessage) - XCTAssertTrue((line.data as AnyObject).contains(""" - {"result":"hello world this is cool"} - """), errorMessage) + + do { + for try await status in stream! { + switch status { + + default: + print("status is:",status) + } } + } catch { + print("Download failed with \(error)") } - } catch { - XCTExpectFailure("Failed to download stream: \(error)") } + //TODO FINISH UNIT TEST LOGIC + task.cancel() + let result = await task.result } } From 6d59fcdfe229d260db004088761d7df70b938386 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 16:07:31 -0600 Subject: [PATCH 09/38] Update FunctionsTests.swift --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index fc29ac7d521..17147d6811b 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -377,9 +377,7 @@ class FunctionsTests: XCTestCase { if let stream = stream { for try await result in stream { - //TODO FINISH THIS UNIT TEST - } } } catch { From 51f02b85baa83e31800f0741832444691a87e210 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 16:10:21 -0600 Subject: [PATCH 10/38] Cleanup HTTPCallable Cleanup --- FirebaseFunctions/Sources/HTTPSCallable.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 9841b2b6eaf..3654526b9b9 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -50,7 +50,6 @@ open class HTTPSCallable: NSObject { @objc open var timeoutInterval: TimeInterval = 70 init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) { self.functions = functions - //Remove after genStream if deployed self.url = url self.options = options } From 7b61076c6d2394b5c2d643d711d2bfb5d7cab7c4 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 16:15:12 -0600 Subject: [PATCH 11/38] Add documentation for processResponseDataForStreamableContent Add doc for processResponseDataForStreamableContent. --- FirebaseFunctions/Sources/Functions.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 858970cd343..61c05da70c5 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -662,6 +662,20 @@ enum FunctionsConstants { return fetcher } + /** + Processes the response data for streamable content. + + This function takes optional `Data` and `Error` parameters, processes the data, and returns an `AsyncThrowingStream` of `HTTPSCallableResult` objects. It handles errors and ensures that the data is properly parsed and yielded to the stream. + + - Parameters: + - data: The optional `Data` object containing the response data. + - error: The optional `Error` object containing any error that occurred during the data retrieval. + + - Returns: An `AsyncThrowingStream` of `HTTPSCallableResult` objects. + + - Throws: An error if the data is nil or if there is an error during the processing of the data. + */ + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) private func processResponseDataForStreamableContent(from data: Data?, error: Error?) throws -> AsyncThrowingStream { From a95449e96e56db04d16b7b0276fcc5a874be0b33 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 16:17:02 -0600 Subject: [PATCH 12/38] Update Functions.swift --- FirebaseFunctions/Sources/Functions.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 61c05da70c5..a63251b011c 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -694,6 +694,7 @@ enum FunctionsConstants { var resultArray = [String]() + //We remove the "data :" field so it can be safely parsed to Json. let dataChunkToJson = dataChunk!.split(separator:"\n").map { String($0.dropFirst(6)) } @@ -729,7 +730,8 @@ enum FunctionsConstants { throw FunctionsError(.internal, userInfo: userInfo) } - // `result` is checked for backwards compatibility: + // `result` is checked for backwards compatibility, + // `message` is checked for StramableContent: guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] else { let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] throw FunctionsError(.internal, userInfo: userInfo) From cdc49eed47f62ad69198a52fb421d026238facba Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 16:17:48 -0600 Subject: [PATCH 13/38] Update Functions.swift --- FirebaseFunctions/Sources/Functions.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index a63251b011c..2e424277df2 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -718,10 +718,6 @@ enum FunctionsConstants { } - - - - private func responseDataJSON(from data: Data) throws -> Any { let responseJSONObject = try JSONSerialization.jsonObject(with: data) From 426b6bcb8e387a561adf323c85719ad974554418 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 16:24:18 -0600 Subject: [PATCH 14/38] Update FunctionsTests.swift --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 17147d6811b..8b1510b2324 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -377,7 +377,10 @@ class FunctionsTests: XCTestCase { if let stream = stream { for try await result in stream { - //TODO FINISH THIS UNIT TEST + let data = result.data as! Data + let dataChunk = String(data: data, encoding: .utf8) + + var resultArray = [String]() } } } catch { @@ -402,11 +405,11 @@ class FunctionsTests: XCTestCase { switch status { default: - print("status is:",status) + break } } } catch { - print("Download failed with \(error)") + XCTExpectFailure("Failed to download stream: \(error)") } } //TODO FINISH UNIT TEST LOGIC From 9cb0a5eccf17b1f27afd083243aeed6181607189 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 20:18:21 -0600 Subject: [PATCH 15/38] Update and Cleanup Update func to have a callback. Update UnitTests --- FirebaseFunctions/Sources/Functions.swift | 30 +++--- .../Tests/Unit/FunctionsTests.swift | 98 ++++++++++++------- 2 files changed, 81 insertions(+), 47 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 2e424277df2..26406734065 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -678,7 +678,7 @@ enum FunctionsConstants { @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) private func processResponseDataForStreamableContent(from data: Data?, error: Error?) throws -> AsyncThrowingStream { - + var resultArray = [String]() return AsyncThrowingStream { continuation in Task { do { @@ -687,20 +687,19 @@ enum FunctionsConstants { } guard let data = data else { - throw NSError(domain: "Error", code: -1, userInfo: nil) + throw NSError(domain:FunctionsErrorDomain.description, code: -1, userInfo: nil) } - let dataChunk = String(data: data, encoding: .utf8) - - var resultArray = [String]() - - //We remove the "data :" field so it can be safely parsed to Json. - let dataChunkToJson = dataChunk!.split(separator:"\n").map { - String($0.dropFirst(6)) + if let dataChunk = String(data: data, encoding: .utf8){ + //We remove the "data :" field so it can be safely parsed to Json. + let dataChunkToJson = dataChunk.split(separator:"\n").map { + String($0.dropFirst(6)) + } + resultArray.append(contentsOf: dataChunkToJson) + }else{ + throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) } - - resultArray.append(contentsOf: dataChunkToJson) - + for dataChunk in resultArray{ let json = try callableResultFromResponse( @@ -709,6 +708,12 @@ enum FunctionsConstants { ) continuation.yield(HTTPSCallableResult(data: json.data)) } + + continuation.onTermination = { @Sendable _ in + //Callback for cancelling the stream + continuation.finish() + } + //Close the stream once it's done continuation.finish() } catch { continuation.finish(throwing: error) @@ -717,7 +722,6 @@ enum FunctionsConstants { } } - private func responseDataJSON(from data: Data) throws -> Any { let responseJSONObject = try JSONSerialization.jsonObject(with: data) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 8b1510b2324..88871eba6e9 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -363,57 +363,87 @@ class FunctionsTests: XCTestCase { func testGenerateStreamContent() async { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - // let genStream = "genStream" - // let query = "Why is the sky blue" - // let errorMessage = "Stream does not contain expected message" + var response = [String]() + let input: [String: Any] = ["data": "Why is the sky blue"] - do { - let stream = try await functions?.stream( - at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, - withObject: input, - options: options, - timeout: 7.0 - ) - - if let stream = stream { - for try await result in stream { - let data = result.data as! Data - let dataChunk = String(data: data, encoding: .utf8) + do { + let stream = try await functions?.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 4.0 + ) + //Fisrt chunk of the stream comes as NSDictionary + if let stream = stream { + for try await result in stream { + if let dataChunk = result.data as? NSDictionary{ + - var resultArray = [String]() + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + }else { + //Last chunk is a the concatened result so we have to parse it as String else will fail. + if ((result.data as? String) != nil){ + response.append(result.data as! String) + } } } - } catch { - XCTExpectFailure("Failed to download stream: \(error)") + XCTAssertEqual( + response, + [ + "chunk hello", + "chunk world", + "chunk this", + "chunk is", + "chunk cool", + "hello world this is cool" + ] + ) } + } catch { + XCTExpectFailure("Failed to download stream: \(error)") + } } func testGenerateStreamContentCanceled() async { - + var response = [String]() let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - let genStream = "genStreamError" - let query = "Why is the sky blue" - + let input: [String: Any] = ["data": "Why is the sky blue"] + let task = Task.detached { [self] in - - let stream = try await functions?.httpsCallable(genStream, options: options).stream( - query + let stream = try await functions?.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 4.0 ) - - do { - for try await status in stream! { - switch status { + //Fisrt chunk of the stream comes as NSDictionary + if let stream = stream { + for try await result in stream { + if let dataChunk = result.data as? NSDictionary{ - default: - break + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + //Last chunk is a the concatened result so we have to parse it as String else will fail. + }else { + if ((result.data as? String) != nil){ + response.append(result.data as! String) + } } } - } catch { - XCTExpectFailure("Failed to download stream: \(error)") + //Since we cancel the call we are expecting an empty array. + XCTAssertEqual( + response, + [] + ) } } - //TODO FINISH UNIT TEST LOGIC + //We cancel the task and we expect a nul respone even if the stream was initiaded. task.cancel() let result = await task.result + XCTAssertNotNil(result) } + } From ad31052519ffc25fcb2528d29a91913eb70a38c5 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 2 Jan 2025 20:22:20 -0600 Subject: [PATCH 16/38] Update IntegrationTests.swift Remove test. --- .../Tests/Integration/IntegrationTests.swift | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift index 8a702b5a57a..508425668f7 100644 --- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift +++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift @@ -742,45 +742,6 @@ class IntegrationTests: XCTestCase { } } - func testStreamable() async { - var byName = functions.httpsCallable( - "timeoutTest", - requestAs: [Int].self, - responseAs: Int.self - ) - - do { - let stream = try await functions.httpsCallable("Why is the sky blue").stream("genStream") - - for try await line in stream { - print(line) - - } - print(stream as Any) - } catch { - print("Failed to download stream: \(error)") - } - - byName.timeoutInterval = 0.05 - var byURL = functions.httpsCallable( - emulatorURL("timeoutTest"), - requestAs: [Int].self, - responseAs: Int.self - ) - byURL.timeoutInterval = 0.05 - for function in [byName, byURL] { - do { - _ = try await function.call([]) - XCTAssertFalse(true, "Failed to throw error for missing result") - } catch { - let error = error as NSError - XCTAssertEqual(FunctionsErrorCode.deadlineExceeded.rawValue, error.code) - XCTAssertEqual("DEADLINE EXCEEDED", error.localizedDescription) - XCTAssertNil(error.userInfo["details"]) - } - } - } - func testCallAsFunction() { let data = DataTestRequest( bool: true, From 6ee90003e4f17d27263d4458dbd7b52b84267ce8 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 3 Jan 2025 15:23:00 -0600 Subject: [PATCH 17/38] Clean up Remove spaces. --- FirebaseFunctions/Sources/Functions.swift | 192 +++++++++--------- FirebaseFunctions/Sources/HTTPSCallable.swift | 6 +- .../Tests/Unit/FunctionsTests.swift | 34 ++-- scripts/check.sh | 2 +- 4 files changed, 120 insertions(+), 114 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 26406734065..fecdf0881be 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -182,14 +182,14 @@ enum FunctionsConstants { /// Functions invocations. @available(iOS 13.0, *) open func httpsCallable(_ name: String, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ name: String, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(name), encoder: encoder, @@ -211,15 +211,15 @@ enum FunctionsConstants { /// Functions invocations. @available(iOS 13.0, *) open func httpsCallable(_ name: String, - options: HTTPSCallableOptions, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ name: String, + options: HTTPSCallableOptions, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(name, options: options), encoder: encoder, @@ -240,14 +240,14 @@ enum FunctionsConstants { /// Functions invocations. @available(iOS 13.0, *) open func httpsCallable(_ url: URL, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ url: URL, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(url), encoder: encoder, @@ -269,15 +269,15 @@ enum FunctionsConstants { /// Functions invocations. @available(iOS 13.0, *) open func httpsCallable(_ url: URL, - options: HTTPSCallableOptions, - requestAs: Request.Type = Request.self, - responseAs: Response.Type = Response.self, - encoder: FirebaseDataEncoder = FirebaseDataEncoder( - ), - decoder: FirebaseDataDecoder = FirebaseDataDecoder( - )) - -> Callable { + Response: Decodable>(_ url: URL, + options: HTTPSCallableOptions, + requestAs: Request.Type = Request.self, + responseAs: Response.Type = Response.self, + encoder: FirebaseDataEncoder = FirebaseDataEncoder( + ), + decoder: FirebaseDataDecoder = FirebaseDataDecoder( + )) + -> Callable { return Callable( callable: httpsCallable(url, options: options), encoder: encoder, @@ -499,15 +499,18 @@ enum FunctionsConstants { @available(iOS 13.0, *) func callableResultFromResponseAsync(data: Data?, error: Error?) throws -> AsyncThrowingStream< - HTTPSCallableResult, Error - - > { - - let processedData = try processResponseDataForStreamableContent(from: data, error: error) - - return processedData - } - + HTTPSCallableResult, Error + + > { + let processedData = + try processResponseDataForStreamableContent( + from: data, + error: error + ) + + return processedData + } + private func makeFetcher(url: URL, data: Any?, options: HTTPSCallableOptions?, @@ -648,11 +651,11 @@ enum FunctionsConstants { forHTTPHeaderField: Constants.appCheckTokenHeader ) } - //Remove after genStream is updated on the emulator or deployed -#if DEBUG - fetcher.allowLocalhostRequest = true - fetcher.allowedInsecureSchemes = ["http"] -#endif + // Remove after genStream is updated on the emulator or deployed + #if DEBUG + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] + #endif // Override normal security rules if this is a local test. if emulatorOrigin != nil { fetcher.allowLocalhostRequest = true @@ -677,51 +680,55 @@ enum FunctionsConstants { */ @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) - private func processResponseDataForStreamableContent(from data: Data?, error: Error?) throws -> AsyncThrowingStream { - var resultArray = [String]() - return AsyncThrowingStream { continuation in - Task { - do { - if let error = error { - throw error - } - - guard let data = data else { - throw NSError(domain:FunctionsErrorDomain.description, code: -1, userInfo: nil) - } - - if let dataChunk = String(data: data, encoding: .utf8){ - //We remove the "data :" field so it can be safely parsed to Json. - let dataChunkToJson = dataChunk.split(separator:"\n").map { - String($0.dropFirst(6)) - } - resultArray.append(contentsOf: dataChunkToJson) - }else{ - throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) - } - - for dataChunk in resultArray{ - - let json = try callableResultFromResponse( - data: dataChunk.data(using: .utf8, allowLossyConversion: true), - error: error - ) - continuation.yield(HTTPSCallableResult(data: json.data)) - } - - continuation.onTermination = { @Sendable _ in - //Callback for cancelling the stream - continuation.finish() - } - //Close the stream once it's done - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } + private func processResponseDataForStreamableContent(from data: Data?, + error: Error?) throws -> AsyncThrowingStream< + HTTPSCallableResult, + Error + > { + + return AsyncThrowingStream { continuation in + Task { + var resultArray = [String]() + do { + if let error = error { + throw error + } + + guard let data = data else { + throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) } + + if let dataChunk = String(data: data, encoding: .utf8) { + // We remove the "data :" field so it can be safely parsed to Json. + let dataChunkToJson = dataChunk.split(separator: "\n").map { + String($0.dropFirst(6)) + } + resultArray.append(contentsOf: dataChunkToJson) + } else { + throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) + } + + for dataChunk in resultArray { + let json = try callableResultFromResponse( + data: dataChunk.data(using: .utf8, allowLossyConversion: true), + error: error + ) + continuation.yield(HTTPSCallableResult(data: json.data)) + } + + continuation.onTermination = { @Sendable _ in + // Callback for cancelling the stream + continuation.finish() + } + // Close the stream once it's done + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } } - + } + private func responseDataJSON(from data: Data) throws -> Any { let responseJSONObject = try JSONSerialization.jsonObject(with: data) @@ -732,7 +739,8 @@ enum FunctionsConstants { // `result` is checked for backwards compatibility, // `message` is checked for StramableContent: - guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] else { + guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] + else { let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] throw FunctionsError(.internal, userInfo: userInfo) } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 3654526b9b9..d1ae377e321 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -136,8 +136,8 @@ open class HTTPSCallable: NSObject { @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func stream(_ data: Any? = nil) async throws - -> AsyncThrowingStream { - try await functions - .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) + -> AsyncThrowingStream { + try await functions + .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) } } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 88871eba6e9..e3f830ba66b 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -376,15 +376,14 @@ class FunctionsTests: XCTestCase { //Fisrt chunk of the stream comes as NSDictionary if let stream = stream { for try await result in stream { - if let dataChunk = result.data as? NSDictionary{ - - + if let dataChunk = result.data as? NSDictionary { for (key, value) in dataChunk { response.append("\(key) \(value)") } - }else { - //Last chunk is a the concatened result so we have to parse it as String else will fail. - if ((result.data as? String) != nil){ + } else { + // Last chunk is a the concatened result so we have to parse it as String else will + // fail. + if (result.data as? String) != nil { response.append(result.data as! String) } } @@ -397,7 +396,7 @@ class FunctionsTests: XCTestCase { "chunk this", "chunk is", "chunk cool", - "hello world this is cool" + "hello world this is cool", ] ) } @@ -405,12 +404,12 @@ class FunctionsTests: XCTestCase { XCTExpectFailure("Failed to download stream: \(error)") } } - + func testGenerateStreamContentCanceled() async { var response = [String]() let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) let input: [String: Any] = ["data": "Why is the sky blue"] - + let task = Task.detached { [self] in let stream = try await functions?.stream( at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, @@ -418,32 +417,31 @@ class FunctionsTests: XCTestCase { options: options, timeout: 4.0 ) - //Fisrt chunk of the stream comes as NSDictionary + // Fisrt chunk of the stream comes as NSDictionary if let stream = stream { for try await result in stream { - if let dataChunk = result.data as? NSDictionary{ - + if let dataChunk = result.data as? NSDictionary { for (key, value) in dataChunk { response.append("\(key) \(value)") } - //Last chunk is a the concatened result so we have to parse it as String else will fail. - }else { - if ((result.data as? String) != nil){ + // Last chunk is a the concatened result so we have to parse it as String else will + // fail. + } else { + if (result.data as? String) != nil { response.append(result.data as! String) } } } - //Since we cancel the call we are expecting an empty array. + // Since we cancel the call we are expecting an empty array. XCTAssertEqual( response, [] ) } } - //We cancel the task and we expect a nul respone even if the stream was initiaded. + // We cancel the task and we expect a nul respone even if the stream was initiaded. task.cancel() let result = await task.result XCTAssertNotNil(result) } - } diff --git a/scripts/check.sh b/scripts/check.sh index 59cad32aa93..6a7bb70c651 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -283,7 +283,7 @@ fi set -x # Print the versions of tools being used. -python --version +python3 --version # Check lint errors. "${top_dir}/scripts/check_whitespace.sh" From 1ffe73db28d4bd50568d8faf906007ecc9f097cf Mon Sep 17 00:00:00 2001 From: Eblen M Date: Fri, 3 Jan 2025 15:32:34 -0600 Subject: [PATCH 18/38] Update check.sh --- scripts/check.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check.sh b/scripts/check.sh index 6a7bb70c651..59cad32aa93 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -283,7 +283,7 @@ fi set -x # Print the versions of tools being used. -python3 --version +python --version # Check lint errors. "${top_dir}/scripts/check_whitespace.sh" From 9fcd91e6e5d304c3743cce0208a47adaec6556d6 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Mon, 6 Jan 2025 17:27:58 -0600 Subject: [PATCH 19/38] Bump to Main. Fetch Main branch. --- FirebaseFunctions/Sources/Functions.swift | 259 +++--------------- FirebaseFunctions/Sources/HTTPSCallable.swift | 11 +- .../Tests/Unit/FunctionsTests.swift | 188 ++++--------- 3 files changed, 98 insertions(+), 360 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index fecdf0881be..d4cd4e4f54f 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -41,7 +41,7 @@ enum FunctionsConstants { /// `Functions` is the client for Cloud Functions for a Firebase project. @objc(FIRFunctions) open class Functions: NSObject { // MARK: - Private Variables - + /// The network client to use for http requests. private let fetcherService: GTMSessionFetcherService /// The projectID to use for all function references. @@ -50,25 +50,25 @@ enum FunctionsConstants { private let serializer = FunctionsSerializer() /// A factory for getting the metadata to include with function calls. private let contextProvider: FunctionsContextProvider - + /// 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 static var instances: [String: [Functions]] = [:] - + /// Lock to manage access to the instances array to avoid race conditions. private static var instancesLock: os_unfair_lock = .init() - + /// The custom domain to use for all functions references (optional). let customDomain: String? - + /// The region to use for all function references. let region: String - + // MARK: - Public APIs - + /// The current emulator origin, or `nil` if it is not set. open private(set) var emulatorOrigin: String? - + /// Creates a Cloud Functions client using the default or returns a pre-existing instance if it /// already exists. /// - Returns: A shared Functions instance initialized with the default `FirebaseApp`. @@ -79,7 +79,7 @@ enum FunctionsConstants { customDomain: nil ) } - + /// Creates a Cloud Functions client with the given app, or returns a pre-existing /// instance if one already exists. /// - Parameter app: The app for the Firebase project. @@ -87,7 +87,7 @@ enum FunctionsConstants { @objc(functionsForApp:) open class func functions(app: FirebaseApp) -> Functions { return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: nil) } - + /// Creates a Cloud Functions client with the default app and given region. /// - Parameter region: The region for the HTTP trigger, such as `us-central1`. /// - Returns: A shared Functions instance initialized with the default `FirebaseApp` and a @@ -95,7 +95,7 @@ enum FunctionsConstants { @objc(functionsForRegion:) open class func functions(region: String) -> Functions { return functions(app: FirebaseApp.app(), region: region, customDomain: nil) } - + /// Creates a Cloud Functions client with the given custom domain or returns a pre-existing /// instance if one already exists. /// - Parameter customDomain: A custom domain for the HTTP trigger, such as @@ -106,7 +106,7 @@ enum FunctionsConstants { return functions(app: FirebaseApp.app(), region: FunctionsConstants.defaultRegion, customDomain: customDomain) } - + /// Creates a Cloud Functions client with the given app and region, or returns a pre-existing /// instance if one already exists. /// - Parameters: @@ -117,7 +117,7 @@ enum FunctionsConstants { region: String) -> Functions { return functions(app: app, region: region, customDomain: nil) } - + /// Creates a Cloud Functions client with the given app and custom domain, or returns a /// pre-existing /// instance if one already exists. @@ -127,17 +127,17 @@ enum FunctionsConstants { /// - Returns: An instance of `Functions` with a custom app and HTTP trigger domain. @objc(functionsForApp:customDomain:) open class func functions(app: FirebaseApp, customDomain: String) - -> Functions { + -> Functions { return functions(app: app, region: FunctionsConstants.defaultRegion, customDomain: customDomain) } - + /// Creates a reference to the Callable HTTPS trigger with the given name. /// - Parameter name: The name of the Callable HTTPS trigger. /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithName:) open func httpsCallable(_ name: String) -> HTTPSCallable { HTTPSCallable(functions: self, url: functionURL(for: name)!) } - + /// Creates a reference to the Callable HTTPS trigger with the given name and configuration /// options. /// - Parameters: @@ -146,17 +146,17 @@ enum FunctionsConstants { /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithName:options:) public func httpsCallable(_ name: String, options: HTTPSCallableOptions) - -> HTTPSCallable { + -> HTTPSCallable { HTTPSCallable(functions: self, url: functionURL(for: name)!, options: options) } - + /// Creates a reference to the Callable HTTPS trigger with the given name. /// - Parameter url: The URL of the Callable HTTPS trigger. /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithURL:) open func httpsCallable(_ url: URL) -> HTTPSCallable { return HTTPSCallable(functions: self, url: url) } - + /// Creates a reference to the Callable HTTPS trigger with the given name and configuration /// options. /// - Parameters: @@ -165,10 +165,10 @@ enum FunctionsConstants { /// - Returns: A reference to a Callable HTTPS trigger. @objc(HTTPSCallableWithURL:options:) public func httpsCallable(_ url: URL, options: HTTPSCallableOptions) - -> HTTPSCallable { + -> HTTPSCallable { return HTTPSCallable(functions: self, url: url, options: options) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -180,7 +180,6 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. - @available(iOS 13.0, *) open func httpsCallable(_ name: String, requestAs: Request.Type = Request.self, @@ -196,7 +195,7 @@ enum FunctionsConstants { decoder: decoder ) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -209,7 +208,6 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. - @available(iOS 13.0, *) open func httpsCallable(_ name: String, options: HTTPSCallableOptions, @@ -226,7 +224,7 @@ enum FunctionsConstants { decoder: decoder ) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -238,7 +236,6 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. - @available(iOS 13.0, *) open func httpsCallable(_ url: URL, requestAs: Request.Type = Request.self, @@ -254,7 +251,7 @@ enum FunctionsConstants { decoder: decoder ) } - + /// Creates a reference to the Callable HTTPS trigger with the given name, the type of an /// `Encodable` /// request and the type of a `Decodable` response. @@ -267,7 +264,6 @@ enum FunctionsConstants { /// - decoder: The decoder instance to use to perform decoding. /// - Returns: A reference to an HTTPS-callable Cloud Function that can be used to make Cloud /// Functions invocations. - @available(iOS 13.0, *) open func httpsCallable(_ url: URL, options: HTTPSCallableOptions, @@ -284,7 +280,7 @@ enum FunctionsConstants { decoder: decoder ) } - + /** * Changes this instance to point to a Cloud Functions emulator running locally. * See https://firebase.google.com/docs/functions/local-emulator @@ -297,9 +293,9 @@ enum FunctionsConstants { let origin = String(format: "\(prefix)\(host):%li", port) emulatorOrigin = origin } - + // MARK: - Private Funcs (or Internal for tests) - + /// Solely used to have one precondition and one location where we fetch from the container. This /// previously was avoided due to default arguments but that doesn't work well with Obj-C /// compatibility. @@ -309,10 +305,10 @@ enum FunctionsConstants { fatalError("`FirebaseApp.configure()` needs to be called before using Functions.") } os_unfair_lock_lock(&instancesLock) - + // Unlock before the function returns. defer { os_unfair_lock_unlock(&instancesLock) } - + if let associatedInstances = instances[app.name] { for instance in associatedInstances { // Domains may be nil, so handle with care. @@ -333,7 +329,7 @@ enum FunctionsConstants { instances[app.name] = existingInstances + [newInstance] return newInstance } - + @objc init(projectID: String, region: String, customDomain: String?, @@ -350,7 +346,7 @@ enum FunctionsConstants { appCheck: appCheck) self.fetcherService = fetcherService } - + /// Using the component system for initialization. convenience init(app: FirebaseApp, region: String, @@ -361,7 +357,7 @@ enum FunctionsConstants { in: app.container) let appCheck = ComponentType.instance(for: AppCheckInterop.self, in: app.container) - + guard let projectID = app.options.projectID else { fatalError("Firebase Functions requires the projectID to be set in the App's Options.") } @@ -372,23 +368,23 @@ enum FunctionsConstants { messaging: messaging, appCheck: appCheck) } - + func functionURL(for name: String) -> URL? { assert(!name.isEmpty, "Name cannot be empty") - + // Check if we're using the emulator if let emulatorOrigin { return URL(string: "\(emulatorOrigin)/\(projectID)/\(region)/\(name)") } - + // Check the custom domain. if let customDomain { return URL(string: "\(customDomain)/\(name)") } - + 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?, @@ -402,7 +398,7 @@ enum FunctionsConstants { timeout: timeout, context: context ) - + do { let rawData = try await fetcher.beginFetch() return try callableResultFromResponse(data: rawData, error: nil) @@ -412,7 +408,7 @@ enum FunctionsConstants { return try callableResultFromResponse(data: nil, error: error) } } - + func callFunction(at url: URL, withObject data: Any?, options: HTTPSCallableOptions?, @@ -434,7 +430,7 @@ enum FunctionsConstants { } } } - + private func callFunction(url: URL, withObject data: Any?, options: HTTPSCallableOptions?, @@ -456,7 +452,7 @@ enum FunctionsConstants { } return } - + fetcher.beginFetch { [self] data, error in let result: Result do { @@ -464,52 +460,12 @@ enum FunctionsConstants { } catch { result = .failure(error) } - + DispatchQueue.main.async { completion(result) } } } - - @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) - func stream(at url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval) async throws - -> AsyncThrowingStream { - let context = try await contextProvider.context(options: options) - let fetcher = try makeFetcherForStreamableContent( - url: url, - data: data, - options: options, - timeout: timeout, - context: context - ) - - do { - let rawData = try await fetcher.beginFetch() - return try callableResultFromResponseAsync(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 callableResultFromResponseAsync(data: nil, error: error) - } - } - - @available(iOS 13.0, *) - func callableResultFromResponseAsync(data: Data?, - error: Error?) throws -> AsyncThrowingStream< - HTTPSCallableResult, Error - - > { - let processedData = - try processResponseDataForStreamableContent( - from: data, - error: error - ) - - return processedData - } private func makeFetcher(url: URL, data: Any?, @@ -605,129 +561,6 @@ enum FunctionsConstants { // Case 4: `error` is `nil`; `data` is not `nil`; `data` doesn’t specify an error -> OK return data } - - - private func makeFetcherForStreamableContent(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, options: [.fragmentsAllowed]) - fetcher.bodyData = payload - - // Set the headers for starting a streaming session. - fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type") - fetcher.setRequestValue("text/event-stream", forHTTPHeaderField: "Accept") - fetcher.request?.httpMethod = "POST" - if let authToken = context.authToken { - let value = "Bearer \(authToken)" - fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization") - } - - if let fcmToken = context.fcmToken { - fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) - } - - if options?.requireLimitedUseAppCheckTokens == true { - if let appCheckToken = context.limitedUseAppCheckToken { - fetcher.setRequestValue( - appCheckToken, - forHTTPHeaderField: Constants.appCheckTokenHeader - ) - } - } else if let appCheckToken = context.appCheckToken { - fetcher.setRequestValue( - appCheckToken, - forHTTPHeaderField: Constants.appCheckTokenHeader - ) - } - // Remove after genStream is updated on the emulator or deployed - #if DEBUG - fetcher.allowLocalhostRequest = true - fetcher.allowedInsecureSchemes = ["http"] - #endif - // Override normal security rules if this is a local test. - if emulatorOrigin != nil { - fetcher.allowLocalhostRequest = true - fetcher.allowedInsecureSchemes = ["http"] - } - - return fetcher - } - - /** - Processes the response data for streamable content. - - This function takes optional `Data` and `Error` parameters, processes the data, and returns an `AsyncThrowingStream` of `HTTPSCallableResult` objects. It handles errors and ensures that the data is properly parsed and yielded to the stream. - - - Parameters: - - data: The optional `Data` object containing the response data. - - error: The optional `Error` object containing any error that occurred during the data retrieval. - - - Returns: An `AsyncThrowingStream` of `HTTPSCallableResult` objects. - - - Throws: An error if the data is nil or if there is an error during the processing of the data. - */ - - @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) - private func processResponseDataForStreamableContent(from data: Data?, - error: Error?) throws -> AsyncThrowingStream< - HTTPSCallableResult, - Error - > { - - return AsyncThrowingStream { continuation in - Task { - var resultArray = [String]() - do { - if let error = error { - throw error - } - - guard let data = data else { - throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) - } - - if let dataChunk = String(data: data, encoding: .utf8) { - // We remove the "data :" field so it can be safely parsed to Json. - let dataChunkToJson = dataChunk.split(separator: "\n").map { - String($0.dropFirst(6)) - } - resultArray.append(contentsOf: dataChunkToJson) - } else { - throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) - } - - for dataChunk in resultArray { - let json = try callableResultFromResponse( - data: dataChunk.data(using: .utf8, allowLossyConversion: true), - error: error - ) - continuation.yield(HTTPSCallableResult(data: json.data)) - } - - continuation.onTermination = { @Sendable _ in - // Callback for cancelling the stream - continuation.finish() - } - // Close the stream once it's done - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } private func responseDataJSON(from data: Data) throws -> Any { let responseJSONObject = try JSONSerialization.jsonObject(with: data) @@ -737,14 +570,12 @@ enum FunctionsConstants { throw FunctionsError(.internal, userInfo: userInfo) } - // `result` is checked for backwards compatibility, - // `message` is checked for StramableContent: - guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] - else { + // `result` is checked for backwards compatibility: + guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] else { let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] throw FunctionsError(.internal, userInfo: userInfo) } - + return dataJSON } } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index d1ae377e321..2c772bc8c78 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -32,14 +32,13 @@ open class HTTPSCallableResult: NSObject { /** * A `HTTPSCallable` is a reference to a particular Callable HTTPS trigger in Cloud Functions. */ - - @objc(FIRHTTPSCallable) open class HTTPSCallable: NSObject { // MARK: - Private Properties // The functions client to use for making calls. private let functions: Functions + private let url: URL private let options: HTTPSCallableOptions? @@ -48,6 +47,7 @@ open class HTTPSCallable: NSObject { /// The timeout to use when calling the function. Defaults to 70 seconds. @objc open var timeoutInterval: TimeInterval = 70 + init(functions: Functions, url: URL, options: HTTPSCallableOptions? = nil) { self.functions = functions self.url = url @@ -133,11 +133,4 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } - - @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - open func stream(_ data: Any? = nil) async throws - -> AsyncThrowingStream { - try await functions - .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) - } } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index e3f830ba66b..42e684cdf1a 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -30,7 +30,7 @@ class FunctionsTests: XCTestCase { var functionsCustomDomain: Functions? let fetcherService = GTMSessionFetcherService() let appCheckFake = FIRAppCheckFake() - + override func setUp() { super.setUp() functions = Functions( @@ -47,13 +47,13 @@ class FunctionsTests: XCTestCase { messaging: nil, appCheck: nil, fetcherService: fetcherService) } - + override func tearDown() { functions = nil functionsCustomDomain = nil super.tearDown() } - + func testFunctionsInstanceIsStablePerApp() throws { let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000", gcmSenderID: "00000000000000000-00000000000-000000000") @@ -62,30 +62,30 @@ class FunctionsTests: XCTestCase { var functions1 = Functions.functions() var functions2 = Functions.functions(app: FirebaseApp.app()!) XCTAssertEqual(functions1, functions2) - + FirebaseApp.configure(name: "test", options: options) let app2 = try XCTUnwrap(FirebaseApp.app(name: "test")) functions2 = Functions.functions(app: app2, region: "us-central2") XCTAssertNotEqual(functions1, functions2) - + functions1 = Functions.functions(app: app2, region: "us-central2") XCTAssertEqual(functions1, functions2) - + functions1 = Functions.functions(customDomain: "test_domain") functions2 = Functions.functions(region: "us-central1") XCTAssertNotEqual(functions1, functions2) - + functions2 = Functions.functions(app: FirebaseApp.app()!, customDomain: "test_domain") XCTAssertEqual(functions1, functions2) } - + func testFunctionURLForName() throws { XCTAssertEqual( functions?.functionURL(for: "my-endpoint")?.absoluteString, "https://my-region-my-project.cloudfunctions.net/my-endpoint" ) } - + func testFunctionURLForNameEmulator() throws { functionsCustomDomain?.useEmulator(withHost: "localhost", port: 5005) XCTAssertEqual( @@ -93,7 +93,7 @@ class FunctionsTests: XCTestCase { "http://localhost:5005/my-project/my-region/my-endpoint" ) } - + func testFunctionURLForNameRegionWithEmulatorWithScheme() throws { functionsCustomDomain?.useEmulator(withHost: "http://localhost", port: 5005) XCTAssertEqual( @@ -101,19 +101,19 @@ class FunctionsTests: XCTestCase { "http://localhost:5005/my-project/my-region/my-endpoint" ) } - + func testFunctionURLForNameCustomDomain() throws { XCTAssertEqual( functionsCustomDomain?.functionURL(for: "my-endpoint")?.absoluteString, "https://mydomain.com/my-endpoint" ) } - + func testSetEmulatorSettings() throws { functions?.useEmulator(withHost: "localhost", port: 1000) XCTAssertEqual("http://localhost:1000", functions?.emulatorOrigin) } - + /// Test that Functions instances get deallocated. func testFunctionsLifecycle() throws { weak var weakApp: FirebaseApp? @@ -131,9 +131,9 @@ class FunctionsTests: XCTestCase { XCTAssertNil(weakApp) XCTAssertNil(weakFunctions) } - + // MARK: - App Check Integration - + func testCallFunctionWhenUsingLimitedUseAppCheckTokenThenTokenSuccess() { // Given // Stub returns of two different kinds of App Check tokens. Only the @@ -143,7 +143,7 @@ class FunctionsTests: XCTestCase { token: "limited_use_valid_token", error: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -153,10 +153,10 @@ class FunctionsTests: XCTestCase { testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -165,15 +165,15 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenLimitedUseAppCheckTokenDisabledThenCallWithoutToken() { // Given let limitedUseDummyToken = "limited use dummy token" @@ -181,7 +181,7 @@ class FunctionsTests: XCTestCase { token: limitedUseDummyToken, error: NSError(domain: #function, code: -1) ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in // Assert that header does not contain an AppCheck token. @@ -190,14 +190,14 @@ class FunctionsTests: XCTestCase { XCTAssertNotEqual(value, limitedUseDummyToken) } } - + testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: false) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -206,36 +206,36 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenLimitedUseAppCheckTokenCannotBeGeneratedThenCallWithoutToken() { // Given appCheckFake.limitedUseTokenResult = FIRAppCheckTokenResultFake( token: "dummy token", error: NSError(domain: #function, code: -1) ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in // Assert that header does not contain an AppCheck token. fetcherToTest.request?.allHTTPHeaderFields?.forEach { key, _ in XCTAssertNotEqual(key, "X-Firebase-AppCheck") } - + testResponse(nil, "{\"data\":\"May the force be with you!\"}".data(using: .utf8), nil) httpRequestExpectation.fulfill() } - + // When let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - + // Then let completionExpectation = expectation(description: "completionExpectation") functions? @@ -244,15 +244,15 @@ class FunctionsTests: XCTestCase { guard let result = result else { return XCTFail("Unexpected error: \(error!).") } - + XCTAssertEqual(result.data as! String, "May the force be with you!") - + completionExpectation.fulfill() } - + waitForExpectations(timeout: 1.5) } - + func testCallFunctionWhenAppCheckIsInstalledAndFACTokenSuccess() { // Stub returns of two different kinds of App Check tokens. Only the // shared use token should be present in Functions's request header. @@ -261,13 +261,13 @@ class FunctionsTests: XCTestCase { token: "limited_use_valid_token", error: nil ) - + let networkError = NSError( domain: "testCallFunctionWhenAppCheckIsInstalled", code: -1, userInfo: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -276,7 +276,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + let completionExpectation = expectation(description: "completionExpectation") functions? .httpsCallable("fake_func") @@ -284,22 +284,22 @@ class FunctionsTests: XCTestCase { guard let error = error else { return XCTFail("Unexpected success: \(result!).") } - + XCTAssertEqual(error as NSError, networkError) - + completionExpectation.fulfill() } - + 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? @@ -308,7 +308,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + do { _ = try await functionsCustomDomain? .callFunction( @@ -321,17 +321,17 @@ class FunctionsTests: XCTestCase { } catch { XCTAssertEqual(error as NSError, networkError) } - + await fulfillment(of: [httpRequestExpectation], timeout: 1.5) } - + func testCallFunctionWhenAppCheckIsNotInstalled() { let networkError = NSError( domain: "testCallFunctionWhenAppCheckIsInstalled", code: -1, userInfo: nil ) - + let httpRequestExpectation = expectation(description: "HTTPRequestExpectation") fetcherService.testBlock = { fetcherToTest, testResponse in let appCheckTokenHeader = fetcherToTest.request? @@ -340,7 +340,7 @@ class FunctionsTests: XCTestCase { testResponse(nil, nil, networkError) httpRequestExpectation.fulfill() } - + let completionExpectation = expectation(description: "completionExpectation") functionsCustomDomain?.callFunction( at: URL(string: "https://example.com/fake_func")!, @@ -358,90 +358,4 @@ class FunctionsTests: XCTestCase { } waitForExpectations(timeout: 1.5) } - - - - func testGenerateStreamContent() async { - let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - var response = [String]() - - let input: [String: Any] = ["data": "Why is the sky blue"] - do { - let stream = try await functions?.stream( - at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, - withObject: input, - options: options, - timeout: 4.0 - ) - //Fisrt chunk of the stream comes as NSDictionary - if let stream = stream { - for try await result in stream { - if let dataChunk = result.data as? NSDictionary { - for (key, value) in dataChunk { - response.append("\(key) \(value)") - } - } else { - // Last chunk is a the concatened result so we have to parse it as String else will - // fail. - if (result.data as? String) != nil { - response.append(result.data as! String) - } - } - } - XCTAssertEqual( - response, - [ - "chunk hello", - "chunk world", - "chunk this", - "chunk is", - "chunk cool", - "hello world this is cool", - ] - ) - } - } catch { - XCTExpectFailure("Failed to download stream: \(error)") - } - } - - func testGenerateStreamContentCanceled() async { - var response = [String]() - let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - let input: [String: Any] = ["data": "Why is the sky blue"] - - let task = Task.detached { [self] in - let stream = try await functions?.stream( - at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, - withObject: input, - options: options, - timeout: 4.0 - ) - // Fisrt chunk of the stream comes as NSDictionary - if let stream = stream { - for try await result in stream { - if let dataChunk = result.data as? NSDictionary { - for (key, value) in dataChunk { - response.append("\(key) \(value)") - } - // Last chunk is a the concatened result so we have to parse it as String else will - // fail. - } else { - if (result.data as? String) != nil { - response.append(result.data as! String) - } - } - } - // Since we cancel the call we are expecting an empty array. - XCTAssertEqual( - response, - [] - ) - } - } - // We cancel the task and we expect a nul respone even if the stream was initiaded. - task.cancel() - let result = await task.result - XCTAssertNotNil(result) - } } From f4d678b20063f8a48faaf5d99f0f7f0e112becc4 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Mon, 6 Jan 2025 17:36:47 -0600 Subject: [PATCH 20/38] Cleanup Project clean up. --- FirebaseFunctions/Sources/Functions.swift | 174 ++++++++++++++++-- FirebaseFunctions/Sources/HTTPSCallable.swift | 6 + .../Tests/Unit/FunctionsTests.swift | 84 +++++++++ 3 files changed, 252 insertions(+), 12 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index d4cd4e4f54f..044d396ec4e 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -466,7 +466,105 @@ enum FunctionsConstants { } } } + + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) + func stream(at url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) async throws + -> AsyncThrowingStream { + let context = try await contextProvider.context(options: options) + let fetcher = try makeFetcherForStreamableContent( + url: url, + data: data, + options: options, + timeout: timeout, + context: context + ) + + do { + let rawData = try await fetcher.beginFetch() + return try callableResultFromResponseAsync(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 callableResultFromResponseAsync(data: nil, error: error) + } + } + + @available(iOS 13.0, *) + func callableResultFromResponseAsync(data: Data?, + error: Error?) throws -> AsyncThrowingStream< + HTTPSCallableResult, Error + + > { + let processedData = + try processResponseDataForStreamableContent( + from: data, + error: error + ) + + return processedData + } + private func makeFetcherForStreamableContent(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, options: [.fragmentsAllowed]) + fetcher.bodyData = payload + + // Set the headers for starting a streaming session. + fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type") + fetcher.setRequestValue("text/event-stream", forHTTPHeaderField: "Accept") + fetcher.request?.httpMethod = "POST" + if let authToken = context.authToken { + let value = "Bearer \(authToken)" + fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization") + } + + if let fcmToken = context.fcmToken { + fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) + } + + if options?.requireLimitedUseAppCheckTokens == true { + if let appCheckToken = context.limitedUseAppCheckToken { + fetcher.setRequestValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + } else if let appCheckToken = context.appCheckToken { + fetcher.setRequestValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + // Remove after genStream is updated on the emulator or deployed + #if DEBUG + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] + #endif + // Override normal security rules if this is a local test. + if emulatorOrigin != nil { + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] + } + + return fetcher + } + private func makeFetcher(url: URL, data: Any?, options: HTTPSCallableOptions?, @@ -561,21 +659,73 @@ enum FunctionsConstants { // Case 4: `error` is `nil`; `data` is not `nil`; `data` doesn’t specify an error -> OK return data } + + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) + private func processResponseDataForStreamableContent(from data: Data?, + error: Error?) throws -> AsyncThrowingStream< + HTTPSCallableResult, + Error + > { + + return AsyncThrowingStream { continuation in + Task { + var resultArray = [String]() + do { + if let error = error { + throw error + } + + guard let data = data else { + throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) + } + + if let dataChunk = String(data: data, encoding: .utf8) { + // We remove the "data :" field so it can be safely parsed to Json. + let dataChunkToJson = dataChunk.split(separator: "\n").map { + String($0.dropFirst(6)) + } + resultArray.append(contentsOf: dataChunkToJson) + } else { + throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) + } + + for dataChunk in resultArray { + let json = try callableResultFromResponse( + data: dataChunk.data(using: .utf8, allowLossyConversion: true), + error: error + ) + continuation.yield(HTTPSCallableResult(data: json.data)) + } + + continuation.onTermination = { @Sendable _ in + // Callback for cancelling the stream + continuation.finish() + } + // Close the stream once it's done + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } private func responseDataJSON(from data: Data) throws -> Any { - let responseJSONObject = try JSONSerialization.jsonObject(with: data) + let responseJSONObject = try JSONSerialization.jsonObject(with: data) - guard let responseJSON = responseJSONObject as? NSDictionary else { - let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] - throw FunctionsError(.internal, userInfo: userInfo) - } + guard let responseJSON = responseJSONObject as? NSDictionary else { + let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] + throw FunctionsError(.internal, userInfo: userInfo) + } - // `result` is checked for backwards compatibility: - guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] else { - let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] - throw FunctionsError(.internal, userInfo: userInfo) + // `result` is checked for backwards compatibility, + // `message` is checked for StramableContent: + guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] + else { + let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] + throw FunctionsError(.internal, userInfo: userInfo) + } + + return dataJSON } - - return dataJSON - } } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 2c772bc8c78..34585b5a884 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -133,4 +133,10 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) + open func stream(_ data: Any? = nil) async throws + -> AsyncThrowingStream { + try await functions + .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) + } } diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 42e684cdf1a..c9bc92af361 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -358,4 +358,88 @@ class FunctionsTests: XCTestCase { } waitForExpectations(timeout: 1.5) } + + func testGenerateStreamContent() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + var response = [String]() + + let input: [String: Any] = ["data": "Why is the sky blue"] + do { + let stream = try await functions?.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 4.0 + ) + //Fisrt chunk of the stream comes as NSDictionary + if let stream = stream { + for try await result in stream { + if let dataChunk = result.data as? NSDictionary { + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + } else { + // Last chunk is a the concatened result so we have to parse it as String else will + // fail. + if (result.data as? String) != nil { + response.append(result.data as! String) + } + } + } + XCTAssertEqual( + response, + [ + "chunk hello", + "chunk world", + "chunk this", + "chunk is", + "chunk cool", + "hello world this is cool", + ] + ) + } + } catch { + XCTExpectFailure("Failed to download stream: \(error)") + } + } + + func testGenerateStreamContentCanceled() async { + var response = [String]() + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = try await functions?.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 4.0 + ) + // Fisrt chunk of the stream comes as NSDictionary + if let stream = stream { + for try await result in stream { + if let dataChunk = result.data as? NSDictionary { + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + // Last chunk is a the concatened result so we have to parse it as String else will + // fail. + } else { + if (result.data as? String) != nil { + response.append(result.data as! String) + } + } + } + // Since we cancel the call we are expecting an empty array. + XCTAssertEqual( + response, + [] + ) + } + } + // We cancel the task and we expect a nul respone even if the stream was initiaded. + task.cancel() + let result = await task.result + XCTAssertNotNil(result) + } } From 18f748b507d0d00f2f4fb7ddb7bd53a8274574b0 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Mon, 6 Jan 2025 18:52:14 -0600 Subject: [PATCH 21/38] Update Functions.swift Updated to renamed code. From callableResultFromResponse to callableResult --- FirebaseFunctions/Sources/Functions.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index ca4e053c90b..795b211cfb9 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -684,9 +684,12 @@ enum FunctionsConstants { } for dataChunk in resultArray { - let json = try callableResultFromResponse( - data: dataChunk.data(using: .utf8, allowLossyConversion: true), - error: error + let json = try callableResult( + fromResponseData: dataChunk.data( + using: .utf8, + allowLossyConversion: true + ) ?? Data() + ) continuation.yield(HTTPSCallableResult(data: json.data)) } From 4f956fb247ff62f8640d42cadaa1a286fa240e2f Mon Sep 17 00:00:00 2001 From: Eblen M Date: Mon, 6 Jan 2025 22:11:27 -0600 Subject: [PATCH 22/38] Lint check Run style.sh --- FirebaseFunctions/Sources/Functions.swift | 292 +++++++++--------- FirebaseFunctions/Sources/HTTPSCallable.swift | 11 +- .../Tests/Integration/IntegrationTests.swift | 2 +- .../Tests/Unit/FunctionsTests.swift | 144 ++++----- 4 files changed, 225 insertions(+), 224 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 795b211cfb9..990ff82cc2f 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -470,105 +470,106 @@ enum FunctionsConstants { } } } - + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) - func stream(at url: URL, - withObject data: Any?, - options: HTTPSCallableOptions?, - timeout: TimeInterval) async throws + func stream(at url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) async throws -> AsyncThrowingStream { - let context = try await contextProvider.context(options: options) - let fetcher = try makeFetcherForStreamableContent( - url: url, - data: data, - options: options, - timeout: timeout, - context: context - ) - - do { - let rawData = try await fetcher.beginFetch() - return try callableResultFromResponseAsync(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 callableResultFromResponseAsync(data: nil, error: error) - } + let context = try await contextProvider.context(options: options) + let fetcher = try makeFetcherForStreamableContent( + url: url, + data: data, + options: options, + timeout: timeout, + context: context + ) + + do { + let rawData = try await fetcher.beginFetch() + return try callableResultFromResponseAsync(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 callableResultFromResponseAsync(data: nil, error: error) } + } @available(iOS 13.0, *) - func callableResultFromResponseAsync(data: Data?, - error: Error?) throws -> AsyncThrowingStream< - HTTPSCallableResult, Error + func callableResultFromResponseAsync(data: Data?, + error: Error?) throws -> AsyncThrowingStream< + HTTPSCallableResult, Error + + > { + let processedData = + try processResponseDataForStreamableContent( + from: data, + error: error + ) - > { - let processedData = - try processResponseDataForStreamableContent( - from: data, - error: error - ) + return processedData + } + + private func makeFetcherForStreamableContent(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) - return processedData + let data = data ?? NSNull() + let encoded = try serializer.encode(data) + let body = ["data": encoded] + let payload = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]) + fetcher.bodyData = payload + + // Set the headers for starting a streaming session. + fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type") + fetcher.setRequestValue("text/event-stream", forHTTPHeaderField: "Accept") + fetcher.request?.httpMethod = "POST" + if let authToken = context.authToken { + let value = "Bearer \(authToken)" + fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization") } - private func makeFetcherForStreamableContent(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, options: [.fragmentsAllowed]) - fetcher.bodyData = payload - - // Set the headers for starting a streaming session. - fetcher.setRequestValue("application/json", forHTTPHeaderField: "Content-Type") - fetcher.setRequestValue("text/event-stream", forHTTPHeaderField: "Accept") - fetcher.request?.httpMethod = "POST" - if let authToken = context.authToken { - let value = "Bearer \(authToken)" - fetcher.setRequestValue(value, forHTTPHeaderField: "Authorization") - } - - if let fcmToken = context.fcmToken { - fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) - } - - if options?.requireLimitedUseAppCheckTokens == true { - if let appCheckToken = context.limitedUseAppCheckToken { - fetcher.setRequestValue( - appCheckToken, - forHTTPHeaderField: Constants.appCheckTokenHeader - ) - } - } else if let appCheckToken = context.appCheckToken { + if let fcmToken = context.fcmToken { + fetcher.setRequestValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) + } + + if options?.requireLimitedUseAppCheckTokens == true { + if let appCheckToken = context.limitedUseAppCheckToken { fetcher.setRequestValue( appCheckToken, forHTTPHeaderField: Constants.appCheckTokenHeader ) } - // Remove after genStream is updated on the emulator or deployed - #if DEBUG - fetcher.allowLocalhostRequest = true - fetcher.allowedInsecureSchemes = ["http"] - #endif - // Override normal security rules if this is a local test. - if emulatorOrigin != nil { - fetcher.allowLocalhostRequest = true - fetcher.allowedInsecureSchemes = ["http"] - } - - return fetcher + } else if let appCheckToken = context.appCheckToken { + fetcher.setRequestValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + // Remove after genStream is updated on the emulator or deployed + #if DEBUG + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] + #endif + // Override normal security rules if this is a local test. + if emulatorOrigin != nil { + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] } - + + return fetcher + } + private func makeFetcher(url: URL, data: Any?, options: HTTPSCallableOptions?, @@ -653,76 +654,75 @@ enum FunctionsConstants { return data } - + @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) - private func processResponseDataForStreamableContent(from data: Data?, - error: Error?) throws -> AsyncThrowingStream< - HTTPSCallableResult, - Error - > { - - return AsyncThrowingStream { continuation in - Task { - var resultArray = [String]() - do { - if let error = error { - throw error - } - - guard let data = data else { - throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) - } - - if let dataChunk = String(data: data, encoding: .utf8) { - // We remove the "data :" field so it can be safely parsed to Json. - let dataChunkToJson = dataChunk.split(separator: "\n").map { - String($0.dropFirst(6)) - } - resultArray.append(contentsOf: dataChunkToJson) - } else { - throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) - } - - for dataChunk in resultArray { - let json = try callableResult( + private func processResponseDataForStreamableContent(from data: Data?, + error: Error?) throws + -> AsyncThrowingStream< + HTTPSCallableResult, + Error + > { + return AsyncThrowingStream { continuation in + Task { + var resultArray = [String]() + do { + if let error = error { + throw error + } + + guard let data = data else { + throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) + } + + if let dataChunk = String(data: data, encoding: .utf8) { + // We remove the "data :" field so it can be safely parsed to Json. + let dataChunkToJson = dataChunk.split(separator: "\n").map { + String($0.dropFirst(6)) + } + resultArray.append(contentsOf: dataChunkToJson) + } else { + throw NSError(domain: FunctionsErrorDomain.description, code: -1, userInfo: nil) + } + + for dataChunk in resultArray { + let json = try callableResult( fromResponseData: dataChunk.data( using: .utf8, allowLossyConversion: true ) ?? Data() - - ) - continuation.yield(HTTPSCallableResult(data: json.data)) - } - - continuation.onTermination = { @Sendable _ in - // Callback for cancelling the stream - continuation.finish() - } - // Close the stream once it's done - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } + ) + continuation.yield(HTTPSCallableResult(data: json.data)) + } + + continuation.onTermination = { @Sendable _ in + // Callback for cancelling the stream + continuation.finish() + } + // Close the stream once it's done + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } private func responseDataJSON(from data: Data) throws -> Any { - let responseJSONObject = try JSONSerialization.jsonObject(with: data) + let responseJSONObject = try JSONSerialization.jsonObject(with: data) - guard let responseJSON = responseJSONObject as? NSDictionary else { - let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] - throw FunctionsError(.internal, userInfo: userInfo) - } + guard let responseJSON = responseJSONObject as? NSDictionary else { + let userInfo = [NSLocalizedDescriptionKey: "Response was not a dictionary."] + throw FunctionsError(.internal, userInfo: userInfo) + } - // `result` is checked for backwards compatibility, - // `message` is checked for StramableContent: - guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] - else { - let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] - throw FunctionsError(.internal, userInfo: userInfo) - } - - return dataJSON + // `result` is checked for backwards compatibility, + // `message` is checked for StramableContent: + guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] ?? responseJSON["message"] + else { + let userInfo = [NSLocalizedDescriptionKey: "Response is missing data field."] + throw FunctionsError(.internal, userInfo: userInfo) } + + return dataJSON + } } diff --git a/FirebaseFunctions/Sources/HTTPSCallable.swift b/FirebaseFunctions/Sources/HTTPSCallable.swift index 40c17585c2c..c53ddd941c9 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -143,10 +143,11 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } + @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) - open func stream(_ data: Any? = nil) async throws - -> AsyncThrowingStream { - try await functions - .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) - } + open func stream(_ data: Any? = nil) async throws + -> AsyncThrowingStream { + try await functions + .stream(at: url, withObject: data, options: options, timeout: timeoutInterval) + } } diff --git a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift index 508425668f7..5260bd10b2b 100644 --- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift +++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift @@ -741,7 +741,7 @@ class IntegrationTests: XCTestCase { } } } - + func testCallAsFunction() { let data = DataTestRequest( bool: true, diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index c9bc92af361..089a099036c 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -358,88 +358,88 @@ class FunctionsTests: XCTestCase { } waitForExpectations(timeout: 1.5) } - + func testGenerateStreamContent() async { - let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - var response = [String]() - - let input: [String: Any] = ["data": "Why is the sky blue"] - do { - let stream = try await functions?.stream( - at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, - withObject: input, - options: options, - timeout: 4.0 - ) - //Fisrt chunk of the stream comes as NSDictionary - if let stream = stream { - for try await result in stream { - if let dataChunk = result.data as? NSDictionary { - for (key, value) in dataChunk { - response.append("\(key) \(value)") - } - } else { - // Last chunk is a the concatened result so we have to parse it as String else will - // fail. - if (result.data as? String) != nil { - response.append(result.data as! String) - } + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + var response = [String]() + + let input: [String: Any] = ["data": "Why is the sky blue"] + do { + let stream = try await functions?.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 4.0 + ) + // Fisrt chunk of the stream comes as NSDictionary + if let stream = stream { + for try await result in stream { + if let dataChunk = result.data as? NSDictionary { + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + } else { + // Last chunk is a the concatened result so we have to parse it as String else will + // fail. + if (result.data as? String) != nil { + response.append(result.data as! String) } } - XCTAssertEqual( - response, - [ - "chunk hello", - "chunk world", - "chunk this", - "chunk is", - "chunk cool", - "hello world this is cool", - ] - ) } - } catch { - XCTExpectFailure("Failed to download stream: \(error)") + XCTAssertEqual( + response, + [ + "chunk hello", + "chunk world", + "chunk this", + "chunk is", + "chunk cool", + "hello world this is cool", + ] + ) } + } catch { + XCTExpectFailure("Failed to download stream: \(error)") } + } - func testGenerateStreamContentCanceled() async { - var response = [String]() - let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - let input: [String: Any] = ["data": "Why is the sky blue"] - - let task = Task.detached { [self] in - let stream = try await functions?.stream( - at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, - withObject: input, - options: options, - timeout: 4.0 - ) - // Fisrt chunk of the stream comes as NSDictionary - if let stream = stream { - for try await result in stream { - if let dataChunk = result.data as? NSDictionary { - for (key, value) in dataChunk { - response.append("\(key) \(value)") - } - // Last chunk is a the concatened result so we have to parse it as String else will - // fail. - } else { - if (result.data as? String) != nil { - response.append(result.data as! String) - } + func testGenerateStreamContentCanceled() async { + var response = [String]() + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = try await functions?.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 4.0 + ) + // Fisrt chunk of the stream comes as NSDictionary + if let stream = stream { + for try await result in stream { + if let dataChunk = result.data as? NSDictionary { + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + // Last chunk is a the concatened result so we have to parse it as String else will + // fail. + } else { + if (result.data as? String) != nil { + response.append(result.data as! String) } } - // Since we cancel the call we are expecting an empty array. - XCTAssertEqual( - response, - [] - ) } + // Since we cancel the call we are expecting an empty array. + XCTAssertEqual( + response, + [] + ) } - // We cancel the task and we expect a nul respone even if the stream was initiaded. - task.cancel() - let result = await task.result - XCTAssertNotNil(result) } + // We cancel the task and we expect a nul respone even if the stream was initiaded. + task.cancel() + let result = await task.result + XCTAssertNotNil(result) + } } From 4edc0ad1fb86d002ef745c3a40be42927bbc7708 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Tue, 7 Jan 2025 14:45:58 -0600 Subject: [PATCH 23/38] Function concurrency error Fix concurrency " mutation of captured var 'response' in concurrently-executing code" and typos. --- .../Tests/Unit/FunctionsTests.swift | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 089a099036c..76d3b2caca3 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -362,6 +362,7 @@ class FunctionsTests: XCTestCase { func testGenerateStreamContent() async { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) var response = [String]() + let responseQueue = DispatchQueue(label: "responseQueue") let input: [String: Any] = ["data": "Why is the sky blue"] do { @@ -371,18 +372,22 @@ class FunctionsTests: XCTestCase { options: options, timeout: 4.0 ) - // Fisrt chunk of the stream comes as NSDictionary + // First chunk of the stream comes as NSDictionary if let stream = stream { for try await result in stream { if let dataChunk = result.data as? NSDictionary { for (key, value) in dataChunk { - response.append("\(key) \(value)") + responseQueue.sync { + response.append("\(key) \(value)") + } } } else { - // Last chunk is a the concatened result so we have to parse it as String else will + // Last chunk is the concatenated result so we have to parse it as String else will // fail. - if (result.data as? String) != nil { - response.append(result.data as! String) + if let dataString = result.data as? String { + responseQueue.sync { + response.append(dataString) + } } } } @@ -405,6 +410,7 @@ class FunctionsTests: XCTestCase { func testGenerateStreamContentCanceled() async { var response = [String]() + let responseQueue = DispatchQueue(label: "responseQueue") let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) let input: [String: Any] = ["data": "Why is the sky blue"] @@ -415,18 +421,22 @@ class FunctionsTests: XCTestCase { options: options, timeout: 4.0 ) - // Fisrt chunk of the stream comes as NSDictionary + // First chunk of the stream comes as NSDictionary if let stream = stream { for try await result in stream { if let dataChunk = result.data as? NSDictionary { for (key, value) in dataChunk { - response.append("\(key) \(value)") + responseQueue.sync { + response.append("\(key) \(value)") + } } - // Last chunk is a the concatened result so we have to parse it as String else will - // fail. } else { - if (result.data as? String) != nil { - response.append(result.data as! String) + // Last chunk is the concatenated result so we have to parse it as String else will + // fail. + if let dataString = result.data as? String { + responseQueue.sync { + response.append(dataString) + } } } } From e50f69cd5ac0d564f0074104d33a2d114e5147f7 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 17:44:24 -0600 Subject: [PATCH 24/38] Update .github/workflows/functions.yml Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- .github/workflows/functions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/functions.yml b/.github/workflows/functions.yml index 94f016d232b..5b7dc6c26cc 100644 --- a/.github/workflows/functions.yml +++ b/.github/workflows/functions.yml @@ -236,4 +236,4 @@ jobs: - name: PodLibLint Functions Cron run: | scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb \ - FirebaseFunctions.podspec --platforms=${{ matrix.target }} --use-static-frameworks \ No newline at end of file + FirebaseFunctions.podspec --platforms=${{ matrix.target }} --use-static-frameworks From 7356cf96a6d88a093e9ed6003770a951dea5ec95 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 17:45:30 -0600 Subject: [PATCH 25/38] Update FirebaseFunctions/Tests/Unit/FunctionsTests.swift Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 76d3b2caca3..e993381625b 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -447,7 +447,7 @@ class FunctionsTests: XCTestCase { ) } } - // We cancel the task and we expect a nul respone even if the stream was initiaded. + // We cancel the task and we expect a null response even if the stream was initiated. task.cancel() let result = await task.result XCTAssertNotNil(result) From aed47d636b9f00d00843962b6059f62b575e0606 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 17:52:23 -0600 Subject: [PATCH 26/38] Delete firebase-database-emulator.log This file is not needed. --- firebase-database-emulator.log | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 firebase-database-emulator.log diff --git a/firebase-database-emulator.log b/firebase-database-emulator.log deleted file mode 100644 index 1313e5caae9..00000000000 --- a/firebase-database-emulator.log +++ /dev/null @@ -1,2 +0,0 @@ -14:49:26.407 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started -14:49:26.470 [main] INFO com.firebase.server.forge.App$ - Listening at localhost:9000 From f6c6cff7e9a237313767ce773828daca96f9b51f Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 17:54:00 -0600 Subject: [PATCH 27/38] Delete firebase-database-emulator.pid This file is not needed. --- firebase-database-emulator.pid | 1 - 1 file changed, 1 deletion(-) delete mode 100644 firebase-database-emulator.pid diff --git a/firebase-database-emulator.pid b/firebase-database-emulator.pid deleted file mode 100644 index 42654939753..00000000000 --- a/firebase-database-emulator.pid +++ /dev/null @@ -1 +0,0 @@ -35877 From 75a757470546faf52d28788a13fd9f73defe1d77 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 17:57:25 -0600 Subject: [PATCH 28/38] Update function error handling. Add throws Remove DO - CATCH --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index e993381625b..e7147b09af4 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -359,13 +359,12 @@ class FunctionsTests: XCTestCase { waitForExpectations(timeout: 1.5) } - func testGenerateStreamContent() async { + func testGenerateStreamContent() async throws { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) var response = [String]() let responseQueue = DispatchQueue(label: "responseQueue") let input: [String: Any] = ["data": "Why is the sky blue"] - do { let stream = try await functions?.stream( at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, withObject: input, @@ -403,9 +402,7 @@ class FunctionsTests: XCTestCase { ] ) } - } catch { - XCTExpectFailure("Failed to download stream: \(error)") - } + XCTExpectFailure("Failed to download stream") } func testGenerateStreamContentCanceled() async { From 9ef7411268daa77b1a468c11e6321662dd57a2d5 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 18:10:35 -0600 Subject: [PATCH 29/38] Update FirebaseFunctions/Tests/Unit/FunctionsTests.swift Accept suggestion. Thanks. Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- .../Tests/Unit/FunctionsTests.swift | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index e7147b09af4..c1753c3b54e 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -406,43 +406,36 @@ class FunctionsTests: XCTestCase { } func testGenerateStreamContentCanceled() async { - var response = [String]() - let responseQueue = DispatchQueue(label: "responseQueue") let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) let input: [String: Any] = ["data": "Why is the sky blue"] let task = Task.detached { [self] in - let stream = try await functions?.stream( + let stream = try await functions!.stream( at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, withObject: input, options: options, timeout: 4.0 ) // First chunk of the stream comes as NSDictionary - if let stream = stream { - for try await result in stream { - if let dataChunk = result.data as? NSDictionary { - for (key, value) in dataChunk { - responseQueue.sync { - response.append("\(key) \(value)") - } - } - } else { - // Last chunk is the concatenated result so we have to parse it as String else will - // fail. - if let dataString = result.data as? String { - responseQueue.sync { - response.append(dataString) - } - } + var response = [String]() + for try await result in stream { + if let dataChunk = result.data as? NSDictionary { + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + } else { + // Last chunk is the concatenated result so we have to parse it as String else will + // fail. + if let dataString = result.data as? String { + response.append(dataString) } } - // Since we cancel the call we are expecting an empty array. - XCTAssertEqual( - response, - [] - ) } + // Since we cancel the call we are expecting an empty array. + XCTAssertEqual( + response, + [] + ) } // We cancel the task and we expect a null response even if the stream was initiated. task.cancel() From 4ee820eb99dc510c637e6b2a758cd94f40e44392 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 18:12:19 -0600 Subject: [PATCH 30/38] Update FunctionsTests.swift Remove optionals. --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index e7147b09af4..96abc6d3c2c 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -405,29 +405,27 @@ class FunctionsTests: XCTestCase { XCTExpectFailure("Failed to download stream") } - func testGenerateStreamContentCanceled() async { + func testGenerateStreamContentCanceled() async{ var response = [String]() let responseQueue = DispatchQueue(label: "responseQueue") let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) let input: [String: Any] = ["data": "Why is the sky blue"] let task = Task.detached { [self] in - let stream = try await functions?.stream( + let stream = try await functions!.stream( at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, withObject: input, options: options, timeout: 4.0 ) // First chunk of the stream comes as NSDictionary - if let stream = stream { for try await result in stream { if let dataChunk = result.data as? NSDictionary { for (key, value) in dataChunk { responseQueue.sync { response.append("\(key) \(value)") - } + } - } else { // Last chunk is the concatenated result so we have to parse it as String else will // fail. if let dataString = result.data as? String { From f27bf07123c5232f7a10df07d04448dea75eb0d4 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 18:17:49 -0600 Subject: [PATCH 31/38] Update FunctionsTests.swift Revert func to non DISPACTCH. Will work on a fix with @ncooke3 --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index c1753c3b54e..74059c91077 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -362,8 +362,7 @@ class FunctionsTests: XCTestCase { func testGenerateStreamContent() async throws { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) var response = [String]() - let responseQueue = DispatchQueue(label: "responseQueue") - + let input: [String: Any] = ["data": "Why is the sky blue"] let stream = try await functions?.stream( at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, @@ -376,17 +375,13 @@ class FunctionsTests: XCTestCase { for try await result in stream { if let dataChunk = result.data as? NSDictionary { for (key, value) in dataChunk { - responseQueue.sync { - response.append("\(key) \(value)") + response.append("\(key) \(value)") } - } } else { // Last chunk is the concatenated result so we have to parse it as String else will // fail. if let dataString = result.data as? String { - responseQueue.sync { response.append(dataString) - } } } } From 1ffa4f07a3b147be233b312a6337404023dfcf44 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Wed, 15 Jan 2025 19:15:54 -0600 Subject: [PATCH 32/38] Format and refactoring. Lint. --- .../Tests/Unit/FunctionsTests.swift | 100 ++++++++---------- 1 file changed, 46 insertions(+), 54 deletions(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 74059c91077..e190b8ed5bc 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -361,48 +361,33 @@ class FunctionsTests: XCTestCase { func testGenerateStreamContent() async throws { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - var response = [String]() - + var result = [String]() + let input: [String: Any] = ["data": "Why is the sky blue"] - let stream = try await functions?.stream( - at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, - withObject: input, - options: options, - timeout: 4.0 - ) - // First chunk of the stream comes as NSDictionary - if let stream = stream { - for try await result in stream { - if let dataChunk = result.data as? NSDictionary { - for (key, value) in dataChunk { - response.append("\(key) \(value)") - } - } else { - // Last chunk is the concatenated result so we have to parse it as String else will - // fail. - if let dataString = result.data as? String { - response.append(dataString) - } - } - } - XCTAssertEqual( - response, - [ - "chunk hello", - "chunk world", - "chunk this", - "chunk is", - "chunk cool", - "hello world this is cool", - ] - ) - } - XCTExpectFailure("Failed to download stream") + let stream = try await functions!.stream( + at: URL(string: "http://127.0.0.1:5001/demo-project/us-central1/genStream")!, + withObject: input, + options: options, + timeout: 4.0 + ) + result = try await response(from: stream) + XCTAssertEqual( + result, + [ + "chunk hello", + "chunk world", + "chunk this", + "chunk is", + "chunk cool", + "hello world this is cool", + ] + ) } func testGenerateStreamContentCanceled() async { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) let input: [String: Any] = ["data": "Why is the sky blue"] + var result = [String]() let task = Task.detached { [self] in let stream = try await functions!.stream( @@ -411,30 +396,37 @@ class FunctionsTests: XCTestCase { options: options, timeout: 4.0 ) - // First chunk of the stream comes as NSDictionary - var response = [String]() - for try await result in stream { - if let dataChunk = result.data as? NSDictionary { - for (key, value) in dataChunk { - response.append("\(key) \(value)") - } - } else { - // Last chunk is the concatenated result so we have to parse it as String else will - // fail. - if let dataString = result.data as? String { - response.append(dataString) - } - } - } + + result = try await response(from: stream) // Since we cancel the call we are expecting an empty array. XCTAssertEqual( - response, + result, [] ) } // We cancel the task and we expect a null response even if the stream was initiated. task.cancel() - let result = await task.result - XCTAssertNotNil(result) + let respone = await task.result + XCTAssertNotNil(respone) + } +} + +private func response(from stream: AsyncThrowingStream) async throws -> [String] { + var response = [String]() + for try await result in stream { + // First chunk of the stream comes as NSDictionary + if let dataChunk = result.data as? NSDictionary { + for (key, value) in dataChunk { + response.append("\(key) \(value)") + } + } else { + // Last chunk is the concatenated result so we have to parse it as String else will + // fail. + if let dataString = result.data as? String { + response.append(dataString) + } + } } + return response } From 80f0991e30becf2a8155f91e7a590570a4ecda41 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 16 Jan 2025 14:45:13 -0600 Subject: [PATCH 33/38] Update FirebaseFunctions/Tests/Unit/FunctionsTests.swift Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index e190b8ed5bc..cdecf6ec769 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -361,7 +361,6 @@ class FunctionsTests: XCTestCase { func testGenerateStreamContent() async throws { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) - var result = [String]() let input: [String: Any] = ["data": "Why is the sky blue"] let stream = try await functions!.stream( From 756dc2647344ed7f48005408b35beb5fcb494c66 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 16 Jan 2025 14:45:26 -0600 Subject: [PATCH 34/38] Update FirebaseFunctions/Tests/Unit/FunctionsTests.swift Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index cdecf6ec769..c3994d01902 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -369,7 +369,7 @@ class FunctionsTests: XCTestCase { options: options, timeout: 4.0 ) - result = try await response(from: stream) + let result = try await response(from: stream) XCTAssertEqual( result, [ From f031c1feb67b2f16ee566006633215fa00f6a25d Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 16 Jan 2025 14:45:38 -0600 Subject: [PATCH 35/38] Update FirebaseFunctions/Tests/Unit/FunctionsTests.swift Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index c3994d01902..82c74c671b5 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -386,7 +386,6 @@ class FunctionsTests: XCTestCase { func testGenerateStreamContentCanceled() async { let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) let input: [String: Any] = ["data": "Why is the sky blue"] - var result = [String]() let task = Task.detached { [self] in let stream = try await functions!.stream( From 0df7f8dfef0ed859473f6715c4c7a81ec063500c Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 16 Jan 2025 14:45:52 -0600 Subject: [PATCH 36/38] Update FirebaseFunctions/Tests/Unit/FunctionsTests.swift Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseFunctions/Tests/Unit/FunctionsTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 82c74c671b5..a96ef8cc44b 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -395,7 +395,7 @@ class FunctionsTests: XCTestCase { timeout: 4.0 ) - result = try await response(from: stream) + let result = try await response(from: stream) // Since we cancel the call we are expecting an empty array. XCTAssertEqual( result, From 231c7dd822cdc623a34059e5c59d8bec4896223c Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 16 Jan 2025 17:16:54 -0600 Subject: [PATCH 37/38] Update FirebaseFunctions/Sources/Functions.swift Accepted suggestion. Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseFunctions/Sources/Functions.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 990ff82cc2f..73dcb51c919 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -675,9 +675,9 @@ enum FunctionsConstants { } if let dataChunk = String(data: data, encoding: .utf8) { - // We remove the "data :" field so it can be safely parsed to Json. + // We remove the "data:" field so it can be safely parsed to Json. let dataChunkToJson = dataChunk.split(separator: "\n").map { - String($0.dropFirst(6)) + String($0.dropFirst(5)) } resultArray.append(contentsOf: dataChunkToJson) } else { From be80d63931b3e7ea000c9596ae835630cae24d90 Mon Sep 17 00:00:00 2001 From: Eblen M Date: Thu, 16 Jan 2025 17:49:14 -0600 Subject: [PATCH 38/38] Update FirebaseFunctions/Sources/Functions.swift Mark it as public. Co-authored-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com> --- FirebaseFunctions/Sources/Functions.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 73dcb51c919..27d764188bf 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -472,7 +472,7 @@ enum FunctionsConstants { } @available(iOS 13, macCatalyst 13, macOS 10.15, tvOS 13, watchOS 7, *) - func stream(at url: URL, + public func stream(at url: URL, withObject data: Any?, options: HTTPSCallableOptions?, timeout: TimeInterval) async throws