diff --git a/src/main/java/io/neonbee/config/ServerConfig.java b/src/main/java/io/neonbee/config/ServerConfig.java index 544575e7..381d35be 100644 --- a/src/main/java/io/neonbee/config/ServerConfig.java +++ b/src/main/java/io/neonbee/config/ServerConfig.java @@ -16,6 +16,7 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableBiMap; +import io.neonbee.endpoint.health.HealthEndpoint; import io.neonbee.endpoint.metrics.MetricsEndpoint; import io.neonbee.endpoint.odatav4.ODataV4Endpoint; import io.neonbee.endpoint.raw.RawEndpoint; @@ -84,6 +85,12 @@ * basePath: string // the base path to map this endpoint to, defaults to /metrics/ * authenticationChain: array, // a specific authentication chain for this endpoint, defaults to an empty array / no auth. * } + * health: { + * type: "io.neonbee.endpoint.health.HealthEndpoint", // provides an endpoint with health information + * enabled: boolean, // enable the metrics endpoint, defaults to true + * basePath: string // the base path to map this endpoint to, defaults to /health/ + * authenticationChain: array, // a specific authentication chain for this endpoint, defaults to an empty array / no auth. + * } * ], * authenticationChain: [ // authentication chain, defaults to an empty array (no authentication), use any of: * { @@ -192,7 +199,7 @@ public String getCorrelationId(RoutingContext routingContext) { private static final String PROPERTY_PORT = "port"; private static final List DEFAULT_ENDPOINT_CONFIGS = Collections.unmodifiableList(Stream - .of(RawEndpoint.class, ODataV4Endpoint.class, MetricsEndpoint.class) + .of(RawEndpoint.class, ODataV4Endpoint.class, MetricsEndpoint.class, HealthEndpoint.class) .map(endpointClass -> new EndpointConfig().setType(endpointClass.getName())).collect(Collectors.toList())); private static final ImmutableBiMap REPHRASE_MAP = diff --git a/src/main/java/io/neonbee/endpoint/health/HealthCheckHandler.java b/src/main/java/io/neonbee/endpoint/health/HealthCheckHandler.java new file mode 100644 index 00000000..a58553d6 --- /dev/null +++ b/src/main/java/io/neonbee/endpoint/health/HealthCheckHandler.java @@ -0,0 +1,93 @@ +package io.neonbee.endpoint.health; + +import static java.util.Objects.requireNonNull; + +import java.util.stream.Stream; + +import org.apache.olingo.commons.api.http.HttpStatusCode; + +import com.google.common.annotations.VisibleForTesting; + +import io.neonbee.data.internal.DataContextImpl; +import io.neonbee.health.HealthCheckRegistry; +import io.neonbee.logging.LoggingFacade; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.vertx.core.Handler; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.healthchecks.CheckResult; +import io.vertx.ext.web.RoutingContext; + +public class HealthCheckHandler implements Handler { + @VisibleForTesting + static final String APPLICATION_JSON_CHARSET_UTF_8 = "application/json;charset=UTF-8"; + + private static final LoggingFacade LOGGER = LoggingFacade.create(); + + private final HealthCheckRegistry registry; + + /** + * Constructs an instance of {@link HealthCheckHandler}. + * + * @param registry a health check registry + */ + public HealthCheckHandler(HealthCheckRegistry registry) { + this.registry = registry; + } + + @Override + public void handle(RoutingContext rc) { + registry.collectHealthCheckResults(new DataContextImpl(rc)).onSuccess(resp -> handleRequest(rc, resp)) + .onFailure(t -> handleFailure(rc, t)); + } + + private static void handleRequest(RoutingContext rc, JsonObject json) { + requireNonNull(json); + HttpServerResponse response = rc.response().putHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_CHARSET_UTF_8); + + int status = isUp(json) ? HttpResponseStatus.OK.code() : HttpResponseStatus.SERVICE_UNAVAILABLE.code(); + if (status == HttpResponseStatus.SERVICE_UNAVAILABLE.code() && hasProcedureError(json)) { + status = HttpResponseStatus.INTERNAL_SERVER_ERROR.code(); + } + + if ((status == HttpStatusCode.OK.getStatusCode()) && getChecks(json).findAny().isEmpty()) { + // Special case; no checks available + response.setStatusCode(HttpStatusCode.NO_CONTENT.getStatusCode()).end(); + return; + } + + response.setStatusCode(status).end(json.encode()); + } + + private static void handleFailure(RoutingContext rc, Throwable throwable) { + LOGGER.correlateWith(rc).error("Failed to request data from health check registry.", throwable); + rc.response().setStatusCode(HttpResponseStatus.INTERNAL_SERVER_ERROR.code()) + .setStatusMessage("Internal Server Error: Could not request any health data.").end(); + } + + /** + * Retrieves the status from a passed JsonObject. + * + * @param json the {@link JsonObject} to check + * @return the status as boolean + * @see CheckResult#isUp(CheckResult) + */ + private static boolean isUp(JsonObject json) { + return "UP".equals(json.getString("status")); + } + + private static boolean hasProcedureError(JsonObject json) { + JsonObject data = json.getJsonObject("data"); + if (data != null && data.getBoolean("procedure-execution-failure", false)) { + return true; + } + + return getChecks(json).anyMatch(HealthCheckHandler::hasProcedureError); + } + + private static Stream getChecks(JsonObject json) { + return json.getJsonArray("checks", new JsonArray()).stream().map(j -> (JsonObject) j); + } +} diff --git a/src/main/java/io/neonbee/endpoint/health/HealthEndpoint.java b/src/main/java/io/neonbee/endpoint/health/HealthEndpoint.java new file mode 100644 index 00000000..5e37eb86 --- /dev/null +++ b/src/main/java/io/neonbee/endpoint/health/HealthEndpoint.java @@ -0,0 +1,30 @@ +package io.neonbee.endpoint.health; + +import static io.neonbee.endpoint.Endpoint.createRouter; + +import io.neonbee.NeonBee; +import io.neonbee.config.EndpointConfig; +import io.neonbee.endpoint.Endpoint; +import io.neonbee.health.HealthCheckRegistry; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; + +public class HealthEndpoint implements Endpoint { + /** + * The default path that is used by NeonBee to expose the health endpoint. + */ + private static final String DEFAULT_BASE_PATH = "/health/"; + + @Override + public EndpointConfig getDefaultConfig() { + return new EndpointConfig().setType(HealthEndpoint.class.getName()).setBasePath(DEFAULT_BASE_PATH); + } + + @Override + public Future createEndpointRouter(Vertx vertx, String basePath, JsonObject config) { + HealthCheckRegistry registry = NeonBee.get(vertx).getHealthCheckRegistry(); + return Future.succeededFuture(createRouter(vertx, new HealthCheckHandler(registry))); + } +} diff --git a/src/test/java/io/neonbee/endpoint/health/HealthCheckHandlerTest.java b/src/test/java/io/neonbee/endpoint/health/HealthCheckHandlerTest.java new file mode 100644 index 00000000..81874bef --- /dev/null +++ b/src/test/java/io/neonbee/endpoint/health/HealthCheckHandlerTest.java @@ -0,0 +1,112 @@ +package io.neonbee.endpoint.health; + +import static io.neonbee.endpoint.health.HealthCheckHandler.APPLICATION_JSON_CHARSET_UTF_8; +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.matches; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import io.neonbee.data.DataContext; +import io.neonbee.health.HealthCheckRegistry; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; + +class HealthCheckHandlerTest { + HealthCheckRegistry registry; + + RoutingContext routingContext; + + HttpServerResponse httpServerResponse; + + @BeforeEach + void setUp() { + registry = mock(HealthCheckRegistry.class); + routingContext = mock(RoutingContext.class); + httpServerResponse = mock(HttpServerResponse.class); + + when(httpServerResponse.setStatusCode(anyInt())).thenReturn(httpServerResponse); + when(httpServerResponse.setStatusMessage(anyString())).thenReturn(httpServerResponse); + when(httpServerResponse.putHeader(any(CharSequence.class), any(CharSequence.class))) + .thenReturn(httpServerResponse); + when(httpServerResponse.end()).thenReturn(succeededFuture()); + when(routingContext.response()).thenReturn(httpServerResponse); + when(routingContext.request()).thenReturn(mock(HttpServerRequest.class)); + } + + private static JsonObject buildExpectedJson(String status, String outcome, JsonObject... checks) { + JsonObject expected = new JsonObject().put("status", status).put("outcome", outcome); + if (checks != null && checks.length > 0) { + expected.put("checks", new JsonArray(Arrays.asList(checks))); + } + return expected; + } + + static Stream getInputs() { + return Stream.of( + Arguments.of(buildExpectedJson("UP", "UP", new JsonObject().put("id", "dummy").put("status", "UP")), + 200, "status is up"), + Arguments.of(buildExpectedJson("UP", "UP"), 204, "response is empty"), + Arguments.of( + buildExpectedJson("DOWN", "DOWN", new JsonObject().put("id", "dummy").put("status", "DOWN")), + 503, "status is down (without any reason)"), + Arguments.of( + buildExpectedJson("DOWN", "DOWN").put("data", + new JsonObject().put("procedure-execution-failure", true)), + 500, "status is down (with procedure error, but no checks)"), + Arguments.of( + buildExpectedJson("DOWN", "DOWN", + new JsonObject().put("id", "dummy").put("status", "DOWN").put("data", + new JsonObject().put("procedure-execution-failure", true))), + 500, "status is down (with procedure error)")); + } + + @ParameterizedTest(name = "{index}: {2} => HTTP {1}") + @MethodSource("getInputs") + @DisplayName("should handle response, if") + @SuppressWarnings("unused") + void testHandle(JsonObject expectedResponse, int expectedStatusCode, String description) { + when(registry.collectHealthCheckResults(any(DataContext.class))).thenReturn(succeededFuture(expectedResponse)); + + new HealthCheckHandler(registry).handle(routingContext); + + verify(httpServerResponse).putHeader(eq(HttpHeaders.CONTENT_TYPE), eq(APPLICATION_JSON_CHARSET_UTF_8)); + verify(httpServerResponse).setStatusCode(eq(expectedStatusCode)); + if (expectedStatusCode == 204) { + verify(httpServerResponse).end(); + } else { + verify(httpServerResponse).end(anyString()); + } + } + + @Test + @DisplayName("should return internal server error, if retrieving data fails") + void testHandleFailure() { + when(registry.collectHealthCheckResults(any(DataContext.class))) + .thenReturn(failedFuture(new Throwable("oops"))); + + new HealthCheckHandler(registry).handle(routingContext); + + verify(httpServerResponse).setStatusCode(eq(500)); + verify(httpServerResponse).setStatusMessage(matches("Could not request any health data.")); + } +} diff --git a/src/test/java/io/neonbee/endpoint/health/HealthEndpointTest.java b/src/test/java/io/neonbee/endpoint/health/HealthEndpointTest.java new file mode 100644 index 00000000..63ffb9c2 --- /dev/null +++ b/src/test/java/io/neonbee/endpoint/health/HealthEndpointTest.java @@ -0,0 +1,39 @@ +package io.neonbee.endpoint.health; + +import static com.google.common.truth.Truth.assertThat; +import static java.util.stream.Collectors.toList; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.neonbee.test.base.NeonBeeTestBase; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.json.JsonObject; +import io.vertx.junit5.Timeout; +import io.vertx.junit5.VertxTestContext; + +class HealthEndpointTest extends NeonBeeTestBase { + + @Test + @Timeout(value = 2, timeUnit = TimeUnit.SECONDS) + @DisplayName("should return health info of the default checks") + void testHealthEndpointData(VertxTestContext testContext) { + createRequest(HttpMethod.GET, "/health").send(testContext.succeeding(response -> testContext.verify(() -> { + assertThat(response.statusCode()).isEqualTo(200); + + JsonObject result = response.bodyAsJsonObject(); + assertThat(result.containsKey("outcome")).isTrue(); + assertThat(result.containsKey("status")).isTrue(); + assertThat(result.containsKey("checks")).isTrue(); + + List ids = result.getJsonArray("checks").stream().map(JsonObject.class::cast) + .map(c -> c.getString("id")).collect(toList()); + assertThat(ids).contains(String.format("node.%s.os.memory", getNeonBee().getNodeId())); + + testContext.completeNow(); + }))); + } +}