Skip to content

Commit

Permalink
feat: add support for custom error handlers
Browse files Browse the repository at this point in the history
- 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 <pascal.krause@sap.com>
  • Loading branch information
s4heid and pk-work committed May 26, 2021
1 parent 4cdbd15 commit 0b34d82
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 76 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<RoutingContext> {
public class DefaultErrorHandler implements io.vertx.ext.web.handler.ErrorHandler {
/**
* The default template to use for rendering.
*/
Expand All @@ -39,7 +39,7 @@ public final class ErrorHandler implements Handler<RoutingContext> {
/**
* Returns a new ErrorHandler with the default {@link #DEFAULT_ERROR_HANDLER_TEMPLATE template}.
*/
public ErrorHandler() {
public DefaultErrorHandler() {
this(DEFAULT_ERROR_HANDLER_TEMPLATE);
}

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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;
}
}
94 changes: 55 additions & 39 deletions src/main/java/io/neonbee/internal/verticle/ServerVerticle.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -69,6 +70,9 @@ public void parseCredentials(RoutingContext context, Handler<AsyncResult<Credent
}
};

@VisibleForTesting
static final String DEFAULT_ERROR_HANDLER_CLASS_NAME = DefaultErrorHandler.class.getName();

private static final Logger LOGGER = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

@Override
Expand All @@ -82,41 +86,54 @@ public void start(Promise<Void> 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);
}).<Void>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);
}).<Void>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();
}

/**
Expand Down Expand Up @@ -175,9 +192,8 @@ private Future<Void> mountEndpoints(Router router, List<EndpointConfig> 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 {
Expand Down
16 changes: 14 additions & 2 deletions src/test/java/io/neonbee/internal/verticle/ServerVerticleTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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));
Expand All @@ -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));
Expand Down Expand Up @@ -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"
Expand Down

0 comments on commit 0b34d82

Please sign in to comment.