-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(health): provide a
/health
endpoint
- Loading branch information
Showing
5 changed files
with
282 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
93 changes: 93 additions & 0 deletions
93
src/main/java/io/neonbee/endpoint/health/HealthCheckHandler.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
30
src/main/java/io/neonbee/endpoint/health/HealthEndpoint.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
112
src/test/java/io/neonbee/endpoint/health/HealthCheckHandlerTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
39
src/test/java/io/neonbee/endpoint/health/HealthEndpointTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}))); | ||
} | ||
} |