Spring MVC example of server sent events and Spring Security
- Spring dependencies (Web, Security & Jackson)
- JWT dependencies (jsonwebtoken api, impl & jackson)
- KotlinX Integrations (Coroutines & SLF4J)
- Optional: KotlinX Reactor & Reactive Streams
./gradlew bootRun
If the Spring server is started, you can curl the endpoints. First we need to retrieve the token:
curl --location --no-buffer --request GET 'localhost:8080/token'
And then we need replace {token}
with the result of the previous curl
command.
curl --location --no-buffer --request GET 'localhost:8080/events' \
--header 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTcwNjE5OTQ4MywiZXhwIjoxNzA2MjE3NDgzfQ.xt6v-N38fkDvuIAsA-FF785BygNw2ifXn8ZGSlYjTdbw7Pu2gjpkIPBginb0O_6R6_jtAdVUsPaJfHYeUSDiag'
You can also try event2
, and events3
to respectively test SseEmitter
and Flow
.
Only the bare minimum is implemented here in terms of Security to keep the example simple.
We use a hardcoded user with username = admin
and password = admin
,
we gave him ADMIN
role for example use cases.
You can find all the relevant code in com.example.streamingdemo.auth
, and is set up as usual:
Configuring Spring is done using @EnableWebSecurity
, SecurityFilterChain
,
PasswordEncoder
, AuthenticationProvider
& AuthenticationManager
. Nothing special needs to be configured here.
Important: for Spring to be able to complete request processing after the server sent all its events we need to
set shouldNotFilterAsyncDispatch
to true
in OncePerRequestFilter
.
See JWTRequestFilter for practical details.
SecurityHolderContext
and MDC
are ThreadLocal
constructs,
and thus they're not properly propagated between dispatched coroutines.
We want both to be properly managed throughout KotlinX Coroutines,
and therefore we use ThreadContextElement
. This gives us the opportunity to updateThreadContext
,
and restoreThreadContext
whenever we enter or exit a coroutine. Such that the state is properly maintained.
Luckily KotlinX already implements one for MDC
out-of-the-box, but not for SecurityHolderContext
.
The SecurityCoroutineContext implementation can be found here.
In order to launch a coroutine, we need a KotlinX CoroutineScope
,
this is important such that the lifecycle of the coroutines is properly maintained to the Spring application lifecycle.
The easiest way to do this is to implement DestroyableBean
, and make our implementing class a @Component
.
By backing the CoroutineScope
with a SupervisorJob
a child doesn't fail and cancel the parent.
This means that all children have to handle their own errors, but luckily all uncaught errors are properly logged
thanks to CoroutineExceptionHandler
.
We run these coroutines on Spring's AsyncTaskExecutor
, which we convert into a CoroutineDispather
.
The SpringScope implementation can be found here.
We have 3 options of sending server side events:
- ResponseBodyEmitter
- SseEmitter
- KotlinX Flow
ResponseBodyEmitter
allows us to send
messages and complete
or completeWithError
the emitter.
This can easily be done by combining SpringScope
, SecurityCoroutineContext
, and MDCContext
explained above.
As you can see in the snippet below:
- we construct a
ResponseBodyEmitter
- Launch a coroutine on a managed
SpringScope
, setting up the proper contexts - We
try/catch
collecting ourFlow
, and if finished wecomplete
the emitter. If something went wrong wecompleteWithError
the emitter.
@GetMapping("/events")
fun responseBodyEmitter(): ResponseBodyEmitter =
ResponseBodyEmitter().apply {
scope.launch(SecurityCoroutineContext() + MDCContext()) {
try {
mockStream.collect(::send)
complete()
} catch (e: Throwable) {
completeWithError(e)
}
}
}
This is really neat, and powerful since send
allows us to send different kind of messages with different MediaType
.
SseEmitter
add some convenience methods on top of ResponseBodyEmitter
,
but some might be undesired for example it prefixes all send data with data:
.
As you can see, the resulting code is identical.
@GetMapping("/events2")
fun sseEmitter(): SseEmitter = SseEmitter().apply {
scope.launch(SecurityCoroutineContext() + MDCContext()) {
try {
mockStream.collect(::send)
complete()
} catch (e: Throwable) {
completeWithError(e)
}
}
}
Directly returning Flow
to Spring MVC is possible, and Spring will
use ReactiveAdapterRegistry.
Be careful since this requires Reactive Streams, and Reactor to be on the classpath even though it is not used by us directly.
// Required for Flow -> SseEmitter
implementation("org.reactivestreams:reactive-streams:1.0.4")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:1.7.3")
This solution looks simplest from the Controller
point-of-view,
but some care is required because it might become "blocking" depending on the type.
Flow<String
streams correctly over the network, but Flow<Int>
becomes blocking which is not the case
for ResponseBodyEmitter
or SseEmitter
although the might internally convert to toString()
.