Rename health JSON 'details' to 'components' in v3

Update the health endpoint so the nested components are now exposed
under `components` rather than `details` when v3 of the actuator
REST API is being used.

This distinction helps to clarify the difference between composite
health (health composed of other health components) and health
details (technology specific information gathered by the indicator).

Since this is a breaking change for the REST API, it is only returned
for v3 payloads. Requests made accepting only a v2 response will have
JSON provided in the original form.

Closes gh-17929
This commit is contained in:
Phillip Webb
2019-09-25 22:46:37 -07:00
parent cd1b7c1a9c
commit 69c561a69a
21 changed files with 236 additions and 123 deletions

View File

@@ -23,6 +23,8 @@ import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -35,13 +37,14 @@ class CompositeHealthTests {
@Test
void createWhenStatusIsNullThrowsException() {
assertThatIllegalArgumentException().isThrownBy(() -> new CompositeHealth(null, Collections.emptyMap()))
assertThatIllegalArgumentException()
.isThrownBy(() -> new CompositeHealth(ApiVersion.V3, null, Collections.emptyMap()))
.withMessage("Status must not be null");
}
@Test
void getStatusReturnsStatus() {
CompositeHealth health = new CompositeHealth(Status.UP, Collections.emptyMap());
CompositeHealth health = new CompositeHealth(ApiVersion.V3, Status.UP, Collections.emptyMap());
assertThat(health.getStatus()).isEqualTo(Status.UP);
}
@@ -49,16 +52,28 @@ class CompositeHealthTests {
void getComponentReturnsComponents() {
Map<String, HealthComponent> components = new LinkedHashMap<>();
components.put("a", Health.up().build());
CompositeHealth health = new CompositeHealth(Status.UP, components);
assertThat(health.getDetails()).isEqualTo(components);
CompositeHealth health = new CompositeHealth(ApiVersion.V3, Status.UP, components);
assertThat(health.getComponents()).isEqualTo(components);
}
@Test
void serializeWithJacksonReturnsValidJson() throws Exception {
void serializeV3WithJacksonReturnsValidJson() throws Exception {
Map<String, HealthComponent> components = new LinkedHashMap<>();
components.put("db1", Health.up().build());
components.put("db2", Health.down().withDetail("a", "b").build());
CompositeHealth health = new CompositeHealth(Status.UP, components);
CompositeHealth health = new CompositeHealth(ApiVersion.V3, Status.UP, components);
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(health);
assertThat(json).isEqualTo("{\"status\":\"UP\",\"components\":{" + "\"db1\":{\"status\":\"UP\"},"
+ "\"db2\":{\"status\":\"DOWN\",\"details\":{\"a\":\"b\"}}}}");
}
@Test
void serializeV2WithJacksonReturnsValidJson() throws Exception {
Map<String, HealthComponent> components = new LinkedHashMap<>();
components.put("db1", Health.up().build());
components.put("db2", Health.down().withDetail("a", "b").build());
CompositeHealth health = new CompositeHealth(ApiVersion.V2, Status.UP, components);
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(health);
assertThat(json).isEqualTo("{\"status\":\"UP\",\"details\":{" + "\"db1\":{\"status\":\"UP\"},"

View File

@@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
import org.springframework.boot.actuate.health.HealthEndpointSupport.HealthResult;
import static org.assertj.core.api.Assertions.assertThat;
@@ -77,7 +78,8 @@ abstract class HealthEndpointSupportTests<R extends ContributorRegistry<C>, C, T
@Test
void getHealthResultWhenPathIsEmptyUsesPrimaryGroup() {
this.registry.registerContributor("test", createContributor(this.up));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false);
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false);
assertThat(result.getGroup()).isEqualTo(this.primaryGroup);
assertThat(getHealth(result)).isNotSameAs(this.up);
assertThat(getHealth(result).getStatus()).isEqualTo(Status.UP);
@@ -86,7 +88,8 @@ abstract class HealthEndpointSupportTests<R extends ContributorRegistry<C>, C, T
@Test
void getHealthResultWhenPathIsNotGroupReturnsResultFromPrimaryGroup() {
this.registry.registerContributor("test", createContributor(this.up));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false, "test");
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false, "test");
assertThat(result.getGroup()).isEqualTo(this.primaryGroup);
assertThat(getHealth(result)).isEqualTo(this.up);
@@ -95,8 +98,8 @@ abstract class HealthEndpointSupportTests<R extends ContributorRegistry<C>, C, T
@Test
void getHealthResultWhenPathIsGroupReturnsResultFromGroup() {
this.registry.registerContributor("atest", createContributor(this.up));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false, "alltheas",
"atest");
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false, "alltheas", "atest");
assertThat(result.getGroup()).isEqualTo(this.allTheAs);
assertThat(getHealth(result)).isEqualTo(this.up);
}
@@ -104,7 +107,8 @@ abstract class HealthEndpointSupportTests<R extends ContributorRegistry<C>, C, T
@Test
void getHealthResultWhenAlwaysIncludesDetailsIsFalseAndGroupIsTrueIncludesDetails() {
this.registry.registerContributor("test", createContributor(this.up));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false, "test");
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false, "test");
assertThat(((Health) getHealth(result)).getDetails()).containsEntry("spring", "boot");
}
@@ -113,8 +117,8 @@ abstract class HealthEndpointSupportTests<R extends ContributorRegistry<C>, C, T
this.primaryGroup.setIncludeDetails(false);
this.registry.registerContributor("test", createContributor(this.up));
HealthEndpointSupport<C, T> endpoint = create(this.registry, this.groups);
HealthResult<T> rootResult = endpoint.getHealth(SecurityContext.NONE, false);
HealthResult<T> componentResult = endpoint.getHealth(SecurityContext.NONE, false, "test");
HealthResult<T> rootResult = endpoint.getHealth(ApiVersion.V3, SecurityContext.NONE, false);
HealthResult<T> componentResult = endpoint.getHealth(ApiVersion.V3, SecurityContext.NONE, false, "test");
assertThat(((CompositeHealth) getHealth(rootResult)).getStatus()).isEqualTo(Status.UP);
assertThat(componentResult).isNull();
}
@@ -123,7 +127,8 @@ abstract class HealthEndpointSupportTests<R extends ContributorRegistry<C>, C, T
void getHealthResultWhenAlwaysIncludesDetailsIsTrueIncludesDetails() {
this.primaryGroup.setIncludeDetails(false);
this.registry.registerContributor("test", createContributor(this.up));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, true, "test");
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE, true,
"test");
assertThat(((Health) getHealth(result)).getDetails()).containsEntry("spring", "boot");
}
@@ -133,31 +138,35 @@ abstract class HealthEndpointSupportTests<R extends ContributorRegistry<C>, C, T
contributors.put("a", createContributor(this.up));
contributors.put("b", createContributor(this.down));
this.registry.registerContributor("test", createCompositeContributor(contributors));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false);
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false);
CompositeHealth root = (CompositeHealth) getHealth(result);
CompositeHealth component = (CompositeHealth) root.getDetails().get("test");
CompositeHealth component = (CompositeHealth) root.getComponents().get("test");
assertThat(root.getStatus()).isEqualTo(Status.DOWN);
assertThat(component.getStatus()).isEqualTo(Status.DOWN);
assertThat(component.getDetails()).containsOnlyKeys("a", "b");
assertThat(component.getComponents()).containsOnlyKeys("a", "b");
}
@Test
void getHealthResultWhenPathDoesNotExistReturnsNull() {
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false, "missing");
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false, "missing");
assertThat(result).isNull();
}
@Test
void getHealthResultWhenPathIsEmptyIncludesGroups() {
this.registry.registerContributor("test", createContributor(this.up));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false);
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false);
assertThat(((SystemHealth) getHealth(result)).getGroups()).containsOnly("alltheas");
}
@Test
void getHealthResultWhenPathIsGroupDoesNotIncludesGroups() {
this.registry.registerContributor("atest", createContributor(this.up));
HealthResult<T> result = create(this.registry, this.groups).getHealth(SecurityContext.NONE, false, "alltheas");
HealthResult<T> result = create(this.registry, this.groups).getHealth(ApiVersion.V3, SecurityContext.NONE,
false, "alltheas");
assertThat(getHealth(result)).isNotInstanceOf(SystemHealth.class);
}

View File

@@ -21,6 +21,7 @@ import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.health.HealthEndpointSupport.HealthResult;
@@ -42,15 +43,15 @@ class HealthEndpointWebExtensionTests
HealthEndpoint delegate = mock(HealthEndpoint.class);
HealthWebEndpointResponseMapper responseMapper = mock(HealthWebEndpointResponseMapper.class);
assertThatIllegalStateException().isThrownBy(() -> new HealthEndpointWebExtension(delegate, responseMapper))
.withMessage(
"Unable to create class org.springframework.boot.actuate.health.HealthEndpointWebExtension "
+ "using deprecated constructor");
.withMessage("Unable to create class org.springframework.boot.actuate."
+ "health.HealthEndpointWebExtension using deprecated constructor");
}
@Test
void healthReturnsSystemHealth() {
this.registry.registerContributor("test", createContributor(this.up));
WebEndpointResponse<HealthComponent> response = create(this.registry, this.groups).health(SecurityContext.NONE);
WebEndpointResponse<HealthComponent> response = create(this.registry, this.groups).health(ApiVersion.LATEST,
SecurityContext.NONE);
HealthComponent health = response.getBody();
assertThat(health.getStatus()).isEqualTo(Status.UP);
assertThat(health).isInstanceOf(SystemHealth.class);
@@ -60,8 +61,8 @@ class HealthEndpointWebExtensionTests
@Test
void healthWhenPathDoesNotExistReturnsHttp404() {
this.registry.registerContributor("test", createContributor(this.up));
WebEndpointResponse<HealthComponent> response = create(this.registry, this.groups).health(SecurityContext.NONE,
"missing");
WebEndpointResponse<HealthComponent> response = create(this.registry, this.groups).health(ApiVersion.LATEST,
SecurityContext.NONE, "missing");
assertThat(response.getBody()).isNull();
assertThat(response.getStatus()).isEqualTo(404);
}
@@ -69,8 +70,8 @@ class HealthEndpointWebExtensionTests
@Test
void healthWhenPathExistsReturnsHealth() {
this.registry.registerContributor("test", createContributor(this.up));
WebEndpointResponse<HealthComponent> response = create(this.registry, this.groups).health(SecurityContext.NONE,
"test");
WebEndpointResponse<HealthComponent> response = create(this.registry, this.groups).health(ApiVersion.LATEST,
SecurityContext.NONE, "test");
assertThat(response.getBody()).isEqualTo(this.up);
assertThat(response.getStatus()).isEqualTo(200);
}

View File

@@ -23,13 +23,16 @@ import java.util.Map;
import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.ReflectionUtils;
@@ -44,20 +47,45 @@ class HealthEndpointWebIntegrationTests {
@WebEndpointTest
void whenHealthIsUp200ResponseIsReturned(WebTestClient client) {
client.get().uri("/actuator/health").exchange().expectStatus().isOk().expectBody().jsonPath("status")
.isEqualTo("UP").jsonPath("details.alpha.status").isEqualTo("UP").jsonPath("details.bravo.status")
client.get().uri("/actuator/health").accept(MediaType.APPLICATION_JSON).exchange().expectStatus().isOk()
.expectBody().jsonPath("status").isEqualTo("UP").jsonPath("components.alpha.status").isEqualTo("UP")
.jsonPath("components.bravo.status").isEqualTo("UP");
}
@WebEndpointTest
void whenHealthIsUpAndAcceptsV3Request200ResponseIsReturned(WebTestClient client) {
client.get().uri("/actuator/health")
.headers((headers) -> headers.set(HttpHeaders.ACCEPT, ActuatorMediaType.V3_JSON)).exchange()
.expectStatus().isOk().expectBody().jsonPath("status").isEqualTo("UP")
.jsonPath("components.alpha.status").isEqualTo("UP").jsonPath("components.bravo.status")
.isEqualTo("UP");
}
@WebEndpointTest
void whenHealthIsUpAndAcceptsAllRequest200ResponseIsReturned(WebTestClient client) {
client.get().uri("/actuator/health").headers((headers) -> headers.set(HttpHeaders.ACCEPT, "*/*")).exchange()
.expectStatus().isOk().expectBody().jsonPath("status").isEqualTo("UP")
.jsonPath("components.alpha.status").isEqualTo("UP").jsonPath("components.bravo.status")
.isEqualTo("UP");
}
@WebEndpointTest
void whenHealthIsUpAndV2Request200ResponseIsReturnedInV2Format(WebTestClient client) {
client.get().uri("/actuator/health")
.headers((headers) -> headers.set(HttpHeaders.ACCEPT, ActuatorMediaType.V2_JSON)).exchange()
.expectStatus().isOk().expectBody().jsonPath("status").isEqualTo("UP").jsonPath("details.alpha.status")
.isEqualTo("UP").jsonPath("details.bravo.status").isEqualTo("UP");
}
@WebEndpointTest
void whenHealthIsDown503ResponseIsReturned(ApplicationContext context, WebTestClient client) {
HealthIndicator healthIndicator = () -> Health.down().build();
ReactiveHealthIndicator reactiveHealthIndicator = () -> Mono.just(Health.down().build());
withHealthContributor(context, "charlie", healthIndicator, reactiveHealthIndicator,
() -> client.get().uri("/actuator/health").exchange().expectStatus()
() -> client.get().uri("/actuator/health").accept(MediaType.APPLICATION_JSON).exchange().expectStatus()
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE).expectBody().jsonPath("status").isEqualTo("DOWN")
.jsonPath("details.alpha.status").isEqualTo("UP").jsonPath("details.bravo.status")
.isEqualTo("UP").jsonPath("details.charlie.status").isEqualTo("DOWN"));
.jsonPath("components.alpha.status").isEqualTo("UP").jsonPath("components.bravo.status")
.isEqualTo("UP").jsonPath("components.charlie.status").isEqualTo("DOWN"));
}
@WebEndpointTest
@@ -65,8 +93,9 @@ class HealthEndpointWebIntegrationTests {
HealthIndicator healthIndicator = () -> Health.down().build();
ReactiveHealthIndicator reactiveHealthIndicator = () -> Mono.just(Health.down().build());
withHealthContributor(context, "charlie", healthIndicator, reactiveHealthIndicator,
() -> client.get().uri("/actuator/health/charlie").exchange().expectStatus()
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE).expectBody().jsonPath("status").isEqualTo("DOWN"));
() -> client.get().uri("/actuator/health/charlie").accept(MediaType.APPLICATION_JSON).exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE).expectBody().jsonPath("status")
.isEqualTo("DOWN"));
}
@WebEndpointTest
@@ -78,8 +107,9 @@ class HealthEndpointWebIntegrationTests {
CompositeReactiveHealthContributor reactiveComposite = CompositeReactiveHealthContributor
.fromMap(Collections.singletonMap("one", reactiveHealthIndicator));
withHealthContributor(context, "charlie", composite, reactiveComposite,
() -> client.get().uri("/actuator/health/charlie/one").exchange().expectStatus()
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE).expectBody().jsonPath("status").isEqualTo("DOWN"));
() -> client.get().uri("/actuator/health/charlie/one").accept(MediaType.APPLICATION_JSON).exchange()
.expectStatus().isEqualTo(HttpStatus.SERVICE_UNAVAILABLE).expectBody().jsonPath("status")
.isEqualTo("DOWN"));
}
private void withHealthContributor(ApplicationContext context, String name, HealthContributor healthContributor,
@@ -122,9 +152,9 @@ class HealthEndpointWebIntegrationTests {
ReactiveHealthContributor reactiveBravo = (reactiveHealthContributorRegistry != null)
? reactiveHealthContributorRegistry.unregisterContributor(name) : null;
try {
client.get().uri("/actuator/health").exchange().expectStatus().isOk().expectBody().jsonPath("status")
.isEqualTo("UP").jsonPath("details.alpha.status").isEqualTo("UP").jsonPath("details.bravo.status")
.doesNotExist();
client.get().uri("/actuator/health").accept(MediaType.APPLICATION_JSON).exchange().expectStatus().isOk()
.expectBody().jsonPath("status").isEqualTo("UP").jsonPath("components.alpha.status").isEqualTo("UP")
.jsonPath("components.bravo.status").doesNotExist();
}
finally {
healthContributorRegistry.registerContributor(name, bravo);

View File

@@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.health.HealthEndpointSupport.HealthResult;
@@ -52,7 +53,7 @@ class ReactiveHealthEndpointWebExtensionTests extends
void healthReturnsSystemHealth() {
this.registry.registerContributor("test", createContributor(this.up));
WebEndpointResponse<? extends HealthComponent> response = create(this.registry, this.groups)
.health(SecurityContext.NONE).block();
.health(ApiVersion.LATEST, SecurityContext.NONE).block();
HealthComponent health = response.getBody();
assertThat(health.getStatus()).isEqualTo(Status.UP);
assertThat(health).isInstanceOf(SystemHealth.class);
@@ -63,7 +64,7 @@ class ReactiveHealthEndpointWebExtensionTests extends
void healthWhenPathDoesNotExistReturnsHttp404() {
this.registry.registerContributor("test", createContributor(this.up));
WebEndpointResponse<? extends HealthComponent> response = create(this.registry, this.groups)
.health(SecurityContext.NONE, "missing").block();
.health(ApiVersion.LATEST, SecurityContext.NONE, "missing").block();
assertThat(response.getBody()).isNull();
assertThat(response.getStatus()).isEqualTo(404);
}
@@ -72,7 +73,7 @@ class ReactiveHealthEndpointWebExtensionTests extends
void healthWhenPathExistsReturnsHealth() {
this.registry.registerContributor("test", createContributor(this.up));
WebEndpointResponse<? extends HealthComponent> response = create(this.registry, this.groups)
.health(SecurityContext.NONE, "test").block();
.health(ApiVersion.LATEST, SecurityContext.NONE, "test").block();
assertThat(response.getBody()).isEqualTo(this.up);
assertThat(response.getStatus()).isEqualTo(200);
}

View File

@@ -25,6 +25,8 @@ import java.util.Set;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -40,10 +42,10 @@ class SystemHealthTests {
components.put("db1", Health.up().build());
components.put("db2", Health.down().withDetail("a", "b").build());
Set<String> groups = new LinkedHashSet<>(Arrays.asList("liveness", "readiness"));
CompositeHealth health = new SystemHealth(Status.UP, components, groups);
CompositeHealth health = new SystemHealth(ApiVersion.V3, Status.UP, components, groups);
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(health);
assertThat(json).isEqualTo("{\"status\":\"UP\",\"details\":{" + "\"db1\":{\"status\":\"UP\"},"
assertThat(json).isEqualTo("{\"status\":\"UP\",\"components\":{" + "\"db1\":{\"status\":\"UP\"},"
+ "\"db2\":{\"status\":\"DOWN\",\"details\":{\"a\":\"b\"}}},"
+ "\"groups\":[\"liveness\",\"readiness\"]}");
}