Skip to content

Commit de7554d

Browse files
committed
GH-22 Support exception handlers (resolves #22, fixes #28)
1 parent ed741ee commit de7554d

File tree

22 files changed

+446
-223
lines changed

22 files changed

+446
-223
lines changed

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ some people may even say that it's the only one.
6565
Take a look on the example below to see how it looks like:
6666

6767
```java
68-
// register endpoints with prefix
68+
// register endpoints with prefix
6969
@Endpoints("/api")
7070
static final class ExampleEndpoints {
7171

@@ -76,6 +76,12 @@ static final class ExampleEndpoints {
7676
this.exampleService = exampleService;
7777
}
7878

79+
// use Javalin-specific routes
80+
@Before
81+
void beforeEach(Context ctx) {
82+
ctx.header("X-Example", "Example");
83+
}
84+
7985
// describe http method and path with annotation
8086
@Post("/hello")
8187
// use parameters to extract data from request
@@ -106,7 +112,15 @@ static final class ExampleEndpoints {
106112
/* OpenApi [...] */
107113
@Version("1")
108114
@Get("/hello/{name}")
109-
void findExampleV1(Context ctx) { ctx.result("Outdated"); }
115+
void findExampleV1(Context ctx) {
116+
throw new UnsupportedOperationException("Deprecated");
117+
}
118+
119+
// register exception handlers alongside endpoints
120+
@ExceptionHandler(Exception.class)
121+
void defaultExceptionHandler(Exception e, Context ctx) {
122+
ctx.status(500).result("Something went wrong: " + e.getClass());
123+
}
110124

111125
}
112126

@@ -137,7 +151,7 @@ public static void main(String[] args) {
137151
}
138152
```
139153

140-
Unfortunately, this approach requires some reflections under the hood to make work at this moment,
154+
This approach requires some reflections under the hood to make work at this moment,
141155
but **we're working on annotation processor to remove this requirement.**
142156

143157
Another thing you can notice is that we're creating endpoint class instance using constructor,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import kotlin.annotation.AnnotationRetention.RUNTIME
44
import kotlin.annotation.AnnotationTarget.CLASS
55
import kotlin.annotation.AnnotationTarget.FUNCTION
66
import kotlin.annotation.AnnotationTarget.VALUE_PARAMETER
7+
import kotlin.reflect.KClass
78

89
/**
910
* Endpoints annotation is used to define a base path for all endpoints in a class.
@@ -14,7 +15,7 @@ annotation class Endpoints(val value: String = "")
1415

1516
@Retention(RUNTIME)
1617
@Target(FUNCTION)
17-
annotation class Version(val value: String)
18+
annotation class ExceptionHandler(val value: KClass<out Exception>)
1819

1920
@Retention(RUNTIME)
2021
@Target(FUNCTION)
@@ -52,6 +53,10 @@ annotation class Before(val value: String = "*", val async: Boolean = false)
5253
@Target(FUNCTION)
5354
annotation class After(val value: String = "*", val async: Boolean = false)
5455

56+
@Retention(RUNTIME)
57+
@Target(FUNCTION)
58+
annotation class Version(val value: String)
59+
5560
@Retention(RUNTIME)
5661
@Target(VALUE_PARAMETER)
5762
annotation class Body

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package io.javalin.community.routing.annotations
33
import io.javalin.Javalin
44
import io.javalin.community.routing.Route
55
import io.javalin.community.routing.dsl.DslRoute
6-
import io.javalin.community.routing.route
6+
import io.javalin.community.routing.registerRoute
77
import io.javalin.community.routing.sortRoutes
88
import io.javalin.config.JavalinConfig
99
import io.javalin.http.BadRequestResponse
@@ -20,7 +20,8 @@ class AnnotatedRoutingPlugin @JvmOverloads constructor(
2020
private val configuration: AnnotatedRoutingPluginConfiguration = AnnotatedRoutingPluginConfiguration()
2121
) : Plugin {
2222

23-
private val registeredRoutes = mutableListOf<DslRoute<Context, Unit>>()
23+
private val registeredRoutes = mutableListOf<AnnotatedRoute>()
24+
private val registeredExceptionHandlers = mutableListOf<AnnotatedException>()
2425

2526
private data class RouteIdentifier(val route: Route, val path: String)
2627

@@ -35,8 +36,14 @@ class AnnotatedRoutingPlugin @JvmOverloads constructor(
3536
}
3637
}
3738
.forEach { (id, handler) ->
38-
app.route(id.route, id.path, handler)
39+
app.registerRoute(id.route, id.path, handler)
3940
}
41+
42+
registeredExceptionHandlers.forEach { annotatedException ->
43+
app.exception(annotatedException.type.java) { exception, ctx ->
44+
annotatedException.handler.invoke(ctx, exception)
45+
}
46+
}
4047
}
4148

4249
private fun createVersionedRoute(id: RouteIdentifier, routes: List<DslRoute<Context, Unit>>): Handler {
@@ -60,6 +67,9 @@ class AnnotatedRoutingPlugin @JvmOverloads constructor(
6067
fun registerEndpoints(vararg endpoints: Any) {
6168
val detectedRoutes = endpoints.flatMap { ReflectiveEndpointLoader.loadRoutesFromEndpoint(it) }
6269
registeredRoutes.addAll(detectedRoutes)
70+
71+
val detectedExceptionHandlers = endpoints.flatMap { ReflectiveEndpointLoader.loadExceptionHandlers(it) }
72+
registeredExceptionHandlers.addAll(detectedExceptionHandlers)
6373
}
6474

6575
}

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

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,29 @@
11
package io.javalin.community.routing.annotations
22

33
import io.javalin.community.routing.Route
4+
import io.javalin.community.routing.dsl.DefaultDslException
45
import io.javalin.community.routing.dsl.DefaultDslRoute
5-
import io.javalin.community.routing.dsl.DslRoute
66
import io.javalin.http.Context
77
import io.javalin.validation.Validator
8+
import java.lang.reflect.Method
89
import java.lang.reflect.Parameter
10+
import kotlin.reflect.KClass
11+
12+
typealias AnnotatedRoute = DefaultDslRoute<Context, Unit>
13+
typealias AnnotatedException = DefaultDslException<Context, Exception, Unit>
914

1015
object ReflectiveEndpointLoader {
1116

1217
private val repeatedPathSeparatorRegex = Regex("/+")
1318

14-
fun loadRoutesFromEndpoint(endpoint: Any): List<DslRoute<Context, Unit>> {
19+
fun loadRoutesFromEndpoint(endpoint: Any): List<AnnotatedRoute> {
1520
val endpointClass = endpoint::class.java
1621

1722
val endpointPath = endpointClass.getAnnotation(Endpoints::class.java)
1823
?.value
19-
?: throw IllegalArgumentException("Endpoint class must be annotated with @Endpoints")
24+
?: ""
2025

21-
val endpointRoutes = mutableListOf<DslRoute<Context, Unit>>()
26+
val endpointRoutes = mutableListOf<AnnotatedRoute>()
2227

2328
endpointClass.declaredMethods.forEach { method ->
2429
val (httpMethod, path, async) = when {
@@ -38,21 +43,22 @@ object ReflectiveEndpointLoader {
3843
"Unable to access method $method in class $endpointClass"
3944
}
4045

41-
val argumentSuppliers = method.parameters
42-
.map { createArgumentSupplier(it) ?: throw IllegalArgumentException("Unsupported parameter type: $it") }
46+
val argumentSuppliers = method.parameters.map {
47+
createArgumentSupplier<Unit>(it) ?: throw IllegalArgumentException("Unsupported parameter type: $it")
48+
}
4349

44-
val route = DefaultDslRoute<Context, Unit>(
50+
val route = AnnotatedRoute(
4551
method = httpMethod,
4652
path = ("/$endpointPath/$path").replace(repeatedPathSeparatorRegex, "/"),
4753
version = method.getAnnotation(Version::class.java)?.value,
4854
handler = {
4955
val arguments = argumentSuppliers
50-
.map { it(this) }
56+
.map { it(this, Unit) }
5157
.toTypedArray()
5258

5359
when (async) {
54-
true -> async { method.invoke(endpoint, *arguments) }
55-
else -> method.invoke(endpoint, *arguments)
60+
true -> async { invokeAndUnwrapIfErrored(method, endpoint, *arguments) }
61+
else -> invokeAndUnwrapIfErrored(method, endpoint, *arguments)
5662
}
5763
}
5864
)
@@ -63,51 +69,97 @@ object ReflectiveEndpointLoader {
6369
return endpointRoutes
6470
}
6571

66-
private fun createArgumentSupplier(parameter: Parameter): ((Context) -> Any?)? =
72+
@Suppress("UNCHECKED_CAST")
73+
fun loadExceptionHandlers(endpoint: Any): List<AnnotatedException> {
74+
val endpointClass = endpoint::class.java
75+
val dslExceptions = mutableListOf<AnnotatedException>()
76+
77+
endpointClass.declaredMethods.forEach { method ->
78+
val exceptionHandlerAnnotation = method.getAnnotation(ExceptionHandler::class.java) ?: return@forEach
79+
80+
require(method.trySetAccessible()) {
81+
"Unable to access method $method in class $endpointClass"
82+
}
83+
84+
val argumentSuppliers = method.parameters.map {
85+
createArgumentSupplier<Exception>(it) ?: throw IllegalArgumentException("Unsupported parameter type: $it")
86+
}
87+
88+
val dslException = AnnotatedException(
89+
type = exceptionHandlerAnnotation.value as KClass<Exception>,
90+
handler = { exception ->
91+
val arguments = argumentSuppliers
92+
.map { it(this, exception) }
93+
.toTypedArray()
94+
95+
invokeAndUnwrapIfErrored(method, endpoint, *arguments)
96+
}
97+
)
98+
99+
dslExceptions.add(dslException)
100+
}
101+
102+
return dslExceptions
103+
}
104+
105+
private fun invokeAndUnwrapIfErrored(method: Method, instance: Any, vararg arguments: Any?): Any? {
106+
return try {
107+
method.invoke(instance, *arguments)
108+
} catch (reflectionException: ReflectiveOperationException) {
109+
throw reflectionException.cause ?: reflectionException
110+
}
111+
}
112+
113+
private inline fun <reified CUSTOM : Any> createArgumentSupplier(
114+
parameter: Parameter,
115+
noinline custom: (Context, CUSTOM) -> Any? = { _, self -> self }
116+
): ((Context, CUSTOM) -> Any?)? =
67117
with (parameter) {
68118
when {
69-
type.isAssignableFrom(Context::class.java) -> return { ctx ->
119+
CUSTOM::class.java.isAssignableFrom(type) ->
120+
custom
121+
type.isAssignableFrom(Context::class.java) -> { ctx, _ ->
70122
ctx
71123
}
72-
isAnnotationPresent(Param::class.java) -> return { ctx ->
124+
isAnnotationPresent(Param::class.java) -> { ctx, _ ->
73125
getAnnotation(Param::class.java)
74126
.value
75127
.ifEmpty { name }
76128
.let { ctx.pathParamAsClass(it, type) }
77129
.get()
78130
}
79-
isAnnotationPresent(Header::class.java) -> return { ctx ->
131+
isAnnotationPresent(Header::class.java) -> { ctx, _ ->
80132
getAnnotation(Header::class.java)
81133
.value
82134
.ifEmpty { name }
83135
.let { ctx.headerAsClass(it, type) }
84136
.get()
85137
}
86-
isAnnotationPresent(Query::class.java) -> return { ctx ->
138+
isAnnotationPresent(Query::class.java) -> { ctx, _ ->
87139
getAnnotation(Query::class.java)
88140
.value
89141
.ifEmpty { name }
90142
.let { ctx.queryParamAsClass(it, type) }
91143
.get()
92144
}
93-
isAnnotationPresent(Form::class.java) -> return { ctx ->
145+
isAnnotationPresent(Form::class.java) -> { ctx, _ ->
94146
getAnnotation(Form::class.java)
95147
.value
96148
.ifEmpty { name }
97149
.let { ctx.formParamAsClass(it, type) }
98150
.get()
99151
}
100-
isAnnotationPresent(Cookie::class.java) -> return { ctx ->
152+
isAnnotationPresent(Cookie::class.java) -> { ctx, _ ->
101153
getAnnotation(Cookie::class.java)
102154
.value
103155
.ifEmpty { name }
104156
.let { Validator.create(type, ctx.cookie(it), it) }
105157
.get()
106158
}
107-
isAnnotationPresent(Body::class.java) -> return { ctx ->
159+
isAnnotationPresent(Body::class.java) -> { ctx, _ ->
108160
ctx.bodyAsClass(parameter.parameterizedType)
109161
}
110-
else -> return null
162+
else -> null
111163
}
112164
}
113165

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,6 @@ import org.junit.jupiter.api.assertDoesNotThrow
1414

1515
class AnnotatedRoutingTest {
1616

17-
@Test
18-
fun `should throw exception when endpoint class is not annotated`() {
19-
assertThatThrownBy {
20-
AnnotatedRoutingPlugin().registerEndpoints(
21-
object {
22-
@Get("/test")
23-
fun test(ctx: Context) = ctx.result("test")
24-
}
25-
)
26-
}
27-
.isExactlyInstanceOf(IllegalArgumentException::class.java)
28-
.hasMessageContaining("Endpoint class must be annotated with @Endpoints")
29-
}
30-
3117
@Test
3218
fun `should sanitize repeated path separators`() {
3319
val app = Javalin.create {
@@ -262,4 +248,21 @@ class AnnotatedRoutingTest {
262248
assertThat(v3).isEqualTo("This endpoint does not support the requested API version (3).")
263249
}
264250

251+
@Test
252+
fun `should properly handle exceptions`() =
253+
JavalinTest.test(
254+
Javalin.create {
255+
it.registerAnnotatedEndpoints(
256+
object {
257+
@Get("/throwing")
258+
fun throwing(ctx: Context): Nothing = throw IllegalStateException("This is a test")
259+
@ExceptionHandler(IllegalStateException::class)
260+
fun handleException(ctx: Context, e: IllegalStateException) = ctx.result(e::class.java.name)
261+
}
262+
)
263+
}
264+
) { _, client ->
265+
assertThat(Unirest.get("${client.origin}/throwing").asString().body).isEqualTo("java.lang.IllegalStateException")
266+
}
267+
265268
}

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import io.javalin.Javalin;
44
import io.javalin.community.routing.annotations.AnnotatedRoutingPlugin;
5+
import io.javalin.community.routing.annotations.Before;
56
import io.javalin.community.routing.annotations.Body;
67
import io.javalin.community.routing.annotations.Endpoints;
8+
import io.javalin.community.routing.annotations.ExceptionHandler;
79
import io.javalin.community.routing.annotations.Get;
810
import io.javalin.community.routing.annotations.Header;
911
import io.javalin.community.routing.annotations.Param;
@@ -55,6 +57,12 @@ public ExampleEndpoints(ExampleService exampleService) {
5557
this.exampleService = exampleService;
5658
}
5759

60+
// use Javalin-specific routes
61+
@Before
62+
void beforeEach(Context ctx) {
63+
ctx.header("X-Example", "Example");
64+
}
65+
5866
// describe http method and path with annotation
5967
@Post("/hello")
6068
// use parameters to extract data from request
@@ -85,7 +93,15 @@ void findExampleV2(Context context, @Param String name) {
8593
/* OpenApi [...] */
8694
@Version("1")
8795
@Get("/hello/{name}")
88-
void findExampleV1(Context ctx) { ctx.result("Outdated"); }
96+
void findExampleV1(Context ctx) {
97+
throw new UnsupportedOperationException("Deprecated");
98+
}
99+
100+
// register exception handlers alongside endpoints
101+
@ExceptionHandler(Exception.class)
102+
void defaultExceptionHandler(Exception e, Context ctx) {
103+
ctx.status(500).result("Something went wrong: " + e.getClass());
104+
}
89105

90106
}
91107

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ enum class Route(val isHttpMethod: Boolean = true) {
1313
POST,
1414
DELETE,
1515
AFTER(isHttpMethod = false),
16-
BEFORE(isHttpMethod = false)
16+
BEFORE(isHttpMethod = false),
1717
}
1818

19-
fun Javalin.route(route: Route, path: String, handler: Handler, vararg roles: RouteRole) {
19+
fun Javalin.registerRoute(route: Route, path: String, handler: Handler, vararg roles: RouteRole) {
2020
when (route) {
2121
Route.HEAD -> head(path, handler, *roles)
2222
Route.PATCH -> patch(path, handler, *roles)

0 commit comments

Comments
 (0)