Skip to content

Commit a0b505d

Browse files
committed
GH-45 Support WebSockets in annotated routing plugin (Resolves #45)
1 parent f9eb939 commit a0b505d

9 files changed

Lines changed: 202 additions & 17 deletions

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,28 @@ static final class ExampleEndpoints {
124124
ctx.status(500).result("Something went wrong: " + e.getClass());
125125
}
126126

127+
// register websocket endpoints with @Ws annotation
128+
// method must return WsHandler, keeping all WS event listeners grouped together
129+
@Ws("/events")
130+
WsHandler websocketEvents() {
131+
return new WsHandler() {
132+
@Override
133+
public void onConnect(WsConnectContext ctx) {
134+
System.out.println("WebSocket connected: " + ctx.sessionId());
135+
}
136+
137+
@Override
138+
public void onMessage(WsMessageContext ctx) {
139+
ctx.send("Echo: " + ctx.message());
140+
}
141+
142+
@Override
143+
public void onClose(WsCloseContext ctx) {
144+
System.out.println("WebSocket closed: " + ctx.sessionId());
145+
}
146+
};
147+
}
148+
127149
}
128150

129151
public static void main(String[] args) {

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ subprojects {
114114
apply(plugin = "org.jetbrains.kotlin.kapt")
115115

116116
dependencies {
117-
val javalin = "7.0.0-beta.1"
117+
val javalin = "7.0.0-beta.3"
118118
compileOnly("io.javalin:javalin:$javalin")
119119
testImplementation("io.javalin:javalin:$javalin")
120120
testImplementation("io.javalin:javalin-testtools:$javalin")

routing-annotations/routing-annotated-specification/src/main/kotlin/io/javalin/community/routing/annotations/RoutingAnnotations.kt

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ package io.javalin.community.routing.annotations
22

33
import io.javalin.event.JavalinLifecycleEvent
44
import io.javalin.http.HttpStatus
5+
import io.javalin.websocket.WsBinaryMessageContext
6+
import io.javalin.websocket.WsCloseContext
7+
import io.javalin.websocket.WsConnectContext
8+
import io.javalin.websocket.WsErrorContext
9+
import io.javalin.websocket.WsMessageContext
510
import kotlin.annotation.AnnotationRetention.RUNTIME
611
import kotlin.annotation.AnnotationTarget.CLASS
712
import kotlin.annotation.AnnotationTarget.FUNCTION
@@ -97,4 +102,16 @@ annotation class Form(val value: String = "")
97102

98103
@Retention(RUNTIME)
99104
@Target(VALUE_PARAMETER)
100-
annotation class Cookie(val value: String = "")
105+
annotation class Cookie(val value: String = "")
106+
107+
@Retention(RUNTIME)
108+
@Target(FUNCTION)
109+
annotation class Ws(val value: String = "")
110+
111+
interface WsHandler {
112+
fun onConnect(ctx: WsConnectContext) {}
113+
fun onError(ctx: WsErrorContext) {}
114+
fun onClose(ctx: WsCloseContext) {}
115+
fun onMessage(ctx: WsMessageContext) {}
116+
fun onBinaryMessage(ctx: WsBinaryMessageContext) {}
117+
}

routing-annotations/routing-annotated/src/main/kotlin/io/javalin/community/routing/annotations/AnnotatedRouting.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import io.javalin.event.JavalinLifecycleEvent.SERVER_STOP_FAILED
2020
import io.javalin.http.BadRequestResponse
2121
import io.javalin.http.Context
2222
import io.javalin.http.Handler
23+
import io.javalin.websocket.WsHandlerType
2324

2425
fun interface HandlerResultConsumer<T> {
2526
fun handle(ctx: Context, value: T)
@@ -73,6 +74,7 @@ object AnnotatedRouting : RoutingApiInitializer<AnnotatedRoutingConfig> {
7374
val registeredEventListeners = mutableMapOf<JavalinLifecycleEvent, AnnotatedEvent>()
7475
val registeredRoutes = mutableListOf<AnnotatedRoute>()
7576
val registeredExceptionHandlers = mutableListOf<AnnotatedException>()
77+
val registeredWsRoutes = mutableListOf<AnnotatedWsRoute>()
7678

7779
configuration.registeredRoutes.forEach {
7880
val detectedEventListeners = loader.loadEventHandlers(it)
@@ -83,6 +85,9 @@ object AnnotatedRouting : RoutingApiInitializer<AnnotatedRoutingConfig> {
8385

8486
val detectedExceptionHandlers = loader.loadExceptionHandlers(it)
8587
registeredExceptionHandlers.addAll(detectedExceptionHandlers)
88+
89+
val detectedWsRoutes = loader.loadWsHandlers(it)
90+
registeredWsRoutes.addAll(detectedWsRoutes)
8691
}
8792

8893
registeredEventListeners.forEach { (key, event) ->
@@ -118,6 +123,10 @@ object AnnotatedRouting : RoutingApiInitializer<AnnotatedRoutingConfig> {
118123
annotatedException.handler.invoke(ctx, exception)
119124
}
120125
}
126+
127+
registeredWsRoutes.forEach { wsRoute ->
128+
state.internalRouter.addWsHandler(WsHandlerType.WEBSOCKET, wsRoute.path, wsRoute.wsConfig)
129+
}
121130
}
122131

123132
private fun createVersionedRoute(apiVersionHeader: String, id: RouteIdentifier, routes: List<DslRoute<Context, Unit>>): Handler {

routing-annotations/routing-annotated/src/main/kotlin/io/javalin/community/routing/annotations/ReflectiveEndpointLoader.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,22 @@ import io.javalin.router.InternalRouter
1212
import io.javalin.util.Util.firstOrNull
1313
import io.javalin.validation.Validation
1414
import io.javalin.validation.Validator
15+
import io.javalin.websocket.WsConfig
1516
import java.lang.reflect.AnnotatedElement
1617
import java.lang.reflect.Method
1718
import java.lang.reflect.Parameter
1819
import java.lang.reflect.ParameterizedType
1920
import java.lang.reflect.Type
2021
import java.util.*
22+
import java.util.function.Consumer
2123
import kotlin.reflect.KClass
2224

2325
typealias AnnotatedRoute = DefaultDslRoute<Context, Unit>
2426
typealias AnnotatedException = DefaultDslException<Context, Exception, Unit>
2527
typealias AnnotatedEvent = () -> Unit
2628

29+
data class AnnotatedWsRoute(val path: String, val wsConfig: Consumer<WsConfig>)
30+
2731
internal class ReflectiveEndpointLoader(
2832
private val internalRouter: InternalRouter,
2933
private val resultHandlers: Map<Class<*>, HandlerResultConsumer<*>>
@@ -164,6 +168,44 @@ internal class ReflectiveEndpointLoader(
164168
return dslEvents
165169
}
166170

171+
fun loadWsHandlers(endpoint: Any): List<AnnotatedWsRoute> {
172+
val endpointClass = endpoint::class.java
173+
174+
val endpointPath = endpointClass.getAnnotation<Endpoints>()
175+
?.value
176+
?: ""
177+
178+
val wsRoutes = mutableListOf<AnnotatedWsRoute>()
179+
180+
getAllDeclaredMethods(endpointClass).forEach { method ->
181+
val wsAnnotation = method.getAnnotation<Ws>() ?: return@forEach
182+
183+
require(method.trySetAccessible()) {
184+
"Unable to access method $method in class $endpointClass"
185+
}
186+
187+
require(WsHandler::class.java.isAssignableFrom(method.returnType)) {
188+
"Method $method annotated with @Ws must return WsHandler"
189+
}
190+
191+
val path = "/$endpointPath/${wsAnnotation.value}".replace(repeatedPathSeparatorRegex, "/").dropLastWhile { it == '/' }
192+
193+
wsRoutes.add(AnnotatedWsRoute(
194+
path = path,
195+
wsConfig = { wsConfig ->
196+
val handler = method.invoke(endpoint) as WsHandler
197+
wsConfig.onConnect { handler.onConnect(it) }
198+
wsConfig.onError { handler.onError(it) }
199+
wsConfig.onClose { handler.onClose(it) }
200+
wsConfig.onMessage { handler.onMessage(it) }
201+
wsConfig.onBinaryMessage { handler.onBinaryMessage(it) }
202+
}
203+
))
204+
}
205+
206+
return wsRoutes
207+
}
208+
167209
@Suppress("UNCHECKED_CAST")
168210
private fun findResultHandler(method: Method): HandlerResultConsumer<Any?> =
169211
resultHandlers.asSequence()

routing-annotations/routing-annotated/src/test/java/io/javalin/community/routing/annotations/AnnotatedRoutingTest.kt

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import io.javalin.http.HttpStatus
1111
import io.javalin.router.Endpoint
1212
import io.javalin.testtools.HttpClient
1313
import io.javalin.testtools.JavalinTest
14+
import io.javalin.websocket.WsConnectContext
15+
import io.javalin.websocket.WsMessageContext
1416
import kong.unirest.Unirest
1517
import kong.unirest.Unirest.request
1618
import org.assertj.core.api.Assertions.assertThat
@@ -19,7 +21,14 @@ import org.junit.jupiter.api.Nested
1921
import org.junit.jupiter.api.Test
2022
import org.junit.jupiter.api.assertDoesNotThrow
2123
import java.io.Closeable
24+
import java.net.URI
25+
import java.net.http.HttpClient as JdkHttpClient
26+
import java.net.http.WebSocket as JdkWebSocket
2227
import java.util.*
28+
import java.util.concurrent.CompletableFuture
29+
import java.util.concurrent.CompletionStage
30+
import java.util.concurrent.ConcurrentLinkedQueue
31+
import java.util.concurrent.TimeUnit
2332

2433
class AnnotatedRoutingTest {
2534

@@ -554,4 +563,79 @@ class AnnotatedRoutingTest {
554563

555564
}
556565

566+
@Nested
567+
inner class WebSockets {
568+
569+
@Test
570+
fun `should register and handle websocket endpoint`() {
571+
val log = ConcurrentLinkedQueue<String>()
572+
573+
val app = Javalin.create { cfg ->
574+
cfg.routes(Annotated) {
575+
registerEndpoints(
576+
@Endpoints("/ws")
577+
object {
578+
@Ws("/echo")
579+
fun echo(): WsHandler = object : WsHandler {
580+
override fun onConnect(ctx: WsConnectContext) {
581+
log.add("connected")
582+
}
583+
override fun onMessage(ctx: WsMessageContext) {
584+
log.add("received: ${ctx.message()}")
585+
ctx.send("echo: ${ctx.message()}")
586+
}
587+
}
588+
}
589+
)
590+
}
591+
}.start(0)
592+
593+
try {
594+
val received = CompletableFuture<String>()
595+
596+
val wsClient = JdkHttpClient.newHttpClient().newWebSocketBuilder()
597+
.buildAsync(
598+
URI.create("ws://localhost:${app.port()}/ws/echo"),
599+
object : JdkWebSocket.Listener {
600+
override fun onText(webSocket: JdkWebSocket, data: CharSequence, last: Boolean): CompletionStage<*> {
601+
received.complete(data.toString())
602+
return CompletableFuture.completedFuture(null)
603+
}
604+
}
605+
)
606+
.get(2, TimeUnit.SECONDS)
607+
608+
wsClient.sendText("hello", true).get(2, TimeUnit.SECONDS)
609+
val response = received.get(2, TimeUnit.SECONDS)
610+
611+
assertThat(response).isEqualTo("echo: hello")
612+
assertThat(log).contains("connected", "received: hello")
613+
614+
wsClient.sendClose(JdkWebSocket.NORMAL_CLOSURE, "done").get(2, TimeUnit.SECONDS)
615+
} finally {
616+
app.stop()
617+
}
618+
}
619+
620+
@Test
621+
fun `should throw if @Ws method does not return WsHandler`() {
622+
assertThatThrownBy {
623+
Javalin.create { cfg ->
624+
cfg.routes(Annotated) {
625+
registerEndpoints(
626+
@Endpoints
627+
object {
628+
@Ws("/bad")
629+
fun bad(): String = "not a WsHandler"
630+
}
631+
)
632+
}
633+
}
634+
}
635+
.isInstanceOf(IllegalArgumentException::class.java)
636+
.hasMessageContaining("must return WsHandler")
637+
}
638+
639+
}
640+
557641
}

routing-annotations/routing-annotated/src/test/java/io/javalin/community/routing/annotations/example/AnnotatedRoutingExample.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,16 @@
88
import io.javalin.openapi.OpenApiParam;
99
import io.javalin.openapi.OpenApiResponse;
1010
import io.javalin.router.Endpoint;
11+
import io.javalin.websocket.WsConnectContext;
12+
import io.javalin.websocket.WsCloseContext;
13+
import io.javalin.websocket.WsMessageContext;
1114
import kong.unirest.HttpResponse;
1215
import kong.unirest.Unirest;
1316
import org.jetbrains.annotations.Nullable;
1417
import java.io.Serializable;
1518
import java.util.HashMap;
1619
import java.util.Map;
17-
import java.util.Objects;
1820

19-
import static io.javalin.community.routing.annotations.AnnotatedRouting.Annotated;
2021
import static io.javalin.http.Header.AUTHORIZATION;
2122
import static io.javalin.openapi.HttpMethod.GET;
2223

@@ -102,6 +103,28 @@ void defaultExceptionHandler(Exception e, Context ctx) {
102103
ctx.status(500).result("Something went wrong: " + e.getClass());
103104
}
104105

106+
// register websocket endpoints with @Ws annotation
107+
// method must return WsHandler, keeping all WS event listeners grouped together
108+
@Ws("/events")
109+
WsHandler websocketEvents() {
110+
return new WsHandler() {
111+
@Override
112+
public void onConnect(WsConnectContext ctx) {
113+
System.out.println("WebSocket connected: " + ctx.sessionId());
114+
}
115+
116+
@Override
117+
public void onMessage(WsMessageContext ctx) {
118+
ctx.send("Echo: " + ctx.message());
119+
}
120+
121+
@Override
122+
public void onClose(WsCloseContext ctx) {
123+
System.out.println("WebSocket closed: " + ctx.sessionId());
124+
}
125+
};
126+
}
127+
105128
}
106129

107130
public static void main(String[] args) {

routing-core/src/main/java/io/javalin/community/routing/JavalinConfigUnsafe.java

Lines changed: 0 additions & 12 deletions
This file was deleted.

routing-core/src/main/kotlin/io/javalin/community/routing/JavalinRoutingExtensions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ fun <SETUP> JavalinConfig.routes(
9898
initializer: RoutingApiInitializer<SETUP>,
9999
setup: Consumer<SETUP>,
100100
): JavalinConfig = also {
101-
initializer.initialize(state = JavalinConfigUnsafe.getState(this)) {
101+
initializer.initialize(state = this.unsafe) {
102102
setup.accept(this)
103103
}
104104
}

0 commit comments

Comments
 (0)