diff --git a/FirebaseFunctions/Backend/index.js b/FirebaseFunctions/Backend/index.js index 3bfd6f31328..bc32a0fc79a 100644 --- a/FirebaseFunctions/Backend/index.js +++ b/FirebaseFunctions/Backend/index.js @@ -16,6 +16,14 @@ const assert = require('assert'); const functionsV1 = require('firebase-functions/v1'); const functionsV2 = require('firebase-functions/v2'); +// MARK: - Utilities + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +// MARK: - Callable Functions + exports.dataTest = functionsV1.https.onRequest((request, response) => { assert.deepEqual(request.body, { data: { @@ -121,10 +129,6 @@ exports.timeoutTest = functionsV1.https.onRequest((request, response) => { const streamData = ["hello", "world", "this", "is", "cool"] -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -}; - async function* generateText() { for (const chunk of streamData) { yield chunk; @@ -153,3 +157,29 @@ exports.genStreamError = functionsV2.https.onCall( } } ); + +const weatherForecasts = { + Toronto: { conditions: 'snowy', temperature: 25 }, + London: { conditions: 'rainy', temperature: 50 }, + Dubai: { conditions: 'sunny', temperature: 75 } +}; + +async function* generateForecast(locations) { + for (const location of locations) { + yield { 'location': location, ...weatherForecasts[location.name] }; + await sleep(500); + } +}; + +exports.genStreamWeather = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + for await (const chunk of generateForecast(request.data)) { + response.sendChunk({ chunk }); + } + } + return "Number of forecasts generated: " + request.data.length; + } +); + +// TODO: Maybe a function that returns Void? diff --git a/FirebaseFunctions/Backend/start.sh b/FirebaseFunctions/Backend/start.sh index 1ee3777cdc8..f464f208cd4 100755 --- a/FirebaseFunctions/Backend/start.sh +++ b/FirebaseFunctions/Backend/start.sh @@ -57,6 +57,7 @@ FUNCTIONS_BIN="./node_modules/.bin/functions" "${FUNCTIONS_BIN}" deploy timeoutTest --trigger-http "${FUNCTIONS_BIN}" deploy genStream --trigger-http "${FUNCTIONS_BIN}" deploy genStreamError --trigger-http +"${FUNCTIONS_BIN}" deploy genStreamWeather --trigger-http if [ "$1" != "synchronous" ]; then # Wait for the user to tell us to stop the server. diff --git a/FirebaseFunctions/Sources/Callable+Codable.swift b/FirebaseFunctions/Sources/Callable+Codable.swift index 489433a0a7e..c77adc9a37b 100644 --- a/FirebaseFunctions/Sources/Callable+Codable.swift +++ b/FirebaseFunctions/Sources/Callable+Codable.swift @@ -159,4 +159,27 @@ public struct Callable { public func callAsFunction(_ data: Request) async throws -> Response { return try await call(data) } + + // TODO: Look into handling parameter-less functions. + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + public func stream(_ data: Request) -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + Task { + do { + let encoded = try encoder.encode(data) + for try await result in callable.stream(encoded) { + if let response = try? decoder.decode([String: Response].self, from: result.data) { + continuation.yield(response["chunk"]!) + } else if let response = try? decoder.decode(Response.self, from: result.data) { + continuation.yield(response) + } + // TODO: Silently failing. The response cannot be decoded to the given type. + } + } catch { + continuation.finish(throwing: error) + } + continuation.finish() + } + } + } } diff --git a/FirebaseFunctions/Sources/Functions.swift b/FirebaseFunctions/Sources/Functions.swift index 51e405b2f39..6b41974ce52 100644 --- a/FirebaseFunctions/Sources/Functions.swift +++ b/FirebaseFunctions/Sources/Functions.swift @@ -471,6 +471,167 @@ enum FunctionsConstants { } } + /// Function to initialize a streamaing event for an HTTPCallable + /// - Parameters: + /// - url: The url of the Callable HTTPS trigger. + /// - data: Object to be sent in the request. + /// - options: The options with which to customize the Callable HTTPS trigger. + /// - timeout: timeout for the HTTPSCallableResult request. + /// - Returns: HTTPSCallableResult Streaming. + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func stream(at url: URL, + withObject data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval) + -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + // TODO: Vertex prints the curl command. Should this? + + Task { + // TODO: This API does not throw. Should the throwing request + // setup be in the stream or one level up? + let urlRequest: URLRequest + do { + let context = try await contextProvider.context(options: options) + urlRequest = try makeRequestForStreamableContent( + url: url, + data: data, + options: options, + timeout: timeout, + context: context + ) + } catch { + continuation.finish(throwing: error) + return + } + + let stream: URLSession.AsyncBytes + let rawResponse: URLResponse + do { + // TODO: Look into injecting URLSession for unit tests. + (stream, rawResponse) = try await URLSession.shared.bytes(for: urlRequest) + } catch { + continuation.finish(throwing: error) + return + } + + // Verify the status code is an HTTP response. + guard let response = rawResponse as? HTTPURLResponse else { + continuation.finish( + throwing: FunctionsError( + .internal, + userInfo: [NSLocalizedDescriptionKey: "Response was not an HTTP response."] + ) + ) + return + } + + // Verify the status code is a 200. + guard response.statusCode == 200 else { + continuation.finish( + throwing: FunctionsError( + .internal, + userInfo: [NSLocalizedDescriptionKey: "Response is not a successful 200."] + ) + ) + return + } + + for try await line in stream.lines { + if line.hasPrefix("data:") { + // We can assume 5 characters since it's utf-8 encoded, removing `data:`. + let jsonText = String(line.dropFirst(5)) + let data: Data + do { + // TODO: The error potentially thrown here is not a Functions error. + data = try jsonData(jsonText: jsonText) + } catch { + continuation.finish(throwing: error) + return + } + + // Handle the content and parse it. + do { + let content = try callableResult(fromResponseData: data) + continuation.yield(content) + } catch { + continuation.finish(throwing: error) + return + } + } else { + continuation.finish( + throwing: FunctionsError( + .internal, + userInfo: [NSLocalizedDescriptionKey: "Unexpected format for streamed response."] + ) + ) + } + } + + continuation.finish(throwing: nil) + } + } + } + + private func jsonData(jsonText: String) throws -> Data { + guard let data = jsonText.data(using: .utf8) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: [], + debugDescription: "Could not parse response as UTF8." + )) + } + return data + } + + private func makeRequestForStreamableContent(url: URL, + data: Any?, + options: HTTPSCallableOptions?, + timeout: TimeInterval, + context: FunctionsContext) throws + -> URLRequest { + var urlRequest = URLRequest( + url: url, + cachePolicy: .useProtocolCachePolicy, + timeoutInterval: timeout + ) + + let data = data ?? NSNull() + let encoded = try serializer.encode(data) + let body = ["data": encoded] + let payload = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed]) + urlRequest.httpBody = payload + + // Set the headers for starting a streaming session. + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept") + urlRequest.httpMethod = "POST" + + if let authToken = context.authToken { + let value = "Bearer \(authToken)" + urlRequest.setValue(value, forHTTPHeaderField: "Authorization") + } + + if let fcmToken = context.fcmToken { + urlRequest.setValue(fcmToken, forHTTPHeaderField: Constants.fcmTokenHeader) + } + + if options?.requireLimitedUseAppCheckTokens == true { + if let appCheckToken = context.limitedUseAppCheckToken { + urlRequest.setValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + } else if let appCheckToken = context.appCheckToken { + urlRequest.setValue( + appCheckToken, + forHTTPHeaderField: Constants.appCheckTokenHeader + ) + } + + return urlRequest + } + private func makeFetcher(url: URL, data: Any?, options: HTTPSCallableOptions?, @@ -564,8 +725,10 @@ enum FunctionsConstants { throw FunctionsError(.internal, userInfo: userInfo) } - // `result` is checked for backwards compatibility: - guard let dataJSON = responseJSON["data"] ?? responseJSON["result"] else { + // `result` is checked for backwards compatibility, + // `message` is checked for StreamableContent: + 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 c2281e54866..600599fa886 100644 --- a/FirebaseFunctions/Sources/HTTPSCallable.swift +++ b/FirebaseFunctions/Sources/HTTPSCallable.swift @@ -39,7 +39,7 @@ open class HTTPSCallable: NSObject { // The functions client to use for making calls. private let functions: Functions - private let url: URL + let url: URL private let options: HTTPSCallableOptions? @@ -143,4 +143,9 @@ open class HTTPSCallable: NSObject { try await functions .callFunction(at: url, withObject: data, options: options, timeout: timeoutInterval) } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func stream(_ data: Any? = nil) -> AsyncThrowingStream { + 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..9e871e96ad9 100644 --- a/FirebaseFunctions/Tests/Integration/IntegrationTests.swift +++ b/FirebaseFunctions/Tests/Integration/IntegrationTests.swift @@ -866,6 +866,179 @@ class IntegrationTests: XCTestCase { XCTAssertEqual(response, expected) } } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func testGenerateStreamContent() async throws { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + + let input: [String: Any] = ["data": "Why is the sky blue"] + + let stream = functions.stream( + at: emulatorURL("genStream"), + withObject: input, + options: options, + timeout: 4.0 + ) + let result = try await response(from: stream) + XCTAssertEqual( + result, + [ + "chunk hello", + "chunk world", + "chunk this", + "chunk is", + "chunk cool", + "hello world this is cool", + ] + ) + } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func testGenerateStreamContent_CodableString() async throws { + let byName: Callable = functions.httpsCallable("genStream") + let stream = byName.stream("This string is not needed.") + let result = try await response(from: stream) + XCTAssertEqual( + result, + [ + "hello", + "world", + "this", + "is", + "cool", + "hello world this is cool", + ] + ) + } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func testGenerateStreamContent_CodableObject() async throws { + struct Location: Codable, Equatable { + let name: String + } + struct WeatherForecast: Decodable, Equatable { + enum Conditions: String, Decodable { + case sunny + case rainy + case snowy + } + + let location: Location + let temperature: Int + let conditions: Conditions + } + + let byName: Callable<[Location], WeatherForecast> = functions.httpsCallable("genStreamWeather") + let stream = byName.stream([ + Location(name: "Toronto"), + Location(name: "London"), + Location(name: "Dubai"), + ]) + let result = try await response(from: stream) + XCTAssertEqual( + result, + [ + WeatherForecast(location: Location(name: "Toronto"), temperature: 25, conditions: .snowy), + WeatherForecast(location: Location(name: "London"), temperature: 50, conditions: .rainy), + WeatherForecast(location: Location(name: "Dubai"), temperature: 75, conditions: .sunny), + ] + ) + } + + @available(macOS 12.0, iOS 15.0, watchOS 8.0, tvOS 15.0, *) + func testGenerateStreamContentCanceled() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = functions.stream( + at: emulatorURL("genStream"), + withObject: input, + options: options, + timeout: 4.0 + ) + + let result = try await response(from: stream) + // Since we cancel the call we are expecting an empty array. + XCTAssertEqual( + result, + [] + ) + } + // We cancel the task and we expect a null response even if the stream was initiated. + task.cancel() + let respone = await task.result + XCTAssertNotNil(respone) + } + + @available(iOS 15, *) + func testGenerateStreamContent_badResponse() async { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = functions.stream( + at: emulatorURL("genStreams"), + withObject: input, + options: options, + timeout: 4.0 + ) + + let result = try await response(from: stream) + // Since we are sending a bad URL we expect an empty array, the reuqets was not a 200. + XCTAssertEqual( + result, + [] + ) + } + } + + @available(iOS 15, *) + func testGenerateStreamContent_streamError() async throws { + let options = HTTPSCallableOptions(requireLimitedUseAppCheckTokens: true) + let input: [String: Any] = ["data": "Why is the sky blue"] + + let task = Task.detached { [self] in + let stream = functions.stream( + at: emulatorURL("genStreamError"), + withObject: input, + options: options, + timeout: 4.0 + ) + + let result = try await response(from: stream) + XCTFail("TODO: FETCH THE ERROR") + } + } + + 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 + } + + private func response(from stream: AsyncThrowingStream) async throws -> [T] where T: Decodable { + var response = [T]() + for try await result in stream { + response.append(result) + } + return response + } } private class AuthTokenProvider: AuthInterop { diff --git a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift index 42e684cdf1a..6398afbbc52 100644 --- a/FirebaseFunctions/Tests/Unit/FunctionsTests.swift +++ b/FirebaseFunctions/Tests/Unit/FunctionsTests.swift @@ -22,7 +22,9 @@ import FirebaseCore import GTMSessionFetcherCore #endif -import SharedTestUtilities +#if SWIFT_PACKAGE + import SharedTestUtilities +#endif import XCTest class FunctionsTests: XCTestCase { @@ -358,4 +360,21 @@ class FunctionsTests: XCTestCase { } waitForExpectations(timeout: 1.5) } + + // TODO: Implement unit test variants. + + @available(iOS 15, *) + func testGenerateStreamContent() async throws { + XCTFail("TODO") + } + + @available(iOS 15, *) + func testGenerateStreamContentCanceled() async { + XCTFail("TODO") + } + + @available(iOS 15, *) + func testGenerateContentStream_badResponse() async throws { + XCTFail("TODO") + } }