Skip to content

Commit

Permalink
feat(health): provide a /health endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
s4heid committed Jul 7, 2022
1 parent 7fbd10e commit 151cf6e
Show file tree
Hide file tree
Showing 5 changed files with 282 additions and 1 deletion.
9 changes: 8 additions & 1 deletion src/main/java/io/neonbee/config/ServerConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:
* {
Expand Down Expand Up @@ -192,7 +199,7 @@ public String getCorrelationId(RoutingContext routingContext) {
private static final String PROPERTY_PORT = "port";

private static final List<EndpointConfig> 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<String, String> REPHRASE_MAP =
Expand Down
93 changes: 93 additions & 0 deletions src/main/java/io/neonbee/endpoint/health/HealthCheckHandler.java
Original file line number Diff line number Diff line change
@@ -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<RoutingContext> {
@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<JsonObject> getChecks(JsonObject json) {
return json.getJsonArray("checks", new JsonArray()).stream().map(j -> (JsonObject) j);
}
}
30 changes: 30 additions & 0 deletions src/main/java/io/neonbee/endpoint/health/HealthEndpoint.java
Original file line number Diff line number Diff line change
@@ -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<Router> createEndpointRouter(Vertx vertx, String basePath, JsonObject config) {
HealthCheckRegistry registry = NeonBee.get(vertx).getHealthCheckRegistry();
return Future.succeededFuture(createRouter(vertx, new HealthCheckHandler(registry)));
}
}
112 changes: 112 additions & 0 deletions src/test/java/io/neonbee/endpoint/health/HealthCheckHandlerTest.java
Original file line number Diff line number Diff line change
@@ -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<Arguments> 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."));
}
}
39 changes: 39 additions & 0 deletions src/test/java/io/neonbee/endpoint/health/HealthEndpointTest.java
Original file line number Diff line number Diff line change
@@ -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<String> 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();
})));
}
}

0 comments on commit 151cf6e

Please sign in to comment.