Skip to content

Commit 4400eda

Browse files
feat: suspend handler (#11)
1 parent c5508e6 commit 4400eda

File tree

11 files changed

+119
-32
lines changed

11 files changed

+119
-32
lines changed

build.gradle.kts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ val authorName = "ccbluex"
1111
val projectUrl = "https://github.com/ccbluex/netty-httpserver"
1212

1313
group = "net.ccbluex"
14-
version = "2.4.2"
14+
version = "2.4.3-alpha.1"
1515

1616
repositories {
1717
mavenCentral()
@@ -31,8 +31,10 @@ dependencies {
3131
api(libs.bundles.netty)
3232
api(libs.gson)
3333
api(libs.tika.core)
34+
api(libs.coroutines.core)
3435

3536
testImplementation(kotlin("test"))
37+
testImplementation(libs.coroutines.test)
3638
testImplementation("com.squareup.retrofit2:retrofit:2.9.0")
3739
testImplementation("com.squareup.retrofit2:converter-gson:2.9.0")
3840
}

src/main/kotlin/net/ccbluex/netty/http/HttpConductor.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import net.ccbluex.netty.http.util.httpNoContent
3434
* @param context The request context to process.
3535
* @return The response to the request.
3636
*/
37-
internal fun HttpServer.processRequestContext(context: RequestContext) = runCatching {
37+
internal suspend fun HttpServer.processRequestContext(context: RequestContext) = runCatching {
3838
val content = context.contentBuffer.toByteArray()
3939
val method = context.httpMethod
4040

src/main/kotlin/net/ccbluex/netty/http/HttpServerHandler.kt

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,16 @@ import io.netty.handler.codec.http.HttpHeaderNames
2626
import io.netty.handler.codec.http.HttpRequest
2727
import io.netty.handler.codec.http.LastHttpContent
2828
import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory
29+
import kotlinx.coroutines.CoroutineExceptionHandler
30+
import kotlinx.coroutines.CoroutineName
31+
import kotlinx.coroutines.CoroutineScope
32+
import kotlinx.coroutines.asCoroutineDispatcher
33+
import kotlinx.coroutines.cancel
34+
import kotlinx.coroutines.launch
2935
import net.ccbluex.netty.http.HttpServer.Companion.logger
3036
import net.ccbluex.netty.http.middleware.Middleware
3137
import net.ccbluex.netty.http.model.RequestContext
38+
import net.ccbluex.netty.http.util.forEachIsInstance
3239
import net.ccbluex.netty.http.websocket.WebSocketHandler
3340
import java.net.URLDecoder
3441

@@ -40,13 +47,37 @@ import java.net.URLDecoder
4047
internal class HttpServerHandler(private val server: HttpServer) : ChannelInboundHandlerAdapter() {
4148

4249
private val localRequestContext = ThreadLocal<RequestContext>()
50+
private lateinit var channelScope: CoroutineScope
4351

4452
/**
4553
* Extension property to get the WebSocket URL from an HttpRequest.
4654
*/
4755
private val HttpRequest.webSocketUrl: String
4856
get() = "ws://${headers().get("Host")}${uri()}"
4957

58+
/**
59+
* Adds the [CoroutineScope] of current [io.netty.channel.Channel].
60+
*/
61+
override fun handlerAdded(ctx: ChannelHandlerContext) {
62+
super.handlerAdded(ctx)
63+
64+
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
65+
val ctxName = ctx.name()
66+
val channelId = ctx.channel().id().asLongText()
67+
logger.error(
68+
"Uncaught coroutine error in [ctx: $ctxName, channel: $channelId]",
69+
throwable
70+
)
71+
}
72+
73+
channelScope = CoroutineScope(
74+
ctx.channel().eventLoop().asCoroutineDispatcher()
75+
+ CoroutineName("${ctx.name()}#${ctx.channel().id().asShortText()}")
76+
+ exceptionHandler
77+
)
78+
ctx.channel().closeFuture().addListener { channelScope.cancel() }
79+
}
80+
5081
/**
5182
* Reads the incoming messages and processes HTTP requests.
5283
*
@@ -68,11 +99,11 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun
6899
if (connection.equals("Upgrade", ignoreCase = true) &&
69100
upgrade.equals("WebSocket", ignoreCase = true)) {
70101

71-
server.middlewares.filterIsInstance<Middleware.OnWebSocketUpgrade>().forEach { middleware ->
102+
server.middlewares.forEachIsInstance<Middleware.OnWebSocketUpgrade> { middleware ->
72103
val response = middleware.invoke(ctx, msg)
73104
if (response != null) {
74105
ctx.writeAndFlush(response)
75-
return
106+
return super.channelRead(ctx, msg)
76107
}
77108
}
78109

@@ -99,15 +130,15 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun
99130
URLDecoder.decode(msg.uri(), Charsets.UTF_8),
100131
msg.headers(),
101132
)
102-
133+
103134
localRequestContext.set(requestContext)
104135
}
105136
}
106137

107138
is HttpContent -> {
108139
val requestContext = localRequestContext.get() ?: run {
109140
logger.warn("Received HttpContent without HttpRequest")
110-
return
141+
return super.channelRead(ctx, msg)
111142
}
112143

113144
// Append content to the buffer
@@ -117,18 +148,21 @@ internal class HttpServerHandler(private val server: HttpServer) : ChannelInboun
117148
if (msg is LastHttpContent) {
118149
localRequestContext.remove()
119150

120-
server.middlewares.filterIsInstance<Middleware.OnRequest>().forEach { middleware ->
151+
server.middlewares.forEachIsInstance<Middleware.OnRequest> { middleware ->
121152
val response = middleware.invoke(requestContext)
122153
if (response != null) {
123154
ctx.writeAndFlush(response)
124-
return
155+
return super.channelRead(ctx, msg)
125156
}
126157
}
127-
var response = server.processRequestContext(requestContext)
128-
server.middlewares.filterIsInstance<Middleware.OnResponse>().forEach { middleware ->
129-
response = middleware.invoke(requestContext, response)
158+
159+
channelScope.launch {
160+
var response = server.processRequestContext(requestContext)
161+
server.middlewares.forEachIsInstance<Middleware.OnResponse> { middleware ->
162+
response = middleware.invoke(requestContext, response)
163+
}
164+
ctx.writeAndFlush(response)
130165
}
131-
ctx.writeAndFlush(response)
132166
}
133167
}
134168

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package net.ccbluex.netty.http.coroutines
2+
3+
import io.netty.util.concurrent.Future
4+
import io.netty.util.concurrent.GenericFutureListener
5+
import kotlinx.coroutines.CancellableContinuation
6+
import kotlinx.coroutines.CancellationException
7+
import kotlinx.coroutines.suspendCancellableCoroutine
8+
9+
/**
10+
* Suspend until this Netty Future completes.
11+
*
12+
* Returns the Future result. Throws on failure or cancellation.
13+
*/
14+
suspend fun <V, F : Future<V>> F.suspend(): V {
15+
if (isDone) return unwrapDone().getOrThrow()
16+
17+
return suspendCancellableCoroutine { cont ->
18+
addListener(futureContinuationListener(cont))
19+
20+
cont.invokeOnCancellation {
21+
this.cancel(false)
22+
}
23+
}
24+
}
25+
26+
private fun <V, F : Future<V>> futureContinuationListener(
27+
cont: CancellableContinuation<V>
28+
): GenericFutureListener<F> = GenericFutureListener { future ->
29+
if (cont.isActive) {
30+
cont.resumeWith(future.unwrapDone())
31+
}
32+
}
33+
34+
private fun <V, F : Future<V>> F.unwrapDone(): Result<V> =
35+
when {
36+
isSuccess -> Result.success(this.now)
37+
isCancelled -> Result.failure(CancellationException("Netty Future was cancelled"))
38+
else -> Result.failure(
39+
this.cause() ?: IllegalStateException("Future failed without cause")
40+
)
41+
}

src/main/kotlin/net/ccbluex/netty/http/model/RequestHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ package net.ccbluex.netty.http.model
33
import io.netty.handler.codec.http.FullHttpResponse
44

55
fun interface RequestHandler {
6-
fun handle(request: RequestObject): FullHttpResponse
6+
suspend fun handle(request: RequestObject): FullHttpResponse
77
}

src/main/kotlin/net/ccbluex/netty/http/rest/FileServant.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class FileServant(part: String, private val baseFolder: File) : Node(part) {
3737

3838
override val isExecutable = true
3939

40-
override fun handle(request: RequestObject): FullHttpResponse {
40+
override suspend fun handle(request: RequestObject): FullHttpResponse {
4141
val path = request.remainingPath
4242
val sanitizedPath = path.replace("..", "")
4343
val file = baseFolder.resolve(sanitizedPath)

src/main/kotlin/net/ccbluex/netty/http/rest/Node.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ open class Node(val part: String) : RequestHandler {
135135
* @param request The request object.
136136
* @return The HTTP response.
137137
*/
138-
override fun handle(request: RequestObject): FullHttpResponse = throw NotImplementedError()
138+
override suspend fun handle(request: RequestObject): FullHttpResponse = throw NotImplementedError()
139139

140140
/**
141141
* Checks if the node matches a part of the path and HTTP method.

src/main/kotlin/net/ccbluex/netty/http/rest/Route.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import net.ccbluex.netty.http.model.RequestObject
3333
open class Route(name: String, private val method: HttpMethod, val handler: RequestHandler)
3434
: Node(name) {
3535
override val isExecutable = true
36-
override fun handle(request: RequestObject) = handler.handle(request)
36+
override suspend fun handle(request: RequestObject) = handler.handle(request)
3737
override fun matchesMethod(method: HttpMethod) =
3838
this.method == method && super.matchesMethod(method)
3939

src/main/kotlin/net/ccbluex/netty/http/rest/ZipServant.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ class ZipServant(part: String, zipInputStream: InputStream) : Node(part) {
110110
return files
111111
}
112112

113-
override fun handle(request: RequestObject): FullHttpResponse {
113+
override suspend fun handle(request: RequestObject): FullHttpResponse {
114114
val path = request.remainingPath.removePrefix("/")
115115
val cleanPath = path.substringBefore("?")
116116
val sanitizedPath = cleanPath.replace("..", "")
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package net.ccbluex.netty.http.util
2+
3+
inline fun <reified E> Iterable<*>.forEachIsInstance(action: (E) -> Unit) {
4+
for (it in this) {
5+
if (it is E) {
6+
action(it)
7+
}
8+
}
9+
}

0 commit comments

Comments
 (0)