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) + } + } + +}