From 0b34d82cf0682ad9698011446576e3152cbe7973 Mon Sep 17 00:00:00 2001 From: Sebastian Heid Date: Tue, 13 Apr 2021 16:51:59 +0200 Subject: [PATCH] feat: add support for custom error handlers - rename ErrorHandler to DefaultErrorHandler - remove create() pattern from the handler, because the DefaultErrorHandler is a class and not an interface like the ErrorHandler in Vert.x - As in Vert.x an ErrorHandler interface already exists, reuse this interface when implementing our own error handler. - add errorHandler property to the ServerVerticleConfig Co-authored-by: Pascal Krause --- ...ernal.verticle.ServerVerticle.example.yaml | 3 + ...rHandler.java => DefaultErrorHandler.java} | 102 ++++++++++++------ .../internal/verticle/ServerVerticle.java | 94 +++++++++------- .../internal/verticle/ServerVerticleTest.java | 16 ++- 4 files changed, 139 insertions(+), 76 deletions(-) rename src/main/java/io/neonbee/internal/handler/{ErrorHandler.java => DefaultErrorHandler.java} (63%) diff --git a/resources/config/io.neonbee.internal.verticle.ServerVerticle.example.yaml b/resources/config/io.neonbee.internal.verticle.ServerVerticle.example.yaml index e32a0dfd..bad5fc8e 100644 --- a/resources/config/io.neonbee.internal.verticle.ServerVerticle.example.yaml +++ b/resources/config/io.neonbee.internal.verticle.ServerVerticle.example.yaml @@ -34,6 +34,9 @@ config: # the maximum initial line length of the HTTP header (e.g. "GET / HTTP/1.0"), defaults to 4096 bytes maxInitialLineLength: 4096 + # configure the error handler. If not specified, defaults to io.neonbee.internal.handler.ErrorHandler + errorHandler: io.neonbee.internal.handler.DefaultErrorHandler + # specific endpoint configuration, defaults to the object seen below endpoints: # provides a OData V4 compliant endpoint, for accessing entity verticle data diff --git a/src/main/java/io/neonbee/internal/handler/ErrorHandler.java b/src/main/java/io/neonbee/internal/handler/DefaultErrorHandler.java similarity index 63% rename from src/main/java/io/neonbee/internal/handler/ErrorHandler.java rename to src/main/java/io/neonbee/internal/handler/DefaultErrorHandler.java index 9d0aff5e..1befb5ff 100644 --- a/src/main/java/io/neonbee/internal/handler/ErrorHandler.java +++ b/src/main/java/io/neonbee/internal/handler/DefaultErrorHandler.java @@ -7,10 +7,10 @@ import java.util.List; import java.util.Objects; +import java.util.Optional; import io.neonbee.data.DataException; import io.neonbee.logging.LoggingFacade; -import io.vertx.core.Handler; import io.vertx.core.http.HttpServerResponse; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.MIMEHeader; @@ -19,7 +19,7 @@ /** * Similar to the io.vertx.ext.web.handler.impl.ErrorHandlerImpl, w/ minor adoptions for error text and template. */ -public final class ErrorHandler implements Handler { +public class DefaultErrorHandler implements io.vertx.ext.web.handler.ErrorHandler { /** * The default template to use for rendering. */ @@ -39,7 +39,7 @@ public final class ErrorHandler implements Handler { /** * Returns a new ErrorHandler with the default {@link #DEFAULT_ERROR_HANDLER_TEMPLATE template}. */ - public ErrorHandler() { + public DefaultErrorHandler() { this(DEFAULT_ERROR_HANDLER_TEMPLATE); } @@ -48,9 +48,9 @@ public ErrorHandler() { * * @param errorTemplateName resource path to the template. */ - public ErrorHandler(String errorTemplateName) { + public DefaultErrorHandler(String errorTemplateName) { Objects.requireNonNull(errorTemplateName); - this.errorTemplate = readResourceToBuffer(errorTemplateName).toString(); + this.errorTemplate = Objects.requireNonNull(readResourceToBuffer(errorTemplateName)).toString(); } @Override @@ -77,6 +77,68 @@ public void handle(RoutingContext routingContext) { answerWithError(routingContext, errorCode, errorMessage); } + /** + * Creates a String with the text/plain error response. + * + * @param routingContext the routing context + * @param errorCode the error code of the response + * @param errorMessage the error message of the response + * @return A String with the plain text error response. + */ + protected String createPlainResponse(RoutingContext routingContext, int errorCode, String errorMessage) { + StringBuilder builder = + new StringBuilder().append("Error ").append(errorCode).append(": ").append(errorMessage); + String correlationId = getCorrelationId(routingContext); + if (correlationId != null) { + builder.append(" (Correlation ID: ").append(correlationId).append(')'); + } + return builder.toString(); + } + + /** + * Creates a String with the application/json error response. + * + * @param routingContext the routing context + * @param errorCode the error code of the response + * @param errorMessage the error message of the response + * @return A JsonObject with the error response. + */ + protected JsonObject createJsonResponse(RoutingContext routingContext, int errorCode, String errorMessage) { + JsonObject error = new JsonObject().put("code", errorCode).put("message", errorMessage); + Optional.ofNullable(getCorrelationId(routingContext)).ifPresent(id -> error.put("correlationId", id)); + return new JsonObject().put("error", error); + } + + /** + * Creates a String with the text/html error response. + * + * @param routingContext the routing context + * @param errorCode the error code of the response + * @param errorMessage the error message of the response + * @return A String with the html encoded error response. + */ + protected String createHtmlResponse(RoutingContext routingContext, int errorCode, String errorMessage) { + return errorTemplate.replace("{errorCode}", Integer.toString(errorCode)).replace("{errorMessage}", errorMessage) + .replace("{correlationId}", getCorrelationId(routingContext)); + } + + private boolean sendError(RoutingContext routingContext, String mime, int errorCode, String errorMessage) { + HttpServerResponse response = routingContext.response(); + if (mime.startsWith("text/html")) { + response.putHeader(CONTENT_TYPE, "text/html"); + response.end(createHtmlResponse(routingContext, errorCode, errorMessage)); + } else if (mime.startsWith("application/json")) { + response.putHeader(CONTENT_TYPE, "application/json"); + response.end(createJsonResponse(routingContext, errorCode, errorMessage).toBuffer()); + } else if (mime.startsWith("text/plain")) { + response.putHeader(CONTENT_TYPE, "text/plain"); + response.end(createPlainResponse(routingContext, errorCode, errorMessage)); + } else { + return false; + } + return true; + } + private void answerWithError(RoutingContext routingContext, int errorCode, String errorMessage) { routingContext.response().setStatusCode(errorCode); if (!sendErrorResponseMIME(routingContext, errorCode, errorMessage) @@ -101,34 +163,4 @@ private boolean sendErrorAcceptMIME(RoutingContext routingContext, int errorCode } return false; } - - private boolean sendError(RoutingContext routingContext, String mime, int errorCode, String errorMessage) { - String correlationId = getCorrelationId(routingContext); - HttpServerResponse response = routingContext.response(); - if (mime.startsWith("text/html")) { - response.putHeader(CONTENT_TYPE, "text/html"); - response.end(errorTemplate.replace("{errorCode}", Integer.toString(errorCode)) - .replace("{errorMessage}", errorMessage).replace("{correlationId}", correlationId)); - return true; - } else if (mime.startsWith("application/json")) { - JsonObject jsonError = new JsonObject(); - jsonError.put("code", errorCode).put("message", errorMessage); - if (correlationId != null) { - jsonError.put("correlationId", correlationId); - } - response.putHeader(CONTENT_TYPE, "application/json"); - response.end(new JsonObject().put("error", jsonError).encode()); - return true; - } else if (mime.startsWith("text/plain")) { - response.putHeader(CONTENT_TYPE, "text/plain"); - StringBuilder builder = new StringBuilder(); - builder.append("Error ").append(errorCode).append(": ").append(errorMessage); - if (correlationId != null) { - builder.append(" (Correlation ID: ").append(correlationId).append(')'); - } - response.end(builder.toString()); - return true; - } - return false; - } } diff --git a/src/main/java/io/neonbee/internal/verticle/ServerVerticle.java b/src/main/java/io/neonbee/internal/verticle/ServerVerticle.java index a53afd80..5265ca40 100644 --- a/src/main/java/io/neonbee/internal/verticle/ServerVerticle.java +++ b/src/main/java/io/neonbee/internal/verticle/ServerVerticle.java @@ -25,7 +25,7 @@ import io.neonbee.endpoint.Endpoint; import io.neonbee.internal.handler.CacheControlHandler; import io.neonbee.internal.handler.CorrelationIdHandler; -import io.neonbee.internal.handler.ErrorHandler; +import io.neonbee.internal.handler.DefaultErrorHandler; import io.neonbee.internal.handler.HooksHandler; import io.neonbee.internal.handler.InstanceInfoHandler; import io.neonbee.internal.handler.LoggerHandler; @@ -44,6 +44,7 @@ import io.vertx.ext.web.handler.AuthenticationHandler; import io.vertx.ext.web.handler.BodyHandler; import io.vertx.ext.web.handler.ChainAuthHandler; +import io.vertx.ext.web.handler.ErrorHandler; import io.vertx.ext.web.handler.SessionHandler; import io.vertx.ext.web.handler.TimeoutHandler; import io.vertx.ext.web.sstore.ClusteredSessionStore; @@ -69,6 +70,9 @@ public void parseCredentials(RoutingContext context, Handler startPromise) { // sequence issues, block scope the variable to prevent using it after the endpoints have been mounted Route rootRoute = router.route(); - rootRoute.failureHandler(new ErrorHandler()); - rootRoute.handler(new LoggerHandler()); - rootRoute.handler(BodyHandler.create(false /* do not handle file uploads */)); - rootRoute.handler(new CorrelationIdHandler(config.getCorrelationStrategy())); - rootRoute.handler(TimeoutHandler.create(SECONDS.toMillis(config.getTimeout()), config.getTimeoutStatusCode())); - rootRoute.handler(new CacheControlHandler()); - rootRoute.handler(new InstanceInfoHandler()); - - createSessionStore(vertx, config.getSessionHandling()).map(SessionHandler::create) - .ifPresent(sessionHandler -> rootRoute - .handler(sessionHandler.setSessionCookieName(config.getSessionCookieName()))); - - // add all endpoint handlers as sub-routes here - mountEndpoints(router, config.getEndpointConfigs(), createAuthChainHandler(config.getAuthChainConfig()), - new HooksHandler()).onFailure(startPromise::fail).onSuccess(nothing -> { - // the NotFoundHandler fails the routing context finally - router.route().handler(new NotFoundHandler()); - - // Use the port passed via command line options, instead the configured one. - Optional.ofNullable(NeonBee.get(vertx).getOptions().getServerPort()) - .ifPresent(port -> config.setPort(port)); - - vertx.createHttpServer(config /* ServerConfig is a HttpServerOptions subclass */) - .exceptionHandler(throwable -> { - LOGGER.error("HTTP Socket Exception", throwable); - }).requestHandler(router).listen().onSuccess(httpServer -> { - LOGGER.info("HTTP server started on port {}", httpServer.actualPort()); - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("HTTP server configured with routes: {}", router.getRoutes().stream() - .map(Route::toString).collect(Collectors.joining(","))); - } - }).onFailure(cause -> { - LOGGER.error("HTTP server could not be started", cause); - }).mapEmpty().onComplete(startPromise); - }); + try { + rootRoute.failureHandler(getErrorHandler(config())); + rootRoute.handler(new LoggerHandler()); + rootRoute.handler(BodyHandler.create(false /* do not handle file uploads */)); + rootRoute.handler(new CorrelationIdHandler(config.getCorrelationStrategy())); + rootRoute.handler( + TimeoutHandler.create(SECONDS.toMillis(config.getTimeout()), config.getTimeoutStatusCode())); + rootRoute.handler(new CacheControlHandler()); + rootRoute.handler(new InstanceInfoHandler()); + + createSessionStore(vertx, config.getSessionHandling()).map(SessionHandler::create) + .ifPresent(sessionHandler -> rootRoute + .handler(sessionHandler.setSessionCookieName(config.getSessionCookieName()))); + + // add all endpoint handlers as sub-routes here + mountEndpoints(router, config.getEndpointConfigs(), createAuthChainHandler(config.getAuthChainConfig()), + new HooksHandler()).onFailure(startPromise::fail).onSuccess(nothing -> { + // the NotFoundHandler fails the routing context finally + router.route().handler(new NotFoundHandler()); + + // Use the port passed via command line options, instead the configured one. + Optional.ofNullable(NeonBee.get(vertx).getOptions().getServerPort()).ifPresent(config::setPort); + + vertx.createHttpServer(config /* ServerConfig is a HttpServerOptions subclass */) + .exceptionHandler(throwable -> { + LOGGER.error("HTTP Socket Exception", throwable); + }).requestHandler(router).listen().onSuccess(httpServer -> { + LOGGER.info("HTTP server started on port {}", httpServer.actualPort()); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("HTTP server configured with routes: {}", router.getRoutes() + .stream().map(Route::toString).collect(Collectors.joining(","))); + } + }).onFailure(cause -> { + LOGGER.error("HTTP server could not be started", cause); + }).mapEmpty().onComplete(startPromise); + }); + } catch (Exception e) { + LOGGER.error("Server could not be started", e); + startPromise.fail(e); + } + } + + @VisibleForTesting + static ErrorHandler getErrorHandler(JsonObject config) throws ClassNotFoundException, NoSuchMethodException, + IllegalAccessException, InvocationTargetException, InstantiationException { + String className = config.getString("errorHandler", DEFAULT_ERROR_HANDLER_CLASS_NAME); + String errorHandlerClassName = className.isEmpty() ? DEFAULT_ERROR_HANDLER_CLASS_NAME : className; + return (ErrorHandler) Class.forName(errorHandlerClassName).getConstructor().newInstance(); } /** @@ -175,9 +192,8 @@ private Future mountEndpoints(Router router, List endpoint } JsonObject endpointAdditionalConfig = Optional.ofNullable(defaultEndpointConfig.getAdditionalConfig()) - .map(JsonObject::copy).orElseGet(() -> new JsonObject()); - Optional.ofNullable(endpointConfig.getAdditionalConfig()) - .ifPresent(config -> endpointAdditionalConfig.mergeIn(config)); + .map(JsonObject::copy).orElseGet(JsonObject::new); + Optional.ofNullable(endpointConfig.getAdditionalConfig()).ifPresent(endpointAdditionalConfig::mergeIn); Router endpointRouter; try { diff --git a/src/test/java/io/neonbee/internal/verticle/ServerVerticleTest.java b/src/test/java/io/neonbee/internal/verticle/ServerVerticleTest.java index fef6651d..5cc287d2 100644 --- a/src/test/java/io/neonbee/internal/verticle/ServerVerticleTest.java +++ b/src/test/java/io/neonbee/internal/verticle/ServerVerticleTest.java @@ -2,6 +2,7 @@ import static com.google.common.truth.Truth.assertThat; import static io.neonbee.internal.verticle.ServerVerticle.createSessionStore; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -14,6 +15,7 @@ import org.junit.jupiter.api.TestInfo; import io.neonbee.config.ServerConfig.SessionHandling; +import io.neonbee.internal.handler.DefaultErrorHandler; import io.neonbee.test.base.NeonBeeTestBase; import io.neonbee.test.helper.WorkingDirectoryBuilder; import io.vertx.core.Vertx; @@ -30,7 +32,7 @@ class ServerVerticleTest extends NeonBeeTestBase { @Test @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) - void testCreateSessionStore() throws InterruptedException { + void testCreateSessionStore() { Vertx mockedVertx = mock(Vertx.class); when(mockedVertx.isClustered()).thenReturn(false); when(mockedVertx.sharedData()).thenReturn(mock(SharedData.class)); @@ -43,7 +45,7 @@ void testCreateSessionStore() throws InterruptedException { @Test @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) - void testCreateSessionStoreClustered() throws InterruptedException { + void testCreateSessionStoreClustered() { Vertx mockedVertx = mock(Vertx.class); when(mockedVertx.isClustered()).thenReturn(true); when(mockedVertx.sharedData()).thenReturn(mock(SharedData.class)); @@ -109,6 +111,16 @@ void testLargerMaximumInitialLineAndCookieSizesConfig(VertxTestContext testCtx) }))); } + @Test + void testgGetErrorHandlerDefault() throws Exception { + JsonObject config = new JsonObject(); + assertThat(ServerVerticle.getErrorHandler(config)).isInstanceOf(DefaultErrorHandler.class); + + ClassNotFoundException exception = assertThrows(ClassNotFoundException.class, + () -> ServerVerticle.getErrorHandler(config.put("errorHandler", "Hugo"))); + assertThat(exception).hasMessageThat().contains("Hugo"); + } + @Override protected WorkingDirectoryBuilder provideWorkingDirectoryBuilder(TestInfo testInfo, VertxTestContext testContext) { if ("testLargerMaximumInitialLineAndCookieSizesConfig"