Skip to content

Commit 69c561a

Browse files
committed
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
1 parent cd1b7c1 commit 69c561a

21 files changed

+236
-123
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtension.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
2525
import org.springframework.boot.actuate.endpoint.annotation.Selector;
2626
import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
27+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2728
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
2829
import org.springframework.boot.actuate.health.HealthComponent;
2930
import org.springframework.boot.actuate.health.HealthEndpoint;
@@ -46,14 +47,14 @@ public CloudFoundryReactiveHealthEndpointWebExtension(ReactiveHealthEndpointWebE
4647
}
4748

4849
@ReadOperation
49-
public Mono<WebEndpointResponse<? extends HealthComponent>> health() {
50-
return this.delegate.health(SecurityContext.NONE, true);
50+
public Mono<WebEndpointResponse<? extends HealthComponent>> health(ApiVersion apiVersion) {
51+
return this.delegate.health(apiVersion, SecurityContext.NONE, true);
5152
}
5253

5354
@ReadOperation
54-
public Mono<WebEndpointResponse<? extends HealthComponent>> health(
55+
public Mono<WebEndpointResponse<? extends HealthComponent>> health(ApiVersion apiVersion,
5556
@Selector(match = Match.ALL_REMAINING) String... path) {
56-
return this.delegate.health(SecurityContext.NONE, true, path);
57+
return this.delegate.health(apiVersion, SecurityContext.NONE, true, path);
5758
}
5859

5960
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryHealthEndpointWebExtension.java

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
2323
import org.springframework.boot.actuate.endpoint.annotation.Selector;
2424
import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
25+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2526
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
2627
import org.springframework.boot.actuate.health.HealthComponent;
2728
import org.springframework.boot.actuate.health.HealthEndpoint;
@@ -44,13 +45,14 @@ public CloudFoundryHealthEndpointWebExtension(HealthEndpointWebExtension delegat
4445
}
4546

4647
@ReadOperation
47-
public WebEndpointResponse<HealthComponent> health() {
48-
return this.delegate.health(SecurityContext.NONE, true);
48+
public WebEndpointResponse<HealthComponent> health(ApiVersion apiVersion) {
49+
return this.delegate.health(apiVersion, SecurityContext.NONE, true);
4950
}
5051

5152
@ReadOperation
52-
public WebEndpointResponse<HealthComponent> health(@Selector(match = Match.ALL_REMAINING) String... path) {
53-
return this.delegate.health(SecurityContext.NONE, true, path);
53+
public WebEndpointResponse<HealthComponent> health(ApiVersion apiVersion,
54+
@Selector(match = Match.ALL_REMAINING) String... path) {
55+
return this.delegate.health(apiVersion, SecurityContext.NONE, true, path);
5456
}
5557

5658
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryReactiveHealthEndpointWebExtensionTests.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
2626
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
2727
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
28+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2829
import org.springframework.boot.actuate.health.CompositeHealth;
2930
import org.springframework.boot.actuate.health.Health;
3031
import org.springframework.boot.actuate.health.HealthComponent;
@@ -62,12 +63,12 @@ class CloudFoundryReactiveHealthEndpointWebExtensionTests {
6263
.withUserConfiguration(TestHealthIndicator.class);
6364

6465
@Test
65-
void healthDetailsAlwaysPresent() {
66+
void healthComponentsAlwaysPresent() {
6667
this.contextRunner.run((context) -> {
6768
CloudFoundryReactiveHealthEndpointWebExtension extension = context
6869
.getBean(CloudFoundryReactiveHealthEndpointWebExtension.class);
69-
HealthComponent body = extension.health().block(Duration.ofSeconds(30)).getBody();
70-
HealthComponent health = ((CompositeHealth) body).getDetails().entrySet().iterator().next().getValue();
70+
HealthComponent body = extension.health(ApiVersion.V3).block(Duration.ofSeconds(30)).getBody();
71+
HealthComponent health = ((CompositeHealth) body).getComponents().entrySet().iterator().next().getValue();
7172
assertThat(((Health) health).getDetails()).containsEntry("spring", "boot");
7273
});
7374
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
4646
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
4747
import org.springframework.boot.actuate.endpoint.web.WebOperation;
48+
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
4849
import org.springframework.boot.autoconfigure.AutoConfigurations;
4950
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
5051
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
@@ -299,7 +300,9 @@ private CloudFoundryWebFluxEndpointHandlerMapping getHandlerMapping(ApplicationC
299300

300301
private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, String requestPath) {
301302
for (WebOperation operation : endpoint.getOperations()) {
302-
if (operation.getRequestPredicate().getPath().equals(requestPath)) {
303+
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
304+
if (predicate.getPath().equals(requestPath)
305+
&& predicate.getProduces().contains(ActuatorMediaType.V3_JSON)) {
303306
return operation;
304307
}
305308
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
3535
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
3636
import org.springframework.boot.actuate.endpoint.web.WebOperation;
37+
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
3738
import org.springframework.boot.autoconfigure.AutoConfigurations;
3839
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
3940
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
@@ -243,7 +244,9 @@ private CloudFoundryWebEndpointServletHandlerMapping getHandlerMapping(Applicati
243244

244245
private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, String requestPath) {
245246
for (WebOperation operation : endpoint.getOperations()) {
246-
if (operation.getRequestPredicate().getPath().equals(requestPath)) {
247+
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
248+
if (predicate.getPath().equals(requestPath)
249+
&& predicate.getProduces().contains(ActuatorMediaType.V3_JSON)) {
247250
return operation;
248251
}
249252
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryHealthEndpointWebExtensionTests.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
2525
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
2626
import org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration;
27+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2728
import org.springframework.boot.actuate.health.CompositeHealth;
2829
import org.springframework.boot.actuate.health.Health;
2930
import org.springframework.boot.actuate.health.HealthComponent;
@@ -59,12 +60,12 @@ class CloudFoundryHealthEndpointWebExtensionTests {
5960
.withUserConfiguration(TestHealthIndicator.class);
6061

6162
@Test
62-
void healthDetailsAlwaysPresent() {
63+
void healthComponentsAlwaysPresent() {
6364
this.contextRunner.run((context) -> {
6465
CloudFoundryHealthEndpointWebExtension extension = context
6566
.getBean(CloudFoundryHealthEndpointWebExtension.class);
66-
HealthComponent body = extension.health().getBody();
67-
HealthComponent health = ((CompositeHealth) body).getDetails().entrySet().iterator().next().getValue();
67+
HealthComponent body = extension.health(ApiVersion.V3).getBody();
68+
HealthComponent health = ((CompositeHealth) body).getComponents().entrySet().iterator().next().getValue();
6869
assertThat(((Health) health).getDetails()).containsEntry("spring", "boot");
6970
});
7071
}

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/HealthEndpointDocumentationTests.java

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import org.springframework.context.annotation.Bean;
4949
import org.springframework.context.annotation.Configuration;
5050
import org.springframework.context.annotation.Import;
51+
import org.springframework.http.MediaType;
5152
import org.springframework.restdocs.payload.FieldDescriptor;
5253
import org.springframework.util.unit.DataSize;
5354

@@ -73,28 +74,31 @@ class HealthEndpointDocumentationTests extends MockMvcEndpointDocumentationTests
7374
@Test
7475
void health() throws Exception {
7576
FieldDescriptor status = fieldWithPath("status").description("Overall status of the application.");
76-
FieldDescriptor components = fieldWithPath("details").description("The components that make up the health.");
77-
FieldDescriptor componentStatus = fieldWithPath("details.*.status")
77+
FieldDescriptor components = fieldWithPath("components").description("The components that make up the health.");
78+
FieldDescriptor componentStatus = fieldWithPath("components.*.status")
7879
.description("Status of a specific part of the application.");
79-
FieldDescriptor componentDetails = subsectionWithPath("details.*.details")
80+
FieldDescriptor nestedComponents = subsectionWithPath("components.*.components")
81+
.description("The nested components that make up the health.").optional();
82+
FieldDescriptor componentDetails = subsectionWithPath("components.*.details")
8083
.description("Details of the health of a specific part of the application. "
8184
+ "Presence is controlled by `management.endpoint.health.show-details`. May contain nested "
8285
+ "components that make up the health.")
8386
.optional();
84-
this.mockMvc.perform(get("/actuator/health")).andExpect(status().isOk())
85-
.andDo(document("health", responseFields(status, components, componentStatus, componentDetails)));
87+
this.mockMvc.perform(get("/actuator/health").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk())
88+
.andDo(document("health",
89+
responseFields(status, components, componentStatus, nestedComponents, componentDetails)));
8690
}
8791

8892
@Test
8993
void healthComponent() throws Exception {
90-
this.mockMvc.perform(get("/actuator/health/db")).andExpect(status().isOk())
94+
this.mockMvc.perform(get("/actuator/health/db").accept(MediaType.APPLICATION_JSON)).andExpect(status().isOk())
9195
.andDo(document("health/component", responseFields(componentFields)));
9296
}
9397

9498
@Test
9599
void healthComponentInstance() throws Exception {
96-
this.mockMvc.perform(get("/actuator/health/broker/us1")).andExpect(status().isOk())
97-
.andDo(document("health/instance", responseFields(componentFields)));
100+
this.mockMvc.perform(get("/actuator/health/broker/us1").accept(MediaType.APPLICATION_JSON))
101+
.andExpect(status().isOk()).andDo(document("health/instance", responseFields(componentFields)));
98102
}
99103

100104
@Configuration(proxyBeanMethods = false)

spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/health/HealthEndpointAutoConfigurationTests.java

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import reactor.core.publisher.Mono;
2424

2525
import org.springframework.boot.actuate.endpoint.SecurityContext;
26+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2627
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
2728
import org.springframework.boot.actuate.health.AbstractHealthAggregator;
2829
import org.springframework.boot.actuate.health.DefaultHealthContributorRegistry;
@@ -229,7 +230,8 @@ void runWhenHasReactiveHealthContributorRegistryBeanDoesNotCreateAdditionalReact
229230
void runCreatesHealthEndpointWebExtension() {
230231
this.contextRunner.run((context) -> {
231232
HealthEndpointWebExtension webExtension = context.getBean(HealthEndpointWebExtension.class);
232-
WebEndpointResponse<HealthComponent> response = webExtension.health(SecurityContext.NONE, true, "simple");
233+
WebEndpointResponse<HealthComponent> response = webExtension.health(ApiVersion.V3, SecurityContext.NONE,
234+
true, "simple");
233235
Health health = (Health) response.getBody();
234236
assertThat(response.getStatus()).isEqualTo(200);
235237
assertThat(health.getDetails()).containsEntry("counter", 42);
@@ -240,7 +242,8 @@ void runCreatesHealthEndpointWebExtension() {
240242
void runWhenHasHealthEndpointWebExtensionBeanDoesNotCreateExtraHealthEndpointWebExtension() {
241243
this.contextRunner.withUserConfiguration(HealthEndpointWebExtensionConfiguration.class).run((context) -> {
242244
HealthEndpointWebExtension webExtension = context.getBean(HealthEndpointWebExtension.class);
243-
WebEndpointResponse<HealthComponent> response = webExtension.health(SecurityContext.NONE, true, "simple");
245+
WebEndpointResponse<HealthComponent> response = webExtension.health(ApiVersion.V3, SecurityContext.NONE,
246+
true, "simple");
244247
assertThat(response).isNull();
245248
});
246249
}
@@ -249,8 +252,8 @@ void runWhenHasHealthEndpointWebExtensionBeanDoesNotCreateExtraHealthEndpointWeb
249252
void runCreatesReactiveHealthEndpointWebExtension() {
250253
this.reactiveContextRunner.run((context) -> {
251254
ReactiveHealthEndpointWebExtension webExtension = context.getBean(ReactiveHealthEndpointWebExtension.class);
252-
Mono<WebEndpointResponse<? extends HealthComponent>> response = webExtension.health(SecurityContext.NONE,
253-
true, "simple");
255+
Mono<WebEndpointResponse<? extends HealthComponent>> response = webExtension.health(ApiVersion.V3,
256+
SecurityContext.NONE, true, "simple");
254257
Health health = (Health) (response.block().getBody());
255258
assertThat(health.getDetails()).containsEntry("counter", 42);
256259
});
@@ -262,8 +265,8 @@ void runWhenHasReactiveHealthEndpointWebExtensionBeanDoesNotCreateExtraReactiveH
262265
.run((context) -> {
263266
ReactiveHealthEndpointWebExtension webExtension = context
264267
.getBean(ReactiveHealthEndpointWebExtension.class);
265-
Mono<WebEndpointResponse<? extends HealthComponent>> response = webExtension
266-
.health(SecurityContext.NONE, true, "simple");
268+
Mono<WebEndpointResponse<? extends HealthComponent>> response = webExtension.health(ApiVersion.V3,
269+
SecurityContext.NONE, true, "simple");
267270
assertThat(response).isNull();
268271
});
269272
}

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/CompositeHealth.java

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@
2121

2222
import com.fasterxml.jackson.annotation.JsonInclude;
2323
import com.fasterxml.jackson.annotation.JsonInclude.Include;
24+
import com.fasterxml.jackson.annotation.JsonProperty;
2425

26+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2527
import org.springframework.util.Assert;
2628

2729
/**
@@ -35,14 +37,21 @@
3537
*/
3638
public class CompositeHealth extends HealthComponent {
3739

38-
private Status status;
40+
private final Status status;
3941

40-
private Map<String, HealthComponent> details;
42+
private final Map<String, HealthComponent> components;
4143

42-
CompositeHealth(Status status, Map<String, HealthComponent> details) {
44+
private final Map<String, HealthComponent> details;
45+
46+
CompositeHealth(ApiVersion apiVersion, Status status, Map<String, HealthComponent> components) {
4347
Assert.notNull(status, "Status must not be null");
4448
this.status = status;
45-
this.details = (details != null) ? new TreeMap<>(details) : details;
49+
this.components = (apiVersion != ApiVersion.V3) ? null : sort(components);
50+
this.details = (apiVersion != ApiVersion.V2) ? null : sort(components);
51+
}
52+
53+
private Map<String, HealthComponent> sort(Map<String, HealthComponent> components) {
54+
return (components != null) ? new TreeMap<>(components) : components;
4655
}
4756

4857
@Override
@@ -51,7 +60,13 @@ public Status getStatus() {
5160
}
5261

5362
@JsonInclude(Include.NON_EMPTY)
54-
public Map<String, HealthComponent> getDetails() {
63+
public Map<String, HealthComponent> getComponents() {
64+
return this.components;
65+
}
66+
67+
@JsonInclude(Include.NON_EMPTY)
68+
@JsonProperty
69+
Map<String, HealthComponent> getDetails() {
5570
return this.details;
5671
}
5772

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/Health.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ private Health(Builder builder) {
6565
this.details = Collections.unmodifiableMap(builder.details);
6666
}
6767

68+
Health(Status status, Map<String, Object> details) {
69+
this.status = status;
70+
this.details = details;
71+
}
72+
6873
/**
6974
* Return the status of the health.
7075
* @return the status (never {@code null})

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/health/HealthEndpoint.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
2525
import org.springframework.boot.actuate.endpoint.annotation.Selector;
2626
import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
27+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2728

2829
/**
2930
* {@link Endpoint @Endpoint} to expose application health information.
@@ -61,12 +62,16 @@ public HealthEndpoint(HealthContributorRegistry registry, HealthEndpointGroups g
6162

6263
@ReadOperation
6364
public HealthComponent health() {
64-
return healthForPath(EMPTY_PATH);
65+
return health(ApiVersion.V3, EMPTY_PATH);
6566
}
6667

6768
@ReadOperation
6869
public HealthComponent healthForPath(@Selector(match = Match.ALL_REMAINING) String... path) {
69-
HealthResult<HealthComponent> result = getHealth(SecurityContext.NONE, true, path);
70+
return health(ApiVersion.V3, path);
71+
}
72+
73+
private HealthComponent health(ApiVersion apiVersion, String... path) {
74+
HealthResult<HealthComponent> result = getHealth(apiVersion, SecurityContext.NONE, true, path);
7075
return (result != null) ? result.getHealth() : null;
7176
}
7277

@@ -76,9 +81,9 @@ protected HealthComponent getHealth(HealthContributor contributor, boolean inclu
7681
}
7782

7883
@Override
79-
protected HealthComponent aggregateContributions(Map<String, HealthComponent> contributions,
84+
protected HealthComponent aggregateContributions(ApiVersion apiVersion, Map<String, HealthComponent> contributions,
8085
StatusAggregator statusAggregator, boolean includeDetails, Set<String> groupNames) {
81-
return getCompositeHealth(contributions, statusAggregator, includeDetails, groupNames);
86+
return getCompositeHealth(apiVersion, contributions, statusAggregator, includeDetails, groupNames);
8287
}
8388

8489
}

0 commit comments

Comments
 (0)