Repository: tipsy/javalin (kotlin)

Issue:
Provide a possibility to decide if `before` & `after` handlers should be called for non-existing http routes
This issue was kinda referenced on Discord in our latest discussion about `AccessManager`, but I'm reporting this here as there was a fair point about how before/after handlers are used by users. Assuming that users have several routes like:

```
get
post
put
get
// [...] etc. 50 times
```

And they want to register one `before` handler that will be executed before each of those requests, they have two options:

1. Register `before` for each route
2. Use wildcard `*` to catch them all  // I think most users will use this even tho they want to achieve functionality of 1.

The issue with wildcard here is that it'll be also executed for non-existing http routes by default, and it might be slightly confusing for some users (I guess some of them are simply unaware of this behavior). I think it'd be a good idea to provide a possibility to specify matching policy in Javlain 6.x.

Pull request description:
This PR adds four (or eight) new methods to the routing api which enable certain use cases for both http and web sockets.

The methods are as follows: 

- `beforeMatched(path, handler)` and its non-path version `beforeMatched(handler)`
- `afterMatched(path, hander)` and the convenient `afterMatched(handler)`
- `wsBeforeUpgrade(path, handler)` and  the app-wide version `wsBeforeUpgrade(handler)` 
- `wsAfterUpgrade(path, handler)` and its sibling `wsAfterUpgrade(handler)`

before- and afterMatched work very similarly to before and after except those are only called when we actually have a matched endpoint. 

wsBeforeUpgrade and wsAfterUpgrade differ from existing websocket methods as these new ones do not operate on a WsHandler but instead on a HttpHandler! They work with Websocket upgrade requests and aim to work as a replacement for the AccessManager Websocket usecase by using exceptions to stop the upgrade.

Fixes #1864
And implements 1 and 2 from #1925

Current status represented as a checklist:

- [x] beforeMatched
- [x] afterMatched
- [x] ~~wsBeforeMatched~~ wsBeforeUpgrade
- [x] ~~wsAfterMatched~~ wsAfterUpgrade
- [x] Tests for websocket options
- [x] Test to confirm that `wsBeforeUpgrade` can deny an upgrade request.
- [x] Test for ResourceHandler
- [x] Test for SinglePageHandler
- [x] Test for HEAD request with GET Handler

Will rebase the branch in between my programming sessions and before marking this PR ready

Patch:
diff --git a/javalin/src/main/java/io/javalin/config/PrivateConfig.kt b/javalin/src/main/java/io/javalin/config/PrivateConfig.kt
index c8fe1933a..f6d1e85cd 100644
--- a/javalin/src/main/java/io/javalin/config/PrivateConfig.kt
+++ b/javalin/src/main/java/io/javalin/config/PrivateConfig.kt
@@ -28,7 +28,7 @@ class PrivateConfig(val cfg: JavalinConfig) {
     @JvmField var singlePageHandler = SinglePageHandler()
     @JvmField var wsLogger: WsConfig? = null
     @JvmField var compressionStrategy = CompressionStrategy.GZIP
-    @JvmField var servletRequestLifecycle = listOf(DefaultTasks.BEFORE, DefaultTasks.HTTP, DefaultTasks.ERROR, DefaultTasks.AFTER)
+    @JvmField var servletRequestLifecycle = listOf(DefaultTasks.BEFORE, DefaultTasks.BEFORE_MATCHED, DefaultTasks.HTTP, DefaultTasks.AFTER_MATCHED, DefaultTasks.ERROR, DefaultTasks.AFTER)
     // Jetty
     @JvmField var server: Server? = null
 
diff --git a/javalin/src/main/java/io/javalin/http/HandlerType.kt b/javalin/src/main/java/io/javalin/http/HandlerType.kt
index bdfb72f0c..ec7cedd78 100644
--- a/javalin/src/main/java/io/javalin/http/HandlerType.kt
+++ b/javalin/src/main/java/io/javalin/http/HandlerType.kt
@@ -20,6 +20,8 @@ enum class HandlerType(val isHttpMethod: Boolean = true) {
     BEFORE(isHttpMethod = false),
     BEFORE_MATCHED(isHttpMethod = false),
     AFTER_MATCHED(isHttpMethod = false),
+    WEBSOCKET_BEFORE_UPGRADE(isHttpMethod = false),
+    WEBSOCKET_AFTER_UPGRADE(isHttpMethod = false),
     AFTER(isHttpMethod = false),
     INVALID(isHttpMethod = false);
 
diff --git a/javalin/src/main/java/io/javalin/http/SinglePageHandler.kt b/javalin/src/main/java/io/javalin/http/SinglePageHandler.kt
index 677f8c447..2d6be41b6 100644
--- a/javalin/src/main/java/io/javalin/http/SinglePageHandler.kt
+++ b/javalin/src/main/java/io/javalin/http/SinglePageHandler.kt
@@ -41,6 +41,16 @@ class SinglePageHandler {
         pathHandlerMap[hostedPath] = handler
     }
 
+    fun canHandle(ctx: Context): Boolean {
+        val accept = ctx.header(Header.ACCEPT) ?: ""
+        return when {
+            ContentType.HTML !in accept && "*/*" !in accept && accept != "" -> false
+            pathPageMap.findByPath(ctx.path()) != null -> true
+            pathHandlerMap.findByPath(ctx.path()) != null -> true
+            else -> false
+        }
+    }
+
     fun handle(ctx: Context): Boolean {
         val accept = ctx.header(Header.ACCEPT) ?: ""
         if (ContentType.HTML !in accept && "*/*" !in accept && accept != "") return false
diff --git a/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt b/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
index bfd72634a..e827ba219 100644
--- a/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
+++ b/javalin/src/main/java/io/javalin/http/servlet/DefaultTasks.kt
@@ -1,6 +1,5 @@
 package io.javalin.http.servlet
 
-import io.javalin.http.Context
 import io.javalin.http.HandlerType
 import io.javalin.http.HandlerType.GET
 import io.javalin.http.HandlerType.HEAD
@@ -10,18 +9,39 @@ import io.javalin.http.servlet.SubmitOrder.FIRST
 import io.javalin.http.servlet.SubmitOrder.LAST
 import io.javalin.http.util.MethodNotAllowedUtil
 import io.javalin.security.accessManagerNotConfiguredException
-import jakarta.servlet.ServletOutputStream
 import io.javalin.util.Util.firstOrNull
-import jakarta.servlet.http.HttpServletResponseWrapper
 
 object DefaultTasks {
 
     val BEFORE = TaskInitializer<JavalinServletContext> { submitTask, servlet, ctx, requestUri ->
-        servlet.cfg.pvt.internalRouter.findHttpHandlerEntries(HandlerType.BEFORE, requestUri).forEach { entry ->
+        servlet.router.findHttpHandlerEntries(HandlerType.BEFORE, requestUri).forEach { entry ->
             submitTask(LAST, Task(skipIfExceptionOccurred = true) { entry.handle(ctx, requestUri) })
         }
     }
 
+    val BEFORE_MATCHED = TaskInitializer<JavalinServletContext> { submitTask, servlet, ctx, requestUri ->
+        val willMatch = willMatch(servlet, ctx, requestUri)
+        servlet.router.findHttpHandlerEntries(HandlerType.BEFORE_MATCHED, requestUri).forEach { entry ->
+            if (willMatch) {
+                submitTask(LAST, Task(skipIfExceptionOccurred = true) { entry.handle(ctx, requestUri) })
+            }
+        }
+    }
+
+    private fun willMatch(
+        servlet: JavalinServlet,
+        ctx: JavalinServletContext,
+        requestUri: String
+    ): Boolean {
+        return when {
+            ctx.method() == HEAD && servlet.router.hasHttpHandlerEntry(GET, requestUri) -> true
+            servlet.router.findHttpHandlerEntries(ctx.method(), requestUri).firstOrNull() != null -> true
+            servlet.cfg.pvt.resourceHandler?.canHandle(ctx) == true -> true
+            servlet.cfg.pvt.singlePageHandler.canHandle(ctx) -> true
+            else -> false
+        }
+    }
+
     val HTTP = TaskInitializer<JavalinServletContext> { submitTask, servlet, ctx, requestUri ->
         servlet.router.findHttpHandlerEntries(ctx.method(), requestUri).firstOrNull { entry ->
             submitTask(
@@ -35,7 +55,7 @@ object DefaultTasks {
                                 handler = { submitTask(FIRST, Task { entry.handle(ctx, requestUri) }) }, // we wrap the handler with [submitTask] to treat it as a separate task
                                 ctx = ctx,
                                 routeRoles = entry.roles
-                            )
+                           )
                         }
                         else -> entry.handle(ctx, requestUri)
                     }
@@ -62,6 +82,15 @@ object DefaultTasks {
         })
     }
 
+    val AFTER_MATCHED = TaskInitializer<JavalinServletContext> { submitTask, servlet, ctx, requestUri ->
+        val willMatch = willMatch(servlet, ctx, requestUri)
+        servlet.router.findHttpHandlerEntries(HandlerType.AFTER_MATCHED, requestUri).forEach { entry ->
+            if (willMatch) {
+                submitTask(LAST, Task(skipIfExceptionOccurred = false) { entry.handle(ctx, requestUri) })
+            }
+        }
+    }
+
     val ERROR = TaskInitializer<JavalinServletContext> { submitTask, servlet, ctx, _ ->
         submitTask(LAST, Task(skipIfExceptionOccurred = false) { servlet.router.handleHttpError(ctx.statusCode(), ctx) })
     }
diff --git a/javalin/src/main/java/io/javalin/http/servlet/JavalinServletContext.kt b/javalin/src/main/java/io/javalin/http/servlet/JavalinServletContext.kt
index f2a79a10e..5cb2c387a 100644
--- a/javalin/src/main/java/io/javalin/http/servlet/JavalinServletContext.kt
+++ b/javalin/src/main/java/io/javalin/http/servlet/JavalinServletContext.kt
@@ -141,7 +141,9 @@ class JavalinServletContext(
         userFutureSupplier = future
     }
 
-    override fun skipRemainingHandlers(): Context = also { tasks.clear() }
+    override fun skipRemainingHandlers(): Context = also {
+        tasks.clear()
+    }
 
     override fun writeJsonStream(stream: Stream<*>) {
         minSizeForCompression = 0
diff --git a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
index d0797406c..4f7f2b1e2 100644
--- a/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
+++ b/javalin/src/main/java/io/javalin/http/staticfiles/ResourceHandler.java
@@ -3,6 +3,8 @@
 import io.javalin.http.Context;
 
 public interface ResourceHandler {
+    boolean canHandle(Context context);
+
     boolean handle(Context context);
 
     boolean addStaticFileConfig(StaticFileConfig config);
diff --git a/javalin/src/main/java/io/javalin/jetty/JavalinJettyServlet.kt b/javalin/src/main/java/io/javalin/jetty/JavalinJettyServlet.kt
index 0a90aeadf..c685a1bb5 100644
--- a/javalin/src/main/java/io/javalin/jetty/JavalinJettyServlet.kt
+++ b/javalin/src/main/java/io/javalin/jetty/JavalinJettyServlet.kt
@@ -9,11 +9,13 @@ package io.javalin.jetty
 import io.javalin.config.JavalinConfig
 import io.javalin.http.Context
 import io.javalin.http.Handler
+import io.javalin.http.HandlerType
 import io.javalin.http.Header
 import io.javalin.http.HttpStatus
 import io.javalin.http.servlet.JavalinServlet
 import io.javalin.http.servlet.JavalinServletContext
 import io.javalin.http.servlet.JavalinServletContextConfig
+import io.javalin.http.servlet.Task
 import io.javalin.security.accessManagerNotConfiguredException
 import io.javalin.websocket.*
 import jakarta.servlet.http.HttpServletRequest
@@ -70,7 +72,24 @@ class JavalinJettyServlet(val cfg: JavalinConfig, private val httpServlet: Javal
         if (!allowedByAccessManager(entry, upgradeContext)) return res.sendError(HttpStatus.UNAUTHORIZED.code, HttpStatus.UNAUTHORIZED.message)
         req.setAttribute(upgradeContextKey, upgradeContext)
         setWsProtocolHeader(req, res)
-        super.service(req, res) // everything is okay, perform websocket upgrade
+        // add before handlers
+        cfg.pvt.internalRouter.findHttpHandlerEntries(HandlerType.WEBSOCKET_BEFORE_UPGRADE, requestUri)
+            .forEach { handler -> upgradeContext.tasks.offer(Task { handler.handle(upgradeContext, requestUri) }) }
+        // add actual upgrade handler
+        upgradeContext.tasks.offer(Task { super.service(req, res) })
+        // add after handlers
+        cfg.pvt.internalRouter.findHttpHandlerEntries(HandlerType.WEBSOCKET_AFTER_UPGRADE, requestUri)
+            .forEach { handler -> upgradeContext.tasks.offer(Task { handler.handle(upgradeContext, requestUri) }) }
+
+        while (upgradeContext.tasks.isNotEmpty()) {
+            try {
+                val task = upgradeContext.tasks.poll()
+                task.handler.handle()
+            } catch (e: Exception) {
+                cfg.pvt.internalRouter.handleHttpException(upgradeContext, e)
+                break
+            }
+        }
     }
 
     private val setUpgradeAllowed = Handler { it.attribute("javalin-ws-upgrade-allowed", true) }
diff --git a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
index 443bf5164..1c54982ee 100644
--- a/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
+++ b/javalin/src/main/java/io/javalin/jetty/JettyResourceHandler.kt
@@ -40,10 +40,19 @@ class JettyResourceHandler(val pvt: PrivateConfig) : JavalinResourceHandler {
     override fun addStaticFileConfig(config: StaticFileConfig): Boolean =
         if (pvt.server?.isStarted == true) handlers.add(ConfigurableHandler(config, pvt.server!!)) else lateInitConfigs.add(config)
 
+    override fun canHandle(ctx: Context) = nonSkippedHandlers().any { handler ->
+        return try {
+            fileOrWelcomeFile(handler, ctx.target) != null
+        } catch (e: Exception) {
+            e.message?.contains("Rejected alias reference") == true ||  // we want to say these are un-handleable (404)
+                e.message?.contains("Failed alias check") == true // we want to say these are un-handleable (404)
+        }
+    }
+
     override fun handle(ctx: Context): Boolean {
-        handlers.filter { !it.config.skipFileFunction(ctx.req()) }.forEach { handler ->
+        nonSkippedHandlers().forEach { handler ->
             try {
-                val target = ctx.req().requestURI.removePrefix(ctx.req().contextPath)
+                val target = ctx.target
                 val fileOrWelcomeFile = fileOrWelcomeFile(handler, target)
                 if (fileOrWelcomeFile != null) {
                     handler.config.headers.forEach { ctx.header(it.key, it.value) } // set user headers
@@ -72,6 +81,11 @@ class JettyResourceHandler(val pvt: PrivateConfig) : JavalinResourceHandler {
 
     private fun jettyRequest() = HttpConnection.getCurrentConnection().httpChannel.request as Request
 
+    private fun nonSkippedHandlers() =
+        handlers.asSequence().filter { !it.config.skipFileFunction(jettyRequest()) }
+
+    private val Context.target get() = this.req().requestURI.removePrefix(this.req().contextPath)
+
 }
 
 open class ConfigurableHandler(val config: StaticFileConfig, jettyServer: Server) : ResourceHandler() {
diff --git a/javalin/src/main/java/io/javalin/router/JavalinDefaultRoutingApi.kt b/javalin/src/main/java/io/javalin/router/JavalinDefaultRoutingApi.kt
index 4308c86d0..f35141f32 100644
--- a/javalin/src/main/java/io/javalin/router/JavalinDefaultRoutingApi.kt
+++ b/javalin/src/main/java/io/javalin/router/JavalinDefaultRoutingApi.kt
@@ -5,7 +5,9 @@ import io.javalin.http.ExceptionHandler
 import io.javalin.http.Handler
 import io.javalin.http.HandlerType
 import io.javalin.http.HandlerType.AFTER
+import io.javalin.http.HandlerType.AFTER_MATCHED
 import io.javalin.http.HandlerType.BEFORE
+import io.javalin.http.HandlerType.BEFORE_MATCHED
 import io.javalin.http.HandlerType.DELETE
 import io.javalin.http.HandlerType.GET
 import io.javalin.http.HandlerType.HEAD
@@ -13,6 +15,8 @@ import io.javalin.http.HandlerType.OPTIONS
 import io.javalin.http.HandlerType.PATCH
 import io.javalin.http.HandlerType.POST
 import io.javalin.http.HandlerType.PUT
+import io.javalin.http.HandlerType.WEBSOCKET_AFTER_UPGRADE
+import io.javalin.http.HandlerType.WEBSOCKET_BEFORE_UPGRADE
 import io.javalin.http.HttpStatus
 import io.javalin.http.sse.SseClient
 import io.javalin.http.sse.SseHandler
@@ -232,6 +236,18 @@ interface JavalinDefaultRoutingApi<API : RoutingApi> : RoutingApi {
      */
     fun before(handler: Handler): API = before("*", handler)
 
+    /**
+     * Adds a BEFORE_MATCHED request handler for the specified path to the instance.
+     * See: [Handlers in docs](https://javalin.io/documentation.before-handlers)
+     */
+    fun beforeMatched(path: String, handler: Handler): API = addHttpHandler(BEFORE_MATCHED, path, handler)
+
+    /**
+     * Adds a BEFORE_MATCHED request handler for all routes in the instance.
+     * See: [Handlers in docs](https://javalin.io/documentation.before-handlers)
+     */
+    fun beforeMatched(handler: Handler): API = beforeMatched("*", handler)
+
     /**
      * Adds an AFTER request handler for the specified path to the instance.
      * See: [Handlers in docs](https://javalin.io/documentation.before-handlers)
@@ -244,6 +260,18 @@ interface JavalinDefaultRoutingApi<API : RoutingApi> : RoutingApi {
      */
     fun after(handler: Handler): API = after("*", handler)
 
+    /**
+     * Adds an AFTER_MATCHED request handler for the specified path to the instance.
+     * See: [Handlers in docs](https://javalin.io/documentation.before-handlers)
+     */
+    fun afterMatched(path: String, handler: Handler): API = addHttpHandler(AFTER_MATCHED, path, handler)
+
+    /**
+     * Adds an AFTER_MATCHED request handler for all routes in the instance.
+     * See: [Handlers in docs](https://javalin.io/documentation.before-handlers)
+     */
+    fun afterMatched(handler: Handler): API = afterMatched("*", handler)
+
     /**
      * Adds a WebSocket exception mapper to the instance.
      * See: [Exception mapping in docs](https://javalin.io/documentation.exception-mapping)
@@ -280,6 +308,16 @@ interface JavalinDefaultRoutingApi<API : RoutingApi> : RoutingApi {
      */
     fun wsBefore(wsConfig: Consumer<WsConfig>): API = wsBefore("*", wsConfig)
 
+    /**
+     * Adds a WebSocket before upgrade handler for the specified path to the instance.
+     */
+    fun wsBeforeUpgrade(path: String, handler: Handler): API = addHttpHandler(WEBSOCKET_BEFORE_UPGRADE, path, handler)
+
+    /**
+     * Adds a WebSocket before upgrade handler for all routes in the instance.
+     */
+    fun wsBeforeUpgrade(handler: Handler): API = wsBeforeUpgrade("*", handler)
+
     /**
      * Adds a WebSocket after handler for the specified path to the instance.
      */
@@ -290,4 +328,14 @@ interface JavalinDefaultRoutingApi<API : RoutingApi> : RoutingApi {
      */
     fun wsAfter(wsConfig: Consumer<WsConfig>): API = wsAfter("*", wsConfig)
 
+    /**
+     * Adds a WebSocket after upgrade handler for the specified path to the instance.
+     */
+    fun wsAfterUpgrade(path: String, handler: Handler): API = addHttpHandler(WEBSOCKET_AFTER_UPGRADE, path, handler)
+
+    /**
+     * Adds a WebSocket after upgrade handler for all routes in the instance.
+     */
+    fun wsAfterUpgrade(handler: Handler): API = wsAfterUpgrade("*", handler)
+
 }


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestBeforeAfterMatched.kt b/javalin/src/test/java/io/javalin/TestBeforeAfterMatched.kt
new file mode 100644
index 000000000..5ff220f93
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestBeforeAfterMatched.kt
@@ -0,0 +1,319 @@
+package io.javalin
+
+import io.javalin.http.ContentType
+import io.javalin.http.HttpStatus
+import io.javalin.http.servlet.DefaultTasks.AFTER
+import io.javalin.http.servlet.DefaultTasks.AFTER_MATCHED
+import io.javalin.http.servlet.DefaultTasks.BEFORE
+import io.javalin.http.servlet.DefaultTasks.ERROR
+import io.javalin.http.servlet.DefaultTasks.HTTP
+import io.javalin.http.staticfiles.Location
+import io.javalin.testing.TestUtil
+import kong.unirest.HttpResponse
+import org.assertj.core.api.Assertions.assertThat
+import org.eclipse.jetty.server.handler.ContextHandler
+import org.junit.jupiter.api.Test
+
+class TestBeforeAfterMatched {
+
+    @Test
+    fun `beforeMatched and afterMatched work`() = TestUtil.test { app, http ->
+        app.before { it.result("foo") }
+        app.beforeMatched { it.result("before-matched") }
+        app.get("/hello") { it.result(it.result() + "-hello") }
+        app.afterMatched { it.result(it.result() + "-after-matched") }
+        app.after { it.result(it.result() + "!") }
+
+        assertThat(http.getBody("/other-path")).isEqualToIgnoringCase("Not Found!")
+        assertThat(http.getBody("/hello")).isEqualTo("before-matched-hello-after-matched!")
+    }
+
+    @Test
+    fun `path - totally different path leads to 404 without global headers`() = TestUtil.test { app, http ->
+        setAppRoutes(app)
+        setHeaders(app)
+
+        val path = "/other"
+        val response = http.get(path)
+        assertResponse(
+            response,
+            path,
+            404,
+            "Not Found",
+            "",
+            "",
+            ""
+        )
+    }
+
+    @Test
+    fun `path - matching sub curly in beforeMatched but no matching http handler leads to 404`() = TestUtil.test { app, http ->
+        setAppRoutes(app)
+        setHeaders(app)
+
+        val path = "/sub/id/fake"
+        val response = http.get(path)
+        assertResponse(
+            response,
+            path,
+            404,
+            "Not Found",
+            "",
+            "",
+            ""
+        )
+    }
+
+    @Test
+    fun `path - matching angle brackets in beforeMatched but no matching http handler leads to 404`() = TestUtil.test { app, http ->
+        setAppRoutes(app)
+        setHeaders(app)
+
+        val path = "/angle/i/see/nothing"
+        val response = http.get(path)
+        assertResponse(
+            response,
+            path,
+            404,
+            "Not Found",
+            "",
+            "",
+            ""
+        )
+    }
+
+    @Test
+    fun `path - perfect match`() = TestUtil.test { app, http ->
+        setAppRoutes(app)
+        setHeaders(app)
+
+        val path = "/root"
+        val response = http.get(path)
+        assertResponse(
+            response,
+            path,
+            200,
+            "root",
+            "true",
+            "",
+            ""
+        )
+    }
+
+    @Test
+    fun `path - sub curly in before matched happy path`() = TestUtil.test { app, http ->
+        setAppRoutes(app)
+        setHeaders(app)
+
+        val path = "/sub/id/other/stuff"
+        val response = http.get(path)
+        assertResponse(
+            response,
+            path,
+            200,
+            "id-other-stuff",
+            "true",
+            "id",
+            ""
+        )
+    }
+
+    @Test
+    fun `path - angle brackets in beforeMatched happy path`() = TestUtil.test { app, http ->
+        setAppRoutes(app)
+        setHeaders(app)
+
+        val path = "/angle/i/see/slashes"
+        val response = http.get(path)
+        assertResponse(
+            response,
+            path,
+            200,
+            "i-see-slashes",
+            "true",
+            "",
+            "i/see/slashes"
+        )
+    }
+
+
+    private fun setAppRoutes(app: Javalin) {
+        app.get("/root") { it.result("root") }
+        app.get("/sub/id/other/stuff") { it.result("id-other-stuff") }
+        app.get("/angle/i/see/slashes") { it.result("i-see-slashes") }
+    }
+
+    private fun setHeaders(app: Javalin) {
+        app.before { it.header("X-Always", "true") }
+        app.beforeMatched { it.header("X-Before-Star", "true") }
+        app.beforeMatched("/sub/{p}*") { it.header("X-Before-Sub-Curly", it.pathParam("p")) }
+        app.beforeMatched("/angle/<a>") { it.header("X-Before-Angle", it.pathParam("a")) }
+        app.afterMatched { it.header("X-After-Star", "true") }
+    }
+
+    private fun assertResponse(
+        res: HttpResponse<String>, path: String,
+        statusCode: Int, body: String?,
+        beforeStar: String?, beforeSubCurly: String?,
+        beforeAngle: String?
+    ) {
+        assertThat(res.status).describedAs("$path - status").isEqualTo(statusCode)
+        assertThat(res.body).describedAs("$path - body").isEqualTo(body)
+        assertThat(res.headers.getFirst("X-Always")).describedAs("$path - X-Always").isEqualTo("true")
+        assertThat(res.headers.getFirst("X-Before-Star")).describedAs("$path - X-Before-Star")
+            .isEqualTo(beforeStar)
+        assertThat(res.headers.getFirst("X-After-Star")).describedAs("$path - X-After-Star")
+            .isEqualTo(beforeStar)
+        assertThat(res.headers.getFirst("X-Before-Sub-Curly")).describedAs("$path - X-Before-Sub-Curly")
+            .isEqualTo(beforeSubCurly)
+        assertThat(res.headers.getFirst("X-Before-Angle")).describedAs("$path - X-Before-Angle")
+            .isEqualTo(beforeAngle)
+    }
+
+    @Test
+    fun `beforeMatched can skip remaining handlers`() = TestUtil.test { app, http ->
+        app.beforeMatched {
+            it.result("static-before")
+            it.skipRemainingHandlers()
+        }
+        app.get("/hello") { it.result("hello") }
+        assertThat(http.getBody("/other-path")).isEqualToIgnoringCase("Not Found")
+        assertThat(http.getBody("/hello")).isEqualTo("static-before")
+    }
+
+    @Test
+    fun `beforeMatched works with singlePageHandler`() = TestUtil.test(Javalin.create { config ->
+        config.spaRoot.addHandler("/") { ctx ->
+            ctx.result(ctx.attribute<String>("before") ?: "n/a")
+        }
+    }) { app, http ->
+        app.beforeMatched { it.attribute("before", "matched") }
+        app.afterMatched { it.result(it.result() + "!") }
+
+        assertThat(http.getBody("/")).isEqualTo("matched!")
+        assertThat(http.getBody("/other")).isEqualTo("matched!")
+    }
+
+    @Test
+    fun `beforeMatched fires for head request on get handler`() = TestUtil.test { app, http ->
+        app.beforeMatched { it.status(HttpStatus.IM_A_TEAPOT) }
+        app.get("/hello") { it.result("hello") }
+        app.afterMatched { it.result(it.result() + "!") }
+
+        assertThat(http.call(kong.unirest.HttpMethod.HEAD, "/hello").status).isEqualTo(418)
+        assertThat(http.getStatus("/hello")).isEqualTo(HttpStatus.IM_A_TEAPOT)
+        assertThat(http.getBody("/hello")).isEqualTo("hello!")
+        assertThat(http.getStatus("/other")).isEqualTo(HttpStatus.NOT_FOUND)
+    }
+
+    @Test
+    fun `beforeMatched runs for ResourceHandler`() = TestUtil.test(Javalin.create { config ->
+        config.staticFiles.add("public", Location.CLASSPATH)
+    }) { app, http ->
+        app.beforeMatched { it.header("X-Matched-Before", "true") }
+        app.afterMatched { it.header("X-Matched-After", "true") }
+
+        val res = http.get("/html.html")
+        assertThat(res.status).describedAs("status").isEqualTo(HttpStatus.OK.code)
+        assertThat(res.headers.getFirst("X-Matched-Before")).describedAs("before-header").isEqualTo("true")
+        assertThat(res.headers.getFirst("X-Matched-After")).describedAs("after-header").isEqualTo("true")
+        assertThat(res.headers.getFirst("Content-Type")).describedAs("content-type").isEqualTo(ContentType.HTML)
+        assertThat(res.body).describedAs("body").contains("<h1>HTML works</h1>")
+    }
+
+    @Test
+    fun `beforeMatched runs for ResourceHandler - precompress`() = TestUtil.test(Javalin.create { config ->
+        config.staticFiles.add {
+            it.directory = "public"
+            it.location = Location.CLASSPATH
+            it.precompress = true
+        }
+    }) { app, http ->
+        var afterMatchedRan = false
+        var afterRan = false
+        app.beforeMatched { it.header("X-Matched-Before", "true") }
+
+        app.afterMatched {
+            it.header("X-Matched-After", "true")
+            afterMatchedRan = true
+        }
+
+        app.after {
+            it.header("X-After", "true")
+            afterRan = true
+        }
+
+        val res = http.get("/html.html")
+        assertThat(res.status).describedAs("status").isEqualTo(HttpStatus.OK.code)
+        assertThat(res.headers).isNotNull
+        assertThat(res.headers.getFirst("X-Matched-Before")).describedAs("before-header").isEqualTo("true")
+        assertThat(afterMatchedRan).describedAs("after-matched-ran").isEqualTo(true)
+        assertThat(afterRan).describedAs("after-ran").isEqualTo(true)
+        assertThat(res.headers.getFirst("X-After")).describedAs("after-header").isEqualTo("true")
+        assertThat(res.headers.getFirst("X-Matched-After")).describedAs("after-matched-header").isEqualTo("true")
+        assertThat(res.headers.getFirst("Content-Type")).describedAs("content-type").isEqualTo(ContentType.HTML)
+        assertThat(res.body).describedAs("body").contains("<h1>HTML works</h1>")
+    }
+
+    @Test
+    fun `alias problem does not occur`() = TestUtil.test(Javalin.create { config ->
+        config.staticFiles.add {
+            it.directory = "public"
+            it.location = Location.CLASSPATH
+        }
+    }) { app, http ->
+        app.afterMatched { it.header("X-After", "true") }
+
+        val slash = http.get("/file/")
+        assertThat(slash.status).isEqualTo(HttpStatus.NOT_FOUND.code)
+
+        val noSlash = http.get("/file")
+        assertThat(noSlash.body).isEqualTo("TESTFILE")
+        assertThat(noSlash.status).isEqualTo(HttpStatus.OK.code)
+        assertThat(noSlash.headers.getFirst("X-After")).isEqualTo("true")
+    }
+
+    @Test
+    fun `alias problem does not occur with alias check`() = TestUtil.test(Javalin.create { config ->
+        config.staticFiles.add {
+            it.directory = "public"
+            it.location = Location.CLASSPATH
+            it.aliasCheck = ContextHandler.AliasCheck { _, _ -> true }
+        }
+    }) { app, http ->
+        app.afterMatched { it.header("X-After", "true") }
+
+        val noSlash = http.get("/file")
+        assertThat(noSlash.body).isEqualTo("TESTFILE")
+        assertThat(noSlash.status).isEqualTo(HttpStatus.OK.code)
+        assertThat(noSlash.headers.getFirst("X-After")).isEqualTo("true")
+
+        val slash = http.get("/file/")
+        assertThat(slash.body).isEqualTo("TESTFILE")
+        assertThat(slash.status).isEqualTo(HttpStatus.OK.code)
+        assertThat(slash.headers.getFirst("X-After")).isEqualTo("true")
+    }
+
+    @Test
+    fun `alias problem does not occur when the BEFORE_MATCHED stage is skipped`() =
+        TestUtil.test(Javalin.create { config ->
+            config.staticFiles.add {
+                it.directory = "public"
+                it.location = Location.CLASSPATH
+                it.aliasCheck = ContextHandler.AliasCheck { _, _ -> true }
+            }
+            config.pvt.servletRequestLifecycle = listOf(BEFORE, HTTP, AFTER_MATCHED, ERROR, AFTER)
+        }) { app, http ->
+            app.afterMatched { it.header("X-After", "true") }
+
+            val noSlash = http.get("/file")
+            assertThat(noSlash.body).isEqualTo("TESTFILE")
+            assertThat(noSlash.status).isEqualTo(HttpStatus.OK.code)
+            assertThat(noSlash.headers.getFirst("X-After")).isEqualTo("true")
+
+            val slash = http.get("/file/")
+            assertThat(slash.body).isEqualTo("TESTFILE")
+            assertThat(slash.status).isEqualTo(HttpStatus.OK.code)
+            assertThat(slash.headers.getFirst("X-After")).isEqualTo("true")
+        }
+}
diff --git a/javalin/src/test/java/io/javalin/TestWebSocket.kt b/javalin/src/test/java/io/javalin/TestWebSocket.kt
index 8ac05a29d..1df128909 100644
--- a/javalin/src/test/java/io/javalin/TestWebSocket.kt
+++ b/javalin/src/test/java/io/javalin/TestWebSocket.kt
@@ -9,6 +9,7 @@ package io.javalin
 import io.javalin.apibuilder.ApiBuilder.ws
 import io.javalin.config.JavalinConfig
 import io.javalin.http.Header
+import io.javalin.http.HttpStatus
 import io.javalin.http.UnauthorizedResponse
 import io.javalin.json.toJsonString
 import io.javalin.plugin.bundled.DevLoggingPlugin.Companion.DevLogging
@@ -603,6 +604,120 @@ class TestWebSocket {
         client.disconnectBlocking()
     }
 
+    @Test
+    fun `wsBeforeUpgrade and wsAfterUpgrade are invoked`() = TestUtil.test { app, _ ->
+        app.wsBeforeUpgrade {
+            app.logger().log.add("before")
+        }
+
+        app.wsAfterUpgrade {
+            app.logger().log.add("after")
+        }
+
+        app.ws("/ws") {}
+        Unirest.get("http://localhost:${app.port()}/ws")
+            .header(Header.SEC_WEBSOCKET_KEY, "not-null")
+            .asString()
+        assertThat(app.logger().log).containsExactly("before", "after")
+    }
+
+    @Test
+    fun `wsBeforeUpgrade can modify the upgrade request but wsAfterUpgrade can not`() = TestUtil.test { app, _ ->
+        app.wsBeforeUpgrade { ctx ->
+            ctx.header("X-Before", "demo")
+        }
+        app.wsAfterUpgrade { ctx ->
+            ctx.header("X-After", "after")
+        }
+
+        app.ws("/ws") {}
+        val response = Unirest.get("http://localhost:${app.port()}/ws")
+            .header(Header.SEC_WEBSOCKET_KEY, "not-null")
+            .asString()
+        assertThat(response.headers.getFirst("X-Before")).isEqualTo("demo")
+        assertThat(response.headers.containsKey("X-After")).isFalse()
+    }
+
+    @Test
+    fun `wsBeforeUpgrade can stop an upgrade request in progress`() = TestUtil.test { app, _ ->
+        app.wsBeforeUpgrade { _ ->
+            throw IllegalStateException("denied")
+        }
+
+        app.ws("/ws") { ws ->
+            ws.onConnect {
+                app.logger().log.add("connected")
+            }
+        }
+
+        val response = Unirest.get("http://localhost:${app.port()}/ws")
+            .header(Header.SEC_WEBSOCKET_KEY, "not-null")
+            .asString()
+        assertThat(response.status).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.code)
+        assertThat(app.logger().log).isEmpty()
+    }
+
+    @Test
+    fun `wsBeforeUpgrade exception pattern can be combined with a custom exception handler`() = TestUtil.test { app, _ ->
+        app.wsBeforeUpgrade {
+            throw IllegalStateException("denied")
+        }
+
+        app.exception(IllegalStateException::class.java) { _, ctx ->
+            app.logger().log.add("exception handled")
+            ctx.status(HttpStatus.FORBIDDEN)
+        }
+
+        app.ws("/ws") {}
+
+        val response = Unirest.get("http://localhost:${app.port()}/ws")
+            .header(Header.SEC_WEBSOCKET_KEY, "not-null")
+            .asString()
+        assertThat(app.logger().log).containsExactly("exception handled")
+        assertThat(response.status).isEqualTo(HttpStatus.FORBIDDEN.code)
+    }
+
+    @Test
+    fun `wsBeforeUpgrade does work with skipRemainingHandlers`() = TestUtil.test { app, _ ->
+        app.wsBeforeUpgrade { it.status(HttpStatus.FORBIDDEN).skipRemainingHandlers() }
+
+        app.ws("/ws") { ws ->
+            ws.onConnect {
+                app.logger().log.add("connected")
+            }
+        }
+
+        val client = TestClient(app, "/ws")
+        client.connectAndDisconnect()
+        val response = Unirest.get("http://localhost:${app.port()}/ws")
+            .header(Header.SEC_WEBSOCKET_KEY, "not-null")
+            .asString()
+        assertThat(response.status).isEqualTo(HttpStatus.FORBIDDEN.code)
+        assertThat(app.logger().log).isEmpty()
+    }
+
+    @Test
+    fun `wsBeforeUpgrade in full lifecycle`() = TestUtil.test { app, _ ->
+        app.wsBeforeUpgrade {
+            app.logger().log.add("before-upgrade")
+        }
+
+        app.wsAfterUpgrade {
+            app.logger().log.add("after-upgrade")
+        }
+
+        app.ws("/ws") { ws ->
+            ws.onConnect { app.logger().log.add("connect") }
+            ws.onMessage { app.logger().log.add("msg") }
+            ws.onClose { app.logger().log.add("close") }
+        }
+
+        val client = TestClient(app, "/ws")
+        client.connectSendAndDisconnect("test-message")
+        assertThat(app.logger().log).containsExactly("before-upgrade", "after-upgrade", "connect", "msg", "close")
+    }
+
+
     // ********************************************************************************************
     // Helpers
     // ********************************************************************************************