From 535fb9099d1679fc45f461e855449f83dd85ab68 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 16 Dec 2024 13:57:49 -0800 Subject: [PATCH 01/41] Extend Firebase SDK with new APIs to consume streaming callable function response. - Handling the server-sent event (SSE) parsing internally - Providing proper error handling and connection management - Maintaining memory efficiency for long-running streams --- .../google/firebase/functions/StramTests.kt | 128 ++++++++++ .../firebase/functions/FirebaseFunctions.kt | 226 ++++++++++++++++++ .../functions/HttpsCallableReference.kt | 83 +++++++ .../firebase/functions/SSETaskListener.kt | 14 ++ 4 files changed, 451 insertions(+) create mode 100644 firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt new file mode 100644 index 00000000000..6f2a2693ec2 --- /dev/null +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt @@ -0,0 +1,128 @@ +package com.google.firebase.functions.ktx + +import androidx.test.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.FirebaseFunctionsException +import com.google.firebase.functions.SSETaskListener +import com.google.firebase.ktx.Firebase +import com.google.firebase.ktx.initialize +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StreamTests { + + private lateinit var app: FirebaseApp + private lateinit var listener: SSETaskListener + + private lateinit var functions: FirebaseFunctions + var onNext = mutableListOf() + var onError: Any? = null + var onComplete: Any? = null + + @Before + fun setup() { + app = Firebase.initialize(InstrumentationRegistry.getContext())!! + functions = FirebaseFunctions.getInstance() + functions.useEmulator("10.0.2.2", 5001) + listener = + object : SSETaskListener { + override fun onNext(event: Any) { + onNext.add(event) + } + + override fun onError(event: Any) { + onError = event + } + + override fun onComplete(event: Any) { + onComplete = event + } + } + } + + @After + fun clear() { + onNext.clear() + onError = null + onComplete = null + } + + @Test + fun testGenStream() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStream") + val httpsCallableResult = Tasks.await(function.stream(input, listener)) + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isEqualTo("hello world this is cool") + assertThat(httpsCallableResult.data).isEqualTo("hello world this is cool") + } + + @Test + fun testGenStreamError() { + val input = hashMapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStreamError").withTimeout(7, TimeUnit.SECONDS) + + try { + Tasks.await(function.stream(input, listener)) + } catch (exception: Exception) { + onError = exception + } + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isInstanceOf(ExecutionException::class.java) + val cause = (onError as ExecutionException).cause + assertThat(cause).isInstanceOf(FirebaseFunctionsException::class.java) + assertThat((cause as FirebaseFunctionsException).message).contains("Socket closed") + assertThat(onComplete).isNull() + } + + @Test + fun testGenStreamNoReturn() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStreamNoReturn") + try { + Tasks.await(function.stream(input, listener), 7, TimeUnit.SECONDS) + } catch (_: Exception) {} + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isNull() + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 3c0e7d6553e..2858c009ce5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -30,7 +30,10 @@ import com.google.firebase.functions.FirebaseFunctionsException.Code.Companion.f import com.google.firebase.functions.FirebaseFunctionsException.Companion.fromResponse import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.BufferedReader import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.MalformedURLException import java.net.URL @@ -311,6 +314,229 @@ internal constructor( return tcs.task } + internal fun stream( + name: String, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + val url = getURL(name) + stream(url, data, options, context, listener) + } + } + + internal fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + stream(url, data, options, context, listener) + } + } + + private fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + context: HttpsCallableContext?, + listener: SSETaskListener + ): Task { + Preconditions.checkNotNull(url, "url cannot be null") + val tcs = TaskCompletionSource() + val callClient = options.apply(client) + callClient.postStream(url, tcs, listener) { applyCommonConfiguration(data, context) } + + return tcs.task + } + + private inline fun OkHttpClient.postStream( + url: URL, + tcs: TaskCompletionSource, + listener: SSETaskListener, + crossinline config: Request.Builder.() -> Unit = {} + ) { + val requestBuilder = Request.Builder().url(url) + requestBuilder.config() + val request = requestBuilder.build() + + val call = newCall(request) + call.enqueue( + object : Callback { + override fun onFailure(ignored: Call, e: IOException) { + val exception: Exception = + if (e is InterruptedIOException) { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED, + null, + e + ) + } else { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.INTERNAL.name, + FirebaseFunctionsException.Code.INTERNAL, + null, + e + ) + } + listener.onError(exception) + tcs.setException(exception) + } + + @Throws(IOException::class) + override fun onResponse(ignored: Call, response: Response) { + try { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream, serializer, listener, tcs) + } else { + val error = + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + listener.onError(error) + tcs.setException(error) + } + } catch (e: FirebaseFunctionsException) { + listener.onError(e) + tcs.setException(e) + } + } + } + ) + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val trimMargin: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + throw FirebaseFunctionsException( + trimMargin, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), + null + ) + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Unexpected Response:\n$text ", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) + } + + private fun Request.Builder.applyCommonConfiguration(data: Any?, context: HttpsCallableContext?) { + val body: MutableMap = HashMap() + val encoded = serializer.encode(data) + body["data"] = encoded + if (context!!.authToken != null) { + header("Authorization", "Bearer " + context.authToken) + } + if (context.instanceIdToken != null) { + header("Firebase-Instance-ID-Token", context.instanceIdToken) + } + if (context.appCheckToken != null) { + header("X-Firebase-AppCheck", context.appCheckToken) + } + header("Accept", "text/event-stream") + val bodyJSON = JSONObject(body) + val contentType = MediaType.parse("application/json") + val requestBody = RequestBody.create(contentType, bodyJSON.toString()) + post(requestBody) + } + + private fun processSSEStream( + inputStream: InputStream, + serializer: Serializer, + listener: SSETaskListener, + tcs: TaskCompletionSource + ) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + reader.lineSequence().forEach { line -> + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach + } + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { listener.onNext(it) } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + throw FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + listener.onComplete(it) + tcs.setResult(HttpsCallableResult(it)) + } + return + } + } + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + throw FirebaseFunctionsException( + "Stream ended unexpectedly without completion.", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + } catch (e: Exception) { + throw FirebaseFunctionsException( + e.message ?: "Error reading stream", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + } + public companion object { /** A task that will be resolved once ProviderInstaller has installed what it needs to. */ private val providerInstalled = TaskCompletionSource() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 90bdb63221b..da8734757d5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -125,6 +125,89 @@ public class HttpsCallableReference { } } + /** + * Streams data to the specified HTTPS endpoint asynchronously. + * + * The data passed into the endpoint can be any of the following types: + * + * * Any primitive type, including `null`, `int`, `long`, `float`, and `boolean`. + * * [String] + * * [List<?>][java.util.List], where the contained objects are also one of these types. + * * [Map<String, ?>][java.util.Map], where the values are also one of these types. + * * [org.json.JSONArray] + * * [org.json.JSONObject] + * * [org.json.JSONObject.NULL] + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * @param data Parameters to pass to the endpoint. + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see org.json.JSONArray + * @see org.json.JSONObject + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(data: Any?, listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, data, options, listener) + } else { + functionsClient.stream(url!!, data, options, listener) + } + } + + /** + * Streams data to the specified HTTPS endpoint asynchronously without arguments. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, null, options, listener) + } else { + functionsClient.stream(url!!, null, options, listener) + } + } + /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. * diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt new file mode 100644 index 00000000000..dffeddfeec2 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt @@ -0,0 +1,14 @@ +package com.google.firebase.functions + +/** Listener for events from a Server-Sent Events stream. */ +public interface SSETaskListener { + + /** Called when a new event is received. */ + public fun onNext(event: Any) + + /** Called when an error occurs. */ + public fun onError(event: Any) + + /** Called when the stream is closed. */ + public fun onComplete(event: Any) +} From 9e13ef7fc0478b01c022439c55e03425346ab7ea Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 16 Dec 2024 13:57:49 -0800 Subject: [PATCH 02/41] Extend Firebase SDK with new APIs to consume streaming callable function response. - Handling the server-sent event (SSE) parsing internally - Providing proper error handling and connection management - Maintaining memory efficiency for long-running streams --- .../androidTest/backend/functions/index.js | 116 ++++++--- .../google/firebase/functions/StramTests.kt | 127 ++++++++++ .../firebase/functions/FirebaseFunctions.kt | 226 ++++++++++++++++++ .../functions/HttpsCallableReference.kt | 83 +++++++ .../firebase/functions/SSETaskListener.kt | 14 ++ 5 files changed, 536 insertions(+), 30 deletions(-) create mode 100644 firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index fed5a371b89..631945dfa75 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -12,32 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -const assert = require('assert'); -const functions = require('firebase-functions'); +const assert = require("assert"); +const functions = require("firebase-functions"); exports.dataTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, { data: { - bool: true, - int: 2, - long: { - value: '3', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "bool": true, + "int": 2, + "long": { + "value": "3", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, - string: 'four', - array: [5, 6], - 'null': null, - } + "string": "four", + "array": [5, 6], + "null": null, + }, }); response.send({ data: { - message: 'stub response', + message: "stub response", code: 42, long: { - value: '420', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "value": "420", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, - } + }, }); }); @@ -47,28 +47,29 @@ exports.scalarTest = functions.https.onRequest((request, response) => { }); exports.tokenTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('Authorization'), 'Bearer token'); + assert.equal(request.get("Authorization"), "Bearer token"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); exports.instanceIdTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('Firebase-Instance-ID-Token'), 'iid'); + assert.equal(request.get("Firebase-Instance-ID-Token"), "iid"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); exports.appCheckTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck'); + assert.equal(request.get("X-Firebase-AppCheck"), "appCheck"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.appCheckLimitedUseTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck-limited-use'); - assert.deepEqual(request.body, {data: {}}); - response.send({data: {}}); -}); +exports.appCheckLimitedUseTest = functions.https.onRequest( + (request, response) => { + assert.equal(request.get("X-Firebase-AppCheck"), "appCheck-limited-use"); + assert.deepEqual(request.body, {data: {}}); + response.send({data: {}}); + }); exports.nullTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: null}); @@ -82,15 +83,15 @@ exports.missingResultTest = functions.https.onRequest((request, response) => { exports.unhandledErrorTest = functions.https.onRequest((request, response) => { // Fail in a way that the client shouldn't see. - throw 'nope'; + throw new Error("nope"); }); exports.unknownErrorTest = functions.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: 'THIS_IS_NOT_VALID', - message: 'this should be ignored', + status: "THIS_IS_NOT_VALID", + message: "this should be ignored", }, }); }); @@ -99,14 +100,14 @@ exports.explicitErrorTest = functions.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: 'OUT_OF_RANGE', - message: 'explicit nope', + status: "OUT_OF_RANGE", + message: "explicit nope", details: { start: 10, end: 20, long: { - value: '30', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "value": "30", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, }, }, @@ -122,3 +123,58 @@ exports.timeoutTest = functions.https.onRequest((request, response) => { // Wait for longer than 500ms. setTimeout(() => response.send({data: true}), 500); }); + +const data = ["hello", "world", "this", "is", "cool"]; + +/** + * Pauses the execution for a specified amount of time. + * @param {number} ms - The number of milliseconds to sleep. + * @return {Promise} A promise that resolves after the specified time. + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Generates chunks of text asynchronously, yielding one chunk at a time. + * @async + * @generator + * @yields {string} A chunk of text from the data array. + */ +async function* generateText() { + for (const chunk of data) { + yield chunk; + await sleep(1000); + } +} + +exports.genStream = functions.https.onCall(async (request, response) => { + if (response && response.acceptsStreaming) { + for await (const chunk of generateText()) { + console.log("got chunk", chunk); + response.write({chunk}); + } + } + return data.join(" "); +}); + +exports.genStreamError = functions.https.onCall(async (request, response) => { + if (response && response.acceptsStreaming) { + for await (const chunk of generateText()) { + console.log("got chunk", chunk); + response.write({chunk}); + } + throw new Error("BOOM"); + } +}); + +exports.genStreamNoReturn = functions.https.onCall( + async (request, response) => { + if (response && response.acceptsStreaming) { + for await (const chunk of generateText()) { + console.log("got chunk", chunk); + response.write({chunk}); + } + } + }, +); diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt new file mode 100644 index 00000000000..3548c56f05f --- /dev/null +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt @@ -0,0 +1,127 @@ +package com.google.firebase.functions.ktx + +import androidx.test.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.FirebaseFunctionsException +import com.google.firebase.functions.SSETaskListener +import com.google.firebase.ktx.Firebase +import com.google.firebase.ktx.initialize +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StreamTests { + + private lateinit var app: FirebaseApp + private lateinit var listener: SSETaskListener + + private lateinit var functions: FirebaseFunctions + var onNext = mutableListOf() + var onError: Any? = null + var onComplete: Any? = null + + @Before + fun setup() { + app = Firebase.initialize(InstrumentationRegistry.getContext())!! + functions = FirebaseFunctions.getInstance() + listener = + object : SSETaskListener { + override fun onNext(event: Any) { + onNext.add(event) + } + + override fun onError(event: Any) { + onError = event + } + + override fun onComplete(event: Any) { + onComplete = event + } + } + } + + @After + fun clear() { + onNext.clear() + onError = null + onComplete = null + } + + @Test + fun testGenStream() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStream") + val httpsCallableResult = Tasks.await(function.stream(input, listener)) + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isEqualTo("hello world this is cool") + assertThat(httpsCallableResult.data).isEqualTo("hello world this is cool") + } + + @Test + fun testGenStreamError() { + val input = hashMapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStreamError").withTimeout(7, TimeUnit.SECONDS) + + try { + Tasks.await(function.stream(input, listener)) + } catch (exception: Exception) { + onError = exception + } + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isInstanceOf(ExecutionException::class.java) + val cause = (onError as ExecutionException).cause + assertThat(cause).isInstanceOf(FirebaseFunctionsException::class.java) + assertThat((cause as FirebaseFunctionsException).message).contains("stream was reset: CANCEL") + assertThat(onComplete).isNull() + } + + @Test + fun testGenStreamNoReturn() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStreamNoReturn") + try { + Tasks.await(function.stream(input, listener), 7, TimeUnit.SECONDS) + } catch (_: Exception) {} + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isNull() + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 3c0e7d6553e..2858c009ce5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -30,7 +30,10 @@ import com.google.firebase.functions.FirebaseFunctionsException.Code.Companion.f import com.google.firebase.functions.FirebaseFunctionsException.Companion.fromResponse import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.BufferedReader import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.MalformedURLException import java.net.URL @@ -311,6 +314,229 @@ internal constructor( return tcs.task } + internal fun stream( + name: String, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + val url = getURL(name) + stream(url, data, options, context, listener) + } + } + + internal fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + stream(url, data, options, context, listener) + } + } + + private fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + context: HttpsCallableContext?, + listener: SSETaskListener + ): Task { + Preconditions.checkNotNull(url, "url cannot be null") + val tcs = TaskCompletionSource() + val callClient = options.apply(client) + callClient.postStream(url, tcs, listener) { applyCommonConfiguration(data, context) } + + return tcs.task + } + + private inline fun OkHttpClient.postStream( + url: URL, + tcs: TaskCompletionSource, + listener: SSETaskListener, + crossinline config: Request.Builder.() -> Unit = {} + ) { + val requestBuilder = Request.Builder().url(url) + requestBuilder.config() + val request = requestBuilder.build() + + val call = newCall(request) + call.enqueue( + object : Callback { + override fun onFailure(ignored: Call, e: IOException) { + val exception: Exception = + if (e is InterruptedIOException) { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED, + null, + e + ) + } else { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.INTERNAL.name, + FirebaseFunctionsException.Code.INTERNAL, + null, + e + ) + } + listener.onError(exception) + tcs.setException(exception) + } + + @Throws(IOException::class) + override fun onResponse(ignored: Call, response: Response) { + try { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream, serializer, listener, tcs) + } else { + val error = + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + listener.onError(error) + tcs.setException(error) + } + } catch (e: FirebaseFunctionsException) { + listener.onError(e) + tcs.setException(e) + } + } + } + ) + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val trimMargin: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + throw FirebaseFunctionsException( + trimMargin, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), + null + ) + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Unexpected Response:\n$text ", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) + } + + private fun Request.Builder.applyCommonConfiguration(data: Any?, context: HttpsCallableContext?) { + val body: MutableMap = HashMap() + val encoded = serializer.encode(data) + body["data"] = encoded + if (context!!.authToken != null) { + header("Authorization", "Bearer " + context.authToken) + } + if (context.instanceIdToken != null) { + header("Firebase-Instance-ID-Token", context.instanceIdToken) + } + if (context.appCheckToken != null) { + header("X-Firebase-AppCheck", context.appCheckToken) + } + header("Accept", "text/event-stream") + val bodyJSON = JSONObject(body) + val contentType = MediaType.parse("application/json") + val requestBody = RequestBody.create(contentType, bodyJSON.toString()) + post(requestBody) + } + + private fun processSSEStream( + inputStream: InputStream, + serializer: Serializer, + listener: SSETaskListener, + tcs: TaskCompletionSource + ) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + reader.lineSequence().forEach { line -> + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach + } + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { listener.onNext(it) } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + throw FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + listener.onComplete(it) + tcs.setResult(HttpsCallableResult(it)) + } + return + } + } + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + throw FirebaseFunctionsException( + "Stream ended unexpectedly without completion.", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + } catch (e: Exception) { + throw FirebaseFunctionsException( + e.message ?: "Error reading stream", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + } + public companion object { /** A task that will be resolved once ProviderInstaller has installed what it needs to. */ private val providerInstalled = TaskCompletionSource() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 90bdb63221b..da8734757d5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -125,6 +125,89 @@ public class HttpsCallableReference { } } + /** + * Streams data to the specified HTTPS endpoint asynchronously. + * + * The data passed into the endpoint can be any of the following types: + * + * * Any primitive type, including `null`, `int`, `long`, `float`, and `boolean`. + * * [String] + * * [List<?>][java.util.List], where the contained objects are also one of these types. + * * [Map<String, ?>][java.util.Map], where the values are also one of these types. + * * [org.json.JSONArray] + * * [org.json.JSONObject] + * * [org.json.JSONObject.NULL] + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * @param data Parameters to pass to the endpoint. + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see org.json.JSONArray + * @see org.json.JSONObject + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(data: Any?, listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, data, options, listener) + } else { + functionsClient.stream(url!!, data, options, listener) + } + } + + /** + * Streams data to the specified HTTPS endpoint asynchronously without arguments. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, null, options, listener) + } else { + functionsClient.stream(url!!, null, options, listener) + } + } + /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. * diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt new file mode 100644 index 00000000000..dffeddfeec2 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt @@ -0,0 +1,14 @@ +package com.google.firebase.functions + +/** Listener for events from a Server-Sent Events stream. */ +public interface SSETaskListener { + + /** Called when a new event is received. */ + public fun onNext(event: Any) + + /** Called when an error occurs. */ + public fun onError(event: Any) + + /** Called when the stream is closed. */ + public fun onComplete(event: Any) +} From 1fa85a625db84280faa41922b9dfdf5c2e9bcc7a Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Fri, 27 Dec 2024 11:30:05 -0800 Subject: [PATCH 03/41] Update the SSETaskListener implementation to conform to the org.reactivestreams.Subscriber interface. --- .../com/google/firebase/functions/StreamTests.kt | 12 ++++++------ .../google/firebase/functions/FirebaseFunctions.kt | 14 +++++++------- .../google/firebase/functions/SSETaskListener.kt | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 3548c56f05f..c66e15a7d4e 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -34,16 +34,16 @@ class StreamTests { functions = FirebaseFunctions.getInstance() listener = object : SSETaskListener { - override fun onNext(event: Any) { - onNext.add(event) + override fun onNext(message: Any) { + onNext.add(message) } - override fun onError(event: Any) { - onError = event + override fun onError(exception: FirebaseFunctionsException) { + onError = exception } - override fun onComplete(event: Any) { - onComplete = event + override fun onComplete(result: Any) { + onComplete = result } } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 2858c009ce5..a1e244eaf8d 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -378,7 +378,7 @@ internal constructor( call.enqueue( object : Callback { override fun onFailure(ignored: Call, e: IOException) { - val exception: Exception = + val exception: FirebaseFunctionsException = if (e is InterruptedIOException) { FirebaseFunctionsException( FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, @@ -406,18 +406,18 @@ internal constructor( if (bodyStream != null) { processSSEStream(bodyStream, serializer, listener, tcs) } else { - val error = + val exception = FirebaseFunctionsException( "Response body is null", FirebaseFunctionsException.Code.INTERNAL, null ) - listener.onError(error) - tcs.setException(error) + listener.onError(exception) + tcs.setException(exception) } - } catch (e: FirebaseFunctionsException) { - listener.onError(e) - tcs.setException(e) + } catch (exception: FirebaseFunctionsException) { + listener.onError(exception) + tcs.setException(exception) } } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt index dffeddfeec2..85d21c7f1df 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt @@ -4,11 +4,11 @@ package com.google.firebase.functions public interface SSETaskListener { /** Called when a new event is received. */ - public fun onNext(event: Any) + public fun onNext(message: Any) /** Called when an error occurs. */ - public fun onError(event: Any) + public fun onError(exception: FirebaseFunctionsException) /** Called when the stream is closed. */ - public fun onComplete(event: Any) + public fun onComplete(result: Any) } From 7034537cea73bf5b3c62a2c301b98f1671405b16 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 29 Jan 2025 21:04:26 -0800 Subject: [PATCH 04/41] Refactor Stream Listener Implementation --- firebase-functions/api.txt | 27 ++++ .../google/firebase/functions/StreamTests.kt | 105 ++++++++------- .../firebase/functions/FirebaseFunctions.kt | 91 ++++++------- .../functions/HttpsCallableReference.kt | 33 ++--- .../firebase/functions/SSETaskListener.kt | 14 -- .../firebase/functions/StreamFunctionsTask.kt | 121 ++++++++++++++++++ .../firebase/functions/StreamListener.kt | 8 ++ 7 files changed, 269 insertions(+), 130 deletions(-) delete mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index 2963a38621f..2de8f1a358b 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -86,6 +86,7 @@ package com.google.firebase.functions { method @NonNull public com.google.android.gms.tasks.Task call(); method public long getTimeout(); method public void setTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); + method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(@Nullable Object data = null); method @NonNull public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); property public final long timeout; } @@ -95,6 +96,32 @@ package com.google.firebase.functions { field @Nullable public final Object data; } + public final class StreamFunctionsTask extends com.google.android.gms.tasks.Task { + ctor public StreamFunctionsTask(); + method @NonNull public com.google.firebase.functions.StreamFunctionsTask addOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); + method public void removeOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); + method public void notifyListeners(@NonNull Object data); + method public void complete(@NonNull com.google.firebase.functions.HttpsCallableResult result); + method public void fail(@NonNull Exception exception); + method @Nullable public Exception getException(); + method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(); + method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(@NonNull Class p0); + method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull com.google.android.gms.tasks.OnFailureListener p0); + method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull com.google.android.gms.tasks.OnSuccessListener p0); + method public boolean isCanceled(); + method public boolean isComplete(); + method public boolean isSuccessful(); + method public void cancel(); + } + + public fun interface StreamListener { + method public void onNext(@NonNull Object message); + } + } package com.google.firebase.functions.ktx { diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index c66e15a7d4e..c3a881a6330 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -1,16 +1,15 @@ package com.google.firebase.functions.ktx -import androidx.test.InstrumentationRegistry -import androidx.test.runner.AndroidJUnit4 -import com.google.android.gms.tasks.Tasks +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.google.firebase.FirebaseApp +import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctionsException -import com.google.firebase.functions.SSETaskListener -import com.google.firebase.ktx.Firebase -import com.google.firebase.ktx.initialize -import java.util.concurrent.ExecutionException +import com.google.firebase.functions.StreamFunctionsTask +import com.google.firebase.functions.StreamListener +import com.google.firebase.functions.functions +import com.google.firebase.initialize import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Before @@ -20,48 +19,35 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class StreamTests { - private lateinit var app: FirebaseApp - private lateinit var listener: SSETaskListener - + private lateinit var listener: StreamListener private lateinit var functions: FirebaseFunctions var onNext = mutableListOf() - var onError: Any? = null - var onComplete: Any? = null @Before fun setup() { - app = Firebase.initialize(InstrumentationRegistry.getContext())!! - functions = FirebaseFunctions.getInstance() + Firebase.initialize(ApplicationProvider.getApplicationContext()) + functions = Firebase.functions listener = - object : SSETaskListener { + object : StreamListener { override fun onNext(message: Any) { onNext.add(message) } - - override fun onError(exception: FirebaseFunctionsException) { - onError = exception - } - - override fun onComplete(result: Any) { - onComplete = result - } } } @After fun clear() { onNext.clear() - onError = null - onComplete = null } @Test fun testGenStream() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStream") - val httpsCallableResult = Tasks.await(function.stream(input, listener)) + val task = function.stream(input).addOnStreamListener(listener) + + Thread.sleep(6000) val onNextStringList = onNext.map { it.toString() } assertThat(onNextStringList) .containsExactly( @@ -71,21 +57,19 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(onError).isNull() - assertThat(onComplete).isEqualTo("hello world this is cool") - assertThat(httpsCallableResult.data).isEqualTo("hello world this is cool") + assertThat(task.result.data).isEqualTo("hello world this is cool") } @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamError").withTimeout(7, TimeUnit.SECONDS) + val function = functions.getHttpsCallable("genStreamError").withTimeout(6, TimeUnit.SECONDS) + var task: StreamFunctionsTask? = null try { - Tasks.await(function.stream(input, listener)) - } catch (exception: Exception) { - onError = exception - } + task = function.stream(input).addOnStreamListener(listener) + } catch (_: Throwable) {} + Thread.sleep(7000) val onNextStringList = onNext.map { it.toString() } assertThat(onNextStringList) @@ -96,21 +80,18 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(onError).isInstanceOf(ExecutionException::class.java) - val cause = (onError as ExecutionException).cause - assertThat(cause).isInstanceOf(FirebaseFunctionsException::class.java) - assertThat((cause as FirebaseFunctionsException).message).contains("stream was reset: CANCEL") - assertThat(onComplete).isNull() + assertThat(requireNotNull(task).isSuccessful).isFalse() + assertThat(task.exception).isInstanceOf(FirebaseFunctionsException::class.java) + assertThat(requireNotNull(task.exception).message).contains("stream was reset: CANCEL") } @Test fun testGenStreamNoReturn() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamNoReturn") - try { - Tasks.await(function.stream(input, listener), 7, TimeUnit.SECONDS) - } catch (_: Exception) {} + + val task = function.stream(input).addOnStreamListener(listener) + Thread.sleep(7000) val onNextStringList = onNext.map { it.toString() } assertThat(onNextStringList) @@ -121,7 +102,37 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(onError).isNull() - assertThat(onComplete).isNull() + try { + task.result + } catch (e: Throwable) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("No result available.") + } + } + + @Test + fun testGenStream_cancelStream() { + val input = hashMapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStreamNoReturn") + val task = function.stream(input).addOnStreamListener(listener) + Thread.sleep(2000) + + task.cancel() + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + ) + try { + task.result + } catch (e: Throwable) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("No result available.") + } + assertThat(task.isCanceled).isTrue() + assertThat(task.isComplete).isFalse() + assertThat(task.isSuccessful).isFalse() } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index e9813b7747a..f9da8dfabe5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -314,39 +314,51 @@ internal constructor( return tcs.task } - internal fun stream( - name: String, - data: Any?, - options: HttpsCallOptions, - listener: SSETaskListener - ): Task { - return providerInstalled.task + internal fun stream(name: String, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { + val task = StreamFunctionsTask() + providerInstalled.task .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .continueWithTask(executor) { task: Task -> - if (!task.isSuccessful) { - return@continueWithTask Tasks.forException(task.exception!!) + .addOnCompleteListener(executor) { contextTask -> + if (!contextTask.isSuccessful) { + task.fail( + FirebaseFunctionsException( + "Error retrieving context", + FirebaseFunctionsException.Code.INTERNAL, + null, + contextTask.exception + ) + ) + return@addOnCompleteListener } - val context = task.result val url = getURL(name) - stream(url, data, options, context, listener) + stream(url, data, options, contextTask.result, task) } + + return task } - internal fun stream( - url: URL, - data: Any?, - options: HttpsCallOptions, - listener: SSETaskListener - ): Task { - return providerInstalled.task + internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { + val task = StreamFunctionsTask() + providerInstalled.task .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .continueWithTask(executor) { task: Task -> - if (!task.isSuccessful) { - return@continueWithTask Tasks.forException(task.exception!!) + .addOnCompleteListener(executor) { contextTask -> + if (!contextTask.isSuccessful) { + task.fail( + FirebaseFunctionsException( + "Error retrieving context", + FirebaseFunctionsException.Code.INTERNAL, + null, + contextTask.exception + ) + ) + + return@addOnCompleteListener } - val context = task.result - stream(url, data, options, context, listener) + + stream(url, data, options, contextTask.result, task) } + + return task } private fun stream( @@ -354,20 +366,16 @@ internal constructor( data: Any?, options: HttpsCallOptions, context: HttpsCallableContext?, - listener: SSETaskListener - ): Task { + task: StreamFunctionsTask + ) { Preconditions.checkNotNull(url, "url cannot be null") - val tcs = TaskCompletionSource() val callClient = options.apply(client) - callClient.postStream(url, tcs, listener) { applyCommonConfiguration(data, context) } - - return tcs.task + callClient.postStream(url, task) { applyCommonConfiguration(data, context) } } private inline fun OkHttpClient.postStream( url: URL, - tcs: TaskCompletionSource, - listener: SSETaskListener, + task: StreamFunctionsTask, crossinline config: Request.Builder.() -> Unit = {} ) { val requestBuilder = Request.Builder().url(url) @@ -394,8 +402,7 @@ internal constructor( e ) } - listener.onError(exception) - tcs.setException(exception) + task.fail(exception) } @Throws(IOException::class) @@ -404,7 +411,7 @@ internal constructor( validateResponse(response) val bodyStream = response.body()?.byteStream() if (bodyStream != null) { - processSSEStream(bodyStream, serializer, listener, tcs) + processSSEStream(bodyStream, serializer, task) } else { val exception = FirebaseFunctionsException( @@ -412,12 +419,10 @@ internal constructor( FirebaseFunctionsException.Code.INTERNAL, null ) - listener.onError(exception) - tcs.setException(exception) + task.fail(exception) } } catch (exception: FirebaseFunctionsException) { - listener.onError(exception) - tcs.setException(exception) + task.fail(exception) } } } @@ -480,8 +485,7 @@ internal constructor( private fun processSSEStream( inputStream: InputStream, serializer: Serializer, - listener: SSETaskListener, - tcs: TaskCompletionSource + task: StreamFunctionsTask ) { BufferedReader(InputStreamReader(inputStream)).use { reader -> try { @@ -496,7 +500,7 @@ internal constructor( val json = JSONObject(dataChunk) when { json.has("message") -> - serializer.decode(json.opt("message"))?.let { listener.onNext(it) } + serializer.decode(json.opt("message"))?.let { task.notifyListeners(it) } json.has("error") -> { serializer.decode(json.opt("error"))?.let { throw FirebaseFunctionsException( @@ -508,8 +512,7 @@ internal constructor( } json.has("result") -> { serializer.decode(json.opt("result"))?.let { - listener.onComplete(it) - tcs.setResult(HttpsCallableResult(it)) + task.complete(HttpsCallableResult(it)) } return } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 08b1bf901f6..a18849f619f 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -154,22 +154,18 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * Streaming events are handled by the provided [SSETaskListener], which will receive events and - * handle errors and completion notifications. - * * @param data Parameters to pass to the endpoint. - * @param listener A listener to handle streaming events, errors, and completion notifications. - * @return A Task that will be completed when the streaming operation has finished. + * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. * @see org.json.JSONArray * @see org.json.JSONObject * @see java.io.IOException * @see FirebaseFunctionsException */ - public fun stream(data: Any?, listener: SSETaskListener): Task { + public fun stream(data: Any?): StreamFunctionsTask { return if (name != null) { - functionsClient.stream(name, data, options, listener) + functionsClient.stream(name, data, options) } else { - functionsClient.stream(url!!, data, options, listener) + functionsClient.stream(requireNotNull(url), data, options) } } @@ -185,26 +181,13 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * Streaming events are handled by the provided [SSETaskListener], which will receive events and - * handle errors and completion notifications. - * - * If the returned task fails, the exception will be one of the following types: - * - * * [java.io.IOException] - * - if the HTTPS request failed to connect. - * * [FirebaseFunctionsException] - * - if the request connected, but the function returned an error. - * - * @param listener A listener to handle streaming events, errors, and completion notifications. - * @return A Task that will be completed when the streaming operation has finished. - * @see java.io.IOException - * @see FirebaseFunctionsException + * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. */ - public fun stream(listener: SSETaskListener): Task { + public fun stream(): StreamFunctionsTask { return if (name != null) { - functionsClient.stream(name, null, options, listener) + functionsClient.stream(name, null, options) } else { - functionsClient.stream(url!!, null, options, listener) + functionsClient.stream(requireNotNull(url), null, options) } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt deleted file mode 100644 index 85d21c7f1df..00000000000 --- a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.google.firebase.functions - -/** Listener for events from a Server-Sent Events stream. */ -public interface SSETaskListener { - - /** Called when a new event is received. */ - public fun onNext(message: Any) - - /** Called when an error occurs. */ - public fun onError(exception: FirebaseFunctionsException) - - /** Called when the stream is closed. */ - public fun onComplete(result: Any) -} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt new file mode 100644 index 00000000000..3c206022838 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt @@ -0,0 +1,121 @@ +package com.google.firebase.functions + +import android.app.Activity +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executor + +public class StreamFunctionsTask : Task() { + + private val listenerQueue: Queue = ConcurrentLinkedQueue() + private var result: HttpsCallableResult? = null + private var exception: Exception? = null + private var isComplete: Boolean = false + private var isCanceled = false + + public fun addOnStreamListener(listener: StreamListener): StreamFunctionsTask { + listenerQueue.add(listener) + return this + } + + public fun removeOnStreamListener(listener: StreamListener) { + listenerQueue.remove(listener) + } + + internal fun notifyListeners(data: Any) { + for (listener in listenerQueue) { + listener.onNext(data) + } + } + + internal fun complete(result: HttpsCallableResult) { + this.result = result + this.isComplete = true + } + + internal fun fail(exception: Exception) { + this.exception = exception + this.isComplete = true + } + + override fun getException(): Exception? { + listenerQueue.clear() + return exception + } + + override fun getResult(): HttpsCallableResult { + listenerQueue.clear() + return result ?: throw IllegalStateException("No result available.") + } + + override fun getResult(p0: Class): HttpsCallableResult { + if (p0.isInstance(exception)) { + throw p0.cast(exception)!! + } + return getResult() + } + + override fun addOnFailureListener(listener: OnFailureListener): Task { + if (exception != null) listener.onFailure(requireNotNull(exception)) + return this + } + + override fun addOnFailureListener( + activity: Activity, + listener: OnFailureListener + ): Task { + if (exception != null) listener.onFailure(requireNotNull(exception)) + return this + } + + override fun addOnFailureListener( + executor: Executor, + listener: OnFailureListener + ): Task { + if (exception != null) executor.execute { listener.onFailure(requireNotNull(exception)) } + return this + } + + override fun addOnSuccessListener( + executor: Executor, + listener: OnSuccessListener + ): Task { + if (result != null) executor.execute { listener.onSuccess(requireNotNull(result)) } + return this + } + + override fun addOnSuccessListener( + activity: Activity, + listener: OnSuccessListener + ): Task { + if (result != null) listener.onSuccess(requireNotNull(result)) + return this + } + + override fun addOnSuccessListener( + listener: OnSuccessListener + ): Task { + if (result != null) listener.onSuccess(requireNotNull(result)) + return this + } + + override fun isCanceled(): Boolean { + return isCanceled + } + + override fun isComplete(): Boolean { + return isComplete + } + + override fun isSuccessful(): Boolean { + return exception == null && result != null + } + + public fun cancel() { + isCanceled = true + listenerQueue.clear() + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt new file mode 100644 index 00000000000..539c55be3a4 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt @@ -0,0 +1,8 @@ +package com.google.firebase.functions + +/** Listener for events from a Server-Sent Events stream. */ +public interface StreamListener { + + /** Called when a new event is received. */ + public fun onNext(message: Any) +} From 7f0382b7e4b63e4850d0026e5dd3bd1b658c16af Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 29 Jan 2025 22:38:32 -0800 Subject: [PATCH 05/41] Fix test cases on StreamTests. --- .../google/firebase/functions/StreamTests.kt | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index c3a881a6330..97f9831b2ce 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -5,7 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions -import com.google.firebase.functions.FirebaseFunctionsException import com.google.firebase.functions.StreamFunctionsTask import com.google.firebase.functions.StreamListener import com.google.firebase.functions.functions @@ -58,17 +57,15 @@ class StreamTests { "{chunk=cool}" ) assertThat(task.result.data).isEqualTo("hello world this is cool") + task.removeOnStreamListener(listener) } @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamError").withTimeout(6, TimeUnit.SECONDS) - var task: StreamFunctionsTask? = null - try { - task = function.stream(input).addOnStreamListener(listener) - } catch (_: Throwable) {} + val task: StreamFunctionsTask = function.stream(input).addOnStreamListener(listener) Thread.sleep(7000) val onNextStringList = onNext.map { it.toString() } @@ -80,9 +77,15 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(requireNotNull(task).isSuccessful).isFalse() - assertThat(task.exception).isInstanceOf(FirebaseFunctionsException::class.java) - assertThat(requireNotNull(task.exception).message).contains("stream was reset: CANCEL") + + assertThat(task.isSuccessful).isFalse() + try { + assertThat(task.result).isNull() + } catch (e: Throwable) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("No result available.") + } + task.removeOnStreamListener(listener) } @Test @@ -103,11 +106,12 @@ class StreamTests { "{chunk=cool}" ) try { - task.result + assertThat(task.result).isNull() } catch (e: Throwable) { assertThat(e).isInstanceOf(IllegalStateException::class.java) assertThat(e.message).isEqualTo("No result available.") } + task.removeOnStreamListener(listener) } @Test @@ -125,14 +129,9 @@ class StreamTests { "{chunk=hello}", "{chunk=world}", ) - try { - task.result - } catch (e: Throwable) { - assertThat(e).isInstanceOf(IllegalStateException::class.java) - assertThat(e.message).isEqualTo("No result available.") - } assertThat(task.isCanceled).isTrue() assertThat(task.isComplete).isFalse() assertThat(task.isSuccessful).isFalse() + task.removeOnStreamListener(listener) } } From e0099601aefd86ad7d27d99192fc36fe2b1ce9f9 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 16:57:08 -0800 Subject: [PATCH 06/41] Optimize streaming by introducing reactive.streams --- .../firebase-functions.gradle.kts | 2 + .../google/firebase/functions/StreamTests.kt | 122 +++--- .../firebase/functions/FirebaseFunctions.kt | 385 ++++++++++-------- .../functions/HttpsCallableReference.kt | 9 +- 4 files changed, 286 insertions(+), 232 deletions(-) diff --git a/firebase-functions/firebase-functions.gradle.kts b/firebase-functions/firebase-functions.gradle.kts index 7ec958bdd79..aee9b06c24b 100644 --- a/firebase-functions/firebase-functions.gradle.kts +++ b/firebase-functions/firebase-functions.gradle.kts @@ -112,6 +112,8 @@ dependencies { implementation(libs.okhttp) implementation(libs.playservices.base) implementation(libs.playservices.basement) + implementation(libs.reactive.streams) + api(libs.playservices.tasks) kapt(libs.autovalue) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 97f9831b2ce..a1a5efbf569 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -5,8 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions -import com.google.firebase.functions.StreamFunctionsTask -import com.google.firebase.functions.StreamListener import com.google.firebase.functions.functions import com.google.firebase.initialize import java.util.concurrent.TimeUnit @@ -14,29 +12,47 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription @RunWith(AndroidJUnit4::class) class StreamTests { - private lateinit var listener: StreamListener private lateinit var functions: FirebaseFunctions - var onNext = mutableListOf() + var onNextList = mutableListOf() + private lateinit var subscriber: Subscriber + private var throwable: Throwable? = null + private var isComplete = false @Before fun setup() { Firebase.initialize(ApplicationProvider.getApplicationContext()) functions = Firebase.functions - listener = - object : StreamListener { - override fun onNext(message: Any) { - onNext.add(message) + subscriber = + object : Subscriber { + override fun onSubscribe(subscription: Subscription?) { + subscription?.request(1) + } + + override fun onNext(t: Any) { + onNextList.add(t) + } + + override fun onError(t: Throwable?) { + throwable = t + } + + override fun onComplete() { + isComplete = true } } } @After fun clear() { - onNext.clear() + onNextList.clear() + throwable = null + isComplete = false } @Test @@ -44,48 +60,39 @@ class StreamTests { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStream") - val task = function.stream(input).addOnStreamListener(listener) + function.stream(input).subscribe(subscriber) - Thread.sleep(6000) - val onNextStringList = onNext.map { it.toString() } + Thread.sleep(8000) + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", "{chunk=world}", "{chunk=this}", "{chunk=is}", - "{chunk=cool}" + "{chunk=cool}", + "hello world this is cool" ) - assertThat(task.result.data).isEqualTo("hello world this is cool") - task.removeOnStreamListener(listener) + assertThat(throwable).isNull() + assertThat(isComplete).isTrue() } @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamError").withTimeout(6, TimeUnit.SECONDS) + val function = functions.getHttpsCallable("genStreamError").withTimeout(1, TimeUnit.SECONDS) - val task: StreamFunctionsTask = function.stream(input).addOnStreamListener(listener) - Thread.sleep(7000) + function.stream(input).subscribe(subscriber) + Thread.sleep(8000) - val onNextStringList = onNext.map { it.toString() } + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", - "{chunk=world}", - "{chunk=this}", - "{chunk=is}", - "{chunk=cool}" ) - - assertThat(task.isSuccessful).isFalse() - try { - assertThat(task.result).isNull() - } catch (e: Throwable) { - assertThat(e).isInstanceOf(IllegalStateException::class.java) - assertThat(e.message).isEqualTo("No result available.") - } - task.removeOnStreamListener(listener) + assertThat(throwable).isNotNull() + assertThat(requireNotNull(throwable).message).isEqualTo("timeout") + assertThat(isComplete).isFalse() } @Test @@ -93,10 +100,10 @@ class StreamTests { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") - val task = function.stream(input).addOnStreamListener(listener) - Thread.sleep(7000) + function.stream(input).subscribe(subscriber) + Thread.sleep(8000) - val onNextStringList = onNext.map { it.toString() } + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -105,33 +112,48 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - try { - assertThat(task.result).isNull() - } catch (e: Throwable) { - assertThat(e).isInstanceOf(IllegalStateException::class.java) - assertThat(e.message).isEqualTo("No result available.") - } - task.removeOnStreamListener(listener) + assertThat(isComplete).isFalse() } @Test fun testGenStream_cancelStream() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") - val task = function.stream(input).addOnStreamListener(listener) - Thread.sleep(2000) + val publisher = function.stream(input) + var subscription: Subscription? = null + val cancelableSubscriber = + object : Subscriber { + override fun onSubscribe(s: Subscription?) { + subscription = s + s?.request(1) + } - task.cancel() + override fun onNext(message: Any) { + onNextList.add(message) + } + + override fun onError(t: Throwable?) { + throwable = t + } + + override fun onComplete() { + isComplete = true + } + } + + publisher.subscribe(cancelableSubscriber) + Thread.sleep(2000) + subscription?.cancel() + Thread.sleep(6000) - val onNextStringList = onNext.map { it.toString() } + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", "{chunk=world}", ) - assertThat(task.isCanceled).isTrue() - assertThat(task.isComplete).isFalse() - assertThat(task.isSuccessful).isFalse() - task.removeOnStreamListener(listener) + assertThat(throwable).isNotNull() + assertThat(requireNotNull(throwable).message).isEqualTo("Stream was canceled") + assertThat(isComplete).isFalse() } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index f9da8dfabe5..2bbc904372b 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -37,6 +37,7 @@ import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.MalformedURLException import java.net.URL +import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executor import javax.inject.Named import okhttp3.Call @@ -48,6 +49,9 @@ import okhttp3.RequestBody import okhttp3.Response import org.json.JSONException import org.json.JSONObject +import org.reactivestreams.Publisher +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription /** FirebaseFunctions lets you call Cloud Functions for Firebase. */ public class FirebaseFunctions @@ -314,36 +318,57 @@ internal constructor( return tcs.task } - internal fun stream(name: String, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { - val task = StreamFunctionsTask() - providerInstalled.task - .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .addOnCompleteListener(executor) { contextTask -> - if (!contextTask.isSuccessful) { - task.fail( - FirebaseFunctionsException( - "Error retrieving context", - FirebaseFunctionsException.Code.INTERNAL, - null, - contextTask.exception - ) - ) - return@addOnCompleteListener - } - val url = getURL(name) - stream(url, data, options, contextTask.result, task) + internal fun stream(name: String, data: Any?, options: HttpsCallOptions): Publisher { + val task = + providerInstalled.task.continueWithTask(executor) { + contextProvider.getContext(options.limitedUseAppCheckTokens) } - return task + return PublisherStream(getURL(name), data, options, client, serializer, task, executor) } - internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { - val task = StreamFunctionsTask() - providerInstalled.task - .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .addOnCompleteListener(executor) { contextTask -> + internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { + val task = + providerInstalled.task.continueWithTask(executor) { + contextProvider.getContext(options.limitedUseAppCheckTokens) + } + + return PublisherStream(url, data, options, client, this.serializer, task, executor) + } + + internal class PublisherStream( + private val url: URL, + private val data: Any?, + private val options: HttpsCallOptions, + private val client: OkHttpClient, + private val serializer: Serializer, + private val contextTask: Task, + private val executor: Executor + ) : Publisher { + + private val subscribers = ConcurrentLinkedQueue>() + private var activeCall: Call? = null + + override fun subscribe(subscriber: Subscriber) { + subscribers.add(subscriber) + subscriber.onSubscribe( + object : Subscription { + override fun request(n: Long) { + startStreaming() + } + + override fun cancel() { + cancelStream() + subscribers.remove(subscriber) + } + } + ) + } + + private fun startStreaming() { + contextTask.addOnCompleteListener(executor) { contextTask -> if (!contextTask.isSuccessful) { - task.fail( + notifyError( FirebaseFunctionsException( "Error retrieving context", FirebaseFunctionsException.Code.INTERNAL, @@ -351,192 +376,196 @@ internal constructor( contextTask.exception ) ) - return@addOnCompleteListener } - stream(url, data, options, contextTask.result, task) - } - - return task - } - - private fun stream( - url: URL, - data: Any?, - options: HttpsCallOptions, - context: HttpsCallableContext?, - task: StreamFunctionsTask - ) { - Preconditions.checkNotNull(url, "url cannot be null") - val callClient = options.apply(client) - callClient.postStream(url, task) { applyCommonConfiguration(data, context) } - } + Preconditions.checkNotNull(url, "url cannot be null") + val context = contextTask.result + val callClient = options.apply(client) + val requestBody = + RequestBody.create( + MediaType.parse("application/json"), + JSONObject(mapOf("data" to serializer.encode(data))).toString() + ) - private inline fun OkHttpClient.postStream( - url: URL, - task: StreamFunctionsTask, - crossinline config: Request.Builder.() -> Unit = {} - ) { - val requestBuilder = Request.Builder().url(url) - requestBuilder.config() - val request = requestBuilder.build() - - val call = newCall(request) - call.enqueue( - object : Callback { - override fun onFailure(ignored: Call, e: IOException) { - val exception: FirebaseFunctionsException = - if (e is InterruptedIOException) { - FirebaseFunctionsException( - FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, - FirebaseFunctionsException.Code.DEADLINE_EXCEEDED, - null, - e - ) - } else { - FirebaseFunctionsException( - FirebaseFunctionsException.Code.INTERNAL.name, - FirebaseFunctionsException.Code.INTERNAL, - null, - e - ) + val requestBuilder = + Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") + + applyCommonConfiguration(requestBuilder, context) + + val request = requestBuilder.build() + val call = callClient.newCall(request) + activeCall = call + + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + val message: String + val code: FirebaseFunctionsException.Code + if (e is InterruptedIOException) { + message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name + code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED + } else { + message = FirebaseFunctionsException.Code.INTERNAL.name + code = FirebaseFunctionsException.Code.INTERNAL + } + notifyError(FirebaseFunctionsException(message, code, null, e)) } - task.fail(exception) - } - @Throws(IOException::class) - override fun onResponse(ignored: Call, response: Response) { - try { - validateResponse(response) - val bodyStream = response.body()?.byteStream() - if (bodyStream != null) { - processSSEStream(bodyStream, serializer, task) - } else { - val exception = - FirebaseFunctionsException( - "Response body is null", - FirebaseFunctionsException.Code.INTERNAL, - null + override fun onResponse(call: Call, response: Response) { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream) + } else { + notifyError( + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) ) - task.fail(exception) + } } - } catch (exception: FirebaseFunctionsException) { - task.fail(exception) } - } + ) } - ) - } - - private fun validateResponse(response: Response) { - if (response.isSuccessful) return - - val htmlContentType = "text/html; charset=utf-8" - val trimMargin: String - if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { - trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() - throw FirebaseFunctionsException( - trimMargin, - FirebaseFunctionsException.Code.fromHttpStatus(response.code()), - null - ) } - val text = response.body()?.string() ?: "" - val error: Any? - try { - val json = JSONObject(text) - error = serializer.decode(json.opt("error")) - } catch (e: Throwable) { - throw FirebaseFunctionsException( - "${e.message} Unexpected Response:\n$text ", - FirebaseFunctionsException.Code.INTERNAL, - e + private fun cancelStream() { + activeCall?.cancel() + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) ) } - throw FirebaseFunctionsException( - error.toString(), - FirebaseFunctionsException.Code.INTERNAL, - error - ) - } - private fun Request.Builder.applyCommonConfiguration(data: Any?, context: HttpsCallableContext?) { - val body: MutableMap = HashMap() - val encoded = serializer.encode(data) - body["data"] = encoded - if (context!!.authToken != null) { - header("Authorization", "Bearer " + context.authToken) + private fun applyCommonConfiguration( + requestBuilder: Request.Builder, + context: HttpsCallableContext? + ) { + context?.authToken?.let { requestBuilder.header("Authorization", "Bearer $it") } + context?.instanceIdToken?.let { requestBuilder.header("Firebase-Instance-ID-Token", it) } + context?.appCheckToken?.let { requestBuilder.header("X-Firebase-AppCheck", it) } } - if (context.instanceIdToken != null) { - header("Firebase-Instance-ID-Token", context.instanceIdToken) - } - if (context.appCheckToken != null) { - header("X-Firebase-AppCheck", context.appCheckToken) - } - header("Accept", "text/event-stream") - val bodyJSON = JSONObject(body) - val contentType = MediaType.parse("application/json") - val requestBody = RequestBody.create(contentType, bodyJSON.toString()) - post(requestBody) - } - private fun processSSEStream( - inputStream: InputStream, - serializer: Serializer, - task: StreamFunctionsTask - ) { - BufferedReader(InputStreamReader(inputStream)).use { reader -> - try { - reader.lineSequence().forEach { line -> - val dataChunk = - when { - line.startsWith("data:") -> line.removePrefix("data:") - line.startsWith("result:") -> line.removePrefix("result:") - else -> return@forEach - } - try { - val json = JSONObject(dataChunk) - when { - json.has("message") -> - serializer.decode(json.opt("message"))?.let { task.notifyListeners(it) } - json.has("error") -> { - serializer.decode(json.opt("error"))?.let { - throw FirebaseFunctionsException( - it.toString(), - FirebaseFunctionsException.Code.INTERNAL, - it - ) - } + private fun processSSEStream(inputStream: InputStream) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + reader.lineSequence().forEach { line -> + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach } - json.has("result") -> { - serializer.decode(json.opt("result"))?.let { - task.complete(HttpsCallableResult(it)) + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { notifyData(it) } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + notifyError( + FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + notifyData(it) + notifyComplete() + } + return } - return } + } catch (e: Throwable) { + notifyError( + FirebaseFunctionsException( + "Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) } - } catch (e: Throwable) { - throw FirebaseFunctionsException( - "${e.message} Invalid JSON: $dataChunk", + } + notifyError( + FirebaseFunctionsException( + "Stream ended unexpectedly without completion", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + ) + } catch (e: Exception) { + notifyError( + FirebaseFunctionsException( + e.message ?: "Error reading stream", FirebaseFunctionsException.Code.INTERNAL, e ) - } + ) } + } + } + + private fun notifyData(data: Any?) { + for (subscriber in subscribers) { + subscriber.onNext(data!!) + } + } + + private fun notifyError(e: FirebaseFunctionsException) { + for (subscriber in subscribers) { + subscriber.onError(e) + } + subscribers.clear() + } + + private fun notifyComplete() { + for (subscriber in subscribers) { + subscriber.onComplete() + } + subscribers.clear() + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val trimMargin: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() throw FirebaseFunctionsException( - "Stream ended unexpectedly without completion.", - FirebaseFunctionsException.Code.INTERNAL, + trimMargin, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), null ) - } catch (e: Exception) { + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { throw FirebaseFunctionsException( - e.message ?: "Error reading stream", + "${e.message} Unexpected Response:\n$text ", FirebaseFunctionsException.Code.INTERNAL, e ) } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index a18849f619f..9b451d74c40 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -17,6 +17,7 @@ import androidx.annotation.VisibleForTesting import com.google.android.gms.tasks.Task import java.net.URL import java.util.concurrent.TimeUnit +import org.reactivestreams.Publisher /** A reference to a particular Callable HTTPS trigger in Cloud Functions. */ public class HttpsCallableReference { @@ -155,13 +156,13 @@ public class HttpsCallableReference { * Instance ID the next time you call this method. * * @param data Parameters to pass to the endpoint. - * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. + * @return [Publisher] that will be completed when the streaming operation has finished. * @see org.json.JSONArray * @see org.json.JSONObject * @see java.io.IOException * @see FirebaseFunctionsException */ - public fun stream(data: Any?): StreamFunctionsTask { + public fun stream(data: Any?): Publisher { return if (name != null) { functionsClient.stream(name, data, options) } else { @@ -181,9 +182,9 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. + * @return [Publisher] that will be completed when the streaming operation has finished. */ - public fun stream(): StreamFunctionsTask { + public fun stream(): Publisher { return if (name != null) { functionsClient.stream(name, null, options) } else { From 1584847eeca9a2e6fb67c328758886bc2bae4c1b Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 22:50:37 -0800 Subject: [PATCH 07/41] Fix test case testGenStreamError. --- .../java/com/google/firebase/functions/StreamTests.kt | 7 ++++--- .../com/google/firebase/functions/FirebaseFunctions.kt | 7 ++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index a1a5efbf569..0fa8c8c9ad7 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -80,10 +80,11 @@ class StreamTests { @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamError").withTimeout(1, TimeUnit.SECONDS) + val function = + functions.getHttpsCallable("genStreamError").withTimeout(800, TimeUnit.MILLISECONDS) function.stream(input).subscribe(subscriber) - Thread.sleep(8000) + Thread.sleep(2000) val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) @@ -91,7 +92,6 @@ class StreamTests { "{chunk=hello}", ) assertThat(throwable).isNotNull() - assertThat(requireNotNull(throwable).message).isEqualTo("timeout") assertThat(isComplete).isFalse() } @@ -112,6 +112,7 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) + assertThat(throwable).isNull() assertThat(isComplete).isFalse() } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 2bbc904372b..6962cb4fb07 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -336,7 +336,7 @@ internal constructor( return PublisherStream(url, data, options, client, this.serializer, task, executor) } - internal class PublisherStream( + private class PublisherStream( private val url: URL, private val data: Any?, private val options: HttpsCallOptions, @@ -387,12 +387,9 @@ internal constructor( MediaType.parse("application/json"), JSONObject(mapOf("data" to serializer.encode(data))).toString() ) - val requestBuilder = Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") - applyCommonConfiguration(requestBuilder, context) - val request = requestBuilder.build() val call = callClient.newCall(request) activeCall = call @@ -517,7 +514,7 @@ internal constructor( private fun notifyData(data: Any?) { for (subscriber in subscribers) { - subscriber.onNext(data!!) + subscriber.onNext(data) } } From ee964a5228e6fb6960ac7eb807802a678e4b5139 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 22:55:43 -0800 Subject: [PATCH 08/41] Update api.txt. --- firebase-functions/api.txt | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index 2de8f1a358b..aaa8fe4fa5c 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -87,6 +87,7 @@ package com.google.firebase.functions { method public long getTimeout(); method public void setTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(@Nullable Object data = null); + method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(); method @NonNull public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); property public final long timeout; } @@ -96,32 +97,6 @@ package com.google.firebase.functions { field @Nullable public final Object data; } - public final class StreamFunctionsTask extends com.google.android.gms.tasks.Task { - ctor public StreamFunctionsTask(); - method @NonNull public com.google.firebase.functions.StreamFunctionsTask addOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); - method public void removeOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); - method public void notifyListeners(@NonNull Object data); - method public void complete(@NonNull com.google.firebase.functions.HttpsCallableResult result); - method public void fail(@NonNull Exception exception); - method @Nullable public Exception getException(); - method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(); - method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(@NonNull Class p0); - method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull com.google.android.gms.tasks.OnFailureListener p0); - method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull com.google.android.gms.tasks.OnSuccessListener p0); - method public boolean isCanceled(); - method public boolean isComplete(); - method public boolean isSuccessful(); - method public void cancel(); - } - - public fun interface StreamListener { - method public void onNext(@NonNull Object message); - } - } package com.google.firebase.functions.ktx { From 895ee023d481e4a268a120e3dc4782b3897bb294 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 23:01:33 -0800 Subject: [PATCH 09/41] Remove StreamListener.kt and StreamFunctionsTask.kt --- firebase-functions/api.txt | 4 +- .../firebase/functions/StreamFunctionsTask.kt | 121 ------------------ .../firebase/functions/StreamListener.kt | 8 -- 3 files changed, 2 insertions(+), 131 deletions(-) delete mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt delete mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index aaa8fe4fa5c..d1a24bc1749 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -86,8 +86,8 @@ package com.google.firebase.functions { method @NonNull public com.google.android.gms.tasks.Task call(); method public long getTimeout(); method public void setTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); - method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(@Nullable Object data = null); - method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(); + method @NonNull public org.reactivestreams.Publisher stream(@Nullable Object data = null); + method @NonNull public org.reactivestreams.Publisher stream(); method @NonNull public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); property public final long timeout; } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt deleted file mode 100644 index 3c206022838..00000000000 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.google.firebase.functions - -import android.app.Activity -import com.google.android.gms.tasks.OnFailureListener -import com.google.android.gms.tasks.OnSuccessListener -import com.google.android.gms.tasks.Task -import java.util.Queue -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.Executor - -public class StreamFunctionsTask : Task() { - - private val listenerQueue: Queue = ConcurrentLinkedQueue() - private var result: HttpsCallableResult? = null - private var exception: Exception? = null - private var isComplete: Boolean = false - private var isCanceled = false - - public fun addOnStreamListener(listener: StreamListener): StreamFunctionsTask { - listenerQueue.add(listener) - return this - } - - public fun removeOnStreamListener(listener: StreamListener) { - listenerQueue.remove(listener) - } - - internal fun notifyListeners(data: Any) { - for (listener in listenerQueue) { - listener.onNext(data) - } - } - - internal fun complete(result: HttpsCallableResult) { - this.result = result - this.isComplete = true - } - - internal fun fail(exception: Exception) { - this.exception = exception - this.isComplete = true - } - - override fun getException(): Exception? { - listenerQueue.clear() - return exception - } - - override fun getResult(): HttpsCallableResult { - listenerQueue.clear() - return result ?: throw IllegalStateException("No result available.") - } - - override fun getResult(p0: Class): HttpsCallableResult { - if (p0.isInstance(exception)) { - throw p0.cast(exception)!! - } - return getResult() - } - - override fun addOnFailureListener(listener: OnFailureListener): Task { - if (exception != null) listener.onFailure(requireNotNull(exception)) - return this - } - - override fun addOnFailureListener( - activity: Activity, - listener: OnFailureListener - ): Task { - if (exception != null) listener.onFailure(requireNotNull(exception)) - return this - } - - override fun addOnFailureListener( - executor: Executor, - listener: OnFailureListener - ): Task { - if (exception != null) executor.execute { listener.onFailure(requireNotNull(exception)) } - return this - } - - override fun addOnSuccessListener( - executor: Executor, - listener: OnSuccessListener - ): Task { - if (result != null) executor.execute { listener.onSuccess(requireNotNull(result)) } - return this - } - - override fun addOnSuccessListener( - activity: Activity, - listener: OnSuccessListener - ): Task { - if (result != null) listener.onSuccess(requireNotNull(result)) - return this - } - - override fun addOnSuccessListener( - listener: OnSuccessListener - ): Task { - if (result != null) listener.onSuccess(requireNotNull(result)) - return this - } - - override fun isCanceled(): Boolean { - return isCanceled - } - - override fun isComplete(): Boolean { - return isComplete - } - - override fun isSuccessful(): Boolean { - return exception == null && result != null - } - - public fun cancel() { - isCanceled = true - listenerQueue.clear() - } -} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt deleted file mode 100644 index 539c55be3a4..00000000000 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.google.firebase.functions - -/** Listener for events from a Server-Sent Events stream. */ -public interface StreamListener { - - /** Called when a new event is received. */ - public fun onNext(message: Any) -} From 4f8f7d46090b99a86b050de9fed4d16bccc32c1a Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Tue, 11 Feb 2025 13:39:47 -0800 Subject: [PATCH 10/41] Introduce StreamResponse to accommodate Stream Message and Result. --- .../google/firebase/functions/StreamTests.kt | 25 ++++++++++--------- .../firebase/functions/FirebaseFunctions.kt | 22 ++++++++++------ .../functions/HttpsCallableReference.kt | 4 +-- .../firebase/functions/StreamResponse.kt | 11 ++++++++ 4 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 0fa8c8c9ad7..37abb603b44 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -5,6 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.StreamResponse import com.google.firebase.functions.functions import com.google.firebase.initialize import java.util.concurrent.TimeUnit @@ -19,8 +20,8 @@ import org.reactivestreams.Subscription class StreamTests { private lateinit var functions: FirebaseFunctions - var onNextList = mutableListOf() - private lateinit var subscriber: Subscriber + var onNextList = mutableListOf() + private lateinit var subscriber: Subscriber private var throwable: Throwable? = null private var isComplete = false @@ -29,13 +30,13 @@ class StreamTests { Firebase.initialize(ApplicationProvider.getApplicationContext()) functions = Firebase.functions subscriber = - object : Subscriber { + object : Subscriber { override fun onSubscribe(subscription: Subscription?) { subscription?.request(1) } - override fun onNext(t: Any) { - onNextList.add(t) + override fun onNext(streamResponse: StreamResponse) { + onNextList.add(streamResponse) } override fun onError(t: Throwable?) { @@ -63,7 +64,7 @@ class StreamTests { function.stream(input).subscribe(subscriber) Thread.sleep(8000) - val onNextStringList = onNextList.map { it.toString() } + val onNextStringList = onNextList.map { it.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -86,7 +87,7 @@ class StreamTests { function.stream(input).subscribe(subscriber) Thread.sleep(2000) - val onNextStringList = onNextList.map { it.toString() } + val onNextStringList = onNextList.map { it.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -103,7 +104,7 @@ class StreamTests { function.stream(input).subscribe(subscriber) Thread.sleep(8000) - val onNextStringList = onNextList.map { it.toString() } + val onNextStringList = onNextList.map { it.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -123,14 +124,14 @@ class StreamTests { val publisher = function.stream(input) var subscription: Subscription? = null val cancelableSubscriber = - object : Subscriber { + object : Subscriber { override fun onSubscribe(s: Subscription?) { subscription = s s?.request(1) } - override fun onNext(message: Any) { - onNextList.add(message) + override fun onNext(streamResponse: StreamResponse) { + onNextList.add(streamResponse) } override fun onError(t: Throwable?) { @@ -147,7 +148,7 @@ class StreamTests { subscription?.cancel() Thread.sleep(6000) - val onNextStringList = onNextList.map { it.toString() } + val onNextStringList = onNextList.map { it.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 6962cb4fb07..1c930928053 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -318,7 +318,11 @@ internal constructor( return tcs.task } - internal fun stream(name: String, data: Any?, options: HttpsCallOptions): Publisher { + internal fun stream( + name: String, + data: Any?, + options: HttpsCallOptions + ): Publisher { val task = providerInstalled.task.continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) @@ -327,7 +331,7 @@ internal constructor( return PublisherStream(getURL(name), data, options, client, serializer, task, executor) } - internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { + internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { val task = providerInstalled.task.continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) @@ -344,12 +348,12 @@ internal constructor( private val serializer: Serializer, private val contextTask: Task, private val executor: Executor - ) : Publisher { + ) : Publisher { - private val subscribers = ConcurrentLinkedQueue>() + private val subscribers = ConcurrentLinkedQueue>() private var activeCall: Call? = null - override fun subscribe(subscriber: Subscriber) { + override fun subscribe(subscriber: Subscriber) { subscribers.add(subscriber) subscriber.onSubscribe( object : Subscription { @@ -463,7 +467,9 @@ internal constructor( val json = JSONObject(dataChunk) when { json.has("message") -> - serializer.decode(json.opt("message"))?.let { notifyData(it) } + serializer.decode(json.opt("message"))?.let { + notifyData(StreamResponse.Message(data = it)) + } json.has("error") -> { serializer.decode(json.opt("error"))?.let { notifyError( @@ -477,7 +483,7 @@ internal constructor( } json.has("result") -> { serializer.decode(json.opt("result"))?.let { - notifyData(it) + notifyData(StreamResponse.Result(data = it)) notifyComplete() } return @@ -512,7 +518,7 @@ internal constructor( } } - private fun notifyData(data: Any?) { + private fun notifyData(data: StreamResponse?) { for (subscriber in subscribers) { subscriber.onNext(data) } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 9b451d74c40..9ae7f30fc99 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -162,7 +162,7 @@ public class HttpsCallableReference { * @see java.io.IOException * @see FirebaseFunctionsException */ - public fun stream(data: Any?): Publisher { + public fun stream(data: Any?): Publisher { return if (name != null) { functionsClient.stream(name, data, options) } else { @@ -184,7 +184,7 @@ public class HttpsCallableReference { * * @return [Publisher] that will be completed when the streaming operation has finished. */ - public fun stream(): Publisher { + public fun stream(): Publisher { return if (name != null) { functionsClient.stream(name, null, options) } else { diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt new file mode 100644 index 00000000000..4750f732944 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -0,0 +1,11 @@ +package com.google.firebase.functions + +/** A response from a Server-Sent Event stream. */ +public sealed class StreamResponse(public open val data: Any) { + + /** Called when a new event is received. */ + public class Message(override val data: Any) : StreamResponse(data) + + /** Called when the stream is closed. */ + public class Result(override val data: Any) : StreamResponse(data) +} From 38f3e8e6aa294e6688e50bd2d9556aa66f425be3 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 12 Feb 2025 13:46:08 -0800 Subject: [PATCH 11/41] Refactor StreamResponse, PublisherStream. - Embedded the distinction between `message` and `result` in test cases. --- .../google/firebase/functions/StreamTests.kt | 32 ++- .../firebase/functions/FirebaseFunctions.kt | 243 +---------------- .../functions/HttpsCallableReference.kt | 10 +- .../firebase/functions/PublisherStream.kt | 250 ++++++++++++++++++ .../firebase/functions/StreamResponse.kt | 33 ++- 5 files changed, 310 insertions(+), 258 deletions(-) create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 37abb603b44..59b124c502b 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -6,6 +6,8 @@ import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.StreamResponse +import com.google.firebase.functions.StreamResponse.Message +import com.google.firebase.functions.StreamResponse.Result import com.google.firebase.functions.functions import com.google.firebase.initialize import java.util.concurrent.TimeUnit @@ -57,29 +59,31 @@ class StreamTests { } @Test - fun testGenStream() { + fun testGenStream_receivesMessagesAndFinalResult() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStream") function.stream(input).subscribe(subscriber) Thread.sleep(8000) - val onNextStringList = onNextList.map { it.data.toString() } - assertThat(onNextStringList) + val messages = onNextList.filterIsInstance() + val results = onNextList.filterIsInstance() + assertThat(messages.map { it.data.toString() }) .containsExactly( "{chunk=hello}", "{chunk=world}", "{chunk=this}", "{chunk=is}", - "{chunk=cool}", - "hello world this is cool" + "{chunk=cool}" ) + assertThat(results).hasSize(1) + assertThat(results.first().data.toString()).isEqualTo("hello world this is cool") assertThat(throwable).isNull() assertThat(isComplete).isTrue() } @Test - fun testGenStreamError() { + fun testGenStreamError_receivesErrorAndStops() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamError").withTimeout(800, TimeUnit.MILLISECONDS) @@ -87,7 +91,8 @@ class StreamTests { function.stream(input).subscribe(subscriber) Thread.sleep(2000) - val onNextStringList = onNextList.map { it.data.toString() } + val messages = onNextList.filterIsInstance() + val onNextStringList = messages.map { it.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -97,14 +102,17 @@ class StreamTests { } @Test - fun testGenStreamNoReturn() { + fun testGenStreamNoReturn_receivesOnlyMessages() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") function.stream(input).subscribe(subscriber) Thread.sleep(8000) - val onNextStringList = onNextList.map { it.data.toString() } + val messages = onNextList.filterIsInstance() + val results = onNextList.filterIsInstance() + + val onNextStringList = messages.map { it.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -113,12 +121,13 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) + assertThat(results).isEmpty() assertThat(throwable).isNull() assertThat(isComplete).isFalse() } @Test - fun testGenStream_cancelStream() { + fun testGenStream_cancelStream_receivesPartialMessagesAndError() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") val publisher = function.stream(input) @@ -148,7 +157,8 @@ class StreamTests { subscription?.cancel() Thread.sleep(6000) - val onNextStringList = onNextList.map { it.data.toString() } + val messages = onNextList.filterIsInstance() + val onNextStringList = messages.map { it.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 1c930928053..48e5b9d7904 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -30,14 +30,10 @@ import com.google.firebase.functions.FirebaseFunctionsException.Code.Companion.f import com.google.firebase.functions.FirebaseFunctionsException.Companion.fromResponse import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import java.io.BufferedReader import java.io.IOException -import java.io.InputStream -import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.MalformedURLException import java.net.URL -import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executor import javax.inject.Named import okhttp3.Call @@ -50,8 +46,6 @@ import okhttp3.Response import org.json.JSONException import org.json.JSONObject import org.reactivestreams.Publisher -import org.reactivestreams.Subscriber -import org.reactivestreams.Subscription /** FirebaseFunctions lets you call Cloud Functions for Firebase. */ public class FirebaseFunctions @@ -323,15 +317,18 @@ internal constructor( data: Any?, options: HttpsCallOptions ): Publisher { + val url = getURL(name) + Preconditions.checkNotNull(url, "url cannot be null") val task = providerInstalled.task.continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - return PublisherStream(getURL(name), data, options, client, serializer, task, executor) + return PublisherStream(url, data, options, client, serializer, task, executor) } internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { + Preconditions.checkNotNull(url, "url cannot be null") val task = providerInstalled.task.continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) @@ -340,238 +337,6 @@ internal constructor( return PublisherStream(url, data, options, client, this.serializer, task, executor) } - private class PublisherStream( - private val url: URL, - private val data: Any?, - private val options: HttpsCallOptions, - private val client: OkHttpClient, - private val serializer: Serializer, - private val contextTask: Task, - private val executor: Executor - ) : Publisher { - - private val subscribers = ConcurrentLinkedQueue>() - private var activeCall: Call? = null - - override fun subscribe(subscriber: Subscriber) { - subscribers.add(subscriber) - subscriber.onSubscribe( - object : Subscription { - override fun request(n: Long) { - startStreaming() - } - - override fun cancel() { - cancelStream() - subscribers.remove(subscriber) - } - } - ) - } - - private fun startStreaming() { - contextTask.addOnCompleteListener(executor) { contextTask -> - if (!contextTask.isSuccessful) { - notifyError( - FirebaseFunctionsException( - "Error retrieving context", - FirebaseFunctionsException.Code.INTERNAL, - null, - contextTask.exception - ) - ) - return@addOnCompleteListener - } - - Preconditions.checkNotNull(url, "url cannot be null") - val context = contextTask.result - val callClient = options.apply(client) - val requestBody = - RequestBody.create( - MediaType.parse("application/json"), - JSONObject(mapOf("data" to serializer.encode(data))).toString() - ) - val requestBuilder = - Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") - applyCommonConfiguration(requestBuilder, context) - val request = requestBuilder.build() - val call = callClient.newCall(request) - activeCall = call - - call.enqueue( - object : Callback { - override fun onFailure(call: Call, e: IOException) { - val message: String - val code: FirebaseFunctionsException.Code - if (e is InterruptedIOException) { - message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name - code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED - } else { - message = FirebaseFunctionsException.Code.INTERNAL.name - code = FirebaseFunctionsException.Code.INTERNAL - } - notifyError(FirebaseFunctionsException(message, code, null, e)) - } - - override fun onResponse(call: Call, response: Response) { - validateResponse(response) - val bodyStream = response.body()?.byteStream() - if (bodyStream != null) { - processSSEStream(bodyStream) - } else { - notifyError( - FirebaseFunctionsException( - "Response body is null", - FirebaseFunctionsException.Code.INTERNAL, - null - ) - ) - } - } - } - ) - } - } - - private fun cancelStream() { - activeCall?.cancel() - notifyError( - FirebaseFunctionsException( - "Stream was canceled", - FirebaseFunctionsException.Code.CANCELLED, - null - ) - ) - } - - private fun applyCommonConfiguration( - requestBuilder: Request.Builder, - context: HttpsCallableContext? - ) { - context?.authToken?.let { requestBuilder.header("Authorization", "Bearer $it") } - context?.instanceIdToken?.let { requestBuilder.header("Firebase-Instance-ID-Token", it) } - context?.appCheckToken?.let { requestBuilder.header("X-Firebase-AppCheck", it) } - } - - private fun processSSEStream(inputStream: InputStream) { - BufferedReader(InputStreamReader(inputStream)).use { reader -> - try { - reader.lineSequence().forEach { line -> - val dataChunk = - when { - line.startsWith("data:") -> line.removePrefix("data:") - line.startsWith("result:") -> line.removePrefix("result:") - else -> return@forEach - } - try { - val json = JSONObject(dataChunk) - when { - json.has("message") -> - serializer.decode(json.opt("message"))?.let { - notifyData(StreamResponse.Message(data = it)) - } - json.has("error") -> { - serializer.decode(json.opt("error"))?.let { - notifyError( - FirebaseFunctionsException( - it.toString(), - FirebaseFunctionsException.Code.INTERNAL, - it - ) - ) - } - } - json.has("result") -> { - serializer.decode(json.opt("result"))?.let { - notifyData(StreamResponse.Result(data = it)) - notifyComplete() - } - return - } - } - } catch (e: Throwable) { - notifyError( - FirebaseFunctionsException( - "Invalid JSON: $dataChunk", - FirebaseFunctionsException.Code.INTERNAL, - e - ) - ) - } - } - notifyError( - FirebaseFunctionsException( - "Stream ended unexpectedly without completion", - FirebaseFunctionsException.Code.INTERNAL, - null - ) - ) - } catch (e: Exception) { - notifyError( - FirebaseFunctionsException( - e.message ?: "Error reading stream", - FirebaseFunctionsException.Code.INTERNAL, - e - ) - ) - } - } - } - - private fun notifyData(data: StreamResponse?) { - for (subscriber in subscribers) { - subscriber.onNext(data) - } - } - - private fun notifyError(e: FirebaseFunctionsException) { - for (subscriber in subscribers) { - subscriber.onError(e) - } - subscribers.clear() - } - - private fun notifyComplete() { - for (subscriber in subscribers) { - subscriber.onComplete() - } - subscribers.clear() - } - - private fun validateResponse(response: Response) { - if (response.isSuccessful) return - - val htmlContentType = "text/html; charset=utf-8" - val trimMargin: String - if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { - trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() - throw FirebaseFunctionsException( - trimMargin, - FirebaseFunctionsException.Code.fromHttpStatus(response.code()), - null - ) - } - - val text = response.body()?.string() ?: "" - val error: Any? - try { - val json = JSONObject(text) - error = serializer.decode(json.opt("error")) - } catch (e: Throwable) { - throw FirebaseFunctionsException( - "${e.message} Unexpected Response:\n$text ", - FirebaseFunctionsException.Code.INTERNAL, - e - ) - } - throw FirebaseFunctionsException( - error.toString(), - FirebaseFunctionsException.Code.INTERNAL, - error - ) - } - } - public companion object { /** A task that will be resolved once ProviderInstaller has installed what it needs to. */ private val providerInstalled = TaskCompletionSource() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 9ae7f30fc99..3c09aa6aa50 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -129,12 +129,14 @@ public class HttpsCallableReference { /** * Streams data to the specified HTTPS endpoint asynchronously. * - * The data passed into the endpoint can be any of the following types: + * The data passed into the trigger can be any of the following types: * - * * Any primitive type, including `null`, `int`, `long`, `float`, and `boolean`. + * * Any primitive type, including null, int, long, float, and boolean. * * [String] - * * [List<?>][java.util.List], where the contained objects are also one of these types. - * * [Map<String, ?>][java.util.Map], where the values are also one of these types. + * * [List&lt;?&gt;][java.util.List], where the contained objects are also one of these + * types. + * * [Map&lt;String, ?&gt;>][java.util.Map], where the values are also one of these + * types. * * [org.json.JSONArray] * * [org.json.JSONObject] * * [org.json.JSONObject.NULL] diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt new file mode 100644 index 00000000000..d7c3ee3eb89 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -0,0 +1,250 @@ +package com.google.firebase.functions + +import com.google.android.gms.tasks.Task +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.InterruptedIOException +import java.net.URL +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executor +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import org.json.JSONObject +import org.reactivestreams.Publisher +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription + +internal class PublisherStream( + private val url: URL, + private val data: Any?, + private val options: HttpsCallOptions, + private val client: OkHttpClient, + private val serializer: Serializer, + private val contextTask: Task, + private val executor: Executor +) : Publisher { + + private val subscribers = ConcurrentLinkedQueue>() + private var activeCall: Call? = null + + override fun subscribe(subscriber: Subscriber) { + subscribers.add(subscriber) + subscriber.onSubscribe( + object : Subscription { + override fun request(n: Long) { + startStreaming() + } + + override fun cancel() { + cancelStream() + subscribers.remove(subscriber) + } + } + ) + } + + private fun startStreaming() { + contextTask.addOnCompleteListener(executor) { contextTask -> + if (!contextTask.isSuccessful) { + notifyError( + FirebaseFunctionsException( + "Error retrieving context", + FirebaseFunctionsException.Code.INTERNAL, + null, + contextTask.exception + ) + ) + return@addOnCompleteListener + } + + val context = contextTask.result + val callClient = options.apply(client) + val requestBody = + RequestBody.create( + MediaType.parse("application/json"), + JSONObject(mapOf("data" to serializer.encode(data))).toString() + ) + val requestBuilder = + Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") + applyCommonConfiguration(requestBuilder, context) + val request = requestBuilder.build() + val call = callClient.newCall(request) + activeCall = call + + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + val code: FirebaseFunctionsException.Code = + if (e is InterruptedIOException) { + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED + } else { + FirebaseFunctionsException.Code.INTERNAL + } + notifyError(FirebaseFunctionsException(code.name, code, null, e)) + } + + override fun onResponse(call: Call, response: Response) { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream) + } else { + notifyError( + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + ) + } + } + } + ) + } + } + + private fun cancelStream() { + activeCall?.cancel() + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + } + + private fun applyCommonConfiguration( + requestBuilder: Request.Builder, + context: HttpsCallableContext? + ) { + context?.authToken?.let { requestBuilder.header("Authorization", "Bearer $it") } + context?.instanceIdToken?.let { requestBuilder.header("Firebase-Instance-ID-Token", it) } + context?.appCheckToken?.let { requestBuilder.header("X-Firebase-AppCheck", it) } + } + + private fun processSSEStream(inputStream: InputStream) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + reader.lineSequence().forEach { line -> + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach + } + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { + notifyData(StreamResponse.Message(data = it)) + } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + notifyError( + FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + notifyData(StreamResponse.Result(data = it)) + notifyComplete() + } + return + } + } + } catch (e: Throwable) { + notifyError( + FirebaseFunctionsException( + "Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + } + } + notifyError( + FirebaseFunctionsException( + "Stream ended unexpectedly without completion", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + ) + } catch (e: Exception) { + notifyError( + FirebaseFunctionsException( + e.message ?: "Error reading stream", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + } + } + } + + private fun notifyData(data: StreamResponse?) { + for (subscriber in subscribers) { + subscriber.onNext(data) + } + } + + private fun notifyError(e: FirebaseFunctionsException) { + for (subscriber in subscribers) { + subscriber.onError(e) + } + subscribers.clear() + } + + private fun notifyComplete() { + for (subscriber in subscribers) { + subscriber.onComplete() + } + subscribers.clear() + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val trimMargin: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + throw FirebaseFunctionsException( + trimMargin, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), + null + ) + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Unexpected Response:\n$text ", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index 4750f732944..d25218d47cd 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -1,11 +1,36 @@ package com.google.firebase.functions -/** A response from a Server-Sent Event stream. */ -public sealed class StreamResponse(public open val data: Any) { +/** + * Represents a response from a Server-Sent Event (SSE) stream. + * + * The SSE stream consists of two types of responses: + * - [Message]: Represents an intermediate event pushed from the server. + * - [Result]: Represents the final response that signifies the stream has ended. + */ +public abstract class StreamResponse private constructor(public open val data: Any) { - /** Called when a new event is received. */ + /** + * An event message received during the stream. + * + * Messages are intermediate data chunks sent by the server before the final result. + * + * Example SSE format: + * ```json + * data: { "message": { "chunk": "foo" } } + * ``` + */ public class Message(override val data: Any) : StreamResponse(data) - /** Called when the stream is closed. */ + /** + * The final response that terminates the stream. + * + * This result is sent as the last message in the stream and indicates that no further messages + * will be received. + * + * Example SSE format: + * ```json + * data: { "result": { "text": "foo bar" } } + * ``` + */ public class Result(override val data: Any) : StreamResponse(data) } From 5883572d2d8aa9d170824a2acd81bf105ed127df Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Thu, 13 Feb 2025 13:59:03 -0800 Subject: [PATCH 12/41] Refactor StreamResponse. - Implemented the usage of Flow in test case genStream_withFlow_receivesMessagesAndFinalResult --- .../firebase-functions.gradle.kts | 1 + .../google/firebase/functions/StreamTests.kt | 62 ++++++++++++++----- .../firebase/functions/PublisherStream.kt | 4 +- .../firebase/functions/StreamResponse.kt | 6 +- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/firebase-functions/firebase-functions.gradle.kts b/firebase-functions/firebase-functions.gradle.kts index aee9b06c24b..08a797112b9 100644 --- a/firebase-functions/firebase-functions.gradle.kts +++ b/firebase-functions/firebase-functions.gradle.kts @@ -133,6 +133,7 @@ dependencies { androidTestImplementation(libs.truth) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.kotlinx.coroutines.reactive) androidTestImplementation(libs.mockito.core) androidTestImplementation(libs.mockito.dexmaker) kapt("com.google.dagger:dagger-android-processor:2.43.2") diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 59b124c502b..0b9bb330b69 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -11,6 +11,9 @@ import com.google.firebase.functions.StreamResponse.Result import com.google.firebase.functions.functions import com.google.firebase.initialize import java.util.concurrent.TimeUnit +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Before import org.junit.Test @@ -59,8 +62,8 @@ class StreamTests { } @Test - fun testGenStream_receivesMessagesAndFinalResult() { - val input = hashMapOf("data" to "Why is the sky blue") + fun genStream_withPublisher_receivesMessagesAndFinalResult() { + val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStream") function.stream(input).subscribe(subscriber) @@ -68,7 +71,7 @@ class StreamTests { Thread.sleep(8000) val messages = onNextList.filterIsInstance() val results = onNextList.filterIsInstance() - assertThat(messages.map { it.data.toString() }) + assertThat(messages.map { it.data.data.toString() }) .containsExactly( "{chunk=hello}", "{chunk=world}", @@ -77,14 +80,44 @@ class StreamTests { "{chunk=cool}" ) assertThat(results).hasSize(1) - assertThat(results.first().data.toString()).isEqualTo("hello world this is cool") + assertThat(results.first().data.data.toString()).isEqualTo("hello world this is cool") assertThat(throwable).isNull() assertThat(isComplete).isTrue() } @Test - fun testGenStreamError_receivesErrorAndStops() { - val input = hashMapOf("data" to "Why is the sky blue") + fun genStream_withFlow_receivesMessagesAndFinalResult() = runBlocking { + val input = mapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStream") + + val flow = function.stream(input).asFlow() + val receivedResponses = mutableListOf() + try { + withTimeout(8000) { flow.collect { response -> receivedResponses.add(response) } } + isComplete = true + } catch (e: Throwable) { + throwable = e + } + + val messages = receivedResponses.filterIsInstance() + val results = receivedResponses.filterIsInstance() + assertThat(messages.map { it.data.data.toString() }) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(results).hasSize(1) + assertThat(results.first().data.data.toString()).isEqualTo("hello world this is cool") + assertThat(throwable).isNull() + assertThat(isComplete).isTrue() + } + + @Test + fun genStreamError_receivesErrorAndStops() { + val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamError").withTimeout(800, TimeUnit.MILLISECONDS) @@ -92,7 +125,7 @@ class StreamTests { Thread.sleep(2000) val messages = onNextList.filterIsInstance() - val onNextStringList = messages.map { it.data.toString() } + val onNextStringList = messages.map { it.data.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -102,8 +135,8 @@ class StreamTests { } @Test - fun testGenStreamNoReturn_receivesOnlyMessages() { - val input = hashMapOf("data" to "Why is the sky blue") + fun genStreamNoReturn_receivesOnlyMessages() { + val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") function.stream(input).subscribe(subscriber) @@ -112,7 +145,7 @@ class StreamTests { val messages = onNextList.filterIsInstance() val results = onNextList.filterIsInstance() - val onNextStringList = messages.map { it.data.toString() } + val onNextStringList = messages.map { it.data.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -127,8 +160,8 @@ class StreamTests { } @Test - fun testGenStream_cancelStream_receivesPartialMessagesAndError() { - val input = hashMapOf("data" to "Why is the sky blue") + fun genStream_cancelStream_receivesPartialMessagesAndError() { + val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") val publisher = function.stream(input) var subscription: Subscription? = null @@ -153,16 +186,15 @@ class StreamTests { } publisher.subscribe(cancelableSubscriber) - Thread.sleep(2000) + Thread.sleep(500) subscription?.cancel() Thread.sleep(6000) val messages = onNextList.filterIsInstance() - val onNextStringList = messages.map { it.data.toString() } + val onNextStringList = messages.map { it.data.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", - "{chunk=world}", ) assertThat(throwable).isNotNull() assertThat(requireNotNull(throwable).message).isEqualTo("Stream was canceled") diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index d7c3ee3eb89..3fa68c3f535 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -145,7 +145,7 @@ internal class PublisherStream( when { json.has("message") -> serializer.decode(json.opt("message"))?.let { - notifyData(StreamResponse.Message(data = it)) + notifyData(StreamResponse.Message(data = HttpsCallableResult(it))) } json.has("error") -> { serializer.decode(json.opt("error"))?.let { @@ -160,7 +160,7 @@ internal class PublisherStream( } json.has("result") -> { serializer.decode(json.opt("result"))?.let { - notifyData(StreamResponse.Result(data = it)) + notifyData(StreamResponse.Result(data = HttpsCallableResult(it))) notifyComplete() } return diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index d25218d47cd..475fcb748cf 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -7,7 +7,7 @@ package com.google.firebase.functions * - [Message]: Represents an intermediate event pushed from the server. * - [Result]: Represents the final response that signifies the stream has ended. */ -public abstract class StreamResponse private constructor(public open val data: Any) { +public abstract class StreamResponse private constructor(public val data: HttpsCallableResult) { /** * An event message received during the stream. @@ -19,7 +19,7 @@ public abstract class StreamResponse private constructor(public open val data: A * data: { "message": { "chunk": "foo" } } * ``` */ - public class Message(override val data: Any) : StreamResponse(data) + public class Message(data: HttpsCallableResult) : StreamResponse(data) /** * The final response that terminates the stream. @@ -32,5 +32,5 @@ public abstract class StreamResponse private constructor(public open val data: A * data: { "result": { "text": "foo bar" } } * ``` */ - public class Result(override val data: Any) : StreamResponse(data) + public class Result(data: HttpsCallableResult) : StreamResponse(data) } From ca17db7f793090c369f8e650e920281699ea3aa9 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 06:58:49 -0800 Subject: [PATCH 13/41] Add Copyright to StreamTests. --- .../google/firebase/functions/StreamTests.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 0b9bb330b69..141286c7d20 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -1,14 +1,27 @@ -package com.google.firebase.functions.ktx +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.functions import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase -import com.google.firebase.functions.FirebaseFunctions -import com.google.firebase.functions.StreamResponse import com.google.firebase.functions.StreamResponse.Message import com.google.firebase.functions.StreamResponse.Result -import com.google.firebase.functions.functions import com.google.firebase.initialize import java.util.concurrent.TimeUnit import kotlinx.coroutines.reactive.asFlow From f2efd11b0f3480b21775da80d9b639663c9b8e4d Mon Sep 17 00:00:00 2001 From: mustafa jadid Date: Wed, 19 Feb 2025 07:02:12 -0800 Subject: [PATCH 14/41] Update firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt Co-authored-by: Rodrigo Lazo --- .../com/google/firebase/functions/HttpsCallableReference.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 3c09aa6aa50..11801bdea92 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -158,7 +158,7 @@ public class HttpsCallableReference { * Instance ID the next time you call this method. * * @param data Parameters to pass to the endpoint. - * @return [Publisher] that will be completed when the streaming operation has finished. + * @return [Publisher] that will emit intermediate data, and the final result, as it is generated by the function. * @see org.json.JSONArray * @see org.json.JSONObject * @see java.io.IOException From c3fb9f1183858aeb52ef5bcd4a798857a870780c Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 07:31:01 -0800 Subject: [PATCH 15/41] Design StreamSubscriber class inside StreamTests to prevent calling extra @After steps. --- .../google/firebase/functions/StreamTests.kt | 109 ++++++++---------- .../functions/HttpsCallableReference.kt | 3 +- 2 files changed, 47 insertions(+), 65 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 141286c7d20..2c7b5fab2e8 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -27,7 +27,6 @@ import java.util.concurrent.TimeUnit import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout -import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -38,52 +37,48 @@ import org.reactivestreams.Subscription class StreamTests { private lateinit var functions: FirebaseFunctions - var onNextList = mutableListOf() - private lateinit var subscriber: Subscriber - private var throwable: Throwable? = null - private var isComplete = false @Before fun setup() { Firebase.initialize(ApplicationProvider.getApplicationContext()) functions = Firebase.functions - subscriber = - object : Subscriber { - override fun onSubscribe(subscription: Subscription?) { - subscription?.request(1) - } - - override fun onNext(streamResponse: StreamResponse) { - onNextList.add(streamResponse) - } - - override fun onError(t: Throwable?) { - throwable = t - } - - override fun onComplete() { - isComplete = true - } - } } - @After - fun clear() { - onNextList.clear() - throwable = null - isComplete = false + internal class StreamSubscriber : Subscriber { + internal val onNextList = mutableListOf() + internal var throwable: Throwable? = null + internal var isComplete = false + internal var subscription: Subscription? = null + + override fun onSubscribe(subscription: Subscription?) { + this.subscription = subscription + subscription?.request(1) + } + + override fun onNext(streamResponse: StreamResponse) { + onNextList.add(streamResponse) + } + + override fun onError(t: Throwable?) { + throwable = t + } + + override fun onComplete() { + isComplete = true + } } @Test fun genStream_withPublisher_receivesMessagesAndFinalResult() { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStream") + val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) Thread.sleep(8000) - val messages = onNextList.filterIsInstance() - val results = onNextList.filterIsInstance() + val messages = subscriber.onNextList.filterIsInstance() + val results = subscriber.onNextList.filterIsInstance() assertThat(messages.map { it.data.data.toString() }) .containsExactly( "{chunk=hello}", @@ -94,14 +89,16 @@ class StreamTests { ) assertThat(results).hasSize(1) assertThat(results.first().data.data.toString()).isEqualTo("hello world this is cool") - assertThat(throwable).isNull() - assertThat(isComplete).isTrue() + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.isComplete).isTrue() } @Test fun genStream_withFlow_receivesMessagesAndFinalResult() = runBlocking { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStream") + var isComplete = false + var throwable: Throwable? = null val flow = function.stream(input).asFlow() val receivedResponses = mutableListOf() @@ -133,30 +130,32 @@ class StreamTests { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamError").withTimeout(800, TimeUnit.MILLISECONDS) + val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) Thread.sleep(2000) - val messages = onNextList.filterIsInstance() + val messages = subscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.data.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", ) - assertThat(throwable).isNotNull() - assertThat(isComplete).isFalse() + assertThat(subscriber.throwable).isNotNull() + assertThat(subscriber.isComplete).isFalse() } @Test fun genStreamNoReturn_receivesOnlyMessages() { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") + val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) Thread.sleep(8000) - val messages = onNextList.filterIsInstance() - val results = onNextList.filterIsInstance() + val messages = subscriber.onNextList.filterIsInstance() + val results = subscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.data.data.toString() } assertThat(onNextStringList) @@ -168,8 +167,8 @@ class StreamTests { "{chunk=cool}" ) assertThat(results).isEmpty() - assertThat(throwable).isNull() - assertThat(isComplete).isFalse() + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.isComplete).isFalse() } @Test @@ -177,40 +176,22 @@ class StreamTests { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") val publisher = function.stream(input) - var subscription: Subscription? = null - val cancelableSubscriber = - object : Subscriber { - override fun onSubscribe(s: Subscription?) { - subscription = s - s?.request(1) - } - - override fun onNext(streamResponse: StreamResponse) { - onNextList.add(streamResponse) - } - - override fun onError(t: Throwable?) { - throwable = t - } - - override fun onComplete() { - isComplete = true - } - } + val cancelableSubscriber = StreamSubscriber() publisher.subscribe(cancelableSubscriber) Thread.sleep(500) - subscription?.cancel() + cancelableSubscriber.subscription?.cancel() Thread.sleep(6000) - val messages = onNextList.filterIsInstance() + val messages = cancelableSubscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.data.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", ) - assertThat(throwable).isNotNull() - assertThat(requireNotNull(throwable).message).isEqualTo("Stream was canceled") - assertThat(isComplete).isFalse() + assertThat(cancelableSubscriber.throwable).isNotNull() + assertThat(requireNotNull(cancelableSubscriber.throwable).message) + .isEqualTo("Stream was canceled") + assertThat(cancelableSubscriber.isComplete).isFalse() } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 11801bdea92..e4bb29e02e5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -158,7 +158,8 @@ public class HttpsCallableReference { * Instance ID the next time you call this method. * * @param data Parameters to pass to the endpoint. - * @return [Publisher] that will emit intermediate data, and the final result, as it is generated by the function. + * @return [Publisher] that will emit intermediate data, and the final result, as it is generated + * by the function. * @see org.json.JSONArray * @see org.json.JSONObject * @see java.io.IOException From 2a4591a847afe045cfba64ab716feb8f82843f5c Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 07:37:59 -0800 Subject: [PATCH 16/41] Remove duplicated code inside the stream method. --- .../google/firebase/functions/FirebaseFunctions.kt | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 48e5b9d7904..0dae105b999 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -316,16 +316,7 @@ internal constructor( name: String, data: Any?, options: HttpsCallOptions - ): Publisher { - val url = getURL(name) - Preconditions.checkNotNull(url, "url cannot be null") - val task = - providerInstalled.task.continueWithTask(executor) { - contextProvider.getContext(options.limitedUseAppCheckTokens) - } - - return PublisherStream(url, data, options, client, serializer, task, executor) - } + ): Publisher = stream(getURL(name), data, options) internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { Preconditions.checkNotNull(url, "url cannot be null") From e29489eb23709767401858269bef2b1880ac072a Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 07:39:39 -0800 Subject: [PATCH 17/41] Add Copyright to PublisherStream. --- .../google/firebase/functions/PublisherStream.kt | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index 3fa68c3f535..16aa4c64c35 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.google.firebase.functions import com.google.android.gms.tasks.Task From 5d5656104e9cda2328a4af4b251eedd7e9efc6f5 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 12:18:52 -0800 Subject: [PATCH 18/41] Add Copyright to StreamResponse. - Use coroutine on the StreamTests. --- .../google/firebase/functions/StreamTests.kt | 49 ++++++++++--------- .../firebase/functions/StreamResponse.kt | 16 ++++++ 2 files changed, 41 insertions(+), 24 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 2c7b5fab2e8..4a032e7dfe7 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -24,6 +24,7 @@ import com.google.firebase.functions.StreamResponse.Message import com.google.firebase.functions.StreamResponse.Result import com.google.firebase.initialize import java.util.concurrent.TimeUnit +import kotlinx.coroutines.delay import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -69,14 +70,16 @@ class StreamTests { } @Test - fun genStream_withPublisher_receivesMessagesAndFinalResult() { + fun genStream_withPublisher_receivesMessagesAndFinalResult() = runBlocking { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStream") val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) - Thread.sleep(8000) + while (!subscriber.isComplete) { + delay(100) + } val messages = subscriber.onNextList.filterIsInstance() val results = subscriber.onNextList.filterIsInstance() assertThat(messages.map { it.data.data.toString() }) @@ -103,7 +106,7 @@ class StreamTests { val flow = function.stream(input).asFlow() val receivedResponses = mutableListOf() try { - withTimeout(8000) { flow.collect { response -> receivedResponses.add(response) } } + withTimeout(1000) { flow.collect { response -> receivedResponses.add(response) } } isComplete = true } catch (e: Throwable) { throwable = e @@ -126,37 +129,36 @@ class StreamTests { } @Test - fun genStreamError_receivesErrorAndStops() { + fun genStreamError_receivesErrorAndStops() = runBlocking { val input = mapOf("data" to "Why is the sky blue") val function = - functions.getHttpsCallable("genStreamError").withTimeout(800, TimeUnit.MILLISECONDS) + functions.getHttpsCallable("genStreamError").withTimeout(1000, TimeUnit.MILLISECONDS) val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) - Thread.sleep(2000) + delay(1000) val messages = subscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.data.data.toString() } - assertThat(onNextStringList) - .containsExactly( - "{chunk=hello}", - ) + assertThat(onNextStringList).contains("{chunk=hello}") assertThat(subscriber.throwable).isNotNull() + assertThat(subscriber.throwable!!.message).isEqualTo("{message=INTERNAL, status=INTERNAL}") assertThat(subscriber.isComplete).isFalse() } @Test - fun genStreamNoReturn_receivesOnlyMessages() { + fun genStreamNoReturn_receivesOnlyMessages() = runBlocking { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) - Thread.sleep(8000) + while (subscriber.onNextList.size < 5) { + delay(100) + } val messages = subscriber.onNextList.filterIsInstance() val results = subscriber.onNextList.filterIsInstance() - val onNextStringList = messages.map { it.data.data.toString() } assertThat(onNextStringList) .containsExactly( @@ -172,26 +174,25 @@ class StreamTests { } @Test - fun genStream_cancelStream_receivesPartialMessagesAndError() { + fun genStream_cancelStream_receivesPartialMessagesAndError() = runBlocking { val input = mapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") val publisher = function.stream(input) val cancelableSubscriber = StreamSubscriber() - publisher.subscribe(cancelableSubscriber) - Thread.sleep(500) + while (cancelableSubscriber.onNextList.isEmpty()) { + delay(50) + } cancelableSubscriber.subscription?.cancel() - Thread.sleep(6000) + while (cancelableSubscriber.throwable == null) { + delay(300) + } val messages = cancelableSubscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.data.data.toString() } - assertThat(onNextStringList) - .containsExactly( - "{chunk=hello}", - ) - assertThat(cancelableSubscriber.throwable).isNotNull() - assertThat(requireNotNull(cancelableSubscriber.throwable).message) - .isEqualTo("Stream was canceled") + assertThat(onNextStringList).containsExactly("{chunk=hello}") + assertThat(onNextStringList).doesNotContain("{chunk=cool}") + assertThat(cancelableSubscriber.throwable!!.message!!.uppercase()).contains("CANCEL") assertThat(cancelableSubscriber.isComplete).isFalse() } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index 475fcb748cf..f20101e6a10 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.google.firebase.functions /** From dd1bf7ab64fc3951779a0e47a0da283b43a83535 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 12:24:01 -0800 Subject: [PATCH 19/41] Modified the index.js stream functions by using sendChunk method. --- .../androidTest/backend/functions/index.js | 150 +++++++++--------- 1 file changed, 76 insertions(+), 74 deletions(-) diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index b55625fbd32..dbd8f55da81 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -12,127 +12,131 @@ // See the License for the specific language governing permissions and // limitations under the License. -const assert = require('assert'); -const functions = require('firebase-functions'); +const assert = require("assert"); +const functionsV1 = require("firebase-functions"); +const functionsV2 = require("firebase-functions/v2"); -exports.dataTest = functions.https.onRequest((request, response) => { +/** + * Pauses the execution for a specified amount of time. + * @param {number} ms - The number of milliseconds to sleep. + * @return {Promise} A promise that resolves after the specified time. + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +exports.dataTest = functionsV1.https.onRequest((request, response) => { assert.deepEqual(request.body, { data: { - bool: true, - int: 2, - long: { - value: '3', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "bool": true, + "int": 2, + "long": { + "value": "3", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, - string: 'four', - array: [5, 6], - 'null': null, - } + "string": "four", + "array": [5, 6], + "null": null, + }, }); response.send({ data: { - message: 'stub response', + message: "stub response", code: 42, long: { - value: '420', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "value": "420", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, - } + }, }); }); -exports.scalarTest = functions.https.onRequest((request, response) => { +exports.scalarTest = functionsV1.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: 17}); response.send({data: 76}); }); -exports.tokenTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('Authorization'), 'Bearer token'); +exports.tokenTest = functionsV1.https.onRequest((request, response) => { + assert.equal(request.get("Authorization"), "Bearer token"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.instanceIdTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('Firebase-Instance-ID-Token'), 'iid'); +exports.instanceIdTest = functionsV1.https.onRequest((request, response) => { + assert.equal(request.get("Firebase-Instance-ID-Token"), "iid"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.appCheckTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck'); +exports.appCheckTest = functionsV1.https.onRequest((request, response) => { + assert.equal(request.get("X-Firebase-AppCheck"), "appCheck"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.appCheckLimitedUseTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck-limited-use'); - assert.deepEqual(request.body, {data: {}}); - response.send({data: {}}); -}); +exports.appCheckLimitedUseTest = functionsV1.https.onRequest( + (request, response) => { + assert.equal(request.get("X-Firebase-AppCheck"), "appCheck-limited-use"); + assert.deepEqual(request.body, {data: {}}); + response.send({data: {}}); + }); -exports.nullTest = functions.https.onRequest((request, response) => { +exports.nullTest = functionsV1.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: null}); response.send({data: null}); }); -exports.missingResultTest = functions.https.onRequest((request, response) => { +exports.missingResultTest = functionsV1.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: null}); response.send({}); }); -exports.unhandledErrorTest = functions.https.onRequest((request, response) => { - // Fail in a way that the client shouldn't see. - throw 'nope'; -}); +exports.unhandledErrorTest = functionsV1.https.onRequest( + (request, response) => { + // Fail in a way that the client shouldn't see. + throw new Error("nope"); + }, +); -exports.unknownErrorTest = functions.https.onRequest((request, response) => { +exports.unknownErrorTest = functionsV1.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: 'THIS_IS_NOT_VALID', - message: 'this should be ignored', + status: "THIS_IS_NOT_VALID", + message: "this should be ignored", }, }); }); -exports.explicitErrorTest = functions.https.onRequest((request, response) => { +exports.explicitErrorTest = functionsV1.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: 'OUT_OF_RANGE', - message: 'explicit nope', + status: "OUT_OF_RANGE", + message: "explicit nope", details: { start: 10, end: 20, long: { - value: '30', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "value": "30", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, }, }, }); }); -exports.httpErrorTest = functions.https.onRequest((request, response) => { +exports.httpErrorTest = functionsV1.https.onRequest((request, response) => { // Send an http error with no body. response.status(400).send(); }); -exports.timeoutTest = functions.https.onRequest((request, response) => { +exports.timeoutTest = functionsV1.https.onRequest((request, response) => { // Wait for longer than 500ms. setTimeout(() => response.send({data: true}), 500); }); -const data = ["hello", "world", "this", "is", "cool"]; - -/** - * Pauses the execution for a specified amount of time. - * @param {number} ms - The number of milliseconds to sleep. - * @return {Promise} A promise that resolves after the specified time. - */ -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +const streamData = ["hello", "world", "this", "is", "cool"]; /** * Generates chunks of text asynchronously, yielding one chunk at a time. @@ -141,38 +145,36 @@ function sleep(ms) { * @yields {string} A chunk of text from the data array. */ async function* generateText() { - for (const chunk of data) { + for (const chunk of streamData) { yield chunk; - await sleep(1000); + await sleep(100); } } -exports.genStream = functions.https.onCall(async (request, response) => { - if (response && response.acceptsStreaming) { +exports.genStream = functionsV2.https.onCall(async (request, response) => { + if (request.acceptsStreaming) { for await (const chunk of generateText()) { - console.log("got chunk", chunk); - response.write({chunk}); + response.sendChunk({chunk}); } } - return data.join(" "); + return streamData.join(" "); }); -exports.genStreamError = functions.https.onCall(async (request, response) => { - if (response && response.acceptsStreaming) { - for await (const chunk of generateText()) { - console.log("got chunk", chunk); - response.write({chunk}); - } - throw new Error("BOOM"); - } -}); +exports.genStreamError = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + for await (const chunk of generateText()) { + response.sendChunk({chunk}); + } + } + throw new Error("BOOM"); + }); -exports.genStreamNoReturn = functions.https.onCall( +exports.genStreamNoReturn = functionsV2.https.onCall( async (request, response) => { - if (response && response.acceptsStreaming) { + if (request.acceptsStreaming) { for await (const chunk of generateText()) { - console.log("got chunk", chunk); - response.write({chunk}); + response.sendChunk({chunk}); } } }, From e5fd3c071eaabd57a20c7dfb2b85f750966da817 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 12:53:59 -0800 Subject: [PATCH 20/41] Rename data to message inside the StreamResponse class. - Rename callClient to configuredClient. - Refactor applyCommonConfiguration. --- .../google/firebase/functions/StreamTests.kt | 14 ++++++------- .../firebase/functions/PublisherStream.kt | 21 +++++++------------ .../firebase/functions/StreamResponse.kt | 6 +++--- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 4a032e7dfe7..f584f1b7f69 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -82,7 +82,7 @@ class StreamTests { } val messages = subscriber.onNextList.filterIsInstance() val results = subscriber.onNextList.filterIsInstance() - assertThat(messages.map { it.data.data.toString() }) + assertThat(messages.map { it.message.data.toString() }) .containsExactly( "{chunk=hello}", "{chunk=world}", @@ -91,7 +91,7 @@ class StreamTests { "{chunk=cool}" ) assertThat(results).hasSize(1) - assertThat(results.first().data.data.toString()).isEqualTo("hello world this is cool") + assertThat(results.first().message.data.toString()).isEqualTo("hello world this is cool") assertThat(subscriber.throwable).isNull() assertThat(subscriber.isComplete).isTrue() } @@ -114,7 +114,7 @@ class StreamTests { val messages = receivedResponses.filterIsInstance() val results = receivedResponses.filterIsInstance() - assertThat(messages.map { it.data.data.toString() }) + assertThat(messages.map { it.message.data.toString() }) .containsExactly( "{chunk=hello}", "{chunk=world}", @@ -123,7 +123,7 @@ class StreamTests { "{chunk=cool}" ) assertThat(results).hasSize(1) - assertThat(results.first().data.data.toString()).isEqualTo("hello world this is cool") + assertThat(results.first().message.data.toString()).isEqualTo("hello world this is cool") assertThat(throwable).isNull() assertThat(isComplete).isTrue() } @@ -139,7 +139,7 @@ class StreamTests { delay(1000) val messages = subscriber.onNextList.filterIsInstance() - val onNextStringList = messages.map { it.data.data.toString() } + val onNextStringList = messages.map { it.message.data.toString() } assertThat(onNextStringList).contains("{chunk=hello}") assertThat(subscriber.throwable).isNotNull() assertThat(subscriber.throwable!!.message).isEqualTo("{message=INTERNAL, status=INTERNAL}") @@ -159,7 +159,7 @@ class StreamTests { } val messages = subscriber.onNextList.filterIsInstance() val results = subscriber.onNextList.filterIsInstance() - val onNextStringList = messages.map { it.data.data.toString() } + val onNextStringList = messages.map { it.message.data.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -189,7 +189,7 @@ class StreamTests { delay(300) } val messages = cancelableSubscriber.onNextList.filterIsInstance() - val onNextStringList = messages.map { it.data.data.toString() } + val onNextStringList = messages.map { it.message.data.toString() } assertThat(onNextStringList).containsExactly("{chunk=hello}") assertThat(onNextStringList).doesNotContain("{chunk=cool}") assertThat(cancelableSubscriber.throwable!!.message!!.uppercase()).contains("CANCEL") diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index 16aa4c64c35..29aba8cdfe2 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -81,7 +81,7 @@ internal class PublisherStream( } val context = contextTask.result - val callClient = options.apply(client) + val configuredClient = options.apply(client) val requestBody = RequestBody.create( MediaType.parse("application/json"), @@ -89,9 +89,11 @@ internal class PublisherStream( ) val requestBuilder = Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") - applyCommonConfiguration(requestBuilder, context) + context?.authToken?.let { requestBuilder.header("Authorization", "Bearer $it") } + context?.instanceIdToken?.let { requestBuilder.header("Firebase-Instance-ID-Token", it) } + context?.appCheckToken?.let { requestBuilder.header("X-Firebase-AppCheck", it) } val request = requestBuilder.build() - val call = callClient.newCall(request) + val call = configuredClient.newCall(request) activeCall = call call.enqueue( @@ -137,15 +139,6 @@ internal class PublisherStream( ) } - private fun applyCommonConfiguration( - requestBuilder: Request.Builder, - context: HttpsCallableContext? - ) { - context?.authToken?.let { requestBuilder.header("Authorization", "Bearer $it") } - context?.instanceIdToken?.let { requestBuilder.header("Firebase-Instance-ID-Token", it) } - context?.appCheckToken?.let { requestBuilder.header("X-Firebase-AppCheck", it) } - } - private fun processSSEStream(inputStream: InputStream) { BufferedReader(InputStreamReader(inputStream)).use { reader -> try { @@ -161,7 +154,7 @@ internal class PublisherStream( when { json.has("message") -> serializer.decode(json.opt("message"))?.let { - notifyData(StreamResponse.Message(data = HttpsCallableResult(it))) + notifyData(StreamResponse.Message(message = HttpsCallableResult(it))) } json.has("error") -> { serializer.decode(json.opt("error"))?.let { @@ -176,7 +169,7 @@ internal class PublisherStream( } json.has("result") -> { serializer.decode(json.opt("result"))?.let { - notifyData(StreamResponse.Result(data = HttpsCallableResult(it))) + notifyData(StreamResponse.Result(message = HttpsCallableResult(it))) notifyComplete() } return diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index f20101e6a10..5a68b5e27ce 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -23,7 +23,7 @@ package com.google.firebase.functions * - [Message]: Represents an intermediate event pushed from the server. * - [Result]: Represents the final response that signifies the stream has ended. */ -public abstract class StreamResponse private constructor(public val data: HttpsCallableResult) { +public abstract class StreamResponse private constructor(public val message: HttpsCallableResult) { /** * An event message received during the stream. @@ -35,7 +35,7 @@ public abstract class StreamResponse private constructor(public val data: HttpsC * data: { "message": { "chunk": "foo" } } * ``` */ - public class Message(data: HttpsCallableResult) : StreamResponse(data) + public class Message(message: HttpsCallableResult) : StreamResponse(message) /** * The final response that terminates the stream. @@ -48,5 +48,5 @@ public abstract class StreamResponse private constructor(public val data: HttpsC * data: { "result": { "text": "foo bar" } } * ``` */ - public class Result(data: HttpsCallableResult) : StreamResponse(data) + public class Result(message: HttpsCallableResult) : StreamResponse(message) } From 22d723e2f27df2bc8bb5ae1eecd518a68cbad1cf Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 13:51:49 -0800 Subject: [PATCH 21/41] Rename data to message inside the StreamResponse class. - Rename callClient to configuredClient. - Refactor applyCommonConfiguration. - Rename trimMargin to errorMessage. - Process line breaks inside the SSE streaming. --- .../google/firebase/functions/StreamTests.kt | 8 +- .../firebase/functions/PublisherStream.kt | 94 ++++++++++--------- 2 files changed, 58 insertions(+), 44 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index f584f1b7f69..799cd9c5dda 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -136,7 +136,11 @@ class StreamTests { val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) - delay(1000) + withTimeout(2000) { + while (subscriber.throwable == null) { + delay(100) + } + } val messages = subscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.message.data.toString() } @@ -190,7 +194,7 @@ class StreamTests { } val messages = cancelableSubscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.message.data.toString() } - assertThat(onNextStringList).containsExactly("{chunk=hello}") + assertThat(onNextStringList).contains("{chunk=hello}") assertThat(onNextStringList).doesNotContain("{chunk=cool}") assertThat(cancelableSubscriber.throwable!!.message!!.uppercase()).contains("CANCEL") assertThat(cancelableSubscriber.isComplete).isFalse() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index 29aba8cdfe2..b2f56dcd178 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -142,47 +142,19 @@ internal class PublisherStream( private fun processSSEStream(inputStream: InputStream) { BufferedReader(InputStreamReader(inputStream)).use { reader -> try { + val eventBuffer = StringBuilder() reader.lineSequence().forEach { line -> - val dataChunk = - when { - line.startsWith("data:") -> line.removePrefix("data:") - line.startsWith("result:") -> line.removePrefix("result:") - else -> return@forEach - } - try { - val json = JSONObject(dataChunk) - when { - json.has("message") -> - serializer.decode(json.opt("message"))?.let { - notifyData(StreamResponse.Message(message = HttpsCallableResult(it))) - } - json.has("error") -> { - serializer.decode(json.opt("error"))?.let { - notifyError( - FirebaseFunctionsException( - it.toString(), - FirebaseFunctionsException.Code.INTERNAL, - it - ) - ) - } - } - json.has("result") -> { - serializer.decode(json.opt("result"))?.let { - notifyData(StreamResponse.Result(message = HttpsCallableResult(it))) - notifyComplete() - } - return + if (line.isBlank()) { + processEvent(eventBuffer.toString()) + eventBuffer.clear() + } else { + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach } - } - } catch (e: Throwable) { - notifyError( - FirebaseFunctionsException( - "Invalid JSON: $dataChunk", - FirebaseFunctionsException.Code.INTERNAL, - e - ) - ) + eventBuffer.append(dataChunk.trim()).append("\n") } } notifyError( @@ -204,6 +176,44 @@ internal class PublisherStream( } } + private fun processEvent(dataChunk: String) { + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { + notifyData(StreamResponse.Message(message = HttpsCallableResult(it))) + } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + notifyError( + FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + notifyData(StreamResponse.Result(message = HttpsCallableResult(it))) + notifyComplete() + } + return + } + } + } catch (e: Throwable) { + notifyError( + FirebaseFunctionsException( + "Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) + } + } + private fun notifyData(data: StreamResponse?) { for (subscriber in subscribers) { subscriber.onNext(data) @@ -228,11 +238,11 @@ internal class PublisherStream( if (response.isSuccessful) return val htmlContentType = "text/html; charset=utf-8" - val trimMargin: String + val errorMessage: String if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { - trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + errorMessage = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() throw FirebaseFunctionsException( - trimMargin, + errorMessage, FirebaseFunctionsException.Code.fromHttpStatus(response.code()), null ) From 63356ab8984651da1090458b1880d4eaaa1e37b6 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 14:09:09 -0800 Subject: [PATCH 22/41] Modify Kdoc on HttpsCallableReference and StreamResponse. --- .../firebase/functions/HttpsCallableReference.kt | 12 ++++++++---- .../com/google/firebase/functions/StreamResponse.kt | 2 ++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index e4bb29e02e5..f5c4964bfc2 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -127,7 +127,7 @@ public class HttpsCallableReference { } /** - * Streams data to the specified HTTPS endpoint asynchronously. + * Streams data to the specified HTTPS endpoint. * * The data passed into the trigger can be any of the following types: * @@ -141,7 +141,7 @@ public class HttpsCallableReference { * * [org.json.JSONObject] * * [org.json.JSONObject.NULL] * - * If the returned task fails, the exception will be one of the following types: + * If the returned streamResponse fails, the exception will be one of the following types: * * * [java.io.IOException] * - if the HTTPS request failed to connect. @@ -161,8 +161,11 @@ public class HttpsCallableReference { * @return [Publisher] that will emit intermediate data, and the final result, as it is generated * by the function. * @see org.json.JSONArray + * * @see org.json.JSONObject + * * @see java.io.IOException + * * @see FirebaseFunctionsException */ public fun stream(data: Any?): Publisher { @@ -174,7 +177,7 @@ public class HttpsCallableReference { } /** - * Streams data to the specified HTTPS endpoint asynchronously without arguments. + * Streams data to the specified HTTPS endpoint without arguments. * * The request to the Cloud Functions backend made by this method automatically includes a * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase @@ -185,7 +188,8 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * @return [Publisher] that will be completed when the streaming operation has finished. + * @return [Publisher] that will emit intermediate data, and the final result, as it is generated + * by the function. */ public fun stream(): Publisher { return if (name != null) { diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index 5a68b5e27ce..8abd4e8aac2 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -22,6 +22,8 @@ package com.google.firebase.functions * The SSE stream consists of two types of responses: * - [Message]: Represents an intermediate event pushed from the server. * - [Result]: Represents the final response that signifies the stream has ended. + * + * @property message The data received from the server in the SSE stream. */ public abstract class StreamResponse private constructor(public val message: HttpsCallableResult) { From 6215438fe9ca197adce4171966925d75dbfdb4aa Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 14:37:22 -0800 Subject: [PATCH 23/41] Optimized the overloaded method stream() on HttpCallableReference. - Clarified the difference between Type Result and Message on StreamResponse. - Removed unnecessary the formats by spotlessApply on index.js --- .../androidTest/backend/functions/index.js | 82 +++++++++---------- .../functions/HttpsCallableReference.kt | 8 +- .../firebase/functions/StreamResponse.kt | 9 +- 3 files changed, 47 insertions(+), 52 deletions(-) diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index dbd8f55da81..b71c0eae623 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -12,9 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -const assert = require("assert"); -const functionsV1 = require("firebase-functions"); -const functionsV2 = require("firebase-functions/v2"); +const assert = require('assert'); +const functions = require('firebase-functions'); +const functionsV2 = require('firebase-functions/v2'); /** * Pauses the execution for a specified amount of time. @@ -25,118 +25,118 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } -exports.dataTest = functionsV1.https.onRequest((request, response) => { +exports.dataTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, { data: { - "bool": true, - "int": 2, - "long": { - "value": "3", - "@type": "type.googleapis.com/google.protobuf.Int64Value", + 'bool': true, + 'int': 2, + 'long': { + 'value': '3', + '@type': 'type.googleapis.com/google.protobuf.Int64Value', }, - "string": "four", - "array": [5, 6], - "null": null, + 'string': 'four', + 'array': [5, 6], + 'null': null, }, }); response.send({ data: { - message: "stub response", + message: 'stub response', code: 42, long: { - "value": "420", - "@type": "type.googleapis.com/google.protobuf.Int64Value", + 'value': '420', + '@type': 'type.googleapis.com/google.protobuf.Int64Value', }, }, }); }); -exports.scalarTest = functionsV1.https.onRequest((request, response) => { +exports.scalarTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: 17}); response.send({data: 76}); }); -exports.tokenTest = functionsV1.https.onRequest((request, response) => { - assert.equal(request.get("Authorization"), "Bearer token"); +exports.tokenTest = functions.https.onRequest((request, response) => { + assert.equal(request.get('Authorization'), 'Bearer token'); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.instanceIdTest = functionsV1.https.onRequest((request, response) => { - assert.equal(request.get("Firebase-Instance-ID-Token"), "iid"); +exports.instanceIdTest = functions.https.onRequest((request, response) => { + assert.equal(request.get('Firebase-Instance-ID-Token'), 'iid'); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.appCheckTest = functionsV1.https.onRequest((request, response) => { - assert.equal(request.get("X-Firebase-AppCheck"), "appCheck"); +exports.appCheckTest = functions.https.onRequest((request, response) => { + assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck'); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.appCheckLimitedUseTest = functionsV1.https.onRequest( +exports.appCheckLimitedUseTest = functions.https.onRequest( (request, response) => { - assert.equal(request.get("X-Firebase-AppCheck"), "appCheck-limited-use"); + assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck-limited-use'); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.nullTest = functionsV1.https.onRequest((request, response) => { +exports.nullTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: null}); response.send({data: null}); }); -exports.missingResultTest = functionsV1.https.onRequest((request, response) => { +exports.missingResultTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: null}); response.send({}); }); -exports.unhandledErrorTest = functionsV1.https.onRequest( +exports.unhandledErrorTest = functions.https.onRequest( (request, response) => { // Fail in a way that the client shouldn't see. - throw new Error("nope"); + throw new Error('nope'); }, ); -exports.unknownErrorTest = functionsV1.https.onRequest((request, response) => { +exports.unknownErrorTest = functions.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: "THIS_IS_NOT_VALID", - message: "this should be ignored", + status: 'THIS_IS_NOT_VALID', + message: 'this should be ignored', }, }); }); -exports.explicitErrorTest = functionsV1.https.onRequest((request, response) => { +exports.explicitErrorTest = functions.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: "OUT_OF_RANGE", - message: "explicit nope", + status: 'OUT_OF_RANGE', + message: 'explicit nope', details: { start: 10, end: 20, long: { - "value": "30", - "@type": "type.googleapis.com/google.protobuf.Int64Value", + 'value': '30', + '@type': 'type.googleapis.com/google.protobuf.Int64Value', }, }, }, }); }); -exports.httpErrorTest = functionsV1.https.onRequest((request, response) => { +exports.httpErrorTest = functions.https.onRequest((request, response) => { // Send an http error with no body. response.status(400).send(); }); -exports.timeoutTest = functionsV1.https.onRequest((request, response) => { +exports.timeoutTest = functions.https.onRequest((request, response) => { // Wait for longer than 500ms. setTimeout(() => response.send({data: true}), 500); }); -const streamData = ["hello", "world", "this", "is", "cool"]; +const streamData = ['hello', 'world', 'this', 'is', 'cool']; /** * Generates chunks of text asynchronously, yielding one chunk at a time. @@ -157,7 +157,7 @@ exports.genStream = functionsV2.https.onCall(async (request, response) => { response.sendChunk({chunk}); } } - return streamData.join(" "); + return streamData.join(' '); }); exports.genStreamError = functionsV2.https.onCall( @@ -167,7 +167,7 @@ exports.genStreamError = functionsV2.https.onCall( response.sendChunk({chunk}); } } - throw new Error("BOOM"); + throw new Error('BOOM'); }); exports.genStreamNoReturn = functionsV2.https.onCall( diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index f5c4964bfc2..7cad3fa301d 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -191,13 +191,7 @@ public class HttpsCallableReference { * @return [Publisher] that will emit intermediate data, and the final result, as it is generated * by the function. */ - public fun stream(): Publisher { - return if (name != null) { - functionsClient.stream(name, null, options) - } else { - functionsClient.stream(requireNotNull(url), null, options) - } - } + public fun stream(): Publisher = stream(data = null) /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index 8abd4e8aac2..f5bcd85b765 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -30,7 +30,7 @@ public abstract class StreamResponse private constructor(public val message: Htt /** * An event message received during the stream. * - * Messages are intermediate data chunks sent by the server before the final result. + * Messages are intermediate data chunks sent by the server while processing a request. * * Example SSE format: * ```json @@ -40,10 +40,11 @@ public abstract class StreamResponse private constructor(public val message: Htt public class Message(message: HttpsCallableResult) : StreamResponse(message) /** - * The final response that terminates the stream. + * The final result of the computation, marking the end of the stream. * - * This result is sent as the last message in the stream and indicates that no further messages - * will be received. + * Unlike [Message], which represents intermediate data chunks, [Result] contains the complete + * computation output. If clients only care about the final result, they can process this type + * alone and ignore intermediate messages. * * Example SSE format: * ```json From ed21d7dcc53bdc99215a44fa004b2726d36ed5c5 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 15:37:14 -0800 Subject: [PATCH 24/41] Fix test case genStreamError_receivesErrorAndStops. --- .../java/com/google/firebase/functions/StreamTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 799cd9c5dda..5afe5ed6330 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -132,7 +132,7 @@ class StreamTests { fun genStreamError_receivesErrorAndStops() = runBlocking { val input = mapOf("data" to "Why is the sky blue") val function = - functions.getHttpsCallable("genStreamError").withTimeout(1000, TimeUnit.MILLISECONDS) + functions.getHttpsCallable("genStreamError").withTimeout(2000, TimeUnit.MILLISECONDS) val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) From a6f661a4037a874eb6d2bca131bd420120734af9 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 17:40:24 -0800 Subject: [PATCH 25/41] Format index.js --- .../androidTest/backend/functions/index.js | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index b71c0eae623..1f417483e3d 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -28,14 +28,14 @@ function sleep(ms) { exports.dataTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, { data: { - 'bool': true, - 'int': 2, - 'long': { - 'value': '3', + bool: true, + int: 2, + long: { + value: '3', '@type': 'type.googleapis.com/google.protobuf.Int64Value', }, - 'string': 'four', - 'array': [5, 6], + string: 'four', + array: [5, 6], 'null': null, }, }); @@ -44,10 +44,10 @@ exports.dataTest = functions.https.onRequest((request, response) => { message: 'stub response', code: 42, long: { - 'value': '420', + value: '420', '@type': 'type.googleapis.com/google.protobuf.Int64Value', }, - }, + } }); }); @@ -74,12 +74,11 @@ exports.appCheckTest = functions.https.onRequest((request, response) => { response.send({data: {}}); }); -exports.appCheckLimitedUseTest = functions.https.onRequest( - (request, response) => { - assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck-limited-use'); - assert.deepEqual(request.body, {data: {}}); - response.send({data: {}}); - }); +exports.appCheckLimitedUseTest = functions.https.onRequest((request, response) => { + assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck-limited-use'); + assert.deepEqual(request.body, {data: {}}); + response.send({data: {}}); +}); exports.nullTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: null}); @@ -91,12 +90,10 @@ exports.missingResultTest = functions.https.onRequest((request, response) => { response.send({}); }); -exports.unhandledErrorTest = functions.https.onRequest( - (request, response) => { - // Fail in a way that the client shouldn't see. - throw new Error('nope'); - }, -); +exports.unhandledErrorTest = functions.https.onRequest((request, response) => { + // Fail in a way that the client shouldn't see. + throw 'nope'; +}); exports.unknownErrorTest = functions.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. @@ -118,7 +115,7 @@ exports.explicitErrorTest = functions.https.onRequest((request, response) => { start: 10, end: 20, long: { - 'value': '30', + value: '30', '@type': 'type.googleapis.com/google.protobuf.Int64Value', }, }, From fdea72b536e3c4b439a39d8a2441462a2b492554 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 19 Feb 2025 17:42:20 -0800 Subject: [PATCH 26/41] Format index.js --- firebase-functions/src/androidTest/backend/functions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index 1f417483e3d..8c852d93b72 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -37,7 +37,7 @@ exports.dataTest = functions.https.onRequest((request, response) => { string: 'four', array: [5, 6], 'null': null, - }, + } }); response.send({ data: { From 31a7120b7fe8c46c0c9e5009885d1c95583b9776 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Thu, 20 Feb 2025 00:53:39 -0800 Subject: [PATCH 27/41] Fix Subscription Handling: Ensure subscribe Does Not Trigger Backend Calls & Honor Subscription.request(n). --- .../google/firebase/functions/StreamTests.kt | 22 +++--- .../firebase/functions/PublisherStream.kt | 70 ++++++++++++------- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 5afe5ed6330..baae070be09 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -58,6 +58,7 @@ class StreamTests { override fun onNext(streamResponse: StreamResponse) { onNextList.add(streamResponse) + subscription?.request(1) } override fun onError(t: Throwable?) { @@ -158,8 +159,10 @@ class StreamTests { function.stream(input).subscribe(subscriber) - while (subscriber.onNextList.size < 5) { - delay(100) + withTimeout(2000) { + while (subscriber.onNextList.size < 5) { + delay(100) + } } val messages = subscriber.onNextList.filterIsInstance() val results = subscriber.onNextList.filterIsInstance() @@ -184,13 +187,16 @@ class StreamTests { val publisher = function.stream(input) val cancelableSubscriber = StreamSubscriber() publisher.subscribe(cancelableSubscriber) - while (cancelableSubscriber.onNextList.isEmpty()) { - delay(50) + withTimeout(2000) { + while (cancelableSubscriber.onNextList.isEmpty()) { + delay(50) + } } - cancelableSubscriber.subscription?.cancel() - - while (cancelableSubscriber.throwable == null) { - delay(300) + withTimeout(2000) { cancelableSubscriber.subscription?.cancel() } + withTimeout(1000) { + while (cancelableSubscriber.throwable == null) { + delay(300) + } } val messages = cancelableSubscriber.onNextList.filterIsInstance() val onNextStringList = messages.map { it.message.data.toString() } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index b2f56dcd178..97406c7d8ec 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -25,6 +25,7 @@ import java.io.InterruptedIOException import java.net.URL import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicLong import okhttp3.Call import okhttp3.Callback import okhttp3.MediaType @@ -47,20 +48,40 @@ internal class PublisherStream( private val executor: Executor ) : Publisher { - private val subscribers = ConcurrentLinkedQueue>() + private val subscribers = ConcurrentLinkedQueue, AtomicLong>>() private var activeCall: Call? = null + private var isStreamingStarted = false + private var isCompleted = false + private val messageQueue = ConcurrentLinkedQueue() override fun subscribe(subscriber: Subscriber) { - subscribers.add(subscriber) + val requestedCount = AtomicLong(0) + subscribers.add(subscriber to requestedCount) + subscriber.onSubscribe( object : Subscription { override fun request(n: Long) { - startStreaming() + if (n <= 0) { + subscriber.onError(IllegalArgumentException("Requested messages must be positive.")) + return + } + requestedCount.addAndGet(n) + dispatchMessages() + if (!isStreamingStarted) { + isStreamingStarted = true + startStreaming() + } } override fun cancel() { cancelStream() - subscribers.remove(subscriber) + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val pair = iterator.next() + if (pair.first == subscriber) { + iterator.remove() + } + } } } ) @@ -157,13 +178,6 @@ internal class PublisherStream( eventBuffer.append(dataChunk.trim()).append("\n") } } - notifyError( - FirebaseFunctionsException( - "Stream ended unexpectedly without completion", - FirebaseFunctionsException.Code.INTERNAL, - null - ) - ) } catch (e: Exception) { notifyError( FirebaseFunctionsException( @@ -180,10 +194,12 @@ internal class PublisherStream( try { val json = JSONObject(dataChunk) when { - json.has("message") -> + json.has("message") -> { serializer.decode(json.opt("message"))?.let { - notifyData(StreamResponse.Message(message = HttpsCallableResult(it))) + messageQueue.add(StreamResponse.Message(message = HttpsCallableResult(it))) } + dispatchMessages() + } json.has("error") -> { serializer.decode(json.opt("error"))?.let { notifyError( @@ -197,10 +213,10 @@ internal class PublisherStream( } json.has("result") -> { serializer.decode(json.opt("result"))?.let { - notifyData(StreamResponse.Result(message = HttpsCallableResult(it))) + messageQueue.add(StreamResponse.Result(message = HttpsCallableResult(it))) + dispatchMessages() notifyComplete() } - return } } } catch (e: Throwable) { @@ -214,24 +230,30 @@ internal class PublisherStream( } } - private fun notifyData(data: StreamResponse?) { - for (subscriber in subscribers) { - subscriber.onNext(data) + private fun dispatchMessages() { + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val (subscriber, requestedCount) = iterator.next() + while (requestedCount.get() > 0 && messageQueue.isNotEmpty()) { + subscriber.onNext(messageQueue.poll()) + requestedCount.decrementAndGet() + } } } private fun notifyError(e: FirebaseFunctionsException) { - for (subscriber in subscribers) { - subscriber.onError(e) - } + subscribers.forEach { (subscriber, _) -> subscriber.onError(e) } subscribers.clear() + messageQueue.clear() } private fun notifyComplete() { - for (subscriber in subscribers) { - subscriber.onComplete() + if (!isCompleted) { + subscribers.forEach { (subscriber, _) -> subscriber.onComplete() } + subscribers.clear() + messageQueue.clear() + isCompleted = true } - subscribers.clear() } private fun validateResponse(response: Response) { From db2d0216f6e9ae090c22a495800b346e1144d0b5 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 24 Feb 2025 20:35:25 -0800 Subject: [PATCH 28/41] Add helper methods to check whether the 'StreamResponse' is a Message or Result. --- .../google/firebase/functions/StreamTests.kt | 28 +++++++++---------- .../firebase/functions/StreamResponse.kt | 6 ++++ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index baae070be09..062c21cb840 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -20,8 +20,6 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase -import com.google.firebase.functions.StreamResponse.Message -import com.google.firebase.functions.StreamResponse.Result import com.google.firebase.initialize import java.util.concurrent.TimeUnit import kotlinx.coroutines.delay @@ -49,16 +47,16 @@ class StreamTests { internal val onNextList = mutableListOf() internal var throwable: Throwable? = null internal var isComplete = false - internal var subscription: Subscription? = null + internal lateinit var subscription: Subscription - override fun onSubscribe(subscription: Subscription?) { + override fun onSubscribe(subscription: Subscription) { this.subscription = subscription - subscription?.request(1) + subscription.request(1) } override fun onNext(streamResponse: StreamResponse) { onNextList.add(streamResponse) - subscription?.request(1) + subscription.request(1) } override fun onError(t: Throwable?) { @@ -81,8 +79,8 @@ class StreamTests { while (!subscriber.isComplete) { delay(100) } - val messages = subscriber.onNextList.filterIsInstance() - val results = subscriber.onNextList.filterIsInstance() + val messages = subscriber.onNextList.filter { it.isMessage() } + val results = subscriber.onNextList.filter { it.isResult() } assertThat(messages.map { it.message.data.toString() }) .containsExactly( "{chunk=hello}", @@ -113,8 +111,8 @@ class StreamTests { throwable = e } - val messages = receivedResponses.filterIsInstance() - val results = receivedResponses.filterIsInstance() + val messages = receivedResponses.filter { it.isMessage() } + val results = receivedResponses.filter { it.isResult() } assertThat(messages.map { it.message.data.toString() }) .containsExactly( "{chunk=hello}", @@ -143,7 +141,7 @@ class StreamTests { } } - val messages = subscriber.onNextList.filterIsInstance() + val messages = subscriber.onNextList.filter { it.isMessage() } val onNextStringList = messages.map { it.message.data.toString() } assertThat(onNextStringList).contains("{chunk=hello}") assertThat(subscriber.throwable).isNotNull() @@ -164,8 +162,8 @@ class StreamTests { delay(100) } } - val messages = subscriber.onNextList.filterIsInstance() - val results = subscriber.onNextList.filterIsInstance() + val messages = subscriber.onNextList.filter { it.isMessage() } + val results = subscriber.onNextList.filter { it.isResult() } val onNextStringList = messages.map { it.message.data.toString() } assertThat(onNextStringList) .containsExactly( @@ -192,13 +190,13 @@ class StreamTests { delay(50) } } - withTimeout(2000) { cancelableSubscriber.subscription?.cancel() } + withTimeout(2000) { cancelableSubscriber.subscription.cancel() } withTimeout(1000) { while (cancelableSubscriber.throwable == null) { delay(300) } } - val messages = cancelableSubscriber.onNextList.filterIsInstance() + val messages = cancelableSubscriber.onNextList.filter { it.isMessage() } val onNextStringList = messages.map { it.message.data.toString() } assertThat(onNextStringList).contains("{chunk=hello}") assertThat(onNextStringList).doesNotContain("{chunk=cool}") diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index f5bcd85b765..ad9fd553ced 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -52,4 +52,10 @@ public abstract class StreamResponse private constructor(public val message: Htt * ``` */ public class Result(message: HttpsCallableResult) : StreamResponse(message) + + /** Returns `true` if this response is a stream message, `false` otherwise. */ + public fun isMessage(): Boolean = this is Message + + /** Returns `true` if this response marks the end of the stream, `false` otherwise. */ + public fun isResult(): Boolean = this is Result } From ef7132ba5c01548691f01aa79c40755e180c4c60 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Tue, 25 Feb 2025 07:53:07 -0800 Subject: [PATCH 29/41] Run script to update the api.txt --- firebase-functions/api.txt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index c9d483b547c..f2f1122dafb 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -82,10 +82,10 @@ package com.google.firebase.functions { public final class HttpsCallableReference { method public com.google.android.gms.tasks.Task call(); method public com.google.android.gms.tasks.Task call(Object? data); - method @NonNull public org.reactivestreams.Publisher stream(@Nullable Object data = null); - method @NonNull public org.reactivestreams.Publisher stream(); method public long getTimeout(); method public void setTimeout(long timeout, java.util.concurrent.TimeUnit units); + method public org.reactivestreams.Publisher stream(); + method public org.reactivestreams.Publisher stream(Object? data); method public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, java.util.concurrent.TimeUnit units); property public final long timeout; } @@ -95,6 +95,21 @@ package com.google.firebase.functions { field public final Object? data; } + public abstract class StreamResponse { + method public final com.google.firebase.functions.HttpsCallableResult getMessage(); + method public final boolean isMessage(); + method public final boolean isResult(); + property public final com.google.firebase.functions.HttpsCallableResult message; + } + + public static final class StreamResponse.Message extends com.google.firebase.functions.StreamResponse { + ctor public StreamResponse.Message(com.google.firebase.functions.HttpsCallableResult message); + } + + public static final class StreamResponse.Result extends com.google.firebase.functions.StreamResponse { + ctor public StreamResponse.Result(com.google.firebase.functions.HttpsCallableResult message); + } + } package com.google.firebase.functions.ktx { From 89fa46abe044499e6c773e22b11b3d5924287cf8 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Tue, 25 Feb 2025 13:59:54 -0800 Subject: [PATCH 30/41] Revert helper methods on StreamResponse and rename properties on Class Result. --- .../google/firebase/functions/StreamTests.kt | 61 ++++++++++--------- .../firebase/functions/PublisherStream.kt | 2 +- .../firebase/functions/StreamResponse.kt | 8 +-- 3 files changed, 34 insertions(+), 37 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 062c21cb840..f45659cd205 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -44,19 +44,23 @@ class StreamTests { } internal class StreamSubscriber : Subscriber { - internal val onNextList = mutableListOf() + internal val messages = mutableListOf() + internal var result: StreamResponse.Result? = null internal var throwable: Throwable? = null internal var isComplete = false internal lateinit var subscription: Subscription override fun onSubscribe(subscription: Subscription) { this.subscription = subscription - subscription.request(1) + subscription.request(Long.MAX_VALUE) } override fun onNext(streamResponse: StreamResponse) { - onNextList.add(streamResponse) - subscription.request(1) + if (streamResponse is StreamResponse.Message) { + messages.add(streamResponse) + } else { + result = streamResponse as StreamResponse.Result + } } override fun onError(t: Throwable?) { @@ -79,9 +83,7 @@ class StreamTests { while (!subscriber.isComplete) { delay(100) } - val messages = subscriber.onNextList.filter { it.isMessage() } - val results = subscriber.onNextList.filter { it.isResult() } - assertThat(messages.map { it.message.data.toString() }) + assertThat(subscriber.messages.map { it.message.data.toString() }) .containsExactly( "{chunk=hello}", "{chunk=world}", @@ -89,8 +91,8 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(results).hasSize(1) - assertThat(results.first().message.data.toString()).isEqualTo("hello world this is cool") + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.message.data.toString()).isEqualTo("hello world this is cool") assertThat(subscriber.throwable).isNull() assertThat(subscriber.isComplete).isTrue() } @@ -101,18 +103,25 @@ class StreamTests { val function = functions.getHttpsCallable("genStream") var isComplete = false var throwable: Throwable? = null + val messages = mutableListOf() + var result: StreamResponse.Result? = null val flow = function.stream(input).asFlow() - val receivedResponses = mutableListOf() try { - withTimeout(1000) { flow.collect { response -> receivedResponses.add(response) } } + withTimeout(1000) { + flow.collect { response -> + if (response is StreamResponse.Message) { + messages.add(response) + } else { + result = response as StreamResponse.Result + } + } + } isComplete = true } catch (e: Throwable) { throwable = e } - val messages = receivedResponses.filter { it.isMessage() } - val results = receivedResponses.filter { it.isResult() } assertThat(messages.map { it.message.data.toString() }) .containsExactly( "{chunk=hello}", @@ -121,8 +130,8 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(results).hasSize(1) - assertThat(results.first().message.data.toString()).isEqualTo("hello world this is cool") + assertThat(result).isNotNull() + assertThat(result!!.message.data.toString()).isEqualTo("hello world this is cool") assertThat(throwable).isNull() assertThat(isComplete).isTrue() } @@ -141,9 +150,7 @@ class StreamTests { } } - val messages = subscriber.onNextList.filter { it.isMessage() } - val onNextStringList = messages.map { it.message.data.toString() } - assertThat(onNextStringList).contains("{chunk=hello}") + assertThat(subscriber.messages.map { it.message.data.toString() }).contains("{chunk=hello}") assertThat(subscriber.throwable).isNotNull() assertThat(subscriber.throwable!!.message).isEqualTo("{message=INTERNAL, status=INTERNAL}") assertThat(subscriber.isComplete).isFalse() @@ -158,14 +165,11 @@ class StreamTests { function.stream(input).subscribe(subscriber) withTimeout(2000) { - while (subscriber.onNextList.size < 5) { + while (subscriber.messages.size < 5) { delay(100) } } - val messages = subscriber.onNextList.filter { it.isMessage() } - val results = subscriber.onNextList.filter { it.isResult() } - val onNextStringList = messages.map { it.message.data.toString() } - assertThat(onNextStringList) + assertThat(subscriber.messages.map { it.message.data.toString() }) .containsExactly( "{chunk=hello}", "{chunk=world}", @@ -173,7 +177,7 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(results).isEmpty() + assertThat(subscriber.result).isNull() assertThat(subscriber.throwable).isNull() assertThat(subscriber.isComplete).isFalse() } @@ -186,7 +190,7 @@ class StreamTests { val cancelableSubscriber = StreamSubscriber() publisher.subscribe(cancelableSubscriber) withTimeout(2000) { - while (cancelableSubscriber.onNextList.isEmpty()) { + while (cancelableSubscriber.messages.isEmpty()) { delay(50) } } @@ -196,10 +200,9 @@ class StreamTests { delay(300) } } - val messages = cancelableSubscriber.onNextList.filter { it.isMessage() } - val onNextStringList = messages.map { it.message.data.toString() } - assertThat(onNextStringList).contains("{chunk=hello}") - assertThat(onNextStringList).doesNotContain("{chunk=cool}") + val messagesAsStringList = cancelableSubscriber.messages.map { it.message.data.toString() } + assertThat(messagesAsStringList).contains("{chunk=hello}") + assertThat(messagesAsStringList).doesNotContain("{chunk=cool}") assertThat(cancelableSubscriber.throwable!!.message!!.uppercase()).contains("CANCEL") assertThat(cancelableSubscriber.isComplete).isFalse() } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index 97406c7d8ec..f4e7645258d 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -213,7 +213,7 @@ internal class PublisherStream( } json.has("result") -> { serializer.decode(json.opt("result"))?.let { - messageQueue.add(StreamResponse.Result(message = HttpsCallableResult(it))) + messageQueue.add(StreamResponse.Result(result = HttpsCallableResult(it))) dispatchMessages() notifyComplete() } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index ad9fd553ced..71fd93a52fb 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -51,11 +51,5 @@ public abstract class StreamResponse private constructor(public val message: Htt * data: { "result": { "text": "foo bar" } } * ``` */ - public class Result(message: HttpsCallableResult) : StreamResponse(message) - - /** Returns `true` if this response is a stream message, `false` otherwise. */ - public fun isMessage(): Boolean = this is Message - - /** Returns `true` if this response marks the end of the stream, `false` otherwise. */ - public fun isResult(): Boolean = this is Result + public class Result(result: HttpsCallableResult) : StreamResponse(result) } From 06b78c82525e21e1f6432f5e4178422af60ee4ae Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Tue, 25 Feb 2025 14:26:30 -0800 Subject: [PATCH 31/41] Remove the base property from the StreamResponse. --- .../com/google/firebase/functions/StreamTests.kt | 4 ++-- .../com/google/firebase/functions/StreamResponse.kt | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index f45659cd205..156134523bb 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -92,7 +92,7 @@ class StreamTests { "{chunk=cool}" ) assertThat(subscriber.result).isNotNull() - assertThat(subscriber.result!!.message.data.toString()).isEqualTo("hello world this is cool") + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("hello world this is cool") assertThat(subscriber.throwable).isNull() assertThat(subscriber.isComplete).isTrue() } @@ -131,7 +131,7 @@ class StreamTests { "{chunk=cool}" ) assertThat(result).isNotNull() - assertThat(result!!.message.data.toString()).isEqualTo("hello world this is cool") + assertThat(result!!.result.data.toString()).isEqualTo("hello world this is cool") assertThat(throwable).isNull() assertThat(isComplete).isTrue() } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index 71fd93a52fb..3df1ae6a45d 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -22,10 +22,8 @@ package com.google.firebase.functions * The SSE stream consists of two types of responses: * - [Message]: Represents an intermediate event pushed from the server. * - [Result]: Represents the final response that signifies the stream has ended. - * - * @property message The data received from the server in the SSE stream. */ -public abstract class StreamResponse private constructor(public val message: HttpsCallableResult) { +public abstract class StreamResponse { /** * An event message received during the stream. @@ -36,8 +34,10 @@ public abstract class StreamResponse private constructor(public val message: Htt * ```json * data: { "message": { "chunk": "foo" } } * ``` + * + * @property message the intermediate data received from the server. */ - public class Message(message: HttpsCallableResult) : StreamResponse(message) + public class Message(public val message: HttpsCallableResult) : StreamResponse() /** * The final result of the computation, marking the end of the stream. @@ -50,6 +50,8 @@ public abstract class StreamResponse private constructor(public val message: Htt * ```json * data: { "result": { "text": "foo bar" } } * ``` + * + * @property result the final computed result received from the server. */ - public class Result(result: HttpsCallableResult) : StreamResponse(result) + public class Result(public val result: HttpsCallableResult) : StreamResponse() } From 06b2e6194ca62e6db80e80f2cccf4bddf678f3e5 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Tue, 25 Feb 2025 14:34:37 -0800 Subject: [PATCH 32/41] Make the private constructor at StreamResponse to prevent inheriting the class. --- .../main/java/com/google/firebase/functions/StreamResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt index 3df1ae6a45d..123f804614d 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamResponse.kt @@ -23,7 +23,7 @@ package com.google.firebase.functions * - [Message]: Represents an intermediate event pushed from the server. * - [Result]: Represents the final response that signifies the stream has ended. */ -public abstract class StreamResponse { +public abstract class StreamResponse private constructor() { /** * An event message received during the stream. From 3f313c7b177033f22be3c92e01964b811169b015 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Mar 2025 17:47:21 -0800 Subject: [PATCH 33/41] Add a test case where the client input is processed. - Update the api.txt --- firebase-functions/api.txt | 10 +++--- .../androidTest/backend/functions/index.js | 33 +++++++++++++++++++ .../google/firebase/functions/StreamTests.kt | 25 ++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index f2f1122dafb..c772fa8d1d1 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -96,18 +96,18 @@ package com.google.firebase.functions { } public abstract class StreamResponse { - method public final com.google.firebase.functions.HttpsCallableResult getMessage(); - method public final boolean isMessage(); - method public final boolean isResult(); - property public final com.google.firebase.functions.HttpsCallableResult message; } public static final class StreamResponse.Message extends com.google.firebase.functions.StreamResponse { ctor public StreamResponse.Message(com.google.firebase.functions.HttpsCallableResult message); + method public com.google.firebase.functions.HttpsCallableResult getMessage(); + property public final com.google.firebase.functions.HttpsCallableResult message; } public static final class StreamResponse.Result extends com.google.firebase.functions.StreamResponse { - ctor public StreamResponse.Result(com.google.firebase.functions.HttpsCallableResult message); + ctor public StreamResponse.Result(com.google.firebase.functions.HttpsCallableResult result); + method public com.google.firebase.functions.HttpsCallableResult getResult(); + property public final com.google.firebase.functions.HttpsCallableResult result; } } diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index 8c852d93b72..10f5aec8571 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -176,3 +176,36 @@ exports.genStreamNoReturn = functionsV2.https.onCall( } }, ); + +const weatherForecasts = { + Toronto: { conditions: 'snowy', temperature: 25 }, + London: { conditions: 'rainy', temperature: 50 }, + Dubai: { conditions: 'sunny', temperature: 75 } +}; + +/** + * Generates weather forecasts asynchronously for the given locations. + * @async + * @generator + * @param {Array<{name: string}>} locations - An array of location objects. + */ +async function* generateForecast(locations) { + for (const location of locations) { + yield { 'location': location, ...weatherForecasts[location.name] }; + await sleep(100); + } +}; + +exports.genStreamWeather = functionsV2.https.onCall( + async (request, response) => { + const locations = request.data && request.data.data? + request.data.data: []; + const forecasts = []; + if (request.acceptsStreaming) { + for await (const chunk of generateForecast(locations)) { + forecasts.push(chunk); + response.sendChunk(chunk); + } + } + return {forecasts}; + }); diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 156134523bb..36e20efe9b1 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -206,4 +206,29 @@ class StreamTests { assertThat(cancelableSubscriber.throwable!!.message!!.uppercase()).contains("CANCEL") assertThat(cancelableSubscriber.isComplete).isFalse() } + + @Test + fun genStreamWeather_receivesWeatherForecasts() = runBlocking { + val inputData = listOf(mapOf("name" to "Toronto"), mapOf("name" to "London")) + val input = mapOf("data" to inputData) + + val function = functions.getHttpsCallable("genStreamWeather") + val subscriber = StreamSubscriber() + + function.stream(input).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + + assertThat(subscriber.messages.map { it.message.data.toString() }) + .containsExactly( + "{temperature=25, location=Toronto, conditions=snowy}", + "{temperature=50, location=London, conditions=rainy}" + ) + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).contains("forecasts") + assertThat(subscriber.throwable).isNull() + assertThat(subscriber.isComplete).isTrue() + } } From e3c26a3dc82898ae70d7f8038c42fe2ede093b71 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Mar 2025 18:00:43 -0800 Subject: [PATCH 34/41] Optimize the stream() method on HttpCallableReference. --- .../firebase/functions/FirebaseFunctions.kt | 1 - .../functions/HttpsCallableReference.kt | 21 ++----------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 0dae105b999..8839763c4a3 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -319,7 +319,6 @@ internal constructor( ): Publisher = stream(getURL(name), data, options) internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { - Preconditions.checkNotNull(url, "url cannot be null") val task = providerInstalled.task.continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 7cad3fa301d..4a94b9a3165 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -157,7 +157,7 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * @param data Parameters to pass to the endpoint. + * @param data Parameters to pass to the endpoint. Defaults to `null` if not provided. * @return [Publisher] that will emit intermediate data, and the final result, as it is generated * by the function. * @see org.json.JSONArray @@ -168,7 +168,7 @@ public class HttpsCallableReference { * * @see FirebaseFunctionsException */ - public fun stream(data: Any?): Publisher { + public fun stream(data: Any? = null): Publisher { return if (name != null) { functionsClient.stream(name, data, options) } else { @@ -176,23 +176,6 @@ public class HttpsCallableReference { } } - /** - * Streams data to the specified HTTPS endpoint without arguments. - * - * The request to the Cloud Functions backend made by this method automatically includes a - * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase - * Auth, an auth token for the user will also be automatically included. - * - * Firebase Instance ID sends data to the Firebase backend periodically to collect information - * regarding the app instance. To stop this, see - * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new - * Instance ID the next time you call this method. - * - * @return [Publisher] that will emit intermediate data, and the final result, as it is generated - * by the function. - */ - public fun stream(): Publisher = stream(data = null) - /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. * From 14ea9a62ac38194b1708b83d315ce4225456b506 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Tue, 4 Mar 2025 14:05:49 -0800 Subject: [PATCH 35/41] Improve Subscription Handling: Ensure onError() Terminates Properly, Synchronize request(n), and Handle Late Subscribers. --- .../google/firebase/functions/StreamTests.kt | 4 +- .../firebase/functions/PublisherStream.kt | 101 ++++++++++++------ 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 36e20efe9b1..a6befd25b3a 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -152,6 +152,7 @@ class StreamTests { assertThat(subscriber.messages.map { it.message.data.toString() }).contains("{chunk=hello}") assertThat(subscriber.throwable).isNotNull() + assertThat(subscriber.throwable).isInstanceOf(FirebaseFunctionsException::class.java) assertThat(subscriber.throwable!!.message).isEqualTo("{message=INTERNAL, status=INTERNAL}") assertThat(subscriber.isComplete).isFalse() } @@ -195,7 +196,7 @@ class StreamTests { } } withTimeout(2000) { cancelableSubscriber.subscription.cancel() } - withTimeout(1000) { + withTimeout(1500) { while (cancelableSubscriber.throwable == null) { delay(300) } @@ -203,6 +204,7 @@ class StreamTests { val messagesAsStringList = cancelableSubscriber.messages.map { it.message.data.toString() } assertThat(messagesAsStringList).contains("{chunk=hello}") assertThat(messagesAsStringList).doesNotContain("{chunk=cool}") + assertThat(cancelableSubscriber.throwable).isInstanceOf(FirebaseFunctionsException::class.java) assertThat(cancelableSubscriber.throwable!!.message!!.uppercase()).contains("CANCEL") assertThat(cancelableSubscriber.isComplete).isFalse() } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index f4e7645258d..023d307cc28 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -50,13 +50,24 @@ internal class PublisherStream( private val subscribers = ConcurrentLinkedQueue, AtomicLong>>() private var activeCall: Call? = null - private var isStreamingStarted = false - private var isCompleted = false + @Volatile private var isStreamingStarted = false + @Volatile private var isCompleted = false private val messageQueue = ConcurrentLinkedQueue() override fun subscribe(subscriber: Subscriber) { - val requestedCount = AtomicLong(0) - subscribers.add(subscriber to requestedCount) + synchronized(this) { + if (isCompleted) { + subscriber.onError( + FirebaseFunctionsException( + "Cannot subscribe: Streaming has already completed.", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + return + } + subscribers.add(subscriber to AtomicLong(0)) + } subscriber.onSubscribe( object : Subscription { @@ -65,21 +76,38 @@ internal class PublisherStream( subscriber.onError(IllegalArgumentException("Requested messages must be positive.")) return } - requestedCount.addAndGet(n) - dispatchMessages() - if (!isStreamingStarted) { - isStreamingStarted = true - startStreaming() + + synchronized(this@PublisherStream) { + if (isCompleted) return + + val subscriberEntry = subscribers.find { it.first == subscriber } + subscriberEntry?.second?.addAndGet(n) + dispatchMessages() + if (!isStreamingStarted) { + isStreamingStarted = true + startStreaming() + } } } override fun cancel() { - cancelStream() - val iterator = subscribers.iterator() - while (iterator.hasNext()) { - val pair = iterator.next() - if (pair.first == subscriber) { - iterator.remove() + synchronized(this@PublisherStream) { + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) + ) + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val pair = iterator.next() + if (pair.first == subscriber) { + iterator.remove() + } + } + if (subscribers.isEmpty()) { + cancelStream() } } } @@ -231,28 +259,41 @@ internal class PublisherStream( } private fun dispatchMessages() { - val iterator = subscribers.iterator() - while (iterator.hasNext()) { - val (subscriber, requestedCount) = iterator.next() - while (requestedCount.get() > 0 && messageQueue.isNotEmpty()) { - subscriber.onNext(messageQueue.poll()) - requestedCount.decrementAndGet() + synchronized(this) { + val iterator = subscribers.iterator() + while (iterator.hasNext()) { + val (subscriber, requestedCount) = iterator.next() + while (requestedCount.get() > 0 && messageQueue.isNotEmpty()) { + subscriber.onNext(messageQueue.poll()) + requestedCount.decrementAndGet() + } } } } - private fun notifyError(e: FirebaseFunctionsException) { - subscribers.forEach { (subscriber, _) -> subscriber.onError(e) } - subscribers.clear() - messageQueue.clear() + private fun notifyError(e: Throwable) { + synchronized(this) { + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> + try { + subscriber.onError(e) + } catch (ignored: Exception) {} + } + subscribers.clear() + messageQueue.clear() + } + } } private fun notifyComplete() { - if (!isCompleted) { - subscribers.forEach { (subscriber, _) -> subscriber.onComplete() } - subscribers.clear() - messageQueue.clear() - isCompleted = true + synchronized(this) { + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> subscriber.onComplete() } + subscribers.clear() + messageQueue.clear() + } } } From 2fa63b868c00b8667a1542f1f7a34266a978a73e Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 5 Mar 2025 12:15:39 -0800 Subject: [PATCH 36/41] Remove excessive synchronization in methods notifyError, notifyComplete. --- .../firebase/functions/PublisherStream.kt | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt index 023d307cc28..6fc6a9d657c 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/PublisherStream.kt @@ -272,28 +272,24 @@ internal class PublisherStream( } private fun notifyError(e: Throwable) { - synchronized(this) { - if (!isCompleted) { - isCompleted = true - subscribers.forEach { (subscriber, _) -> - try { - subscriber.onError(e) - } catch (ignored: Exception) {} - } - subscribers.clear() - messageQueue.clear() + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> + try { + subscriber.onError(e) + } catch (ignored: Exception) {} } + subscribers.clear() + messageQueue.clear() } } private fun notifyComplete() { - synchronized(this) { - if (!isCompleted) { - isCompleted = true - subscribers.forEach { (subscriber, _) -> subscriber.onComplete() } - subscribers.clear() - messageQueue.clear() - } + if (!isCompleted) { + isCompleted = true + subscribers.forEach { (subscriber, _) -> subscriber.onComplete() } + subscribers.clear() + messageQueue.clear() } } From b6d9b22bd515812dc7fa010927cbe2c472a9add3 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 5 Mar 2025 17:04:39 -0800 Subject: [PATCH 37/41] Add firebase functions genStreamEmpty, genStreamResultOnly, genStreamLargeData on index.js. - Modify genStreamError. --- .../androidTest/backend/functions/index.js | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index 10f5aec8571..f26d6615d68 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -151,7 +151,7 @@ async function* generateText() { exports.genStream = functionsV2.https.onCall(async (request, response) => { if (request.acceptsStreaming) { for await (const chunk of generateText()) { - response.sendChunk({chunk}); + response.sendChunk(chunk); } } return streamData.join(' '); @@ -159,24 +159,11 @@ exports.genStream = functionsV2.https.onCall(async (request, response) => { exports.genStreamError = functionsV2.https.onCall( async (request, response) => { - if (request.acceptsStreaming) { - for await (const chunk of generateText()) { - response.sendChunk({chunk}); - } - } - throw new Error('BOOM'); + // Note: The functions backend does not pass the error message to the + // client at this time. + throw Error("BOOM") }); -exports.genStreamNoReturn = functionsV2.https.onCall( - async (request, response) => { - if (request.acceptsStreaming) { - for await (const chunk of generateText()) { - response.sendChunk({chunk}); - } - } - }, -); - const weatherForecasts = { Toronto: { conditions: 'snowy', temperature: 25 }, London: { conditions: 'rainy', temperature: 50 }, @@ -209,3 +196,36 @@ exports.genStreamWeather = functionsV2.https.onCall( } return {forecasts}; }); + +exports.genStreamEmpty = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + // Send no chunks + } + // Implicitly return null. + } +); + +exports.genStreamResultOnly = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + // Do not send any chunks. + } + return "Only a result"; + } +); + +exports.genStreamLargeData = functionsV2.https.onCall( + async (request, response) => { + if (request.acceptsStreaming) { + const largeString = 'A'.repeat(10000); + const chunkSize = 1024; + for (let i = 0; i < largeString.length; i += chunkSize) { + const chunk = largeString.substring(i, i + chunkSize); + response.sendChunk(chunk); + await sleep(100); + } + } + return "Stream Completed"; + } +); From 554185272cb7e64ab1a3d97c41ff9f8307d204a3 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 5 Mar 2025 17:06:12 -0800 Subject: [PATCH 38/41] Correct the KDoc format. --- .../firebase/functions/HttpsCallableReference.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 4a94b9a3165..5201b08e3bd 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -62,10 +62,8 @@ public class HttpsCallableReference { * * * Any primitive type, including null, int, long, float, and boolean. * * [String] - * * [List&lt;?&gt;][java.util.List], where the contained objects are also one of these - * types. - * * [Map&lt;String, ?&gt;>][java.util.Map], where the values are also one of these - * types. + * * [List][java.util.List], where the contained objects are also one of these types. + * * [Map][java.util.Map], where the values are also one of these types. * * [org.json.JSONArray] * * [org.json.JSONObject] * * [org.json.JSONObject.NULL] @@ -133,10 +131,8 @@ public class HttpsCallableReference { * * * Any primitive type, including null, int, long, float, and boolean. * * [String] - * * [List&lt;?&gt;][java.util.List], where the contained objects are also one of these - * types. - * * [Map&lt;String, ?&gt;>][java.util.Map], where the values are also one of these - * types. + * * [List][java.util.List], where the contained objects are also one of these types. + * * [Map][java.util.Map], where the values are also one of these types. * * [org.json.JSONArray] * * [org.json.JSONObject] * * [org.json.JSONObject.NULL] From 38876b596e64ba576e66ed22e32921b7fcfcbfe8 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 5 Mar 2025 17:07:54 -0800 Subject: [PATCH 39/41] Add test cases to cover genStreamEmpty, genStreamResultOnly, genStreamLargeData. --- .../google/firebase/functions/StreamTests.kt | 122 ++++++++---------- 1 file changed, 52 insertions(+), 70 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index a6befd25b3a..e0de5cc2262 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -84,13 +84,7 @@ class StreamTests { delay(100) } assertThat(subscriber.messages.map { it.message.data.toString() }) - .containsExactly( - "{chunk=hello}", - "{chunk=world}", - "{chunk=this}", - "{chunk=is}", - "{chunk=cool}" - ) + .containsExactly("hello", "world", "this", "is", "cool") assertThat(subscriber.result).isNotNull() assertThat(subscriber.result!!.result.data.toString()).isEqualTo("hello world this is cool") assertThat(subscriber.throwable).isNull() @@ -123,13 +117,7 @@ class StreamTests { } assertThat(messages.map { it.message.data.toString() }) - .containsExactly( - "{chunk=hello}", - "{chunk=world}", - "{chunk=this}", - "{chunk=is}", - "{chunk=cool}" - ) + .containsExactly("hello", "world", "this", "is", "cool") assertThat(result).isNotNull() assertThat(result!!.result.data.toString()).isEqualTo("hello world this is cool") assertThat(throwable).isNull() @@ -137,100 +125,94 @@ class StreamTests { } @Test - fun genStreamError_receivesErrorAndStops() = runBlocking { - val input = mapOf("data" to "Why is the sky blue") + fun genStreamError_receivesError() = runBlocking { + val input = mapOf("data" to "test error") val function = functions.getHttpsCallable("genStreamError").withTimeout(2000, TimeUnit.MILLISECONDS) val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) + withTimeout(2000) { while (subscriber.throwable == null) { delay(100) } } - assertThat(subscriber.messages.map { it.message.data.toString() }).contains("{chunk=hello}") assertThat(subscriber.throwable).isNotNull() assertThat(subscriber.throwable).isInstanceOf(FirebaseFunctionsException::class.java) - assertThat(subscriber.throwable!!.message).isEqualTo("{message=INTERNAL, status=INTERNAL}") - assertThat(subscriber.isComplete).isFalse() } @Test - fun genStreamNoReturn_receivesOnlyMessages() = runBlocking { - val input = mapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamNoReturn") + fun genStreamWeather_receivesWeatherForecasts() = runBlocking { + val inputData = listOf(mapOf("name" to "Toronto"), mapOf("name" to "London")) + val input = mapOf("data" to inputData) + + val function = functions.getHttpsCallable("genStreamWeather") val subscriber = StreamSubscriber() function.stream(input).subscribe(subscriber) - withTimeout(2000) { - while (subscriber.messages.size < 5) { - delay(100) - } + while (!subscriber.isComplete) { + delay(100) } + assertThat(subscriber.messages.map { it.message.data.toString() }) .containsExactly( - "{chunk=hello}", - "{chunk=world}", - "{chunk=this}", - "{chunk=is}", - "{chunk=cool}" + "{temperature=25, location={name=Toronto}, conditions=snowy}", + "{temperature=50, location={name=London}, conditions=rainy}" ) - assertThat(subscriber.result).isNull() + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).contains("forecasts") assertThat(subscriber.throwable).isNull() - assertThat(subscriber.isComplete).isFalse() + assertThat(subscriber.isComplete).isTrue() } @Test - fun genStream_cancelStream_receivesPartialMessagesAndError() = runBlocking { - val input = mapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamNoReturn") - val publisher = function.stream(input) - val cancelableSubscriber = StreamSubscriber() - publisher.subscribe(cancelableSubscriber) - withTimeout(2000) { - while (cancelableSubscriber.messages.isEmpty()) { - delay(50) - } - } - withTimeout(2000) { cancelableSubscriber.subscription.cancel() } - withTimeout(1500) { - while (cancelableSubscriber.throwable == null) { - delay(300) - } - } - val messagesAsStringList = cancelableSubscriber.messages.map { it.message.data.toString() } - assertThat(messagesAsStringList).contains("{chunk=hello}") - assertThat(messagesAsStringList).doesNotContain("{chunk=cool}") - assertThat(cancelableSubscriber.throwable).isInstanceOf(FirebaseFunctionsException::class.java) - assertThat(cancelableSubscriber.throwable!!.message!!.uppercase()).contains("CANCEL") - assertThat(cancelableSubscriber.isComplete).isFalse() + fun genStreamEmpty_receivesNoMessages() = runBlocking { + val function = functions.getHttpsCallable("genStreamEmpty") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test")).subscribe(subscriber) + + withTimeout(2000) { delay(500) } + assertThat(subscriber.messages).isEmpty() + assertThat(subscriber.result).isNull() } @Test - fun genStreamWeather_receivesWeatherForecasts() = runBlocking { - val inputData = listOf(mapOf("name" to "Toronto"), mapOf("name" to "London")) - val input = mapOf("data" to inputData) - - val function = functions.getHttpsCallable("genStreamWeather") + fun genStreamResultOnly_receivesOnlyResult() = runBlocking { + val function = functions.getHttpsCallable("genStreamResultOnly") val subscriber = StreamSubscriber() - function.stream(input).subscribe(subscriber) + function.stream(mapOf("data" to "test")).subscribe(subscriber) while (!subscriber.isComplete) { delay(100) } + assertThat(subscriber.messages).isEmpty() + assertThat(subscriber.result).isNotNull() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("Only a result") + } - assertThat(subscriber.messages.map { it.message.data.toString() }) - .containsExactly( - "{temperature=25, location=Toronto, conditions=snowy}", - "{temperature=50, location=London, conditions=rainy}" - ) + @Test + fun genStreamLargeData_receivesMultipleChunks() = runBlocking { + val function = functions.getHttpsCallable("genStreamLargeData") + val subscriber = StreamSubscriber() + + function.stream(mapOf("data" to "test large data")).subscribe(subscriber) + + while (!subscriber.isComplete) { + delay(100) + } + assertThat(subscriber.messages).isNotEmpty() + assertThat(subscriber.messages.size).isEqualTo(10) + val receivedString = + subscriber.messages.joinToString(separator = "") { it.message.data.toString() } + val expectedString = "A".repeat(10000) + assertThat(receivedString.length).isEqualTo(10000) + assertThat(receivedString).isEqualTo(expectedString) assertThat(subscriber.result).isNotNull() - assertThat(subscriber.result!!.result.data.toString()).contains("forecasts") - assertThat(subscriber.throwable).isNull() - assertThat(subscriber.isComplete).isTrue() + assertThat(subscriber.result!!.result.data.toString()).isEqualTo("Stream Completed") } } From 76567b470a96d0f66518c7adb105edc4670c5b8c Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 10 Mar 2025 13:05:17 -0700 Subject: [PATCH 40/41] Add annotation @JvmOverloads to stream method on HttpsCallableReference. --- .../java/com/google/firebase/functions/HttpsCallableReference.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 5201b08e3bd..215722584ba 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -164,6 +164,7 @@ public class HttpsCallableReference { * * @see FirebaseFunctionsException */ + @JvmOverloads public fun stream(data: Any? = null): Publisher { return if (name != null) { functionsClient.stream(name, data, options) From 005223c649db5e227b60dd1160e7d37631dc3b5c Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 10 Mar 2025 13:12:06 -0700 Subject: [PATCH 41/41] Update api.txt. --- firebase-functions/api.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index c772fa8d1d1..1a12a250b35 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -85,7 +85,7 @@ package com.google.firebase.functions { method public long getTimeout(); method public void setTimeout(long timeout, java.util.concurrent.TimeUnit units); method public org.reactivestreams.Publisher stream(); - method public org.reactivestreams.Publisher stream(Object? data); + method public org.reactivestreams.Publisher stream(Object? data = null); method public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, java.util.concurrent.TimeUnit units); property public final long timeout; }