Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
35c6dbd
Add okhttp-sse dependency
MukjepScarlet Aug 8, 2025
3fc685e
SSE/Java9: implementation
MukjepScarlet Aug 11, 2025
e3e63df
SSE/Java9: fix type
MukjepScarlet Aug 11, 2025
4ee2217
SSE/Java9: fix body reading
MukjepScarlet Aug 11, 2025
50667b8
SSE/Java9: error shortcut
MukjepScarlet Aug 11, 2025
b6e1a73
Merge branch 'trunk' into feat_sse
MukjepScarlet Aug 11, 2025
04411d3
Reorganize files
MukjepScarlet Aug 11, 2025
c2acec9
SSE/Java9: fix type adapter
MukjepScarlet Aug 11, 2025
3d916b7
SSE/Java9: rename module
MukjepScarlet Aug 11, 2025
1cfe172
SSE/Ktx: implementation
MukjepScarlet Aug 11, 2025
07b44f9
Add base adapter
MukjepScarlet Aug 11, 2025
23d10e7
sse/juc: Make executor configurable
MukjepScarlet Aug 11, 2025
bff36d5
sse/kts: add other converters test
MukjepScarlet Aug 12, 2025
ca6bc13
spotless + move listener to abstract class
MukjepScarlet Aug 12, 2025
18aa33d
sse/core: callback style adapter
MukjepScarlet Aug 12, 2025
63028d2
move abstract to delegation
MukjepScarlet Aug 12, 2025
22f07cd
sse/juc: allow set maxBufferCapacity
MukjepScarlet Aug 12, 2025
03ef06e
requires GET and Streaming on SSE adapters
MukjepScarlet Aug 12, 2025
3cd2908
refactor: use delegation from Retrofit
MukjepScarlet Aug 12, 2025
12ae45b
change nextCallAdapter to callAdapter
MukjepScarlet Aug 12, 2025
b1f790f
direct cast String
MukjepScarlet Aug 12, 2025
23e0362
sse: improve String type check
MukjepScarlet Aug 12, 2025
d0d1ebc
Merge branch 'trunk' into feat_sse
MukjepScarlet Jan 2, 2026
8b9ab4b
Merge branch 'trunk' into feat_sse
MukjepScarlet Jun 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
21 changes: 21 additions & 0 deletions retrofit-adapters/sse/core/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
apply plugin: 'java-library'
apply plugin: 'org.jetbrains.kotlin.jvm'
apply plugin: 'com.vanniktech.maven.publish'
apply plugin: 'org.jetbrains.dokka'

dependencies {
api projects.retrofit
api libs.okhttp.sse
compileOnly libs.findBugsAnnotations

testImplementation libs.junit
testImplementation libs.truth
testImplementation libs.okhttp.mockwebserver
testImplementation projects.retrofitConverters.scalars
}

jar {
manifest {
attributes 'Automatic-Module-Name': 'retrofit2.adapter.sse-core'
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright (C) 2017 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.sse

import okhttp3.Request

interface EventSource<ID : Any, TYPE : Any, DATA : Any> {

fun request(): Request

fun cancel()

fun subscribe(callback: SseCallback<ID, TYPE, DATA>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2017 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.sse

import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
import retrofit2.CallAdapter
import retrofit2.Retrofit
import retrofit2.adapter.sse.internal.EventSourceCallAdapter
import retrofit2.http.GET
import retrofit2.http.Streaming

object EventSourceCallAdapterFactory : CallAdapter.Factory() {

override fun get(
returnType: Type,
annotations: Array<out Annotation?>,
retrofit: Retrofit,
): CallAdapter<*, *>? {
if (getRawType(returnType) != EventSource::class.java) {
return null
}

if (returnType !is ParameterizedType) {
error(
"EventSource return type must be parameterized as EventSource<ID, TYPE, DATA>" +
" or EventSource<? extends ID, ? extends TYPE, ? extends DATA>",
)
}

if (annotations.none { it is Streaming }) {
error("SSE endpoint must be annotated with @Streaming")
}

if (annotations.none { it is GET }) {
error("SSE endpoint must use @GET method")
}

val idType = getParameterUpperBound(0, returnType)
val typeType = getParameterUpperBound(1, returnType)
val dataType = getParameterUpperBound(2, returnType)

return EventSourceCallAdapter<Any, Any, Any>(
retrofit,
idType,
typeType,
dataType,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2017 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.sse

/**
* A server-sent event.
*/
data class ServerSentEvent<ID : Any, TYPE : Any, DATA : Any>(
@get:JvmName("id")
val id: ID?,

@get:JvmName("type")
val type: TYPE?,

@get:JvmName("data")
val data: DATA,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (C) 2017 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.sse

interface SseCallback<ID : Any, TYPE : Any, DATA : Any> {

fun onOpen(eventSource: EventSource<ID, TYPE, DATA>) {
}

fun onEvent(eventSource: EventSource<ID, TYPE, DATA>, id: ID?, type: TYPE?, data: DATA) {
}

fun onClosed(eventSource: EventSource<ID, TYPE, DATA>) {
}

fun onFailure(eventSource: EventSource<ID, TYPE, DATA>, t: Throwable?) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright (C) 2017 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.sse.internal

import java.lang.reflect.Type
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.CallAdapter
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.adapter.sse.EventSource

private val EMPTY_ARRAY = emptyArray<Annotation>()

class EventSourceCallAdapter<ID : Any, TYPE : Any, DATA : Any>(
retrofit: Retrofit,
private val idType: Type,
private val typeType: Type,
private val dataType: Type,
) : CallAdapter<ResponseBody, EventSource<ID, TYPE, DATA>> {

private val idConverter: Converter<ResponseBody, ID> = retrofit.responseBodyConverter(idType, EMPTY_ARRAY)
private val typeConverter: Converter<ResponseBody, TYPE> = retrofit.responseBodyConverter(typeType, EMPTY_ARRAY)
private val dataConverter: Converter<ResponseBody, DATA> = retrofit.responseBodyConverter(dataType, EMPTY_ARRAY)

override fun responseType(): Type = ResponseBody::class.java

override fun adapt(call: Call<ResponseBody>): EventSource<ID, TYPE, DATA> {
return RealEventSource(idType, typeType, dataType, idConverter, typeConverter, dataConverter, call)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright (C) 2017 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.sse.internal

import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
import java.lang.reflect.WildcardType
import okhttp3.Request
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import retrofit2.Call
import retrofit2.Converter
import retrofit2.Response
import retrofit2.adapter.sse.EventSource
import retrofit2.adapter.sse.SseCallback

@Suppress("NOTHING_TO_INLINE")
private inline fun <T : Any> conversionError(value: T, type: Type): Nothing = error("Failed to convert $value to $type, actual type is ${value.javaClass}")

private fun Response<ResponseBody>.asSse(listener: EventSourceListener) {
val okhttpResponse = raw().newBuilder().body(body() ?: error("Response body is null")).build()
EventSources.processResponse(okhttpResponse, listener)
}

private fun Type.acceptsString(): Boolean =
when (this) {
String::class.java -> true
Object::class.java -> true
CharSequence::class.java -> true
Comparable::class.java -> true
is ParameterizedType -> rawType === Comparable::class.java && actualTypeArguments[0].acceptsString()
is WildcardType -> upperBounds[0].acceptsString()
else -> false
}

internal class RealEventSource<ID : Any, TYPE : Any, DATA : Any>(
private val idType: Type,
private val typeType: Type,
private val dataType: Type,
private val idConverter: Converter<ResponseBody, ID>,
private val typeConverter: Converter<ResponseBody, TYPE>,
private val dataConverter: Converter<ResponseBody, DATA>,
private val call: Call<ResponseBody>,
) : EventSource<ID, TYPE, DATA> {
override fun request(): Request = call.request()

override fun cancel() = call.cancel()

override fun subscribe(callback: SseCallback<ID, TYPE, DATA>) {
call.enqueue(
object : retrofit2.Callback<ResponseBody> {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
response.asSse(
object : EventSourceListener() {
override fun onOpen(eventSource: okhttp3.sse.EventSource, response: okhttp3.Response) {
callback.onOpen(this@RealEventSource)
}

override fun onEvent(eventSource: okhttp3.sse.EventSource, id: String?, type: String?, data: String) {
val convertedId = convertId(id)
val convertedType = convertType(type)
val convertedData = convertData(data)
callback.onEvent(this@RealEventSource, convertedId, convertedType, convertedData)
}

override fun onClosed(eventSource: okhttp3.sse.EventSource) {
callback.onClosed(this@RealEventSource)
}

override fun onFailure(eventSource: okhttp3.sse.EventSource, t: Throwable?, response: okhttp3.Response?) {
callback.onFailure(this@RealEventSource, t)
}
},
)
}

override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
callback.onFailure(this@RealEventSource, t)
}
},
)
}

private fun convertId(id: String?): ID? {
@Suppress("UNCHECKED_CAST")
return when {
idType.acceptsString() -> id as ID?
id != null -> idConverter.convert(id.toResponseBody()) ?: conversionError(id, idType)
else -> null
}
}

private fun convertType(type: String?): TYPE? {
@Suppress("UNCHECKED_CAST")
return when {
typeType.acceptsString() -> type as TYPE?
type != null -> typeConverter.convert(type.toResponseBody()) ?: conversionError(type, typeType)
else -> null
}
}

private fun convertData(data: String): DATA {
@Suppress("UNCHECKED_CAST")
return when {
dataType.acceptsString() -> data as DATA
else -> dataConverter.convert(data.toResponseBody()) ?: conversionError(data, dataType)
}
}
}
Loading