Skip to content

Commit b015958

Browse files
committed
kotlin: Allow Kotlin suspending functions in error handlers
- add custom error handler for coroutines - fix #3405
1 parent b950729 commit b015958

File tree

5 files changed

+201
-45
lines changed

5 files changed

+201
-45
lines changed

jooby/src/main/java/io/jooby/DefaultErrorHandler.java

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@
99
import static io.jooby.MediaType.json;
1010
import static io.jooby.MediaType.text;
1111

12-
import java.util.Arrays;
13-
import java.util.HashSet;
14-
import java.util.Optional;
15-
import java.util.Set;
16-
import java.util.stream.Stream;
12+
import java.util.*;
1713

1814
import org.slf4j.Logger;
1915

@@ -28,9 +24,9 @@
2824
*/
2925
public class DefaultErrorHandler implements ErrorHandler {
3026

31-
private Set<StatusCode> muteCodes = new HashSet<>();
27+
private final Set<StatusCode> muteCodes = new HashSet<>();
3228

33-
private Set<Class> muteTypes = new HashSet<>();
29+
private final Set<Class> muteTypes = new HashSet<>();
3430

3531
/**
3632
* Generate a log.debug call if any of the status code error occurs as exception.
@@ -39,7 +35,7 @@ public class DefaultErrorHandler implements ErrorHandler {
3935
* @return This error handler.
4036
*/
4137
public @NonNull DefaultErrorHandler mute(@NonNull StatusCode... statusCodes) {
42-
Stream.of(statusCodes).forEach(muteCodes::add);
38+
muteCodes.addAll(List.of(statusCodes));
4339
return this;
4440
}
4541

@@ -50,7 +46,7 @@ public class DefaultErrorHandler implements ErrorHandler {
5046
* @return This error handler.
5147
*/
5248
public @NonNull DefaultErrorHandler mute(@NonNull Class<? extends Exception>... exceptionTypes) {
53-
Stream.of(exceptionTypes).forEach(muteTypes::add);
49+
muteTypes.addAll(List.of(exceptionTypes));
5450
return this;
5551
}
5652

@@ -63,7 +59,7 @@ protected void log(Context ctx, Throwable cause, StatusCode code) {
6359
}
6460
}
6561

66-
@NonNull @Override
62+
@Override
6763
public void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code) {
6864
log(ctx, cause, code);
6965
MediaType type = ctx.accept(Arrays.asList(html, json, text));

jooby/src/main/java/io/jooby/ErrorHandler.java

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ public interface ErrorHandler {
2222
* @param cause Application error.
2323
* @param code Status code.
2424
*/
25-
@NonNull
2625
void apply(@NonNull Context ctx, @NonNull Throwable cause, @NonNull StatusCode code);
2726

2827
/**
@@ -50,15 +49,13 @@ public interface ErrorHandler {
5049
* @return Single line message.
5150
*/
5251
static @NonNull String errorMessage(@NonNull Context ctx, @NonNull StatusCode statusCode) {
53-
return new StringBuilder()
54-
.append(ctx.getMethod())
55-
.append(" ")
56-
.append(ctx.getRequestPath())
57-
.append(" ")
58-
.append(statusCode.value())
59-
.append(" ")
60-
.append(statusCode.reason())
61-
.toString();
52+
return ctx.getMethod()
53+
+ " "
54+
+ ctx.getRequestPath()
55+
+ " "
56+
+ statusCode.value()
57+
+ " "
58+
+ statusCode.reason();
6259
}
6360

6461
/**

modules/jooby-kotlin/src/main/kotlin/io/jooby/kt/CoroutineRouter.kt

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,13 @@
55
*/
66
package io.jooby.kt
77

8-
import io.jooby.RequestScope
9-
import io.jooby.Route
10-
import io.jooby.Router
11-
import io.jooby.Router.DELETE
12-
import io.jooby.Router.GET
13-
import io.jooby.Router.HEAD
14-
import io.jooby.Router.OPTIONS
15-
import io.jooby.Router.PATCH
16-
import io.jooby.Router.POST
17-
import io.jooby.Router.PUT
18-
import io.jooby.Router.TRACE
8+
import io.jooby.*
9+
import io.jooby.Router.*
10+
import java.util.function.Predicate
1911
import kotlin.coroutines.CoroutineContext
2012
import kotlin.coroutines.EmptyCoroutineContext
21-
import kotlinx.coroutines.CoroutineExceptionHandler
22-
import kotlinx.coroutines.CoroutineScope
23-
import kotlinx.coroutines.CoroutineStart
24-
import kotlinx.coroutines.asContextElement
25-
import kotlinx.coroutines.asCoroutineDispatcher
26-
import kotlinx.coroutines.launch
13+
import kotlin.reflect.KClass
14+
import kotlinx.coroutines.*
2715

2816
internal class RouterCoroutineScope(override val coroutineContext: CoroutineContext) :
2917
CoroutineScope
@@ -34,6 +22,8 @@ class CoroutineRouter(val coroutineStart: CoroutineStart, val router: Router) {
3422
RouterCoroutineScope(router.worker.asCoroutineDispatcher())
3523
}
3624

25+
private var errorHandler: suspend ErrorHandlerContext.() -> Unit = FALLBACK_ERROR_HANDLER
26+
3727
private var extraCoroutineContextProvider: HandlerContext.() -> CoroutineContext = {
3828
EmptyCoroutineContext
3929
}
@@ -42,6 +32,84 @@ class CoroutineRouter(val coroutineStart: CoroutineStart, val router: Router) {
4232
extraCoroutineContextProvider = provider
4333
}
4434

35+
/**
36+
* Add a custom error handler that matches the given status code.
37+
*
38+
* @param statusCode Status code.
39+
* @param handler Error handler.
40+
* @return This router.
41+
*/
42+
@RouterDsl
43+
fun error(
44+
statusCode: StatusCode,
45+
handler: suspend ErrorHandlerContext.() -> Unit
46+
): CoroutineRouter {
47+
return error({ it: StatusCode -> statusCode == it }, handler)
48+
}
49+
50+
/**
51+
* Add a custom error handler that matches the given exception type.
52+
*
53+
* @param type Exception type.
54+
* @param handler Error handler.
55+
* @return This router.
56+
*/
57+
@RouterDsl
58+
fun error(
59+
type: KClass<Throwable>,
60+
handler: suspend ErrorHandlerContext.() -> Unit
61+
): CoroutineRouter {
62+
return error {
63+
if (type.java.isInstance(cause) || type.java.isInstance(cause.cause)) {
64+
handler.invoke(this)
65+
}
66+
}
67+
}
68+
69+
/**
70+
* Add a custom error handler that matches the given predicate.
71+
*
72+
* @param predicate Status code filter.
73+
* @param handler Error handler.
74+
* @return This router.
75+
*/
76+
@RouterDsl
77+
fun error(
78+
predicate: Predicate<StatusCode>,
79+
handler: suspend ErrorHandlerContext.() -> Unit
80+
): CoroutineRouter {
81+
return error {
82+
if (predicate.test(statusCode)) {
83+
handler.invoke(this)
84+
}
85+
}
86+
}
87+
88+
/**
89+
* Add a custom error handler.
90+
*
91+
* @param handler Error handler.
92+
* @return This router.
93+
*/
94+
@RouterDsl
95+
fun error(handler: suspend ErrorHandlerContext.() -> Unit): CoroutineRouter {
96+
val chain =
97+
fun(
98+
current: suspend ErrorHandlerContext.() -> Unit,
99+
next: suspend ErrorHandlerContext.() -> Unit
100+
): suspend ErrorHandlerContext.() -> Unit {
101+
return {
102+
current(this)
103+
if (!ctx.isResponseStarted) {
104+
next(this)
105+
}
106+
}
107+
}
108+
errorHandler =
109+
if (errorHandler == FALLBACK_ERROR_HANDLER) handler else chain(errorHandler, handler)
110+
return this
111+
}
112+
45113
@RouterDsl
46114
fun get(pattern: String, handler: suspend HandlerContext.() -> Any) = route(GET, pattern, handler)
47115

@@ -77,10 +145,18 @@ class CoroutineRouter(val coroutineStart: CoroutineStart, val router: Router) {
77145
.route(method, pattern) { ctx ->
78146
val handlerContext = HandlerContext(ctx)
79147
launch(handlerContext) {
80-
val result = handler(handlerContext)
81-
ctx.route.after?.apply(ctx, result, null)
82-
if (result != ctx && !ctx.isResponseStarted) {
83-
ctx.render(result)
148+
try {
149+
val result = handler(handlerContext)
150+
ctx.route.after?.apply(ctx, result, null)
151+
if (result != ctx && !ctx.isResponseStarted) {
152+
ctx.render(result)
153+
}
154+
} catch (cause: Throwable) {
155+
try {
156+
ctx.route.after?.apply(ctx, null, cause)
157+
} finally {
158+
errorHandler.invoke(ErrorHandlerContext(ctx, cause, router.errorCode(cause)))
159+
}
84160
}
85161
}
86162
// Return context to mark as handled
@@ -89,6 +165,7 @@ class CoroutineRouter(val coroutineStart: CoroutineStart, val router: Router) {
89165
.setHandle(handler)
90166

91167
internal fun launch(handlerContext: HandlerContext, block: suspend CoroutineScope.() -> Unit) {
168+
// Global catch-all exception handler
92169
val exceptionHandler = CoroutineExceptionHandler { _, x ->
93170
val ctx = handlerContext.ctx
94171
ctx.route.after?.apply(ctx, null, x)
@@ -99,4 +176,10 @@ class CoroutineRouter(val coroutineStart: CoroutineStart, val router: Router) {
99176
exceptionHandler + requestScope + handlerContext.extraCoroutineContextProvider()
100177
coroutineScope.launch(coroutineContext, coroutineStart, block)
101178
}
179+
180+
private companion object {
181+
private val FALLBACK_ERROR_HANDLER: suspend ErrorHandlerContext.() -> Unit = {
182+
ctx.sendError(cause, statusCode)
183+
}
184+
}
102185
}

modules/jooby-kotlin/src/main/kotlin/io/jooby/kt/HandlerContext.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,17 @@
55
*/
66
package io.jooby.kt
77

8-
import io.jooby.Context
9-
import io.jooby.Route
10-
import io.jooby.ServerSentEmitter
11-
import io.jooby.WebSocketConfigurer
8+
import io.jooby.*
129

1310
class AfterContext(val ctx: Context, val result: Any?, val failure: Any?)
1411

1512
class FilterContext(val ctx: Context, val next: Route.Handler)
1613

1714
class HandlerContext(val ctx: Context) : java.io.Serializable
1815

16+
class ErrorHandlerContext(val ctx: Context, val cause: Throwable, val statusCode: StatusCode) :
17+
java.io.Serializable
18+
1919
class WebSocketInitContext(val ctx: Context, val configurer: WebSocketConfigurer)
2020

2121
class ServerSentHandler(val ctx: Context, val sse: ServerSentEmitter)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package i3405
7+
8+
import io.jooby.StatusCode
9+
import io.jooby.exception.StatusCodeException
10+
import io.jooby.junit.ServerTest
11+
import io.jooby.junit.ServerTestRunner
12+
import io.jooby.kt.Kooby
13+
import kotlinx.coroutines.delay
14+
import org.junit.jupiter.api.Assertions.assertEquals
15+
16+
class Issue3405 {
17+
@ServerTest
18+
fun coroutineShouldFallbackToNormalErrorHandler(runner: ServerTestRunner) =
19+
runner
20+
.use {
21+
Kooby {
22+
error { ctx, cause, code -> ctx.send("normal/global") }
23+
coroutine { get("/i3405/normal-error") { ctx.query("q").value() } }
24+
}
25+
}
26+
.ready { client ->
27+
client.get("/i3405/normal-error") { rsp ->
28+
assertEquals("normal/global", rsp.body!!.string())
29+
}
30+
}
31+
32+
@ServerTest
33+
fun coroutineSuspendErrorHandler(runner: ServerTestRunner) =
34+
runner
35+
.use {
36+
Kooby {
37+
error { ctx, cause, code -> ctx.send("normal/global") }
38+
coroutine {
39+
get("/i3405/coroutine-error") {
40+
delay(10)
41+
ctx.query("q").value()
42+
}
43+
44+
error {
45+
delay(10)
46+
ctx.send("coroutine/suspended")
47+
}
48+
}
49+
}
50+
}
51+
.ready { client ->
52+
client.get("/i3405/coroutine-error") { rsp ->
53+
assertEquals("coroutine/suspended", rsp.body!!.string())
54+
}
55+
}
56+
57+
@ServerTest
58+
fun coroutineChainSuspendErrorHandler(runner: ServerTestRunner) =
59+
runner
60+
.use {
61+
Kooby {
62+
error { ctx, cause, code -> ctx.send("normal/global") }
63+
coroutine {
64+
get("/i3405/chain") {
65+
if (ctx.query("q").isMissing) throw StatusCodeException(StatusCode.CONFLICT)
66+
}
67+
get("/i3405/coroutine-error") { ctx.query("q").value() }
68+
69+
error(StatusCode.CONFLICT) { ctx.send("conflict") }
70+
error { ctx.send("coroutine/suspended") }
71+
}
72+
}
73+
}
74+
.ready { client ->
75+
client.get("/i3405/coroutine-error") { rsp ->
76+
assertEquals("coroutine/suspended", rsp.body!!.string())
77+
}
78+
client.get("/i3405/chain") { rsp -> assertEquals("conflict", rsp.body!!.string()) }
79+
}
80+
}

0 commit comments

Comments
 (0)