From b5101e19ee22c8347bf9419ed65c1c7c37c15477 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:13:04 +0000 Subject: [PATCH 01/19] feat: add kotlin-flow adapter with SSE support Agent-Logs-Url: https://github.com/Goooler/retrofit/sessions/3633ae89-651c-4c0d-a27d-8dce1f512c5e Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- retrofit-adapters/kotlin-flow/build.gradle | 20 ++ .../kotlin-flow/gradle.properties | 3 + .../adapter/flow/FlowCallAdapterFactory.kt | 290 ++++++++++++++++++ .../main/java/retrofit2/adapter/flow/SSE.kt | 33 ++ .../retrofit2/adapter/flow/ServerSentEvent.kt | 32 ++ .../java/retrofit2/adapter/flow/SseParser.kt | 73 +++++ .../flow/FlowAdapterIntegrationTest.kt | 236 ++++++++++++++ .../flow/FlowCallAdapterFactoryTest.kt | 82 +++++ settings.gradle | 1 + 9 files changed, 770 insertions(+) create mode 100644 retrofit-adapters/kotlin-flow/build.gradle create mode 100644 retrofit-adapters/kotlin-flow/gradle.properties create mode 100644 retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt create mode 100644 retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt create mode 100644 retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt create mode 100644 retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt create mode 100644 retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt create mode 100644 retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt diff --git a/retrofit-adapters/kotlin-flow/build.gradle b/retrofit-adapters/kotlin-flow/build.gradle new file mode 100644 index 0000000000..d173733dfc --- /dev/null +++ b/retrofit-adapters/kotlin-flow/build.gradle @@ -0,0 +1,20 @@ +apply plugin: 'org.jetbrains.kotlin.jvm' +apply plugin: 'com.vanniktech.maven.publish' + +dependencies { + api projects.retrofit + api libs.kotlinx.coroutines + + compileOnly libs.findBugsAnnotations + + testImplementation libs.junit + testImplementation libs.truth + testImplementation libs.okhttp.mockwebserver + testImplementation libs.kotlinx.coroutines +} + +jar { + manifest { + attributes 'Automatic-Module-Name': 'retrofit2.adapter.kotlinflow' + } +} diff --git a/retrofit-adapters/kotlin-flow/gradle.properties b/retrofit-adapters/kotlin-flow/gradle.properties new file mode 100644 index 0000000000..16acc80b8c --- /dev/null +++ b/retrofit-adapters/kotlin-flow/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=adapter-kotlin-flow +POM_NAME=Adapter: Kotlin Flow +POM_DESCRIPTION=A Retrofit CallAdapter for Kotlin Flow, with support for Server-Sent Events (SSE). diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt new file mode 100644 index 0000000000..72a40102f7 --- /dev/null +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -0,0 +1,290 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * 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 retrofit2.adapter.flow + +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.launch +import okhttp3.ResponseBody +import okio.Timeout +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Callback +import retrofit2.HttpException +import retrofit2.Response +import retrofit2.Retrofit + +/** + * A [CallAdapter.Factory] that supports [Flow] as a service-method return type. + * + * ## SSE (Server-Sent Events) + * + * When the method is also annotated with [@SSE][SSE], the adapter streams the HTTP response body as + * Server-Sent Events, emitting each parsed [ServerSentEvent] to the flow: + * + * ```kotlin + * interface Api { + * @SSE + * @GET("events") + * fun events(): Flow + * + * // suspend is also supported + * @SSE + * @GET("events") + * suspend fun eventsAsync(): Flow + * } + * ``` + * + * Register this factory with [Retrofit.Builder.addCallAdapterFactory]: + * + * ```kotlin + * val retrofit = Retrofit.Builder() + * .baseUrl(baseUrl) + * .addCallAdapterFactory(FlowCallAdapterFactory.create()) + * .build() + * ``` + * + * ## Non-SSE flows + * + * Without [@SSE][SSE], a `Flow` return type will emit a single converted response body (like a + * regular body call) and complete, or fail with [HttpException] / [IOException] as appropriate. + * + * ```kotlin + * interface Api { + * @GET("user") + * fun getUser(): Flow + * } + * ``` + */ +class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { + + companion object { + @JvmStatic + fun create(): FlowCallAdapterFactory = FlowCallAdapterFactory() + } + + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + val isSse = annotations.any { it is SSE } + + // Non-suspend: fun foo(): Flow + if (getRawType(returnType) == Flow::class.java) { + if (returnType !is ParameterizedType) { + throw IllegalStateException( + "Flow return type must be parameterized as Flow or Flow" + ) + } + val elementType = getParameterUpperBound(0, returnType) + // For SSE the adapter reads raw bytes itself; for a regular body call the registered + // converter handles deserialization, so we expose the element type directly. + val responseType: Type = if (isSse) ResponseBody::class.java else elementType + @Suppress("UNCHECKED_CAST") + return BodyFlowCallAdapter(responseType, isSse) as CallAdapter<*, *> + } + + // Suspend: suspend fun foo(): Flow + // Retrofit wraps the continuation return type in Call, so the adapter type seen here + // is Call>. + if (getRawType(returnType) == Call::class.java) { + if (returnType !is ParameterizedType) return null + val callType = getParameterUpperBound(0, returnType) + if (getRawType(callType) != Flow::class.java) return null + if (callType !is ParameterizedType) { + throw IllegalStateException( + "Flow return type must be parameterized as Flow or Flow" + ) + } + val elementType = getParameterUpperBound(0, callType) + val responseType: Type = if (isSse) ResponseBody::class.java else elementType + @Suppress("UNCHECKED_CAST") + return SuspendFlowCallAdapter(responseType, isSse) as CallAdapter<*, *> + } + + return null + } +} + +// --------------------------------------------------------------------------- +// Non-suspend adapter: adapt(Call) → Flow (or Flow for SSE) +// --------------------------------------------------------------------------- + +private class BodyFlowCallAdapter( + private val responseType_: Type, + private val isSse: Boolean, +) : CallAdapter> { + + override fun responseType(): Type = responseType_ + + override fun adapt(call: Call): Flow<*> { + return if (isSse) { + @Suppress("UNCHECKED_CAST") + sseFlow(call as Call) + } else { + bodyFlow(call) + } + } +} + +// --------------------------------------------------------------------------- +// Suspend adapter: adapt(Call) → Call> (or Call> for SSE) +// +// Retrofit's SuspendForBody calls callAdapter.adapt(call) expecting a Call, then +// calls KotlinExtensions.await() on it. We return a lightweight wrapper Call that, when +// enqueued, immediately delivers a cold Flow as the response body without starting the HTTP +// request yet. The actual HTTP request is deferred until the flow is collected. +// --------------------------------------------------------------------------- + +private class SuspendFlowCallAdapter( + private val responseType_: Type, + private val isSse: Boolean, +) : CallAdapter>> { + + override fun responseType(): Type = responseType_ + + override fun adapt(call: Call): Call> { + val flow: Flow<*> = + if (isSse) { + @Suppress("UNCHECKED_CAST") + sseFlow(call as Call) + } else { + bodyFlow(call) + } + return FlowAsCall(call, flow) + } +} + +/** + * A [Call] whose "response body" is a pre-built cold [Flow]. When enqueued it immediately + * delivers the flow to the callback so that Retrofit's suspend machinery can resume the coroutine + * with the flow value. The HTTP request is only started when the returned flow is collected. + */ +private class FlowAsCall( + private val delegate: Call, + private val flow: Flow<*>, +) : Call> { + + override fun enqueue(callback: Callback>) { + callback.onResponse(this, Response.success(flow)) + } + + override fun execute(): Response> = Response.success(flow) + + override fun isExecuted(): Boolean = delegate.isExecuted + + override fun cancel() = delegate.cancel() + + override fun isCanceled(): Boolean = delegate.isCanceled + + override fun clone(): Call> = FlowAsCall(delegate.clone(), flow) + + override fun request(): okhttp3.Request = delegate.request() + + override fun timeout(): Timeout = delegate.timeout() +} + +// --------------------------------------------------------------------------- +// Flow builders +// --------------------------------------------------------------------------- + +/** + * Returns a cold [Flow] that, when collected, makes the HTTP call and emits each parsed + * [ServerSentEvent] from the response body stream. The HTTP connection is closed when the + * stream ends or the flow is cancelled. + * + * The SSE response body is read on [Dispatchers.IO] so that blocking IO does not tie up the + * caller's coroutine dispatcher. + */ +private fun sseFlow(call: Call): Flow = callbackFlow { + val scope: CoroutineScope = this + val channel: SendChannel = this + + call.clone().enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + channel.close(HttpException(response)) + return + } + val body = response.body() + if (body == null) { + channel.close() + return + } + // Read the SSE stream on an IO thread so that blocking reads do not block the + // coroutine dispatcher. `send` suspends when the consumer is slow, providing + // natural backpressure. + scope.launch(Dispatchers.IO) { + try { + body.use { responseBody -> + val reader = responseBody.charStream().buffered() + for (event in parseServerSentEvents(reader)) { + channel.send(event) + } + } + channel.close() + } catch (e: Exception) { + channel.close(e) + } + } + } + + override fun onFailure(call: Call, t: Throwable) { + channel.close(t) + } + } + ) + + awaitClose { call.cancel() } +} + +/** + * Returns a cold [Flow] that, when collected, makes the HTTP call, emits the single converted + * response body, and completes. Errors result in [HttpException] or [IOException]. + */ +private fun bodyFlow(call: Call): Flow = callbackFlow { + call.clone().enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + close(HttpException(response)) + return + } + val body = response.body() + if (body == null) { + close() + return + } + trySend(body) + close() + } + + override fun onFailure(call: Call, t: Throwable) { + close(t) + } + } + ) + + awaitClose { call.cancel() } +} diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt new file mode 100644 index 0000000000..81b43965e3 --- /dev/null +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * 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 retrofit2.adapter.flow + +/** + * Marks a service method as a Server-Sent Events (SSE) endpoint. When combined with a + * [kotlinx.coroutines.flow.Flow] return type and [FlowCallAdapterFactory], the adapter will stream + * SSE events from the HTTP response body and emit each parsed [ServerSentEvent] to the flow. + * + * ```kotlin + * interface Api { + * @SSE + * @GET("events") + * fun events(): Flow + * } + * ``` + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class SSE diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt new file mode 100644 index 0000000000..dc09ea219f --- /dev/null +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * 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 retrofit2.adapter.flow + +/** + * Represents a single Server-Sent Event as defined by the + * [W3C SSE specification](https://html.spec.whatwg.org/multipage/server-sent-events.html). + * + * @property id The last event ID, or `null` if none was set. + * @property event The event type, or `null` if the default "message" type. + * @property data The data payload. Multiple `data:` lines are joined with `\n`. + * @property retry The reconnection time in milliseconds, or `null` if not provided. + */ +data class ServerSentEvent( + val id: String?, + val event: String?, + val data: String, + val retry: Long?, +) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt new file mode 100644 index 0000000000..60971d333f --- /dev/null +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * 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 retrofit2.adapter.flow + +import java.io.BufferedReader + +/** + * Parses a stream of text lines into [ServerSentEvent] objects following the + * [W3C SSE specification](https://html.spec.whatwg.org/multipage/server-sent-events.html). + */ +internal fun parseServerSentEvents(reader: BufferedReader): Sequence = sequence { + var id: String? = null + var event: String? = null + val dataBuffer = StringBuilder() + var retry: Long? = null + var hasData = false + + for (line in reader.lineSequence()) { + if (line.isEmpty()) { + // An empty line dispatches the event. + if (hasData) { + // Remove the trailing newline that was appended after the last data line. + val data = if (dataBuffer.endsWith('\n')) dataBuffer.dropLast(1).toString() else dataBuffer.toString() + yield(ServerSentEvent(id = id, event = event, data = data, retry = retry)) + } + // Reset fields for the next event (id persists per spec, but retry is per-event). + event = null + dataBuffer.clear() + retry = null + hasData = false + } else if (line.startsWith(':')) { + // Comment line — ignore. + } else { + val colonIndex = line.indexOf(':') + val field: String + val value: String + if (colonIndex == -1) { + field = line + value = "" + } else { + field = line.substring(0, colonIndex) + // If the character immediately after `:` is a space, skip it. + value = if (colonIndex + 1 < line.length && line[colonIndex + 1] == ' ') { + line.substring(colonIndex + 2) + } else { + line.substring(colonIndex + 1) + } + } + when (field) { + "data" -> { + dataBuffer.append(value).append('\n') + hasData = true + } + "id" -> id = value + "event" -> event = value + "retry" -> retry = value.toLongOrNull() + } + } + } +} diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt new file mode 100644 index 0000000000..d7dc1fed0e --- /dev/null +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * 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 retrofit2.adapter.flow + +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import java.lang.annotation.Annotation +import java.lang.reflect.Type +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import retrofit2.Converter +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.http.GET + +class FlowAdapterIntegrationTest { + @get:Rule val server = MockWebServer() + + interface Service { + @SSE + @GET("/") + fun sseEvents(): Flow + + @SSE + @GET("/") + suspend fun sseEventsSuspend(): Flow + + @GET("/") + fun body(): Flow + + @GET("/") + suspend fun bodySuspend(): Flow + } + + private fun buildRetrofit(): Retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(StringConverterFactory) + .addCallAdapterFactory(FlowCallAdapterFactory.create()) + .build() + + // --------------------------------------------------------------------------- + // SSE non-suspend + // --------------------------------------------------------------------------- + + @Test + fun sseEvents() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody( + "id: 1\nevent: ping\ndata: hello\n\n" + + "id: 2\ndata: world\n\n" + ) + ) + val service = buildRetrofit().create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = "1", event = "ping", data = "hello", retry = null), + ServerSentEvent(id = "2", event = null, data = "world", retry = null), + ) + .inOrder() + } + + @Test + fun sseEventsMultilineData() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("data: line one\ndata: line two\n\n") + ) + val service = buildRetrofit().create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = null, event = null, data = "line one\nline two", retry = null) + ) + } + + @Test + fun sseEventsWithRetry() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("retry: 3000\ndata: reconnect\n\n") + ) + val service = buildRetrofit().create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = null, event = null, data = "reconnect", retry = 3000L) + ) + } + + @Test + fun sseEventsCommentsIgnored() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody(": this is a comment\ndata: real\n\n") + ) + val service = buildRetrofit().create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = null, event = null, data = "real", retry = null) + ) + } + + @Test + fun sseEventsHttpError() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val service = buildRetrofit().create(Service::class.java) + try { + service.sseEvents().toList() + fail("Expected HttpException") + } catch (e: HttpException) { + assertThat(e.code()).isEqualTo(500) + } + } + + @Test + fun sseEventsNetworkFailure() = + runBlocking { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + val service = buildRetrofit().create(Service::class.java) + try { + service.sseEvents().toList() + fail("Expected IOException") + } catch (_: IOException) { + // expected + } + } + + // --------------------------------------------------------------------------- + // SSE suspend + // --------------------------------------------------------------------------- + + @Test + fun sseEventsSuspend() = + runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("data: suspended\n\n") + ) + val service = buildRetrofit().create(Service::class.java) + val events = service.sseEventsSuspend().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = null, event = null, data = "suspended", retry = null) + ) + } + + // --------------------------------------------------------------------------- + // Non-SSE body flow (non-suspend) + // --------------------------------------------------------------------------- + + @Test + fun bodyFlow() = + runBlocking { + server.enqueue(MockResponse().setBody("hello")) + val service = buildRetrofit().create(Service::class.java) + val values = service.body().toList() + assertThat(values).containsExactly("hello") + } + + @Test + fun bodyFlowHttpError() = + runBlocking { + server.enqueue(MockResponse().setResponseCode(404)) + val service = buildRetrofit().create(Service::class.java) + try { + service.body().toList() + fail("Expected HttpException") + } catch (e: HttpException) { + assertThat(e.code()).isEqualTo(404) + } + } + + // --------------------------------------------------------------------------- + // Non-SSE body flow (suspend) + // --------------------------------------------------------------------------- + + @Test + fun bodyFlowSuspend() = + runBlocking { + server.enqueue(MockResponse().setBody("world")) + val service = buildRetrofit().create(Service::class.java) + val values = service.bodySuspend().toList() + assertThat(values).containsExactly("world") + } + + // --------------------------------------------------------------------------- + // Converter factory that converts ResponseBody to String + // --------------------------------------------------------------------------- + + private object StringConverterFactory : Converter.Factory() { + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit, + ): Converter? { + return if (type == String::class.java) Converter { it.string() } + else null + } + } +} + diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt new file mode 100644 index 0000000000..01ef98f894 --- /dev/null +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * 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 retrofit2.adapter.flow + +import com.google.common.truth.Truth.assertThat +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import kotlinx.coroutines.flow.Flow +import org.junit.Assert.fail +import org.junit.Test +import retrofit2.CallAdapter +import retrofit2.Retrofit + +class FlowCallAdapterFactoryTest { + private val factory = FlowCallAdapterFactory.create() + private val retrofit = + Retrofit.Builder() + .baseUrl("http://localhost:1/") + .addCallAdapterFactory(factory) + .build() + + @Test + fun nonFlowTypeReturnsNull() { + val adapter = factory.get(String::class.java, emptyArray(), retrofit) + assertThat(adapter).isNull() + } + + @Test + fun rawFlowTypeThrows() { + try { + factory.get(Flow::class.java, emptyArray(), retrofit) + fail() + } catch (e: IllegalStateException) { + assertThat(e).hasMessageThat().contains("parameterized") + } + } + + @Test + fun flowResponseTypeIsElementType() { + val type = flowOf(String::class.java) + val adapter = factory.get(type, emptyArray(), retrofit)!! + assertThat(adapter.responseType()).isEqualTo(String::class.java) + } + + @Test + fun flowSseResponseTypeIsResponseBody() { + val type = flowOf(ServerSentEvent::class.java) + val adapter = factory.get(type, arrayOf(SseAnnotation), retrofit)!! + assertThat(adapter.responseType()).isEqualTo(okhttp3.ResponseBody::class.java) + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private object SseAnnotation : SSE, java.lang.annotation.Annotation { + override fun annotationType(): Class = SSE::class.java + } + + private fun flowOf(type: Type): Type = + object : ParameterizedType { + override fun getActualTypeArguments(): Array = arrayOf(type) + + override fun getRawType(): Type = Flow::class.java + + override fun getOwnerType(): Type? = null + } +} + diff --git a/settings.gradle b/settings.gradle index e94afa5343..5bec9958b9 100644 --- a/settings.gradle +++ b/settings.gradle @@ -26,6 +26,7 @@ include ':retrofit-response-type-keeper' include ':retrofit-adapters:guava' include ':retrofit-adapters:java8' +include ':retrofit-adapters:kotlin-flow' include ':retrofit-adapters:rxjava' include ':retrofit-adapters:rxjava2' include ':retrofit-adapters:rxjava3' From 0c907a972f5878b94c2c6c88a45ee372357cf973 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:22:40 +0000 Subject: [PATCH 02/19] fix: correct test compilation issues and test method signatures Agent-Logs-Url: https://github.com/Goooler/retrofit/sessions/3633ae89-651c-4c0d-a27d-8dce1f512c5e Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../flow/FlowAdapterIntegrationTest.kt | 45 ++++++++++++------- .../flow/FlowCallAdapterFactoryTest.kt | 16 +++++-- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt index d7dc1fed0e..08e1dbe9fd 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt @@ -17,7 +17,6 @@ package retrofit2.adapter.flow import com.google.common.truth.Truth.assertThat import java.io.IOException -import java.lang.annotation.Annotation import java.lang.reflect.Type import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.toList @@ -56,7 +55,7 @@ class FlowAdapterIntegrationTest { private fun buildRetrofit(): Retrofit = Retrofit.Builder() .baseUrl(server.url("/")) - .addConverterFactory(StringConverterFactory) + .addConverterFactory(StringConverterFactory()) .addCallAdapterFactory(FlowCallAdapterFactory.create()) .build() @@ -65,7 +64,7 @@ class FlowAdapterIntegrationTest { // --------------------------------------------------------------------------- @Test - fun sseEvents() = + fun sseEvents() { runBlocking { server.enqueue( MockResponse() @@ -84,9 +83,10 @@ class FlowAdapterIntegrationTest { ) .inOrder() } + } @Test - fun sseEventsMultilineData() = + fun sseEventsMultilineData() { runBlocking { server.enqueue( MockResponse() @@ -100,9 +100,10 @@ class FlowAdapterIntegrationTest { ServerSentEvent(id = null, event = null, data = "line one\nline two", retry = null) ) } + } @Test - fun sseEventsWithRetry() = + fun sseEventsWithRetry() { runBlocking { server.enqueue( MockResponse() @@ -116,9 +117,10 @@ class FlowAdapterIntegrationTest { ServerSentEvent(id = null, event = null, data = "reconnect", retry = 3000L) ) } + } @Test - fun sseEventsCommentsIgnored() = + fun sseEventsCommentsIgnored() { runBlocking { server.enqueue( MockResponse() @@ -132,9 +134,10 @@ class FlowAdapterIntegrationTest { ServerSentEvent(id = null, event = null, data = "real", retry = null) ) } + } @Test - fun sseEventsHttpError() = + fun sseEventsHttpError() { runBlocking { server.enqueue(MockResponse().setResponseCode(500)) val service = buildRetrofit().create(Service::class.java) @@ -145,9 +148,10 @@ class FlowAdapterIntegrationTest { assertThat(e.code()).isEqualTo(500) } } + } @Test - fun sseEventsNetworkFailure() = + fun sseEventsNetworkFailure() { runBlocking { server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) val service = buildRetrofit().create(Service::class.java) @@ -158,13 +162,14 @@ class FlowAdapterIntegrationTest { // expected } } + } // --------------------------------------------------------------------------- // SSE suspend // --------------------------------------------------------------------------- @Test - fun sseEventsSuspend() = + fun sseEventsSuspend() { runBlocking { server.enqueue( MockResponse() @@ -178,22 +183,24 @@ class FlowAdapterIntegrationTest { ServerSentEvent(id = null, event = null, data = "suspended", retry = null) ) } + } // --------------------------------------------------------------------------- // Non-SSE body flow (non-suspend) // --------------------------------------------------------------------------- @Test - fun bodyFlow() = + fun bodyFlow() { runBlocking { server.enqueue(MockResponse().setBody("hello")) val service = buildRetrofit().create(Service::class.java) val values = service.body().toList() assertThat(values).containsExactly("hello") } + } @Test - fun bodyFlowHttpError() = + fun bodyFlowHttpError() { runBlocking { server.enqueue(MockResponse().setResponseCode(404)) val service = buildRetrofit().create(Service::class.java) @@ -204,33 +211,37 @@ class FlowAdapterIntegrationTest { assertThat(e.code()).isEqualTo(404) } } + } // --------------------------------------------------------------------------- // Non-SSE body flow (suspend) // --------------------------------------------------------------------------- @Test - fun bodyFlowSuspend() = + fun bodyFlowSuspend() { runBlocking { server.enqueue(MockResponse().setBody("world")) val service = buildRetrofit().create(Service::class.java) val values = service.bodySuspend().toList() assertThat(values).containsExactly("world") } + } // --------------------------------------------------------------------------- // Converter factory that converts ResponseBody to String // --------------------------------------------------------------------------- - private object StringConverterFactory : Converter.Factory() { + private class StringConverterFactory : Converter.Factory() { override fun responseBodyConverter( type: Type, - annotations: Array, + annotations: Array, retrofit: Retrofit, ): Converter? { - return if (type == String::class.java) Converter { it.string() } - else null + return if (type == String::class.java) { + Converter { it.string() } + } else { + null + } } } } - diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index 01ef98f894..a81a68302e 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -23,6 +23,7 @@ import org.junit.Assert.fail import org.junit.Test import retrofit2.CallAdapter import retrofit2.Retrofit +import retrofit2.http.GET class FlowCallAdapterFactoryTest { private val factory = FlowCallAdapterFactory.create() @@ -32,6 +33,13 @@ class FlowCallAdapterFactoryTest { .addCallAdapterFactory(factory) .build() + // Interface used to extract the real @SSE annotation via reflection. + interface SseHelper { + @SSE + @GET("/") + fun events(): Flow + } + @Test fun nonFlowTypeReturnsNull() { val adapter = factory.get(String::class.java, emptyArray(), retrofit) @@ -58,7 +66,7 @@ class FlowCallAdapterFactoryTest { @Test fun flowSseResponseTypeIsResponseBody() { val type = flowOf(ServerSentEvent::class.java) - val adapter = factory.get(type, arrayOf(SseAnnotation), retrofit)!! + val adapter = factory.get(type, sseAnnotations(), retrofit)!! assertThat(adapter.responseType()).isEqualTo(okhttp3.ResponseBody::class.java) } @@ -66,9 +74,9 @@ class FlowCallAdapterFactoryTest { // Helpers // --------------------------------------------------------------------------- - private object SseAnnotation : SSE, java.lang.annotation.Annotation { - override fun annotationType(): Class = SSE::class.java - } + /** Extracts annotations (including [@SSE][SSE]) from [SseHelper.events] for use in tests. */ + private fun sseAnnotations(): Array = + SseHelper::class.java.getMethod("events").annotations.filterIsInstance().toTypedArray() private fun flowOf(type: Type): Type = object : ParameterizedType { From 32139cd065b715295a2d40643fc0c45069a7ace5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:23:59 +0000 Subject: [PATCH 03/19] fix: qualify IOException references in KDoc Agent-Logs-Url: https://github.com/Goooler/retrofit/sessions/3633ae89-651c-4c0d-a27d-8dce1f512c5e Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 72a40102f7..c5412159f8 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -66,7 +66,7 @@ import retrofit2.Retrofit * ## Non-SSE flows * * Without [@SSE][SSE], a `Flow` return type will emit a single converted response body (like a - * regular body call) and complete, or fail with [HttpException] / [IOException] as appropriate. + * regular body call) and complete, or fail with [HttpException] / [java.io.IOException] as appropriate. * * ```kotlin * interface Api { @@ -261,7 +261,7 @@ private fun sseFlow(call: Call): Flow = callbackF /** * Returns a cold [Flow] that, when collected, makes the HTTP call, emits the single converted - * response body, and completes. Errors result in [HttpException] or [IOException]. + * response body, and completes. Errors result in [HttpException] or [java.io.IOException]. */ private fun bodyFlow(call: Call): Flow = callbackFlow { call.clone().enqueue( From ac2670d09e8a6db48e23d37159195cd1ed7a1a42 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 07:32:47 +0000 Subject: [PATCH 04/19] refactor: restrict Flow adapter to suspend functions only Agent-Logs-Url: https://github.com/Goooler/retrofit/sessions/4230e23e-623b-409b-8556-2eca270a1ce9 Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../adapter/flow/FlowCallAdapterFactory.kt | 82 +++++-------------- .../flow/FlowAdapterIntegrationTest.kt | 51 ++---------- .../flow/FlowCallAdapterFactoryTest.kt | 53 ++++++++---- 3 files changed, 64 insertions(+), 122 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index c5412159f8..fbdfb248f8 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -34,7 +34,7 @@ import retrofit2.Response import retrofit2.Retrofit /** - * A [CallAdapter.Factory] that supports [Flow] as a service-method return type. + * A [CallAdapter.Factory] that supports [Flow] as a **suspend** service-method return type. * * ## SSE (Server-Sent Events) * @@ -45,12 +45,7 @@ import retrofit2.Retrofit * interface Api { * @SSE * @GET("events") - * fun events(): Flow - * - * // suspend is also supported - * @SSE - * @GET("events") - * suspend fun eventsAsync(): Flow + * suspend fun events(): Flow * } * ``` * @@ -65,13 +60,14 @@ import retrofit2.Retrofit * * ## Non-SSE flows * - * Without [@SSE][SSE], a `Flow` return type will emit a single converted response body (like a - * regular body call) and complete, or fail with [HttpException] / [java.io.IOException] as appropriate. + * Without [@SSE][SSE], a `suspend fun foo(): Flow` return type will emit a single converted + * response body (like a regular body call) and complete, or fail with [HttpException] / + * [java.io.IOException] as appropriate. * * ```kotlin * interface Api { * @GET("user") - * fun getUser(): Flow + * suspend fun getUser(): Flow * } * ``` */ @@ -89,61 +85,22 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { ): CallAdapter<*, *>? { val isSse = annotations.any { it is SSE } - // Non-suspend: fun foo(): Flow - if (getRawType(returnType) == Flow::class.java) { - if (returnType !is ParameterizedType) { - throw IllegalStateException( - "Flow return type must be parameterized as Flow or Flow" - ) - } - val elementType = getParameterUpperBound(0, returnType) - // For SSE the adapter reads raw bytes itself; for a regular body call the registered - // converter handles deserialization, so we expose the element type directly. - val responseType: Type = if (isSse) ResponseBody::class.java else elementType - @Suppress("UNCHECKED_CAST") - return BodyFlowCallAdapter(responseType, isSse) as CallAdapter<*, *> - } - - // Suspend: suspend fun foo(): Flow + // Only support suspend functions: suspend fun foo(): Flow // Retrofit wraps the continuation return type in Call, so the adapter type seen here // is Call>. - if (getRawType(returnType) == Call::class.java) { - if (returnType !is ParameterizedType) return null - val callType = getParameterUpperBound(0, returnType) - if (getRawType(callType) != Flow::class.java) return null - if (callType !is ParameterizedType) { - throw IllegalStateException( - "Flow return type must be parameterized as Flow or Flow" - ) - } - val elementType = getParameterUpperBound(0, callType) - val responseType: Type = if (isSse) ResponseBody::class.java else elementType - @Suppress("UNCHECKED_CAST") - return SuspendFlowCallAdapter(responseType, isSse) as CallAdapter<*, *> - } - - return null - } -} - -// --------------------------------------------------------------------------- -// Non-suspend adapter: adapt(Call) → Flow (or Flow for SSE) -// --------------------------------------------------------------------------- - -private class BodyFlowCallAdapter( - private val responseType_: Type, - private val isSse: Boolean, -) : CallAdapter> { - - override fun responseType(): Type = responseType_ - - override fun adapt(call: Call): Flow<*> { - return if (isSse) { - @Suppress("UNCHECKED_CAST") - sseFlow(call as Call) - } else { - bodyFlow(call) + if (getRawType(returnType) != Call::class.java) return null + if (returnType !is ParameterizedType) return null + val callType = getParameterUpperBound(0, returnType) + if (getRawType(callType) != Flow::class.java) return null + if (callType !is ParameterizedType) { + throw IllegalStateException( + "Flow return type must be parameterized as Flow or Flow" + ) } + val elementType = getParameterUpperBound(0, callType) + val responseType: Type = if (isSse) ResponseBody::class.java else elementType + @Suppress("UNCHECKED_CAST") + return SuspendFlowCallAdapter(responseType, isSse) as CallAdapter<*, *> } } @@ -288,3 +245,4 @@ private fun bodyFlow(call: Call): Flow = callbackFlow { awaitClose { call.cancel() } } + diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt index 08e1dbe9fd..29a588f756 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt @@ -39,17 +39,10 @@ class FlowAdapterIntegrationTest { interface Service { @SSE @GET("/") - fun sseEvents(): Flow - - @SSE - @GET("/") - suspend fun sseEventsSuspend(): Flow - - @GET("/") - fun body(): Flow + suspend fun sseEvents(): Flow @GET("/") - suspend fun bodySuspend(): Flow + suspend fun body(): Flow } private fun buildRetrofit(): Retrofit = @@ -60,7 +53,7 @@ class FlowAdapterIntegrationTest { .build() // --------------------------------------------------------------------------- - // SSE non-suspend + // SSE (suspend) // --------------------------------------------------------------------------- @Test @@ -165,28 +158,7 @@ class FlowAdapterIntegrationTest { } // --------------------------------------------------------------------------- - // SSE suspend - // --------------------------------------------------------------------------- - - @Test - fun sseEventsSuspend() { - runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody("data: suspended\n\n") - ) - val service = buildRetrofit().create(Service::class.java) - val events = service.sseEventsSuspend().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = null, event = null, data = "suspended", retry = null) - ) - } - } - - // --------------------------------------------------------------------------- - // Non-SSE body flow (non-suspend) + // Non-SSE body flow (suspend) // --------------------------------------------------------------------------- @Test @@ -213,20 +185,6 @@ class FlowAdapterIntegrationTest { } } - // --------------------------------------------------------------------------- - // Non-SSE body flow (suspend) - // --------------------------------------------------------------------------- - - @Test - fun bodyFlowSuspend() { - runBlocking { - server.enqueue(MockResponse().setBody("world")) - val service = buildRetrofit().create(Service::class.java) - val values = service.bodySuspend().toList() - assertThat(values).containsExactly("world") - } - } - // --------------------------------------------------------------------------- // Converter factory that converts ResponseBody to String // --------------------------------------------------------------------------- @@ -245,3 +203,4 @@ class FlowAdapterIntegrationTest { } } } + diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index a81a68302e..66df4a4761 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -21,7 +21,7 @@ import java.lang.reflect.Type import kotlinx.coroutines.flow.Flow import org.junit.Assert.fail import org.junit.Test -import retrofit2.CallAdapter +import retrofit2.Call import retrofit2.Retrofit import retrofit2.http.GET @@ -37,7 +37,7 @@ class FlowCallAdapterFactoryTest { interface SseHelper { @SSE @GET("/") - fun events(): Flow + suspend fun events(): Flow } @Test @@ -46,37 +46,52 @@ class FlowCallAdapterFactoryTest { assertThat(adapter).isNull() } + /** Non-suspend Flow must not be handled — the factory should return null. */ @Test - fun rawFlowTypeThrows() { - try { - factory.get(Flow::class.java, emptyArray(), retrofit) - fail() - } catch (e: IllegalStateException) { - assertThat(e).hasMessageThat().contains("parameterized") - } + fun nonSuspendFlowTypeReturnsNull() { + val adapter = factory.get(flowOf(String::class.java), emptyArray(), retrofit) + assertThat(adapter).isNull() } + /** suspend fun foo(): Flow → responseType is the element type T. */ @Test - fun flowResponseTypeIsElementType() { - val type = flowOf(String::class.java) + fun suspendFlowResponseTypeIsElementType() { + val type = callOf(flowOf(String::class.java)) val adapter = factory.get(type, emptyArray(), retrofit)!! assertThat(adapter.responseType()).isEqualTo(String::class.java) } + /** suspend fun foo(): Flow with @SSE → responseType is ResponseBody. */ @Test - fun flowSseResponseTypeIsResponseBody() { - val type = flowOf(ServerSentEvent::class.java) + fun suspendFlowSseResponseTypeIsResponseBody() { + val type = callOf(flowOf(ServerSentEvent::class.java)) val adapter = factory.get(type, sseAnnotations(), retrofit)!! assertThat(adapter.responseType()).isEqualTo(okhttp3.ResponseBody::class.java) } + /** Unparameterized Flow inside Call should throw. */ + @Test + fun rawFlowInsideCallThrows() { + val type = callOf(Flow::class.java) + try { + factory.get(type, emptyArray(), retrofit) + fail() + } catch (e: IllegalStateException) { + assertThat(e).hasMessageThat().contains("parameterized") + } + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Extracts annotations (including [@SSE][SSE]) from [SseHelper.events] for use in tests. */ private fun sseAnnotations(): Array = - SseHelper::class.java.getMethod("events").annotations.filterIsInstance().toTypedArray() + SseHelper::class.java + .getMethod("events", kotlin.coroutines.Continuation::class.java) + .annotations + .filterIsInstance() + .toTypedArray() private fun flowOf(type: Type): Type = object : ParameterizedType { @@ -84,6 +99,16 @@ class FlowCallAdapterFactoryTest { override fun getRawType(): Type = Flow::class.java + override fun getOwnerType(): Type? = null + } + + /** Wraps [innerType] in Call, matching what Retrofit presents for suspend functions. */ + private fun callOf(innerType: Type): Type = + object : ParameterizedType { + override fun getActualTypeArguments(): Array = arrayOf(innerType) + + override fun getRawType(): Type = Call::class.java + override fun getOwnerType(): Type? = null } } From 2ac52028711e76997929d4b4753789bff5251d2d Mon Sep 17 00:00:00 2001 From: Goooler Date: Sat, 4 Apr 2026 23:57:20 +0800 Subject: [PATCH 05/19] Replace SSE with Streaming --- .../adapter/flow/FlowCallAdapterFactory.kt | 16 +++++---- .../main/java/retrofit2/adapter/flow/SSE.kt | 33 ------------------- .../flow/FlowAdapterIntegrationTest.kt | 3 +- .../flow/FlowCallAdapterFactoryTest.kt | 9 ++--- 4 files changed, 16 insertions(+), 45 deletions(-) delete mode 100644 retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index fbdfb248f8..b26f2ca0e8 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -32,18 +32,20 @@ import retrofit2.Callback import retrofit2.HttpException import retrofit2.Response import retrofit2.Retrofit +import retrofit2.http.Streaming /** * A [CallAdapter.Factory] that supports [Flow] as a **suspend** service-method return type. * * ## SSE (Server-Sent Events) * - * When the method is also annotated with [@SSE][SSE], the adapter streams the HTTP response body as - * Server-Sent Events, emitting each parsed [ServerSentEvent] to the flow: + * When the method is also annotated with [@Streaming][retrofit2.http.Streaming], the adapter + * streams the HTTP response body as Server-Sent Events, emitting each parsed [ServerSentEvent] to + * the flow: * * ```kotlin * interface Api { - * @SSE + * @Streaming * @GET("events") * suspend fun events(): Flow * } @@ -60,9 +62,9 @@ import retrofit2.Retrofit * * ## Non-SSE flows * - * Without [@SSE][SSE], a `suspend fun foo(): Flow` return type will emit a single converted - * response body (like a regular body call) and complete, or fail with [HttpException] / - * [java.io.IOException] as appropriate. + * Without [@Streaming][retrofit2.http.Streaming], a `suspend fun foo(): Flow` return type will + * emit a single converted response body (like a regular body call) and complete, or fail with + * [HttpException] / [java.io.IOException] as appropriate. * * ```kotlin * interface Api { @@ -83,7 +85,7 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { annotations: Array, retrofit: Retrofit, ): CallAdapter<*, *>? { - val isSse = annotations.any { it is SSE } + val isSse = annotations.any { it is Streaming } // Only support suspend functions: suspend fun foo(): Flow // Retrofit wraps the continuation return type in Call, so the adapter type seen here diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt deleted file mode 100644 index 81b43965e3..0000000000 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SSE.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * 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 retrofit2.adapter.flow - -/** - * Marks a service method as a Server-Sent Events (SSE) endpoint. When combined with a - * [kotlinx.coroutines.flow.Flow] return type and [FlowCallAdapterFactory], the adapter will stream - * SSE events from the HTTP response body and emit each parsed [ServerSentEvent] to the flow. - * - * ```kotlin - * interface Api { - * @SSE - * @GET("events") - * fun events(): Flow - * } - * ``` - */ -@Target(AnnotationTarget.FUNCTION) -@Retention(AnnotationRetention.RUNTIME) -annotation class SSE diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt index 29a588f756..3c70c8dc94 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt @@ -32,12 +32,13 @@ import retrofit2.Converter import retrofit2.HttpException import retrofit2.Retrofit import retrofit2.http.GET +import retrofit2.http.Streaming class FlowAdapterIntegrationTest { @get:Rule val server = MockWebServer() interface Service { - @SSE + @Streaming @GET("/") suspend fun sseEvents(): Flow diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index 66df4a4761..6ab49896f6 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -24,6 +24,7 @@ import org.junit.Test import retrofit2.Call import retrofit2.Retrofit import retrofit2.http.GET +import retrofit2.http.Streaming class FlowCallAdapterFactoryTest { private val factory = FlowCallAdapterFactory.create() @@ -33,9 +34,9 @@ class FlowCallAdapterFactoryTest { .addCallAdapterFactory(factory) .build() - // Interface used to extract the real @SSE annotation via reflection. + // Interface used to extract the real @Streaming annotation via reflection. interface SseHelper { - @SSE + @Streaming @GET("/") suspend fun events(): Flow } @@ -61,7 +62,7 @@ class FlowCallAdapterFactoryTest { assertThat(adapter.responseType()).isEqualTo(String::class.java) } - /** suspend fun foo(): Flow with @SSE → responseType is ResponseBody. */ + /** suspend fun foo(): Flow with @Streaming → responseType is ResponseBody. */ @Test fun suspendFlowSseResponseTypeIsResponseBody() { val type = callOf(flowOf(ServerSentEvent::class.java)) @@ -85,7 +86,7 @@ class FlowCallAdapterFactoryTest { // Helpers // --------------------------------------------------------------------------- - /** Extracts annotations (including [@SSE][SSE]) from [SseHelper.events] for use in tests. */ + /** Extracts annotations (including [@Streaming][retrofit2.http.Streaming]) from [SseHelper.events] for use in tests. */ private fun sseAnnotations(): Array = SseHelper::class.java .getMethod("events", kotlin.coroutines.Continuation::class.java) From 38b7a07f6d9de89a932775d8060bd867a04c9767 Mon Sep 17 00:00:00 2001 From: Goooler Date: Sun, 5 Apr 2026 00:51:47 +0800 Subject: [PATCH 06/19] Migrate to OkHttp SSE --- gradle/libs.versions.toml | 1 + retrofit-adapters/kotlin-flow/build.gradle | 1 + .../adapter/flow/FlowCallAdapterFactory.kt | 93 +++++++++---------- .../retrofit2/adapter/flow/ServerSentEvent.kt | 2 - .../java/retrofit2/adapter/flow/SseParser.kt | 73 --------------- .../flow/FlowAdapterIntegrationTest.kt | 10 +- 6 files changed, 50 insertions(+), 130 deletions(-) delete mode 100644 retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78370417f5..0cf50a0021 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serializa kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-serialization-proto = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "kotlinx-serialization" } okhttp-client = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" } okhttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } junit = { module = "junit:junit", version = "4.13.2" } diff --git a/retrofit-adapters/kotlin-flow/build.gradle b/retrofit-adapters/kotlin-flow/build.gradle index d173733dfc..fe6ab36213 100644 --- a/retrofit-adapters/kotlin-flow/build.gradle +++ b/retrofit-adapters/kotlin-flow/build.gradle @@ -3,6 +3,7 @@ apply plugin: 'com.vanniktech.maven.publish' dependencies { api projects.retrofit + api libs.okhttp.sse api libs.kotlinx.coroutines compileOnly libs.findBugsAnnotations diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index b26f2ca0e8..935f5987e2 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -17,14 +17,14 @@ package retrofit2.adapter.flow import java.lang.reflect.ParameterizedType import java.lang.reflect.Type -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.launch +import okhttp3.Request import okhttp3.ResponseBody +import okhttp3.sse.EventSource +import okhttp3.sse.EventSourceListener +import okhttp3.sse.EventSources import okio.Timeout import retrofit2.Call import retrofit2.CallAdapter @@ -101,8 +101,9 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { } val elementType = getParameterUpperBound(0, callType) val responseType: Type = if (isSse) ResponseBody::class.java else elementType + val eventSourceFactory = if (isSse) EventSources.createFactory(retrofit.callFactory()) else null @Suppress("UNCHECKED_CAST") - return SuspendFlowCallAdapter(responseType, isSse) as CallAdapter<*, *> + return SuspendFlowCallAdapter(responseType, isSse, eventSourceFactory) as CallAdapter<*, *> } } @@ -118,6 +119,7 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { private class SuspendFlowCallAdapter( private val responseType_: Type, private val isSse: Boolean, + private val eventSourceFactory: EventSource.Factory?, ) : CallAdapter>> { override fun responseType(): Type = responseType_ @@ -125,8 +127,7 @@ private class SuspendFlowCallAdapter( override fun adapt(call: Call): Call> { val flow: Flow<*> = if (isSse) { - @Suppress("UNCHECKED_CAST") - sseFlow(call as Call) + sseFlow(call.request(), eventSourceFactory!!) } else { bodyFlow(call) } @@ -168,54 +169,46 @@ private class FlowAsCall( // --------------------------------------------------------------------------- /** - * Returns a cold [Flow] that, when collected, makes the HTTP call and emits each parsed - * [ServerSentEvent] from the response body stream. The HTTP connection is closed when the - * stream ends or the flow is cancelled. - * - * The SSE response body is read on [Dispatchers.IO] so that blocking IO does not tie up the - * caller's coroutine dispatcher. + * Returns a cold [Flow] that, when collected, opens an OkHttp [EventSource] for the given + * [request] and emits each parsed [ServerSentEvent]. The connection is closed when the stream ends + * or the flow is cancelled. */ -private fun sseFlow(call: Call): Flow = callbackFlow { - val scope: CoroutineScope = this - val channel: SendChannel = this - - call.clone().enqueue( - object : Callback { - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - channel.close(HttpException(response)) - return +private fun sseFlow( + request: Request, + eventSourceFactory: EventSource.Factory, +): Flow = callbackFlow { + val eventSource = + eventSourceFactory.newEventSource( + request, + object : EventSourceListener() { + override fun onEvent( + eventSource: EventSource, + id: String?, + type: String?, + data: String, + ) { + trySend(ServerSentEvent(id = id, event = type, data = data)) } - val body = response.body() - if (body == null) { - channel.close() - return + + override fun onClosed(eventSource: EventSource) { + close() } - // Read the SSE stream on an IO thread so that blocking reads do not block the - // coroutine dispatcher. `send` suspends when the consumer is slow, providing - // natural backpressure. - scope.launch(Dispatchers.IO) { - try { - body.use { responseBody -> - val reader = responseBody.charStream().buffered() - for (event in parseServerSentEvents(reader)) { - channel.send(event) + + override fun onFailure( + eventSource: EventSource, + t: Throwable?, + response: okhttp3.Response?, + ) { + close( + t + ?: response?.let { + HttpException(Response.error(it.body, it)) } - } - channel.close() - } catch (e: Exception) { - channel.close(e) - } + ) } - } - - override fun onFailure(call: Call, t: Throwable) { - channel.close(t) - } - } - ) - - awaitClose { call.cancel() } + }, + ) + awaitClose { eventSource.cancel() } } /** diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt index dc09ea219f..40d8c64f6d 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt @@ -22,11 +22,9 @@ package retrofit2.adapter.flow * @property id The last event ID, or `null` if none was set. * @property event The event type, or `null` if the default "message" type. * @property data The data payload. Multiple `data:` lines are joined with `\n`. - * @property retry The reconnection time in milliseconds, or `null` if not provided. */ data class ServerSentEvent( val id: String?, val event: String?, val data: String, - val retry: Long?, ) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt deleted file mode 100644 index 60971d333f..0000000000 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/SseParser.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (C) 2024 Square, Inc. - * - * 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 retrofit2.adapter.flow - -import java.io.BufferedReader - -/** - * Parses a stream of text lines into [ServerSentEvent] objects following the - * [W3C SSE specification](https://html.spec.whatwg.org/multipage/server-sent-events.html). - */ -internal fun parseServerSentEvents(reader: BufferedReader): Sequence = sequence { - var id: String? = null - var event: String? = null - val dataBuffer = StringBuilder() - var retry: Long? = null - var hasData = false - - for (line in reader.lineSequence()) { - if (line.isEmpty()) { - // An empty line dispatches the event. - if (hasData) { - // Remove the trailing newline that was appended after the last data line. - val data = if (dataBuffer.endsWith('\n')) dataBuffer.dropLast(1).toString() else dataBuffer.toString() - yield(ServerSentEvent(id = id, event = event, data = data, retry = retry)) - } - // Reset fields for the next event (id persists per spec, but retry is per-event). - event = null - dataBuffer.clear() - retry = null - hasData = false - } else if (line.startsWith(':')) { - // Comment line — ignore. - } else { - val colonIndex = line.indexOf(':') - val field: String - val value: String - if (colonIndex == -1) { - field = line - value = "" - } else { - field = line.substring(0, colonIndex) - // If the character immediately after `:` is a space, skip it. - value = if (colonIndex + 1 < line.length && line[colonIndex + 1] == ' ') { - line.substring(colonIndex + 2) - } else { - line.substring(colonIndex + 1) - } - } - when (field) { - "data" -> { - dataBuffer.append(value).append('\n') - hasData = true - } - "id" -> id = value - "event" -> event = value - "retry" -> retry = value.toLongOrNull() - } - } - } -} diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt index 3c70c8dc94..3002cae14d 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt @@ -72,8 +72,8 @@ class FlowAdapterIntegrationTest { val events = service.sseEvents().toList() assertThat(events) .containsExactly( - ServerSentEvent(id = "1", event = "ping", data = "hello", retry = null), - ServerSentEvent(id = "2", event = null, data = "world", retry = null), + ServerSentEvent(id = "1", event = "ping", data = "hello"), + ServerSentEvent(id = "2", event = null, data = "world"), ) .inOrder() } @@ -91,7 +91,7 @@ class FlowAdapterIntegrationTest { val events = service.sseEvents().toList() assertThat(events) .containsExactly( - ServerSentEvent(id = null, event = null, data = "line one\nline two", retry = null) + ServerSentEvent(id = null, event = null, data = "line one\nline two") ) } } @@ -108,7 +108,7 @@ class FlowAdapterIntegrationTest { val events = service.sseEvents().toList() assertThat(events) .containsExactly( - ServerSentEvent(id = null, event = null, data = "reconnect", retry = 3000L) + ServerSentEvent(id = null, event = null, data = "reconnect") ) } } @@ -125,7 +125,7 @@ class FlowAdapterIntegrationTest { val events = service.sseEvents().toList() assertThat(events) .containsExactly( - ServerSentEvent(id = null, event = null, data = "real", retry = null) + ServerSentEvent(id = null, event = null, data = "real") ) } } From 70bc92ab92789408eb305fdae6f4132b2beaa94b Mon Sep 17 00:00:00 2001 From: Goooler Date: Mon, 6 Apr 2026 12:07:02 +0800 Subject: [PATCH 07/19] Cleanups --- .../adapter/flow/FlowCallAdapterFactory.kt | 34 ++++++++----------- .../retrofit2/adapter/flow/ServerSentEvent.kt | 2 +- .../flow/FlowAdapterIntegrationTest.kt | 2 +- .../flow/FlowCallAdapterFactoryTest.kt | 2 +- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 935f5987e2..2bd9bd6d63 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Square, Inc. + * Copyright (C) 2026 Square, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -44,7 +44,7 @@ import retrofit2.http.Streaming * the flow: * * ```kotlin - * interface Api { + * interface Service { * @Streaming * @GET("events") * suspend fun events(): Flow @@ -67,7 +67,7 @@ import retrofit2.http.Streaming * [HttpException] / [java.io.IOException] as appropriate. * * ```kotlin - * interface Api { + * interface Service { * @GET("user") * suspend fun getUser(): Flow * } @@ -85,25 +85,21 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { annotations: Array, retrofit: Retrofit, ): CallAdapter<*, *>? { - val isSse = annotations.any { it is Streaming } + val isStreaming = annotations.any { it is Streaming } - // Only support suspend functions: suspend fun foo(): Flow - // Retrofit wraps the continuation return type in Call, so the adapter type seen here - // is Call>. if (getRawType(returnType) != Call::class.java) return null if (returnType !is ParameterizedType) return null val callType = getParameterUpperBound(0, returnType) if (getRawType(callType) != Flow::class.java) return null if (callType !is ParameterizedType) { - throw IllegalStateException( + error( "Flow return type must be parameterized as Flow or Flow" ) } val elementType = getParameterUpperBound(0, callType) - val responseType: Type = if (isSse) ResponseBody::class.java else elementType - val eventSourceFactory = if (isSse) EventSources.createFactory(retrofit.callFactory()) else null - @Suppress("UNCHECKED_CAST") - return SuspendFlowCallAdapter(responseType, isSse, eventSourceFactory) as CallAdapter<*, *> + val responseType = if (isStreaming) ResponseBody::class.java else elementType + val eventSourceFactory = if (isStreaming) EventSources.createFactory(retrofit.callFactory()) else null + return SuspendFlowCallAdapter(responseType, isStreaming, eventSourceFactory) } } @@ -117,17 +113,17 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { // --------------------------------------------------------------------------- private class SuspendFlowCallAdapter( - private val responseType_: Type, - private val isSse: Boolean, + private val _responseType: Type, + private val isStreaming: Boolean, private val eventSourceFactory: EventSource.Factory?, ) : CallAdapter>> { - override fun responseType(): Type = responseType_ + override fun responseType(): Type = _responseType override fun adapt(call: Call): Call> { val flow: Flow<*> = - if (isSse) { - sseFlow(call.request(), eventSourceFactory!!) + if (isStreaming) { + streamingFlow(call.request(), requireNotNull(eventSourceFactory)) } else { bodyFlow(call) } @@ -159,7 +155,7 @@ private class FlowAsCall( override fun clone(): Call> = FlowAsCall(delegate.clone(), flow) - override fun request(): okhttp3.Request = delegate.request() + override fun request(): Request = delegate.request() override fun timeout(): Timeout = delegate.timeout() } @@ -173,7 +169,7 @@ private class FlowAsCall( * [request] and emits each parsed [ServerSentEvent]. The connection is closed when the stream ends * or the flow is cancelled. */ -private fun sseFlow( +private fun streamingFlow( request: Request, eventSourceFactory: EventSource.Factory, ): Flow = callbackFlow { diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt index 40d8c64f6d..7ac49019a9 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Square, Inc. + * Copyright (C) 2026 Square, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt index 3002cae14d..5936132007 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Square, Inc. + * Copyright (C) 2026 Square, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index 6ab49896f6..676a709259 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Square, Inc. + * Copyright (C) 2026 Square, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From bde15b07d4f6c09023b0f6570d92db6c5d438346 Mon Sep 17 00:00:00 2001 From: Goooler Date: Mon, 6 Apr 2026 12:23:26 +0800 Subject: [PATCH 08/19] Add README.md --- retrofit-adapters/kotlin-flow/README.md | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 retrofit-adapters/kotlin-flow/README.md diff --git a/retrofit-adapters/kotlin-flow/README.md b/retrofit-adapters/kotlin-flow/README.md new file mode 100644 index 0000000000..8fe4367638 --- /dev/null +++ b/retrofit-adapters/kotlin-flow/README.md @@ -0,0 +1,93 @@ +Kotlin Flow Adapter +=================== + +A `CallAdapter.Factory` for adapting [Kotlin coroutine `Flow`][1] return types in Retrofit `suspend` +service methods. + +Supported return types: + +* `Flow` — emits the single converted response body and completes. +* `Flow` (with `@Streaming`) — streams Server-Sent Events from the response body, + emitting one `ServerSentEvent` per event. + + +Usage +----- + +Add `FlowCallAdapterFactory` as a call adapter when building your `Retrofit` instance: + +```kotlin +val retrofit = Retrofit.Builder() + .baseUrl("https://example.com/") + .addCallAdapterFactory(FlowCallAdapterFactory.create()) + .build() +``` + +### Regular body flow + +Annotate a `suspend` service method with any Retrofit HTTP annotation and return `Flow`. The flow +emits the single converted response body when collected, then completes. On a non-2xx response or a +network failure the flow fails with `HttpException` or `IOException` respectively. + +```kotlin +interface MyService { + @GET("/user") + suspend fun getUser(): Flow +} +``` + +### Server-Sent Events (SSE) + +Add `@Streaming` to stream a response as [Server-Sent Events][2]. The return type must be +`Flow`. The flow emits one `ServerSentEvent` for each event dispatched by the +server and completes when the connection is closed. Cancelling the flow cancels the underlying +OkHttp `EventSource`. + +```kotlin +interface MyService { + @Streaming + @GET("/events") + suspend fun events(): Flow +} +``` + +`ServerSentEvent` exposes the fields defined by the [W3C SSE specification][2]: + +| Property | Type | Description | +| -------- | --------- | ---------------------------------------------------------- | +| `id` | `String?` | Last event ID, or `null` if not set. | +| `event` | `String?` | Event type, or `null` for the default `"message"` type. | +| `data` | `String` | Data payload; multiple `data:` lines are joined with `\n`. | + +Parsing and connection management are delegated to OkHttp's `okhttp-sse` library. The `Accept: +text/event-stream` header is added automatically. + + +Download +-------- + +Download [the latest JAR][3] or grab via [Maven][4]: + +```xml + + com.squareup.retrofit2 + adapter-kotlin-flow + latest.version + +``` + +or [Gradle][4]: + +```kotlin +implementation("com.squareup.retrofit2:adapter-kotlin-flow:latest.version") +``` + +Snapshots of the development version are available in [Sonatype's `snapshots` repository][snap]. + + + + [1]: https://kotlinlang.org/docs/flow.html + [2]: https://html.spec.whatwg.org/multipage/server-sent-events.html + [3]: https://search.maven.org/remote_content?g=com.squareup.retrofit2&a=adapter-kotlin-flow&v=LATEST + [4]: https://search.maven.org/search?q=g:com.squareup.retrofit2%20a:adapter-kotlin-flow + [snap]: https://s01.oss.sonatype.org/content/repositories/snapshots/ From c8d5e0a83fc4670d54755c30d626ccbdd8bef4c6 Mon Sep 17 00:00:00 2001 From: Goooler Date: Mon, 6 Apr 2026 12:26:48 +0800 Subject: [PATCH 09/19] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 582b98174c..f656888708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Add explicit keep rules for RxJava `Result` types to prevent their generic information from being removed. - Add `allowoptimization` flags for most kept types. - Add `Invocation.annotationUrl` which returns the original URL from the method annotation. + - Add Kotlin `Flow` adapter with SSE support. **Changed** From 3a264a5f355a56dcaf7b587b4412a8ce23b5d438 Mon Sep 17 00:00:00 2001 From: Goooler Date: Mon, 6 Apr 2026 20:15:32 +0800 Subject: [PATCH 10/19] Cleanups --- .../adapter/flow/FlowCallAdapterFactory.kt | 4 +- .../flow/FlowAdapterIntegrationTest.kt | 207 ------------------ .../flow/FlowCallAdapterFactoryTest.kt | 193 +++++++++++----- 3 files changed, 138 insertions(+), 266 deletions(-) delete mode 100644 retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 2bd9bd6d63..fea3acec3f 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -35,7 +35,7 @@ import retrofit2.Retrofit import retrofit2.http.Streaming /** - * A [CallAdapter.Factory] that supports [Flow] as a **suspend** service-method return type. + * A [CallAdapter.Factory] that supports [Flow] as a `suspend` service-method return type. * * ## SSE (Server-Sent Events) * @@ -133,7 +133,7 @@ private class SuspendFlowCallAdapter( /** * A [Call] whose "response body" is a pre-built cold [Flow]. When enqueued it immediately - * delivers the flow to the callback so that Retrofit's suspend machinery can resume the coroutine + * delivers the flow to the callback so that Retrofit's `suspend` machinery can resume the coroutine * with the flow value. The HTTP request is only started when the returned flow is collected. */ private class FlowAsCall( diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt deleted file mode 100644 index 5936132007..0000000000 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowAdapterIntegrationTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright (C) 2026 Square, Inc. - * - * 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 retrofit2.adapter.flow - -import com.google.common.truth.Truth.assertThat -import java.io.IOException -import java.lang.reflect.Type -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import okhttp3.ResponseBody -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import okhttp3.mockwebserver.SocketPolicy -import org.junit.Assert.fail -import org.junit.Rule -import org.junit.Test -import retrofit2.Converter -import retrofit2.HttpException -import retrofit2.Retrofit -import retrofit2.http.GET -import retrofit2.http.Streaming - -class FlowAdapterIntegrationTest { - @get:Rule val server = MockWebServer() - - interface Service { - @Streaming - @GET("/") - suspend fun sseEvents(): Flow - - @GET("/") - suspend fun body(): Flow - } - - private fun buildRetrofit(): Retrofit = - Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(StringConverterFactory()) - .addCallAdapterFactory(FlowCallAdapterFactory.create()) - .build() - - // --------------------------------------------------------------------------- - // SSE (suspend) - // --------------------------------------------------------------------------- - - @Test - fun sseEvents() { - runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody( - "id: 1\nevent: ping\ndata: hello\n\n" + - "id: 2\ndata: world\n\n" - ) - ) - val service = buildRetrofit().create(Service::class.java) - val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = "1", event = "ping", data = "hello"), - ServerSentEvent(id = "2", event = null, data = "world"), - ) - .inOrder() - } - } - - @Test - fun sseEventsMultilineData() { - runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody("data: line one\ndata: line two\n\n") - ) - val service = buildRetrofit().create(Service::class.java) - val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = null, event = null, data = "line one\nline two") - ) - } - } - - @Test - fun sseEventsWithRetry() { - runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody("retry: 3000\ndata: reconnect\n\n") - ) - val service = buildRetrofit().create(Service::class.java) - val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = null, event = null, data = "reconnect") - ) - } - } - - @Test - fun sseEventsCommentsIgnored() { - runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody(": this is a comment\ndata: real\n\n") - ) - val service = buildRetrofit().create(Service::class.java) - val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = null, event = null, data = "real") - ) - } - } - - @Test - fun sseEventsHttpError() { - runBlocking { - server.enqueue(MockResponse().setResponseCode(500)) - val service = buildRetrofit().create(Service::class.java) - try { - service.sseEvents().toList() - fail("Expected HttpException") - } catch (e: HttpException) { - assertThat(e.code()).isEqualTo(500) - } - } - } - - @Test - fun sseEventsNetworkFailure() { - runBlocking { - server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) - val service = buildRetrofit().create(Service::class.java) - try { - service.sseEvents().toList() - fail("Expected IOException") - } catch (_: IOException) { - // expected - } - } - } - - // --------------------------------------------------------------------------- - // Non-SSE body flow (suspend) - // --------------------------------------------------------------------------- - - @Test - fun bodyFlow() { - runBlocking { - server.enqueue(MockResponse().setBody("hello")) - val service = buildRetrofit().create(Service::class.java) - val values = service.body().toList() - assertThat(values).containsExactly("hello") - } - } - - @Test - fun bodyFlowHttpError() { - runBlocking { - server.enqueue(MockResponse().setResponseCode(404)) - val service = buildRetrofit().create(Service::class.java) - try { - service.body().toList() - fail("Expected HttpException") - } catch (e: HttpException) { - assertThat(e.code()).isEqualTo(404) - } - } - } - - // --------------------------------------------------------------------------- - // Converter factory that converts ResponseBody to String - // --------------------------------------------------------------------------- - - private class StringConverterFactory : Converter.Factory() { - override fun responseBodyConverter( - type: Type, - annotations: Array, - retrofit: Retrofit, - ): Converter? { - return if (type == String::class.java) { - Converter { it.string() } - } else { - null - } - } - } -} - diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index 676a709259..9c5bcb88d4 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -16,101 +16,180 @@ package retrofit2.adapter.flow import com.google.common.truth.Truth.assertThat -import java.lang.reflect.ParameterizedType +import java.io.IOException import java.lang.reflect.Type import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy import org.junit.Assert.fail +import org.junit.Rule import org.junit.Test -import retrofit2.Call +import retrofit2.Converter +import retrofit2.HttpException import retrofit2.Retrofit import retrofit2.http.GET import retrofit2.http.Streaming class FlowCallAdapterFactoryTest { - private val factory = FlowCallAdapterFactory.create() - private val retrofit = - Retrofit.Builder() - .baseUrl("http://localhost:1/") - .addCallAdapterFactory(factory) - .build() + @get:Rule val server = MockWebServer() - // Interface used to extract the real @Streaming annotation via reflection. - interface SseHelper { + interface Service { @Streaming @GET("/") - suspend fun events(): Flow + suspend fun sseEvents(): Flow + + @GET("/") + suspend fun body(): Flow + } + + private val retrofit get() = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(StringConverterFactory()) + .addCallAdapterFactory(FlowCallAdapterFactory.create()) + .build() + + // --------------------------------------------------------------------------- + // SSE (suspend) + // --------------------------------------------------------------------------- + + @Test + fun sseEvents() = runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody( + "id: 1\nevent: ping\ndata: hello\n\n" + + "id: 2\ndata: world\n\n" + ) + ) + val service = retrofit.create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = "1", event = "ping", data = "hello"), + ServerSentEvent(id = "2", event = null, data = "world"), + ) + .inOrder() } @Test - fun nonFlowTypeReturnsNull() { - val adapter = factory.get(String::class.java, emptyArray(), retrofit) - assertThat(adapter).isNull() + fun sseEventsMultilineData() = runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("data: line one\ndata: line two\n\n") + ) + val service = retrofit.create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = null, event = null, data = "line one\nline two"), + ) + Unit } - /** Non-suspend Flow must not be handled — the factory should return null. */ @Test - fun nonSuspendFlowTypeReturnsNull() { - val adapter = factory.get(flowOf(String::class.java), emptyArray(), retrofit) - assertThat(adapter).isNull() + fun sseEventsWithRetry() = runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("retry: 3000\ndata: reconnect\n\n") + ) + val service = retrofit.create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = null, event = null, data = "reconnect"), + ) + Unit } - /** suspend fun foo(): Flow → responseType is the element type T. */ @Test - fun suspendFlowResponseTypeIsElementType() { - val type = callOf(flowOf(String::class.java)) - val adapter = factory.get(type, emptyArray(), retrofit)!! - assertThat(adapter.responseType()).isEqualTo(String::class.java) + fun sseEventsCommentsIgnored() = runBlocking { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody(": this is a comment\ndata: real\n\n") + ) + val service = retrofit.create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = null, event = null, data = "real"), + ) + Unit } - /** suspend fun foo(): Flow with @Streaming → responseType is ResponseBody. */ @Test - fun suspendFlowSseResponseTypeIsResponseBody() { - val type = callOf(flowOf(ServerSentEvent::class.java)) - val adapter = factory.get(type, sseAnnotations(), retrofit)!! - assertThat(adapter.responseType()).isEqualTo(okhttp3.ResponseBody::class.java) + fun sseEventsHttpError() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val service = retrofit.create(Service::class.java) + try { + service.sseEvents().toList() + fail("Expected HttpException") + } catch (e: HttpException) { + assertThat(e.code()).isEqualTo(500) + } } - /** Unparameterized Flow inside Call should throw. */ @Test - fun rawFlowInsideCallThrows() { - val type = callOf(Flow::class.java) + fun sseEventsNetworkFailure() = runBlocking { + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)) + val service = retrofit.create(Service::class.java) try { - factory.get(type, emptyArray(), retrofit) - fail() - } catch (e: IllegalStateException) { - assertThat(e).hasMessageThat().contains("parameterized") + service.sseEvents().toList() + fail("Expected IOException") + } catch (_: IOException) { + // expected } } // --------------------------------------------------------------------------- - // Helpers + // Non-SSE body flow (suspend) // --------------------------------------------------------------------------- - /** Extracts annotations (including [@Streaming][retrofit2.http.Streaming]) from [SseHelper.events] for use in tests. */ - private fun sseAnnotations(): Array = - SseHelper::class.java - .getMethod("events", kotlin.coroutines.Continuation::class.java) - .annotations - .filterIsInstance() - .toTypedArray() - - private fun flowOf(type: Type): Type = - object : ParameterizedType { - override fun getActualTypeArguments(): Array = arrayOf(type) - - override fun getRawType(): Type = Flow::class.java + @Test + fun bodyFlow() = runBlocking { + server.enqueue(MockResponse().setBody("hello")) + val service = retrofit.create(Service::class.java) + val values = service.body().toList() + assertThat(values).containsExactly("hello") + Unit + } - override fun getOwnerType(): Type? = null + @Test + fun bodyFlowHttpError() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404)) + val service = retrofit.create(Service::class.java) + try { + service.body().toList() + fail("Expected HttpException") + } catch (e: HttpException) { + assertThat(e.code()).isEqualTo(404) } + } - /** Wraps [innerType] in Call, matching what Retrofit presents for suspend functions. */ - private fun callOf(innerType: Type): Type = - object : ParameterizedType { - override fun getActualTypeArguments(): Array = arrayOf(innerType) - - override fun getRawType(): Type = Call::class.java + // --------------------------------------------------------------------------- + // Converter factory that converts ResponseBody to String + // --------------------------------------------------------------------------- - override fun getOwnerType(): Type? = null + private class StringConverterFactory : Converter.Factory() { + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit, + ): Converter? { + return if (type == String::class.java) { + Converter { it.string() } + } else { + null + } } + } } From b60e4b4732c95214f5efb9cd8dedc7dbd3ae7ac3 Mon Sep 17 00:00:00 2001 From: Goooler Date: Mon, 6 Apr 2026 20:32:21 +0800 Subject: [PATCH 11/19] Tweak Spotless --- build.gradle | 24 +- .../adapter/flow/FlowCallAdapterFactory.kt | 81 ++--- .../retrofit2/adapter/flow/ServerSentEvent.kt | 6 +- .../flow/FlowCallAdapterFactoryTest.kt | 92 +++--- .../DeserializationStrategyConverter.kt | 2 +- .../kotlinx/serialization/Factory.kt | 26 +- .../SerializationStrategyConverter.kt | 2 +- .../kotlinx/serialization/Serializer.kt | 19 +- ...nSerializationConverterFactoryBytesTest.kt | 26 +- ...SerializationConverterFactoryStringTest.kt | 23 +- ...ationConverterFactoryContextualListTest.kt | 32 +- ...alizationConverterFactoryContextualTest.kt | 32 +- .../RetrofitResponseTypeKeepProcessor.kt | 34 +- .../RetrofitResponseTypeKeepProcessorTest.kt | 133 ++++---- .../java/retrofit2/BasicCallTest.java | 14 +- .../CompletableFutureAndroidTest.java | 4 +- .../retrofit2/DefaultMethodsAndroidTest.java | 10 +- .../OptionalConverterFactoryAndroidTest.java | 4 +- .../java/retrofit2/UriAndroidTest.java | 9 +- .../java/retrofit2/KotlinExtensionsTest.kt | 7 +- .../retrofit2/KotlinRequestFactoryTest.java | 6 +- .../java/retrofit2/KotlinSuspendRawTest.java | 3 +- .../test/java/retrofit2/KotlinSuspendTest.kt | 308 ++++++++++-------- .../java/retrofit2/RoboVmPlatformTest.java | 15 +- .../retrofit/ConditionalLoggingInterceptor.kt | 38 +-- 25 files changed, 473 insertions(+), 477 deletions(-) diff --git a/build.gradle b/build.gradle index d29acafda3..61d6356b4d 100644 --- a/build.gradle +++ b/build.gradle @@ -74,19 +74,19 @@ subprojects { signature 'net.sf.androidscents.signature:android-api-level-21:5.0.1_r2@signature' } } + } - plugins.apply('com.diffplug.spotless') - spotless { - java { - googleJavaFormat(libs.googleJavaFormat.get().version) - .formatJavadoc(false) - removeUnusedImports() - target 'src/*/java*/**/*.java' - } - kotlin { - ktfmt(libs.ktfmt.get().version).googleStyle() - target 'src/**/*.kt' - } + plugins.apply('com.diffplug.spotless') + spotless { + java { + googleJavaFormat(libs.googleJavaFormat.get().version) + .formatJavadoc(false) + removeUnusedImports() + target 'src/**/*.java' + } + kotlin { + ktfmt(libs.ktfmt.get().version).googleStyle() + target 'src/**/*.kt' } } } diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index fea3acec3f..c16ac0dfdf 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -42,7 +42,6 @@ import retrofit2.http.Streaming * When the method is also annotated with [@Streaming][retrofit2.http.Streaming], the adapter * streams the HTTP response body as Server-Sent Events, emitting each parsed [ServerSentEvent] to * the flow: - * * ```kotlin * interface Service { * @Streaming @@ -52,7 +51,6 @@ import retrofit2.http.Streaming * ``` * * Register this factory with [Retrofit.Builder.addCallAdapterFactory]: - * * ```kotlin * val retrofit = Retrofit.Builder() * .baseUrl(baseUrl) @@ -76,8 +74,7 @@ import retrofit2.http.Streaming class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { companion object { - @JvmStatic - fun create(): FlowCallAdapterFactory = FlowCallAdapterFactory() + @JvmStatic fun create(): FlowCallAdapterFactory = FlowCallAdapterFactory() } override fun get( @@ -92,13 +89,12 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { val callType = getParameterUpperBound(0, returnType) if (getRawType(callType) != Flow::class.java) return null if (callType !is ParameterizedType) { - error( - "Flow return type must be parameterized as Flow or Flow" - ) + error("Flow return type must be parameterized as Flow or Flow") } val elementType = getParameterUpperBound(0, callType) val responseType = if (isStreaming) ResponseBody::class.java else elementType - val eventSourceFactory = if (isStreaming) EventSources.createFactory(retrofit.callFactory()) else null + val eventSourceFactory = + if (isStreaming) EventSources.createFactory(retrofit.callFactory()) else null return SuspendFlowCallAdapter(responseType, isStreaming, eventSourceFactory) } } @@ -132,14 +128,12 @@ private class SuspendFlowCallAdapter( } /** - * A [Call] whose "response body" is a pre-built cold [Flow]. When enqueued it immediately - * delivers the flow to the callback so that Retrofit's `suspend` machinery can resume the coroutine - * with the flow value. The HTTP request is only started when the returned flow is collected. + * A [Call] whose "response body" is a pre-built cold [Flow]. When enqueued it immediately delivers + * the flow to the callback so that Retrofit's `suspend` machinery can resume the coroutine with the + * flow value. The HTTP request is only started when the returned flow is collected. */ -private class FlowAsCall( - private val delegate: Call, - private val flow: Flow<*>, -) : Call> { +private class FlowAsCall(private val delegate: Call, private val flow: Flow<*>) : + Call> { override fun enqueue(callback: Callback>) { callback.onResponse(this, Response.success(flow)) @@ -165,9 +159,9 @@ private class FlowAsCall( // --------------------------------------------------------------------------- /** - * Returns a cold [Flow] that, when collected, opens an OkHttp [EventSource] for the given - * [request] and emits each parsed [ServerSentEvent]. The connection is closed when the stream ends - * or the flow is cancelled. + * Returns a cold [Flow] that, when collected, opens an OkHttp [EventSource] for the given [request] + * and emits each parsed [ServerSentEvent]. The connection is closed when the stream ends or the + * flow is cancelled. */ private fun streamingFlow( request: Request, @@ -177,12 +171,7 @@ private fun streamingFlow( eventSourceFactory.newEventSource( request, object : EventSourceListener() { - override fun onEvent( - eventSource: EventSource, - id: String?, - type: String?, - data: String, - ) { + override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) { trySend(ServerSentEvent(id = id, event = type, data = data)) } @@ -195,12 +184,7 @@ private fun streamingFlow( t: Throwable?, response: okhttp3.Response?, ) { - close( - t - ?: response?.let { - HttpException(Response.error(it.body, it)) - } - ) + close(t ?: response?.let { HttpException(Response.error(it.body, it)) }) } }, ) @@ -212,28 +196,29 @@ private fun streamingFlow( * response body, and completes. Errors result in [HttpException] or [java.io.IOException]. */ private fun bodyFlow(call: Call): Flow = callbackFlow { - call.clone().enqueue( - object : Callback { - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - close(HttpException(response)) - return - } - val body = response.body() - if (body == null) { + call + .clone() + .enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + close(HttpException(response)) + return + } + val body = response.body() + if (body == null) { + close() + return + } + trySend(body) close() - return } - trySend(body) - close() - } - override fun onFailure(call: Call, t: Throwable) { - close(t) + override fun onFailure(call: Call, t: Throwable) { + close(t) + } } - } - ) + ) awaitClose { call.cancel() } } - diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt index 7ac49019a9..10452a06fb 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/ServerSentEvent.kt @@ -23,8 +23,4 @@ package retrofit2.adapter.flow * @property event The event type, or `null` if the default "message" type. * @property data The data payload. Multiple `data:` lines are joined with `\n`. */ -data class ServerSentEvent( - val id: String?, - val event: String?, - val data: String, -) +data class ServerSentEvent(val id: String?, val event: String?, val data: String) diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index 9c5bcb88d4..efe359927f 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -38,20 +38,18 @@ class FlowCallAdapterFactoryTest { @get:Rule val server = MockWebServer() interface Service { - @Streaming - @GET("/") - suspend fun sseEvents(): Flow + @Streaming @GET("/") suspend fun sseEvents(): Flow - @GET("/") - suspend fun body(): Flow + @GET("/") suspend fun body(): Flow } - private val retrofit get() = - Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(StringConverterFactory()) - .addCallAdapterFactory(FlowCallAdapterFactory.create()) - .build() + private val retrofit + get() = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(StringConverterFactory()) + .addCallAdapterFactory(FlowCallAdapterFactory.create()) + .build() // --------------------------------------------------------------------------- // SSE (suspend) @@ -59,53 +57,45 @@ class FlowCallAdapterFactoryTest { @Test fun sseEvents() = runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody( - "id: 1\nevent: ping\ndata: hello\n\n" + - "id: 2\ndata: world\n\n" - ) + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("id: 1\nevent: ping\ndata: hello\n\n" + "id: 2\ndata: world\n\n") + ) + val service = retrofit.create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly( + ServerSentEvent(id = "1", event = "ping", data = "hello"), + ServerSentEvent(id = "2", event = null, data = "world"), ) - val service = retrofit.create(Service::class.java) - val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = "1", event = "ping", data = "hello"), - ServerSentEvent(id = "2", event = null, data = "world"), - ) - .inOrder() + .inOrder() } @Test fun sseEventsMultilineData() = runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody("data: line one\ndata: line two\n\n") - ) - val service = retrofit.create(Service::class.java) - val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = null, event = null, data = "line one\nline two"), - ) + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("data: line one\ndata: line two\n\n") + ) + val service = retrofit.create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events) + .containsExactly(ServerSentEvent(id = null, event = null, data = "line one\nline two")) Unit } @Test fun sseEventsWithRetry() = runBlocking { - server.enqueue( - MockResponse() - .setHeader("Content-Type", "text/event-stream") - .setBody("retry: 3000\ndata: reconnect\n\n") - ) - val service = retrofit.create(Service::class.java) - val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = null, event = null, data = "reconnect"), - ) + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/event-stream") + .setBody("retry: 3000\ndata: reconnect\n\n") + ) + val service = retrofit.create(Service::class.java) + val events = service.sseEvents().toList() + assertThat(events).containsExactly(ServerSentEvent(id = null, event = null, data = "reconnect")) Unit } @@ -118,10 +108,7 @@ class FlowCallAdapterFactoryTest { ) val service = retrofit.create(Service::class.java) val events = service.sseEvents().toList() - assertThat(events) - .containsExactly( - ServerSentEvent(id = null, event = null, data = "real"), - ) + assertThat(events).containsExactly(ServerSentEvent(id = null, event = null, data = "real")) Unit } @@ -192,4 +179,3 @@ class FlowCallAdapterFactoryTest { } } } - diff --git a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/DeserializationStrategyConverter.kt b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/DeserializationStrategyConverter.kt index ee460083ec..ed91580b80 100644 --- a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/DeserializationStrategyConverter.kt +++ b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/DeserializationStrategyConverter.kt @@ -6,7 +6,7 @@ import retrofit2.Converter internal class DeserializationStrategyConverter( private val loader: DeserializationStrategy, - private val serializer: Serializer + private val serializer: Serializer, ) : Converter { override fun convert(value: ResponseBody) = serializer.fromResponseBody(loader, value) } diff --git a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Factory.kt b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Factory.kt index a4b9275e1b..e7c27eddf2 100644 --- a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Factory.kt +++ b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Factory.kt @@ -2,8 +2,6 @@ package retrofit2.converter.kotlinx.serialization -import retrofit2.converter.kotlinx.serialization.Serializer.FromBytes -import retrofit2.converter.kotlinx.serialization.Serializer.FromString import java.lang.reflect.Type import kotlinx.serialization.BinaryFormat import kotlinx.serialization.StringFormat @@ -12,16 +10,16 @@ import okhttp3.RequestBody import okhttp3.ResponseBody import retrofit2.Converter import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.Serializer.FromBytes +import retrofit2.converter.kotlinx.serialization.Serializer.FromString -internal class Factory( - private val contentType: MediaType, - private val serializer: Serializer -) : Converter.Factory() { +internal class Factory(private val contentType: MediaType, private val serializer: Serializer) : + Converter.Factory() { @Suppress("RedundantNullableReturnType") // Retaining interface contract. override fun responseBodyConverter( type: Type, annotations: Array, - retrofit: Retrofit + retrofit: Retrofit, ): Converter? { val loader = serializer.serializer(type) return DeserializationStrategyConverter(loader, serializer) @@ -32,7 +30,7 @@ internal class Factory( type: Type, parameterAnnotations: Array, methodAnnotations: Array, - retrofit: Retrofit + retrofit: Retrofit, ): Converter<*, RequestBody>? { val saver = serializer.serializer(type) return SerializationStrategyConverter(contentType, saver, serializer) @@ -42,9 +40,9 @@ internal class Factory( /** * Return a [Converter.Factory] which uses Kotlin serialization for string-based payloads. * - * Because Kotlin serialization is so flexible in the types it supports, this converter assumes - * that it can handle all types. If you are mixing this with something else, you must add this - * instance last to allow the other converters a chance to see their types. + * Because Kotlin serialization is so flexible in the types it supports, this converter assumes that + * it can handle all types. If you are mixing this with something else, you must add this instance + * last to allow the other converters a chance to see their types. */ @JvmName("create") fun StringFormat.asConverterFactory(contentType: MediaType): Converter.Factory { @@ -54,9 +52,9 @@ fun StringFormat.asConverterFactory(contentType: MediaType): Converter.Factory { /** * Return a [Converter.Factory] which uses Kotlin serialization for byte-based payloads. * - * Because Kotlin serialization is so flexible in the types it supports, this converter assumes - * that it can handle all types. If you are mixing this with something else, you must add this - * instance last to allow the other converters a chance to see their types. + * Because Kotlin serialization is so flexible in the types it supports, this converter assumes that + * it can handle all types. If you are mixing this with something else, you must add this instance + * last to allow the other converters a chance to see their types. */ @JvmName("create") fun BinaryFormat.asConverterFactory(contentType: MediaType): Converter.Factory { diff --git a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/SerializationStrategyConverter.kt b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/SerializationStrategyConverter.kt index 23e145d522..5cbc0b11c1 100644 --- a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/SerializationStrategyConverter.kt +++ b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/SerializationStrategyConverter.kt @@ -8,7 +8,7 @@ import retrofit2.Converter internal class SerializationStrategyConverter( private val contentType: MediaType, private val saver: SerializationStrategy, - private val serializer: Serializer + private val serializer: Serializer, ) : Converter { override fun convert(value: T) = serializer.toRequestBody(contentType, saver, value) } diff --git a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Serializer.kt b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Serializer.kt index a0dbe775c3..fb4033f163 100644 --- a/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Serializer.kt +++ b/retrofit-converters/kotlinx-serialization/src/main/java/retrofit2/converter/kotlinx/serialization/Serializer.kt @@ -15,7 +15,12 @@ import okhttp3.ResponseBody internal sealed class Serializer { abstract fun fromResponseBody(loader: DeserializationStrategy, body: ResponseBody): T - abstract fun toRequestBody(contentType: MediaType, saver: SerializationStrategy, value: T): RequestBody + + abstract fun toRequestBody( + contentType: MediaType, + saver: SerializationStrategy, + value: T, + ): RequestBody protected abstract val format: SerialFormat @@ -27,7 +32,11 @@ internal sealed class Serializer { return format.decodeFromString(loader, string) } - override fun toRequestBody(contentType: MediaType, saver: SerializationStrategy, value: T): RequestBody { + override fun toRequestBody( + contentType: MediaType, + saver: SerializationStrategy, + value: T, + ): RequestBody { val string = format.encodeToString(saver, value) return string.toRequestBody(contentType) } @@ -39,7 +48,11 @@ internal sealed class Serializer { return format.decodeFromByteArray(loader, bytes) } - override fun toRequestBody(contentType: MediaType, saver: SerializationStrategy, value: T): RequestBody { + override fun toRequestBody( + contentType: MediaType, + saver: SerializationStrategy, + value: T, + ): RequestBody { val bytes = format.encodeToByteArray(saver, value) return bytes.toRequestBody(contentType, 0, bytes.size) } diff --git a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryBytesTest.kt b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryBytesTest.kt index 2f53d1a2ff..eabb32d36f 100644 --- a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryBytesTest.kt +++ b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryBytesTest.kt @@ -4,7 +4,6 @@ import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoBuf import kotlinx.serialization.protobuf.ProtoNumber -import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -20,7 +19,8 @@ import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.POST -private val bobBytes = ByteString.of(0x0a, 0x03, 'B'.code.toByte(), 'o'.code.toByte(), 'b'.code.toByte()) +private val bobBytes = + ByteString.of(0x0a, 0x03, 'B'.code.toByte(), 'o'.code.toByte(), 'b'.code.toByte()) @ExperimentalSerializationApi class KotlinSerializationConverterFactoryBytesTest { @@ -30,28 +30,32 @@ class KotlinSerializationConverterFactoryBytesTest { interface Service { @GET("/") fun deserialize(): Call + @POST("/") fun serialize(@Body user: User): Call } - @Serializable - data class User(@ProtoNumber(1) val name: String) + @Serializable data class User(@ProtoNumber(1) val name: String) - @Before fun setUp() { + @Before + fun setUp() { val contentType = "application/x-protobuf".toMediaType() - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ProtoBuf.asConverterFactory(contentType)) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ProtoBuf.asConverterFactory(contentType)) + .build() service = retrofit.create(Service::class.java) } - @Test fun deserialize() { + @Test + fun deserialize() { server.enqueue(MockResponse().setBody(Buffer().write(bobBytes))) val user = service.deserialize().execute().body()!! assertEquals(User("Bob"), user) } - @Test fun serialize() { + @Test + fun serialize() { server.enqueue(MockResponse()) service.serialize(User("Bob")).execute() val request = server.takeRequest() diff --git a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryStringTest.kt b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryStringTest.kt index eff666b53a..9b52a45904 100644 --- a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryStringTest.kt +++ b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinSerializationConverterFactoryStringTest.kt @@ -2,7 +2,6 @@ package retrofit2.converter.kotlinx.serialization import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -23,28 +22,32 @@ class KotlinSerializationConverterFactoryStringTest { interface Service { @GET("/") fun deserialize(): Call + @POST("/") fun serialize(@Body user: User): Call } - @Serializable - data class User(val name: String) + @Serializable data class User(val name: String) - @Before fun setUp() { + @Before + fun setUp() { val contentType = "application/json; charset=utf-8".toMediaType() - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(Json.asConverterFactory(contentType)) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(Json.asConverterFactory(contentType)) + .build() service = retrofit.create(Service::class.java) } - @Test fun deserialize() { + @Test + fun deserialize() { server.enqueue(MockResponse().setBody("""{"name":"Bob"}""")) val user = service.deserialize().execute().body()!! assertEquals(User("Bob"), user) } - @Test fun serialize() { + @Test + fun serialize() { server.enqueue(MockResponse()) service.serialize(User("Bob")).execute() val request = server.takeRequest() diff --git a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualListTest.kt b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualListTest.kt index cb8ec1f789..ca5ce9904c 100644 --- a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualListTest.kt +++ b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualListTest.kt @@ -9,7 +9,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual -import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -24,17 +23,14 @@ import retrofit2.http.GET import retrofit2.http.POST class KotlinxSerializationConverterFactoryContextualListTest { - @get:Rule - val server = MockWebServer() + @get:Rule val server = MockWebServer() private lateinit var service: Service interface Service { - @GET("/") - fun deserialize(): Call> + @GET("/") fun deserialize(): Call> - @POST("/") - fun serialize(@Body users: List): Call + @POST("/") fun serialize(@Body users: List): Call } data class User(val name: String) @@ -43,30 +39,24 @@ class KotlinxSerializationConverterFactoryContextualListTest { override val descriptor = PrimitiveSerialDescriptor("User", PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): User = - decoder.decodeSerializableValue(UserResponse.serializer()).run { - User(name) - } + decoder.decodeSerializableValue(UserResponse.serializer()).run { User(name) } override fun serialize(encoder: Encoder, value: User): Unit = encoder.encodeSerializableValue(UserResponse.serializer(), UserResponse(value.name)) - @Serializable - private data class UserResponse(val name: String) + @Serializable private data class UserResponse(val name: String) } - private val json = Json { - serializersModule = SerializersModule { - contextual(UserSerializer) - } - } + private val json = Json { serializersModule = SerializersModule { contextual(UserSerializer) } } @Before fun setUp() { val contentType = "application/json; charset=utf-8".toMediaType() - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(json.asConverterFactory(contentType)) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() service = retrofit.create(Service::class.java) } diff --git a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualTest.kt b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualTest.kt index 99d94ace13..f5ca38a9f1 100644 --- a/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualTest.kt +++ b/retrofit-converters/kotlinx-serialization/src/test/java/retrofit2/converter/kotlinx/serialization/KotlinxSerializationConverterFactoryContextualTest.kt @@ -9,7 +9,6 @@ import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.contextual -import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaType import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -24,17 +23,14 @@ import retrofit2.http.GET import retrofit2.http.POST class KotlinxSerializationConverterFactoryContextualTest { - @get:Rule - val server = MockWebServer() + @get:Rule val server = MockWebServer() private lateinit var service: Service interface Service { - @GET("/") - fun deserialize(): Call + @GET("/") fun deserialize(): Call - @POST("/") - fun serialize(@Body user: User): Call + @POST("/") fun serialize(@Body user: User): Call } data class User(val name: String) @@ -43,30 +39,24 @@ class KotlinxSerializationConverterFactoryContextualTest { override val descriptor = PrimitiveSerialDescriptor("User", PrimitiveKind.STRING) override fun deserialize(decoder: Decoder): User = - decoder.decodeSerializableValue(UserResponse.serializer()).run { - User(name) - } + decoder.decodeSerializableValue(UserResponse.serializer()).run { User(name) } override fun serialize(encoder: Encoder, value: User): Unit = encoder.encodeSerializableValue(UserResponse.serializer(), UserResponse(value.name)) - @Serializable - private data class UserResponse(val name: String) + @Serializable private data class UserResponse(val name: String) } - private val json = Json { - serializersModule = SerializersModule { - contextual(UserSerializer) - } - } + private val json = Json { serializersModule = SerializersModule { contextual(UserSerializer) } } @Before fun setUp() { val contentType = "application/json; charset=utf-8".toMediaType() - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(json.asConverterFactory(contentType)) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() service = retrofit.create(Service::class.java) } diff --git a/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt index 72a66568b0..9669efde07 100644 --- a/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt +++ b/retrofit-response-type-keeper/src/main/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessor.kt @@ -27,27 +27,27 @@ import javax.tools.StandardLocation.CLASS_OUTPUT class RetrofitResponseTypeKeepProcessor : AbstractProcessor() { override fun getSupportedSourceVersion() = SourceVersion.latestSupported() - override fun getSupportedAnnotationTypes() = setOf( - "retrofit2.http.DELETE", - "retrofit2.http.GET", - "retrofit2.http.HEAD", - "retrofit2.http.HTTP", - "retrofit2.http.OPTIONS", - "retrofit2.http.PATCH", - "retrofit2.http.POST", - "retrofit2.http.PUT", - ) - override fun process( - annotations: Set, - roundEnv: RoundEnvironment, - ): Boolean { + override fun getSupportedAnnotationTypes() = + setOf( + "retrofit2.http.DELETE", + "retrofit2.http.GET", + "retrofit2.http.HEAD", + "retrofit2.http.HTTP", + "retrofit2.http.OPTIONS", + "retrofit2.http.PATCH", + "retrofit2.http.POST", + "retrofit2.http.PUT", + ) + + override fun process(annotations: Set, roundEnv: RoundEnvironment): Boolean { val elements = processingEnv.elementUtils val types = processingEnv.typeUtils - val methods = supportedAnnotationTypes - .mapNotNull(elements::getTypeElement) - .flatMap(roundEnv::getElementsAnnotatedWith) + val methods = + supportedAnnotationTypes + .mapNotNull(elements::getTypeElement) + .flatMap(roundEnv::getElementsAnnotatedWith) val elementToReferencedTypes = mutableMapOf>() for (method in methods) { diff --git a/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt b/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt index 23cc38bb9c..b8be547918 100644 --- a/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt +++ b/retrofit-response-type-keeper/src/test/kotlin/retrofit2/keeper/RetrofitResponseTypeKeepProcessorTest.kt @@ -25,34 +25,36 @@ import org.junit.Test class RetrofitResponseTypeKeepProcessorTest { @Test fun allHttpMethods() { - val service = JavaFileObjects.forSourceString( - "test.Service", - """ - package test; - import retrofit2.*; - import retrofit2.http.*; + val service = + JavaFileObjects.forSourceString( + "test.Service", + """ + package test; + import retrofit2.*; + import retrofit2.http.*; - class DeleteUser {} - class GetUser {} - class HeadUser {} - class HttpUser {} - class OptionsUser {} - class PatchUser {} - class PostUser {} - class PutUser {} + class DeleteUser {} + class GetUser {} + class HeadUser {} + class HttpUser {} + class OptionsUser {} + class PatchUser {} + class PostUser {} + class PutUser {} - interface Service { - @DELETE("/") Call delete(); - @GET("/") Call get(); - @HEAD("/") Call head(); - @HTTP(method = "CUSTOM", path = "/") Call http(); - @OPTIONS("/") Call options(); - @PATCH("/") Call patch(); - @POST("/") Call post(); - @PUT("/") Call put(); - } - """.trimIndent(), - ) + interface Service { + @DELETE("/") Call delete(); + @GET("/") Call get(); + @HEAD("/") Call head(); + @HTTP(method = "CUSTOM", path = "/") Call http(); + @OPTIONS("/") Call options(); + @PATCH("/") Call patch(); + @POST("/") Call post(); + @PUT("/") Call put(); + } + """ + .trimIndent(), + ) assertAbout(javaSource()) .that(service) @@ -63,7 +65,8 @@ class RetrofitResponseTypeKeepProcessorTest { CLASS_OUTPUT, "", "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", - ).withStringContents( + ) + .withStringContents( UTF_8, """ |# test.Service @@ -76,29 +79,31 @@ class RetrofitResponseTypeKeepProcessorTest { |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PatchUser |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PostUser |-keep,allowoptimization,allowshrinking,allowobfuscation class test.PutUser - | - """.trimMargin(), + |""" + .trimMargin(), ) } @Test fun nesting() { - val service = JavaFileObjects.forSourceString( - "test.Service", - """ - package test; - import retrofit2.*; - import retrofit2.http.*; + val service = + JavaFileObjects.forSourceString( + "test.Service", + """ + package test; + import retrofit2.*; + import retrofit2.http.*; - class One {} - class Two {} - class Three {} + class One {} + class Two {} + class Three {} - interface Service { - @GET("/") Call>> get(); - } - """.trimIndent(), - ) + interface Service { + @GET("/") Call>> get(); + } + """ + .trimIndent(), + ) assertAbout(javaSource()) .that(service) @@ -109,7 +114,8 @@ class RetrofitResponseTypeKeepProcessorTest { CLASS_OUTPUT, "", "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", - ).withStringContents( + ) + .withStringContents( UTF_8, """ |# test.Service @@ -117,28 +123,30 @@ class RetrofitResponseTypeKeepProcessorTest { |-keep,allowoptimization,allowshrinking,allowobfuscation class test.One |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Three |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Two - | - """.trimMargin(), + |""" + .trimMargin(), ) } @Test fun kotlinSuspend() { - val service = JavaFileObjects.forSourceString( - "test.Service", - """ - package test; - import kotlin.coroutines.Continuation; - import retrofit2.*; - import retrofit2.http.*; + val service = + JavaFileObjects.forSourceString( + "test.Service", + """ + package test; + import kotlin.coroutines.Continuation; + import retrofit2.*; + import retrofit2.http.*; - class Body {} + class Body {} - interface Service { - @GET("/") Object get(Continuation c); - } - """.trimIndent(), - ) + interface Service { + @GET("/") Object get(Continuation c); + } + """ + .trimIndent(), + ) assertAbout(javaSource()) .that(service) @@ -149,14 +157,15 @@ class RetrofitResponseTypeKeepProcessorTest { CLASS_OUTPUT, "", "META-INF/proguard/retrofit-response-type-keeper-test.Service.pro", - ).withStringContents( + ) + .withStringContents( UTF_8, """ |# test.Service |-keep,allowoptimization,allowshrinking,allowobfuscation class java.lang.Object |-keep,allowoptimization,allowshrinking,allowobfuscation class test.Body - | - """.trimMargin(), + |""" + .trimMargin(), ) } } diff --git a/retrofit/android-test/src/androidTest/java/retrofit2/BasicCallTest.java b/retrofit/android-test/src/androidTest/java/retrofit2/BasicCallTest.java index cd2b8eeec7..ebdb808af1 100644 --- a/retrofit/android-test/src/androidTest/java/retrofit2/BasicCallTest.java +++ b/retrofit/android-test/src/androidTest/java/retrofit2/BasicCallTest.java @@ -15,6 +15,8 @@ */ package retrofit2; +import static org.junit.Assert.assertEquals; + import java.io.IOException; import okhttp3.ResponseBody; import okhttp3.mockwebserver.MockResponse; @@ -23,19 +25,17 @@ import org.junit.Test; import retrofit2.http.GET; -import static org.junit.Assert.assertEquals; - public final class BasicCallTest { @Rule public final MockWebServer server = new MockWebServer(); interface Service { - @GET("/") Call getBody(); + @GET("/") + Call getBody(); } - @Test public void responseBody() throws IOException { - Retrofit retrofit = new Retrofit.Builder() - .baseUrl(server.url("/")) - .build(); + @Test + public void responseBody() throws IOException { + Retrofit retrofit = new Retrofit.Builder().baseUrl(server.url("/")).build(); Service example = retrofit.create(Service.class); server.enqueue(new MockResponse().setBody("1234")); diff --git a/retrofit/android-test/src/androidTest/java/retrofit2/CompletableFutureAndroidTest.java b/retrofit/android-test/src/androidTest/java/retrofit2/CompletableFutureAndroidTest.java index 64f5c068c4..02bd014b4a 100644 --- a/retrofit/android-test/src/androidTest/java/retrofit2/CompletableFutureAndroidTest.java +++ b/retrofit/android-test/src/androidTest/java/retrofit2/CompletableFutureAndroidTest.java @@ -15,6 +15,8 @@ */ package retrofit2; +import static com.google.common.truth.Truth.assertThat; + import androidx.test.filters.SdkSuppress; import java.util.concurrent.CompletableFuture; import okhttp3.mockwebserver.MockResponse; @@ -25,8 +27,6 @@ import retrofit2.helpers.ToStringConverterFactory; import retrofit2.http.GET; -import static com.google.common.truth.Truth.assertThat; - @SdkSuppress(minSdkVersion = 24) public final class CompletableFutureAndroidTest { @Rule public final MockWebServer server = new MockWebServer(); diff --git a/retrofit/android-test/src/androidTest/java/retrofit2/DefaultMethodsAndroidTest.java b/retrofit/android-test/src/androidTest/java/retrofit2/DefaultMethodsAndroidTest.java index 833253abd6..a0ea00fd77 100644 --- a/retrofit/android-test/src/androidTest/java/retrofit2/DefaultMethodsAndroidTest.java +++ b/retrofit/android-test/src/androidTest/java/retrofit2/DefaultMethodsAndroidTest.java @@ -15,6 +15,9 @@ */ package retrofit2; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.fail; + import androidx.test.filters.SdkSuppress; import java.io.IOException; import okhttp3.mockwebserver.MockResponse; @@ -25,9 +28,6 @@ import retrofit2.http.GET; import retrofit2.http.Query; -import static com.google.common.truth.Truth.assertThat; -import static org.junit.Assert.fail; - public final class DefaultMethodsAndroidTest { @Rule public final MockWebServer server = new MockWebServer(); @@ -54,7 +54,9 @@ public void failsOnApi24And25() { example.user(); fail(); } catch (UnsupportedOperationException e) { - assertThat(e).hasMessageThat().isEqualTo("Calling default methods on API 24 and 25 is not supported"); + assertThat(e) + .hasMessageThat() + .isEqualTo("Calling default methods on API 24 and 25 is not supported"); } } diff --git a/retrofit/android-test/src/androidTest/java/retrofit2/OptionalConverterFactoryAndroidTest.java b/retrofit/android-test/src/androidTest/java/retrofit2/OptionalConverterFactoryAndroidTest.java index 481eb469d3..79482d68ee 100644 --- a/retrofit/android-test/src/androidTest/java/retrofit2/OptionalConverterFactoryAndroidTest.java +++ b/retrofit/android-test/src/androidTest/java/retrofit2/OptionalConverterFactoryAndroidTest.java @@ -15,6 +15,8 @@ */ package retrofit2; +import static com.google.common.truth.Truth.assertThat; + import androidx.test.filters.SdkSuppress; import java.io.IOException; import java.util.Optional; @@ -26,8 +28,6 @@ import retrofit2.helpers.ObjectInstanceConverterFactory; import retrofit2.http.GET; -import static com.google.common.truth.Truth.assertThat; - @SdkSuppress(minSdkVersion = 24) public final class OptionalConverterFactoryAndroidTest { interface Service { diff --git a/retrofit/android-test/src/androidTest/java/retrofit2/UriAndroidTest.java b/retrofit/android-test/src/androidTest/java/retrofit2/UriAndroidTest.java index 5ba5654754..e430dc8863 100644 --- a/retrofit/android-test/src/androidTest/java/retrofit2/UriAndroidTest.java +++ b/retrofit/android-test/src/androidTest/java/retrofit2/UriAndroidTest.java @@ -15,6 +15,8 @@ */ package retrofit2; +import static com.google.common.truth.Truth.assertThat; + import android.net.Uri; import java.io.IOException; import okhttp3.HttpUrl; @@ -27,8 +29,6 @@ import retrofit2.http.GET; import retrofit2.http.Url; -import static com.google.common.truth.Truth.assertThat; - public final class UriAndroidTest { @Rule public final MockWebServer server1 = new MockWebServer(); @Rule public final MockWebServer server2 = new MockWebServer(); @@ -42,10 +42,7 @@ interface Service { @Before public void setUp() { - Retrofit retrofit = - new Retrofit.Builder() - .baseUrl(server1.url("/")) - .build(); + Retrofit retrofit = new Retrofit.Builder().baseUrl(server1.url("/")).build(); service = retrofit.create(Service.class); } diff --git a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinExtensionsTest.kt b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinExtensionsTest.kt index 9fddf20cd2..15a3d5431e 100644 --- a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinExtensionsTest.kt +++ b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinExtensionsTest.kt @@ -25,10 +25,9 @@ class KotlinExtensionsTest { interface Empty - @Test fun reifiedCreate() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .build() + @Test + fun reifiedCreate() { + val retrofit = Retrofit.Builder().baseUrl(server.url("/")).build() assertNotNull(retrofit.create()) } diff --git a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinRequestFactoryTest.java b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinRequestFactoryTest.java index 56f9d80eb8..669897a6d3 100644 --- a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinRequestFactoryTest.java +++ b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinRequestFactoryTest.java @@ -1,13 +1,13 @@ package retrofit2; +import static com.google.common.truth.Truth.assertThat; +import static retrofit2.TestingUtils.buildRequest; + import kotlin.Unit; import okhttp3.Request; import org.junit.Test; import retrofit2.http.HEAD; -import static com.google.common.truth.Truth.assertThat; -import static retrofit2.TestingUtils.buildRequest; - public final class KotlinRequestFactoryTest { @Test public void headUnit() { diff --git a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendRawTest.java b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendRawTest.java index 3c839274e2..79f910ff74 100644 --- a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendRawTest.java +++ b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendRawTest.java @@ -46,7 +46,8 @@ public void raw() { fail(); } catch (IllegalArgumentException e) { assertThat(e) - .hasMessageThat().isEqualTo( + .hasMessageThat() + .isEqualTo( "Response must include generic type (e.g., Response)\n" + " for method Service.body"); } diff --git a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt index 41d3d5ba61..6a37feef92 100644 --- a/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt +++ b/retrofit/kotlin-test/src/test/java/retrofit2/KotlinSuspendTest.kt @@ -44,37 +44,29 @@ class KotlinSuspendTest { @get:Rule val server = MockWebServer() interface Service { - @GET("/") - suspend fun body(): String + @GET("/") suspend fun body(): String - @GET("/") - suspend fun bodyNullable(): String? + @GET("/") suspend fun bodyNullable(): String? - @GET("/") - suspend fun response(): Response + @GET("/") suspend fun response(): Response - @GET("/") - suspend fun unit() + @GET("/") suspend fun unit() - @HEAD("/") - suspend fun headUnit() + @HEAD("/") suspend fun headUnit() @GET("/{a}/{b}/{c}") - suspend fun params( - @Path("a") a: String, - @Path("b") b: String, - @Path("c") c: String, - ): String - - @GET("/") - suspend fun bodyWithCallType(): Call + suspend fun params(@Path("a") a: String, @Path("b") b: String, @Path("c") c: String): String + + @GET("/") suspend fun bodyWithCallType(): Call } - @Test fun body() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun body() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setBody("Hi")) @@ -83,11 +75,13 @@ class KotlinSuspendTest { assertThat(body).isEqualTo("Hi") } - @Test fun body404() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun body404() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setResponseCode(404)) @@ -100,11 +94,13 @@ class KotlinSuspendTest { } } - @Test fun bodyFailure() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun bodyFailure() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setSocketPolicy(DISCONNECT_AFTER_REQUEST)) @@ -112,15 +108,16 @@ class KotlinSuspendTest { try { runBlocking { example.body() } fail() - } catch (e: IOException) { - } + } catch (e: IOException) {} } - @Test fun bodyThrowsOnNull() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun bodyThrowsOnNull() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setResponseCode(204)) @@ -131,19 +128,21 @@ class KotlinSuspendTest { } catch (e: KotlinNullPointerException) { // Coroutines wraps exceptions with a synthetic trace so fall back to cause message. val message = e.message ?: (e.cause as KotlinNullPointerException).message - assertThat(message).isEqualTo( - "Response from retrofit2.KotlinSuspendTest\$Service.body was null but response body type was declared as non-null", - ) + assertThat(message) + .isEqualTo( + "Response from retrofit2.KotlinSuspendTest\$Service.body was null but response body type was declared as non-null" + ) } } @Ignore("Not working yet") @Test fun bodyNullable() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setResponseCode(204)) @@ -152,11 +151,13 @@ class KotlinSuspendTest { assertThat(body).isNull() } - @Test fun response() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun response() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setBody("Hi")) @@ -166,11 +167,13 @@ class KotlinSuspendTest { assertThat(response.body()).isEqualTo("Hi") } - @Test fun response404() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun response404() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setResponseCode(404)) @@ -179,11 +182,13 @@ class KotlinSuspendTest { assertThat(response.code()).isEqualTo(404) } - @Test fun responseFailure() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun responseFailure() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setSocketPolicy(DISCONNECT_AFTER_REQUEST)) @@ -191,36 +196,40 @@ class KotlinSuspendTest { try { runBlocking { example.response() } fail() - } catch (e: IOException) { - } + } catch (e: IOException) {} } - @Test fun unit() { + @Test + fun unit() { val retrofit = Retrofit.Builder().baseUrl(server.url("/")).build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setBody("Unit")) runBlocking { example.unit() } } - @Test fun unitNullableBody() { + @Test + fun unitNullableBody() { val retrofit = Retrofit.Builder().baseUrl(server.url("/")).build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setResponseCode(204)) runBlocking { example.unit() } } - @Test fun headUnit() { + @Test + fun headUnit() { val retrofit = Retrofit.Builder().baseUrl(server.url("/")).build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse()) runBlocking { example.headUnit() } } - @Test fun params() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun params() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse()) @@ -230,19 +239,21 @@ class KotlinSuspendTest { assertThat(request.path).isEqualTo("/1/2/3") } - @Test fun cancelationWorks() { + @Test + fun cancelationWorks() { lateinit var call: okhttp3.Call val okHttpClient = OkHttpClient() - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .callFactory { - val newCall = okHttpClient.newCall(it) - call = newCall - newCall - } - .addConverterFactory(ToStringConverterFactory()) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .callFactory { + val newCall = okHttpClient.newCall(it) + call = newCall + newCall + } + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) // This leaves the connection open indefinitely allowing us to cancel without racing a body. @@ -257,12 +268,14 @@ class KotlinSuspendTest { assertTrue(call.isCanceled()) } - @Test fun doesNotUseCallbackExecutor() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .callbackExecutor { fail() } - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun doesNotUseCallbackExecutor() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .callbackExecutor { fail() } + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setBody("Hi")) @@ -271,45 +284,51 @@ class KotlinSuspendTest { assertThat(body).isEqualTo("Hi") } - @Test fun usesCallAdapterForCall() { - val callAdapterFactory = object : CallAdapter.Factory() { - override fun get( - returnType: Type, - annotations: Array, - retrofit: Retrofit, - ): CallAdapter<*, *>? { - if (getRawType(returnType) != Call::class.java) { - return null - } - if (getParameterUpperBound(0, returnType as ParameterizedType) != String::class.java) { - return null - } - return object : CallAdapter> { - override fun responseType() = String::class.java - override fun adapt(call: Call): Call { - return object : Call by call { - override fun enqueue(callback: Callback) { - call.enqueue(object : Callback by callback { - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful) { - callback.onResponse(call, Response.success(response.body()?.repeat(5))) - } else { - callback.onResponse(call, response) + @Test + fun usesCallAdapterForCall() { + val callAdapterFactory = + object : CallAdapter.Factory() { + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + if (getRawType(returnType) != Call::class.java) { + return null + } + if (getParameterUpperBound(0, returnType as ParameterizedType) != String::class.java) { + return null + } + return object : CallAdapter> { + override fun responseType() = String::class.java + + override fun adapt(call: Call): Call { + return object : Call by call { + override fun enqueue(callback: Callback) { + call.enqueue( + object : Callback by callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + callback.onResponse(call, Response.success(response.body()?.repeat(5))) + } else { + callback.onResponse(call, response) + } + } } - } - }) + ) + } } } } } } - } - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addCallAdapterFactory(callAdapterFactory) - .addConverterFactory(ToStringConverterFactory()) - .build() + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addCallAdapterFactory(callAdapterFactory) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.enqueue(MockResponse().setBody("Hi")) @@ -318,11 +337,13 @@ class KotlinSuspendTest { assertThat(body).isEqualTo("HiHiHiHiHi") } - @Test fun checkedExceptionsAreNotSynchronouslyThrownForBody() = runBlocking { - val retrofit = Retrofit.Builder() - .baseUrl("https://unresolved-host.com/") - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun checkedExceptionsAreNotSynchronouslyThrownForBody() = runBlocking { + val retrofit = + Retrofit.Builder() + .baseUrl("https://unresolved-host.com/") + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.shutdown() @@ -344,11 +365,13 @@ class KotlinSuspendTest { } } - @Test fun checkedExceptionsAreNotSynchronouslyThrownForResponse() = runBlocking { - val retrofit = Retrofit.Builder() - .baseUrl("https://unresolved-host.com/") - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun checkedExceptionsAreNotSynchronouslyThrownForResponse() = runBlocking { + val retrofit = + Retrofit.Builder() + .baseUrl("https://unresolved-host.com/") + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) server.shutdown() @@ -370,28 +393,33 @@ class KotlinSuspendTest { } } - @Test fun rejectCallReturnTypeWhenUsingSuspend() { - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(ToStringConverterFactory()) - .build() + @Test + fun rejectCallReturnTypeWhenUsingSuspend() { + val retrofit = + Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(ToStringConverterFactory()) + .build() val example = retrofit.create(Service::class.java) try { runBlocking { example.bodyWithCallType() } fail() } catch (e: IllegalArgumentException) { - assertThat(e).hasMessageThat().isEqualTo( - "Suspend functions should not return Call, as they already execute asynchronously.\n" + - "Change its return type to class java.lang.String\n" + - " for method Service.bodyWithCallType", - ) + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Suspend functions should not return Call, as they already execute asynchronously.\n" + + "Change its return type to class java.lang.String\n" + + " for method Service.bodyWithCallType" + ) } } @Suppress("EXPERIMENTAL_OVERRIDE") private object DirectUnconfinedDispatcher : CoroutineDispatcher() { override fun isDispatchNeeded(context: CoroutineContext): Boolean = false + override fun dispatch(context: CoroutineContext, block: Runnable) = block.run() } } diff --git a/retrofit/robovm-test/src/main/java/retrofit2/RoboVmPlatformTest.java b/retrofit/robovm-test/src/main/java/retrofit2/RoboVmPlatformTest.java index 1b09bd48d6..c878be1236 100644 --- a/retrofit/robovm-test/src/main/java/retrofit2/RoboVmPlatformTest.java +++ b/retrofit/robovm-test/src/main/java/retrofit2/RoboVmPlatformTest.java @@ -17,10 +17,14 @@ public final class RoboVmPlatformTest { public static void main(String[] args) { - Retrofit retrofit = new Retrofit.Builder() - .baseUrl("https://example.com") - .callFactory(c -> { throw new AssertionError(); }) - .build(); + Retrofit retrofit = + new Retrofit.Builder() + .baseUrl("https://example.com") + .callFactory( + c -> { + throw new AssertionError(); + }) + .build(); if (retrofit.callAdapterFactories().size() > 1) { // Everyone gets the callback executor adapter. If RoboVM was correctly detected it will NOT @@ -29,6 +33,5 @@ public static void main(String[] args) { } } - private RoboVmPlatformTest() { - } + private RoboVmPlatformTest() {} } diff --git a/samples/src/main/java/com/example/retrofit/ConditionalLoggingInterceptor.kt b/samples/src/main/java/com/example/retrofit/ConditionalLoggingInterceptor.kt index f8e29f3f0f..fe4e08eae4 100644 --- a/samples/src/main/java/com/example/retrofit/ConditionalLoggingInterceptor.kt +++ b/samples/src/main/java/com/example/retrofit/ConditionalLoggingInterceptor.kt @@ -29,19 +29,15 @@ import retrofit2.http.GET suspend fun main() { val server = MockWebServer() - val client = OkHttpClient.Builder() - .addInterceptor( - ConditionalLoggingInterceptor( - HttpLoggingInterceptor(::println).setLevel( - HttpLoggingInterceptor.Level.BODY, - ), - ), - ) - .build() - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .client(client) - .build() + val client = + OkHttpClient.Builder() + .addInterceptor( + ConditionalLoggingInterceptor( + HttpLoggingInterceptor(::println).setLevel(HttpLoggingInterceptor.Level.BODY) + ) + ) + .build() + val retrofit = Retrofit.Builder().baseUrl(server.url("/")).client(client).build() val exampleApi = retrofit.create() server.enqueue(MockResponse()) @@ -52,23 +48,19 @@ suspend fun main() { } private interface ExampleApi { - @GET("one") - suspend fun one(): ResponseBody + @GET("one") suspend fun one(): ResponseBody - @Log - @GET("two") - suspend fun two(): ResponseBody + @Log @GET("two") suspend fun two(): ResponseBody } /** - * Retrofit service functions which are annotated with this class will have their HTTP calls - * logged. You must add [ConditionalLoggingInterceptor] to your [OkHttpClient] for this to work. + * Retrofit service functions which are annotated with this class will have their HTTP calls logged. + * You must add [ConditionalLoggingInterceptor] to your [OkHttpClient] for this to work. */ annotation class Log -class ConditionalLoggingInterceptor( - private val loggingInterceptor: HttpLoggingInterceptor, -) : Interceptor { +class ConditionalLoggingInterceptor(private val loggingInterceptor: HttpLoggingInterceptor) : + Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() request.tag(Invocation::class.java)?.let { invocation -> From 78ef7602d344955b4bff401eaf6832e21b441678 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 7 Apr 2026 15:35:57 +0800 Subject: [PATCH 12/19] Fix typo --- .../main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index c16ac0dfdf..54aa3897dc 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -161,7 +161,7 @@ private class FlowAsCall(private val delegate: Call, private val flow: Flo /** * Returns a cold [Flow] that, when collected, opens an OkHttp [EventSource] for the given [request] * and emits each parsed [ServerSentEvent]. The connection is closed when the stream ends or the - * flow is cancelled. + * flow is canceled. */ private fun streamingFlow( request: Request, From 0774cbb2093eee19faa6cf31baa81e145d6cf245 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 7 Apr 2026 15:40:18 +0800 Subject: [PATCH 13/19] Remove comments --- .../adapter/flow/FlowCallAdapterFactory.kt | 13 ------------- .../adapter/flow/FlowCallAdapterFactoryTest.kt | 12 ------------ 2 files changed, 25 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 54aa3897dc..b211cd2cc3 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -99,15 +99,6 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { } } -// --------------------------------------------------------------------------- -// Suspend adapter: adapt(Call) → Call> (or Call> for SSE) -// -// Retrofit's SuspendForBody calls callAdapter.adapt(call) expecting a Call, then -// calls KotlinExtensions.await() on it. We return a lightweight wrapper Call that, when -// enqueued, immediately delivers a cold Flow as the response body without starting the HTTP -// request yet. The actual HTTP request is deferred until the flow is collected. -// --------------------------------------------------------------------------- - private class SuspendFlowCallAdapter( private val _responseType: Type, private val isStreaming: Boolean, @@ -154,10 +145,6 @@ private class FlowAsCall(private val delegate: Call, private val flow: Flo override fun timeout(): Timeout = delegate.timeout() } -// --------------------------------------------------------------------------- -// Flow builders -// --------------------------------------------------------------------------- - /** * Returns a cold [Flow] that, when collected, opens an OkHttp [EventSource] for the given [request] * and emits each parsed [ServerSentEvent]. The connection is closed when the stream ends or the diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index efe359927f..f4a297cc4b 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -51,10 +51,6 @@ class FlowCallAdapterFactoryTest { .addCallAdapterFactory(FlowCallAdapterFactory.create()) .build() - // --------------------------------------------------------------------------- - // SSE (suspend) - // --------------------------------------------------------------------------- - @Test fun sseEvents() = runBlocking { server.enqueue( @@ -136,10 +132,6 @@ class FlowCallAdapterFactoryTest { } } - // --------------------------------------------------------------------------- - // Non-SSE body flow (suspend) - // --------------------------------------------------------------------------- - @Test fun bodyFlow() = runBlocking { server.enqueue(MockResponse().setBody("hello")) @@ -161,10 +153,6 @@ class FlowCallAdapterFactoryTest { } } - // --------------------------------------------------------------------------- - // Converter factory that converts ResponseBody to String - // --------------------------------------------------------------------------- - private class StringConverterFactory : Converter.Factory() { override fun responseBodyConverter( type: Type, From 936b701a43198c90b532a4f79864afac0afd153e Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 7 Apr 2026 15:54:21 +0800 Subject: [PATCH 14/19] R extends Any --- .../java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index b211cd2cc3..2c347fea7c 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -99,7 +99,7 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { } } -private class SuspendFlowCallAdapter( +private class SuspendFlowCallAdapter( private val _responseType: Type, private val isStreaming: Boolean, private val eventSourceFactory: EventSource.Factory?, @@ -123,7 +123,7 @@ private class SuspendFlowCallAdapter( * the flow to the callback so that Retrofit's `suspend` machinery can resume the coroutine with the * flow value. The HTTP request is only started when the returned flow is collected. */ -private class FlowAsCall(private val delegate: Call, private val flow: Flow<*>) : +private class FlowAsCall(private val delegate: Call, private val flow: Flow<*>) : Call> { override fun enqueue(callback: Callback>) { @@ -182,7 +182,7 @@ private fun streamingFlow( * Returns a cold [Flow] that, when collected, makes the HTTP call, emits the single converted * response body, and completes. Errors result in [HttpException] or [java.io.IOException]. */ -private fun bodyFlow(call: Call): Flow = callbackFlow { +private fun bodyFlow(call: Call): Flow = callbackFlow { call .clone() .enqueue( From 67cbbdf407573ed79edecb46b6b3d29a702cf6b7 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 7 Apr 2026 16:38:21 +0800 Subject: [PATCH 15/19] Remove redundant type --- .../main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 2c347fea7c..1793616b4d 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -108,7 +108,7 @@ private class SuspendFlowCallAdapter( override fun responseType(): Type = _responseType override fun adapt(call: Call): Call> { - val flow: Flow<*> = + val flow = if (isStreaming) { streamingFlow(call.request(), requireNotNull(eventSourceFactory)) } else { From 962395a4188573e8c7e99096cd0232645d25161b Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 7 Apr 2026 16:44:39 +0800 Subject: [PATCH 16/19] Update error message --- .../java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 1793616b4d..dcabcfea0d 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -89,7 +89,9 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { val callType = getParameterUpperBound(0, returnType) if (getRawType(callType) != Flow::class.java) return null if (callType !is ParameterizedType) { - error("Flow return type must be parameterized as Flow or Flow") + error( + "Flow return type must be parameterized as Flow, Flow, or Flow" + ) } val elementType = getParameterUpperBound(0, callType) val responseType = if (isStreaming) ResponseBody::class.java else elementType From 5d094dab7c8f205604f297f422a0ab7fd3bfdc32 Mon Sep 17 00:00:00 2001 From: Goooler Date: Tue, 7 Apr 2026 16:52:23 +0800 Subject: [PATCH 17/19] Call checkNotNull --- .../main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index dcabcfea0d..70fc4385e2 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -112,7 +112,7 @@ private class SuspendFlowCallAdapter( override fun adapt(call: Call): Call> { val flow = if (isStreaming) { - streamingFlow(call.request(), requireNotNull(eventSourceFactory)) + streamingFlow(call.request(), checkNotNull(eventSourceFactory)) } else { bodyFlow(call) } From 0db231b4c79d38eca410bdd1d6be5f27e68d01ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:07:57 +0000 Subject: [PATCH 18/19] fix: address PR review feedback in FlowCallAdapterFactory Agent-Logs-Url: https://github.com/Goooler/retrofit/sessions/9b23277e-adee-427c-bf19-4ca25610506c Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../adapter/flow/FlowCallAdapterFactory.kt | 52 +++++++++++-------- .../flow/FlowCallAdapterFactoryTest.kt | 30 +++++++++++ 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 70fc4385e2..250d22c1aa 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -94,6 +94,11 @@ class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { ) } val elementType = getParameterUpperBound(0, callType) + if (isStreaming && getRawType(elementType) != ServerSentEvent::class.java) { + error( + "@Streaming on a Flow return type requires Flow, but found Flow<$elementType>" + ) + } val responseType = if (isStreaming) ResponseBody::class.java else elementType val eventSourceFactory = if (isStreaming) EventSources.createFactory(retrofit.callFactory()) else null @@ -161,7 +166,11 @@ private fun streamingFlow( request, object : EventSourceListener() { override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) { - trySend(ServerSentEvent(id = id, event = type, data = data)) + val result = trySend(ServerSentEvent(id = id, event = type, data = data)) + if (result.isFailure) { + eventSource.cancel() + close(result.exceptionOrNull()) + } } override fun onClosed(eventSource: EventSource) { @@ -185,29 +194,28 @@ private fun streamingFlow( * response body, and completes. Errors result in [HttpException] or [java.io.IOException]. */ private fun bodyFlow(call: Call): Flow = callbackFlow { - call - .clone() - .enqueue( - object : Callback { - override fun onResponse(call: Call, response: Response) { - if (!response.isSuccessful) { - close(HttpException(response)) - return - } - val body = response.body() - if (body == null) { - close() - return - } - trySend(body) - close() + val flowCall = call.clone() + flowCall.enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + if (!response.isSuccessful) { + close(HttpException(response)) + return } - - override fun onFailure(call: Call, t: Throwable) { - close(t) + val body = response.body() + if (body == null) { + close(NullPointerException("Response body of a suspend fun was null")) + return } + trySend(body) + close() } - ) - awaitClose { call.cancel() } + override fun onFailure(call: Call, t: Throwable) { + close(t) + } + } + ) + + awaitClose { flowCall.cancel() } } diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index f4a297cc4b..334c3aad18 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -43,6 +43,11 @@ class FlowCallAdapterFactoryTest { @GET("/") suspend fun body(): Flow } + /** Service method that incorrectly uses @Streaming with a non-ServerSentEvent element type. */ + interface BadStreamingService { + @Streaming @GET("/") suspend fun events(): Flow + } + private val retrofit get() = Retrofit.Builder() @@ -153,6 +158,31 @@ class FlowCallAdapterFactoryTest { } } + @Test + fun bodyFlowNullBodyFails() = runBlocking { + // 204 No Content — response body is null + server.enqueue(MockResponse().setResponseCode(204)) + val service = retrofit.create(Service::class.java) + try { + service.body().toList() + fail("Expected NullPointerException") + } catch (_: NullPointerException) { + // expected + } + } + + @Test + fun streamingWithWrongElementTypeThrows() = runBlocking { + val service = retrofit.create(BadStreamingService::class.java) + try { + service.events() + fail("Expected IllegalArgumentException for @Streaming with non-ServerSentEvent element type") + } catch (e: IllegalArgumentException) { + // Retrofit wraps the IllegalStateException from our factory in IllegalArgumentException. + assertThat(e).hasCauseThat().hasMessageThat().contains("Flow") + } + } + private class StringConverterFactory : Converter.Factory() { override fun responseBodyConverter( type: Type, From e36eb1bbfaac9c17896b4cb3212b2c4ca93ecfab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:29:13 +0000 Subject: [PATCH 19/19] fix: apply second round of PR review feedback Agent-Logs-Url: https://github.com/Goooler/retrofit/sessions/bf16f351-1ef4-4553-8caa-fc672d63dd47 Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../adapter/flow/FlowCallAdapterFactory.kt | 26 +++++++++++++++++-- .../flow/FlowCallAdapterFactoryTest.kt | 11 +++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt index 250d22c1aa..d82cf94780 100644 --- a/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt +++ b/retrofit-adapters/kotlin-flow/src/main/java/retrofit2/adapter/flow/FlowCallAdapterFactory.kt @@ -15,6 +15,7 @@ */ package retrofit2.adapter.flow +import java.io.IOException import java.lang.reflect.ParameterizedType import java.lang.reflect.Type import kotlinx.coroutines.channels.awaitClose @@ -30,6 +31,7 @@ import retrofit2.Call import retrofit2.CallAdapter import retrofit2.Callback import retrofit2.HttpException +import retrofit2.Invocation import retrofit2.Response import retrofit2.Retrofit import retrofit2.http.Streaming @@ -182,7 +184,16 @@ private fun streamingFlow( t: Throwable?, response: okhttp3.Response?, ) { - close(t ?: response?.let { HttpException(Response.error(it.body, it)) }) + val failure = + when { + t != null -> t + response != null && !response.isSuccessful -> + HttpException(Response.error(response.body, response)) + response != null -> + IOException("SSE stream failed with unexpected successful response: ${response.code}") + else -> IllegalStateException("SSE stream failed without throwable or response") + } + close(failure) } }, ) @@ -204,7 +215,18 @@ private fun bodyFlow(call: Call): Flow = callbackFlow { } val body = response.body() if (body == null) { - close(NullPointerException("Response body of a suspend fun was null")) + val invocation = + checkNotNull(call.request().tag(Invocation::class.java)) { + "Retrofit Invocation tag missing from request; cannot report null body location" + } + val service = invocation.service() + val method = invocation.method() + close( + KotlinNullPointerException( + "Response from ${service.name}.${method.name}" + + " was null but response body type was declared as non-null" + ) + ) return } trySend(body) diff --git a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt index 334c3aad18..5613dbd26c 100644 --- a/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt +++ b/retrofit-adapters/kotlin-flow/src/test/java/retrofit2/adapter/flow/FlowCallAdapterFactoryTest.kt @@ -165,9 +165,14 @@ class FlowCallAdapterFactoryTest { val service = retrofit.create(Service::class.java) try { service.body().toList() - fail("Expected NullPointerException") - } catch (_: NullPointerException) { - // expected + fail("Expected KotlinNullPointerException") + } catch (e: KotlinNullPointerException) { + assertThat(e) + .hasMessageThat() + .isEqualTo( + "Response from ${Service::class.java.name}.body" + + " was null but response body type was declared as non-null" + ) } }