diff --git a/changelog.md b/changelog.md
index c20ce25..9006990 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,19 +1,21 @@
# Vertx Utils changelog
## [2.1.0] - UNRELEASED
### Breaking Changes
-* None.
+* Updated to use Vert.x v5, which removes deprecated functions from v4, and moves to a `Future` based approach instead of callbacks.
### New Features
* None.
### Enhancements
-* None.
+* `CATCH_ALL_API_FAILURE_HANDLER` and `CATCH_ALL_API_FAILURE_HANDLER_WITH_EXCEPTION_LOGGING` now:
+ * Return the status code of a failed `context`, if it is set, instead of always using `500 - INTERNAL_SERVER_ERROR`.
+ * Capture and ignore `ClosedChannelException` exceptions in addition to `VertxException` versions.
### Fixes
* None.
### Notes
-* None.
+* Removed the `com.jayway.awaitility:awaitility` dependency. Its scope was `provided`, so this should have no impact.
## [2.0.0] - 2026-05-21
### Breaking Changes
diff --git a/pom.xml b/pom.xml
index de6dda9..1a18db0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@
com.zepben.maven
evolve-super-pom
- 0.49.0
+ 0.51.0
4.0.0
@@ -128,9 +128,9 @@
provided
- com.jayway.awaitility
- awaitility
- provided
+ io.mockk
+ mockk-jvm
+ test
org.mockito
diff --git a/src/main/java/com/zepben/vertxutils/routing/handlers/UtilHandlers.kt b/src/main/java/com/zepben/vertxutils/routing/handlers/UtilHandlers.kt
index de4479e..a9b2c52 100644
--- a/src/main/java/com/zepben/vertxutils/routing/handlers/UtilHandlers.kt
+++ b/src/main/java/com/zepben/vertxutils/routing/handlers/UtilHandlers.kt
@@ -14,13 +14,14 @@ import io.vertx.core.Handler
import io.vertx.core.VertxException
import io.vertx.ext.web.RoutingContext
import org.slf4j.Logger
+import java.nio.channels.ClosedChannelException
object UtilHandlers {
/**
- * Returns the default failure handler that if the [RoutingContext.failure] is not null responds with a
- * 500 message with our own "standardised" errors JSON response (See [ErrorFormatter.asJson])
- * containing the message from the failure. If a logger is specified, it will also log the stacktrace on the server side
+ * Returns the default failure handler that, if the [RoutingContext.failure] is not null, responds with our own "standardised"
+ * errors JSON response (See [ErrorFormatter.asJson]). The status of this message will default to 500 if no status is provided,
+ * and will contain the message from the failure. If a logger is specified, it will also log the stacktrace on the server side.
*/
val CATCH_ALL_API_FAILURE_HANDLER_WITH_EXCEPTION_LOGGING: (Logger?) -> Handler = { logger ->
Handler { context: RoutingContext ->
@@ -28,9 +29,16 @@ object UtilHandlers {
if (failure != null && !context.response().ended()) {
logger?.error("Error stack trace:", failure)
- Respond.withJson(context, HttpResponseStatus.INTERNAL_SERVER_ERROR, ErrorFormatter.asJson(failure.toString()))
+ // Use the status of the context if it has been set. This allows handlers to simply fail the context (standard Vert.x
+ // behaviour), instead of needing to send a response to avoid a 500 overwrite of the status.
+ Respond.withJson(
+ context,
+ context.statusCode().takeUnless { it == -1 }?.let { HttpResponseStatus.valueOf(it) }
+ ?: HttpResponseStatus.INTERNAL_SERVER_ERROR,
+ ErrorFormatter.asJson(failure.toString()),
+ )
return@Handler
- } else if (failure is VertxException && failure.message == "Connection was closed") {
+ } else if ((failure is VertxException) && (failure.message == "Connection was closed") || (failure is ClosedChannelException)) {
// Don't call context.next() in this case because it logs it. We don't care.
return@Handler
}
diff --git a/src/main/java/com/zepben/vertxutils/testing/DeployRestVerticleHelper.kt b/src/main/java/com/zepben/vertxutils/testing/DeployRestVerticleHelper.kt
index 6fddf33..833eb3b 100644
--- a/src/main/java/com/zepben/vertxutils/testing/DeployRestVerticleHelper.kt
+++ b/src/main/java/com/zepben/vertxutils/testing/DeployRestVerticleHelper.kt
@@ -7,18 +7,15 @@
*/
package com.zepben.vertxutils.testing
-import com.jayway.awaitility.Awaitility
import io.restassured.builder.RequestSpecBuilder
import io.restassured.specification.RequestSpecification
import io.vertx.core.DeploymentOptions
-import io.vertx.core.Promise
import io.vertx.core.Vertx
import io.vertx.core.json.JsonObject
import java.io.IOException
import java.lang.AutoCloseable
import java.net.ServerSocket
import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicBoolean
class DeployRestVerticleHelper(
verticleClass: Class<*>,
@@ -34,22 +31,15 @@ class DeployRestVerticleHelper(
config.put("http.port", port)
// Start the server
- val promise = Promise.promise()
- val future = promise.future()
vertx = Vertx.vertx()
- val options = DeploymentOptions().setConfig(config)
- vertx.deployVerticle(
- verticleClass.getName(),
- options,
- ) { ar ->
- if (ar!!.succeeded()) promise.complete()
- else promise.fail(ar.cause())
+ runCatching {
+ vertx.deployVerticle(verticleClass.getName(), DeploymentOptions().setConfig(config))
+ .await(5, TimeUnit.SECONDS)
+ }.onFailure {
+ // Catch any exception raised and convert it to an `AssertionError` for the testing framework.
+ throw AssertionError(it.message, it)
}
- Awaitility.await().atMost(5, TimeUnit.SECONDS).until { future.isComplete }
-
- if (!future.succeeded()) throw AssertionError(future.cause().message)
-
requestSpec = RequestSpecBuilder().setBaseUri("http://localhost").setPort(port).build()
} catch (ex: IOException) {
throw AssertionError("Failed to start server", ex)
@@ -57,9 +47,7 @@ class DeployRestVerticleHelper(
}
override fun close() {
- val done = AtomicBoolean(false)
- vertx.close { done.set(true) }
- Awaitility.await().until { done.get() }
+ vertx.close().await()
}
@get:Throws(IOException::class)
diff --git a/src/main/java/com/zepben/vertxutils/testing/TestHttpServer.kt b/src/main/java/com/zepben/vertxutils/testing/TestHttpServer.kt
index 578478b..5510863 100644
--- a/src/main/java/com/zepben/vertxutils/testing/TestHttpServer.kt
+++ b/src/main/java/com/zepben/vertxutils/testing/TestHttpServer.kt
@@ -40,7 +40,7 @@ class TestHttpServer(
server.requestHandler(router)
.listen(
this.randomPortNumber,
- ) { res ->
+ ).onComplete { res ->
if (res.failed()) throw RuntimeException(res.cause())
latch.countDown()
}
@@ -55,8 +55,8 @@ class TestHttpServer(
override fun close() {
val latch = CountDownLatch(2)
- server.close { latch.countDown() }
- vertx.close { latch.countDown() }
+ server.close().onComplete { latch.countDown() }
+ vertx.close().onComplete { latch.countDown() }
try {
latch.await()
diff --git a/src/test/java/com/zepben/vertxutils/routing/ErrorFormatterTest.kt b/src/test/java/com/zepben/vertxutils/routing/ErrorFormatterTest.kt
index 31c4a91..d8800b0 100644
--- a/src/test/java/com/zepben/vertxutils/routing/ErrorFormatterTest.kt
+++ b/src/test/java/com/zepben/vertxutils/routing/ErrorFormatterTest.kt
@@ -7,21 +7,13 @@
*/
package com.zepben.vertxutils.routing
-import com.google.common.net.HttpHeaders
-import com.google.common.net.MediaType
import com.zepben.testutils.junit.SystemLogExtension
import com.zepben.vertxutils.routing.ErrorFormatter.asJson
-import com.zepben.vertxutils.routing.handlers.UtilHandlers.CATCH_ALL_API_FAILURE_HANDLER
-import com.zepben.vertxutils.routing.handlers.UtilHandlers.REDIRECT_NO_TRAILING_SLASH_TO_TRAILING_SLASH_HANDLER
-import io.vertx.core.http.HttpServerRequest
-import io.vertx.core.http.HttpServerResponse
import io.vertx.core.json.JsonObject
-import io.vertx.ext.web.RoutingContext
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
-import org.mockito.Mockito.*
class ErrorFormatterTest {
@@ -33,37 +25,6 @@ class ErrorFormatterTest {
}
- @Test
- fun defaultFailureHandler() {
- val context = mock()
- val response = mock(RETURNS_SELF)
- doReturn(response).`when`(context).response()
-
- val failure: Throwable = RuntimeException("test")
- doReturn(failure).`when`(context).failure()
- CATCH_ALL_API_FAILURE_HANDLER.handle(context)
-
- verify(response).putHeader(HttpHeaders.CONTENT_TYPE, MediaType.JSON_UTF_8.toString())
- verify(response).end(asJson(failure.toString()))
- }
-
- @Test
- fun redirectNoTrailingToTrailing() {
- val context = mock(RoutingContext::class.java)
- val request = mock()
- val response = mock(RETURNS_SELF)
- doReturn(request).`when`(context).request()
- doReturn(response).`when`(context).response()
- doReturn("/some/path/without/slash").`when`(request).path()
- doReturn("test=true").`when`(request).query()
-
- REDIRECT_NO_TRAILING_SLASH_TO_TRAILING_SLASH_HANDLER.handle(context)
-
- verify(response).putHeader("Location", "/some/path/without/slash/?test=true")
- verify(response).statusCode = 301
- verify(response).end()
- }
-
@Test
fun errorToJson() {
val err = "err"
diff --git a/src/test/java/com/zepben/vertxutils/routing/StaticAssetRoutesTest.kt b/src/test/java/com/zepben/vertxutils/routing/StaticAssetRoutesTest.kt
index ab955be..dd2f8b7 100644
--- a/src/test/java/com/zepben/vertxutils/routing/StaticAssetRoutesTest.kt
+++ b/src/test/java/com/zepben/vertxutils/routing/StaticAssetRoutesTest.kt
@@ -46,7 +46,7 @@ class StaticAssetRoutesTest {
@AfterEach
fun tearDown() {
val latch = CountDownLatch(1)
- vertx.close { latch.countDown() }
+ vertx.close().onComplete { latch.countDown() }
latch.await()
}
@@ -121,7 +121,7 @@ class StaticAssetRoutesTest {
val router = Router.router(vertx)
vertx.createHttpServer()
.requestHandler(RouteRegister(router, "", true).add(routes).router)
- .listen(port) {
+ .listen(port).onComplete {
latch.countDown()
if (it.failed()) throw RuntimeException("Failed to start server")
}
diff --git a/src/test/java/com/zepben/vertxutils/routing/handlers/UtilHandlersTest.kt b/src/test/java/com/zepben/vertxutils/routing/handlers/UtilHandlersTest.kt
new file mode 100644
index 0000000..43eca5b
--- /dev/null
+++ b/src/test/java/com/zepben/vertxutils/routing/handlers/UtilHandlersTest.kt
@@ -0,0 +1,221 @@
+/*
+ * Copyright 2026 Zeppelin Bend Pty Ltd
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+package com.zepben.vertxutils.routing.handlers
+
+import com.zepben.testutils.junit.SystemLogExtension
+import com.zepben.vertxutils.routing.ErrorFormatter
+import com.zepben.vertxutils.routing.Respond
+import com.zepben.vertxutils.routing.handlers.UtilHandlers.CATCH_ALL_API_FAILURE_HANDLER
+import com.zepben.vertxutils.routing.handlers.UtilHandlers.REDIRECT_NO_TRAILING_SLASH_TO_TRAILING_SLASH_HANDLER
+import io.mockk.*
+import io.netty.handler.codec.http.HttpResponseStatus
+import io.vertx.core.VertxException
+import io.vertx.core.http.HttpServerRequest
+import io.vertx.core.http.HttpServerResponse
+import io.vertx.ext.web.RoutingContext
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.RegisterExtension
+import java.nio.channels.ClosedChannelException
+
+class UtilHandlersTest {
+
+ companion object {
+
+ @JvmField
+ @RegisterExtension
+ val systemOut: SystemLogExtension = SystemLogExtension.SYSTEM_OUT.captureLog().muteOnSuccess()
+ }
+
+ private var failure: Throwable = RuntimeException("test")
+ private val response = mockk {
+ every { ended() } returns false
+ }.also {
+ every { it.setStatusCode(any()) } returns it
+ every { it.setStatusMessage(any()) } returns it
+ every { it.putHeader(any(), any()) } returns it
+ every { it.setStatusCode(any()) } returns mockk()
+ }
+ private val context = mockk {
+ every { response() } returns response
+ every { statusCode() } returns -1
+ every { failure() } answers { failure }
+ }
+
+ @BeforeEach
+ fun setUp() {
+ mockkObject(Respond, ErrorFormatter)
+
+ justRun { Respond.withJson(any(), any(), any()) }
+ justRun { Respond.with(any(), any()) }
+
+ every { ErrorFormatter.asJson(any()) } returns "formatted error"
+ }
+
+ @AfterEach
+ fun tearDown() {
+ unmockkObject(Respond, ErrorFormatter)
+ }
+
+ // Respond.withJson(
+ // context,
+ // context.statusCode().takeUnless { it == -1 }?.let { HttpResponseStatus.valueOf(it) }
+ // ?: HttpResponseStatus.INTERNAL_SERVER_ERROR,
+ // ErrorFormatter.ErrorFormatter.asJson(failure.toString()),
+ // )
+
+ @Test
+ fun `failure handler uses 500 by default`() {
+ CATCH_ALL_API_FAILURE_HANDLER.handle(context)
+
+ verifySequence {
+ // Calls when checking the context failure.
+ context.failure()
+ context.response()
+ response.ended()
+
+ // Calls when sending the response.
+ context.statusCode()
+ ErrorFormatter.asJson(failure.toString())
+ Respond.withJson(context, HttpResponseStatus.INTERNAL_SERVER_ERROR, "formatted error")
+ }
+ }
+
+ @Test
+ fun `failure handler uses context status when available`() {
+ every { context.statusCode() } returns HttpResponseStatus.UNAUTHORIZED.code()
+
+ CATCH_ALL_API_FAILURE_HANDLER.handle(context)
+
+ verifySequence {
+ // Calls when checking the context failure.
+ context.failure()
+ context.response()
+ response.ended()
+
+ // Calls when sending the response.
+ context.statusCode()
+ ErrorFormatter.asJson(failure.toString())
+ Respond.withJson(context, HttpResponseStatus.UNAUTHORIZED, "formatted error")
+ }
+ }
+
+ @Test
+ internal fun `failure handler ignores vertx closed channel exceptions`() {
+ every { response.ended() } returns true
+ failure = VertxException("Connection was closed")
+
+ CATCH_ALL_API_FAILURE_HANDLER.handle(context)
+
+ verifySequence {
+ // Calls when checking the context failure.
+ context.failure()
+ context.response()
+ response.ended()
+ }
+
+ // Should be no response.
+ confirmVerified(ErrorFormatter, Respond)
+ }
+
+ @Test
+ internal fun `failure handler ignores java closed channel exceptions`() {
+ every { response.ended() } returns true
+ failure = ClosedChannelException()
+
+ CATCH_ALL_API_FAILURE_HANDLER.handle(context)
+
+ verifySequence {
+ // Calls when checking the context failure.
+ context.failure()
+ context.response()
+ response.ended()
+ }
+
+ // Should be no response.
+ confirmVerified(ErrorFormatter, Respond)
+ }
+
+ @Test
+ internal fun `failure handler calls default handler if not processed`() {
+ every { response.ended() } returns true
+ justRun { context.next() }
+
+ CATCH_ALL_API_FAILURE_HANDLER.handle(context)
+
+ verifySequence {
+ // Calls when checking the context failure.
+ context.failure()
+ context.response()
+ response.ended()
+
+ // Should move on to the next handler as we didn't handle it.
+ context.next()
+ }
+
+ // Should be no response.
+ confirmVerified(ErrorFormatter, Respond)
+ }
+
+ @Test
+ fun `redirects no trailing to trailing`() {
+ val request = mockk {
+ every { path() } returns "/some/path/without/slash"
+ every { query() } returns null
+ }.also {
+ every { context.request() } returns it
+ }
+
+ REDIRECT_NO_TRAILING_SLASH_TO_TRAILING_SLASH_HANDLER.handle(context)
+
+ verifySequence {
+ context.request()
+ request.path()
+
+ // Query is read once if it has no value.
+ context.request()
+ request.query()
+
+ // Configure and send the redirect.
+ context.response()
+ response.putHeader("Location", "/some/path/without/slash/")
+ Respond.with(context, HttpResponseStatus.MOVED_PERMANENTLY)
+ }
+ }
+
+ @Test
+ fun `redirects no trailing to trailing with query params`() {
+ val request = mockk {
+ every { path() } returns "/some/path/without/slash"
+ every { query() } returns "test=true"
+ }.also {
+ every { context.request() } returns it
+ }
+
+ REDIRECT_NO_TRAILING_SLASH_TO_TRAILING_SLASH_HANDLER.handle(context)
+
+ verifySequence {
+ context.request()
+ request.path()
+
+ // Query is read twice if it has a value.
+ context.request()
+ request.query()
+ context.request()
+ request.query()
+
+ // Configure and send the redirect.
+ context.response()
+ response.putHeader("Location", "/some/path/without/slash/?test=true")
+ Respond.with(context, HttpResponseStatus.MOVED_PERMANENTLY)
+ }
+ }
+
+}