Skip to content

Commit deb9d67

Browse files
committed
Add Actuator ApiVersion support and bump version
Add `ApiVersion` enum that can be injected into actuator endpoints if they need to support more than one API revision. Spring MVC, WebFlux and Jersey integrations now detect the API version based on the HTTP accept header. If the request explicitly accepts a `application/vnd.spring-boot.actuator.v` media type then the version is set from the header. If no explicit Spring Boot media type is accepted then the latest `ApiVersion` is assumed. A new v3 API revision has also been introduced to allow upcoming health endpoint format changes. By default all endpoints now consume and can produce v3, v2 and `application/json` media types. See gh-17929
1 parent d83238a commit deb9d67

File tree

16 files changed

+391
-33
lines changed

16 files changed

+391
-33
lines changed

spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,8 @@
1616

1717
package org.springframework.boot.actuate.autoconfigure.endpoint.web;
1818

19-
import java.util.Arrays;
2019
import java.util.Collection;
2120
import java.util.Collections;
22-
import java.util.List;
2321
import java.util.stream.Collectors;
2422

2523
import org.springframework.beans.factory.ObjectProvider;
@@ -28,7 +26,6 @@
2826
import org.springframework.boot.actuate.endpoint.EndpointFilter;
2927
import org.springframework.boot.actuate.endpoint.EndpointsSupplier;
3028
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
31-
import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
3229
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
3330
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
3431
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
@@ -67,8 +64,6 @@
6764
@EnableConfigurationProperties(WebEndpointProperties.class)
6865
public class WebEndpointAutoConfiguration {
6966

70-
private static final List<String> MEDIA_TYPES = Arrays.asList(ActuatorMediaType.V2_JSON, "application/json");
71-
7267
private final ApplicationContext applicationContext;
7368

7469
private final WebEndpointProperties properties;
@@ -86,7 +81,7 @@ public PathMapper webEndpointPathMapper() {
8681
@Bean
8782
@ConditionalOnMissingBean
8883
public EndpointMediaTypes endpointMediaTypes() {
89-
return new EndpointMediaTypes(MEDIA_TYPES, MEDIA_TYPES);
84+
return EndpointMediaTypes.DEFAULT;
9085
}
9186

9287
@Bean

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ void cloudfoundryapplicationProducesActuatorMediaType() throws Exception {
9898
"vcap.application.cf_api:https://my-cloud-controller.com").run((context) -> {
9999
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
100100
mockMvc.perform(get("/cloudfoundryapplication"))
101-
.andExpect(header().string("Content-Type", ActuatorMediaType.V2_JSON));
101+
.andExpect(header().string("Content-Type", ActuatorMediaType.V3_JSON));
102102
});
103103
}
104104

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ class WebEndpointAutoConfigurationTests {
6161
void webApplicationConfiguresEndpointMediaTypes() {
6262
this.contextRunner.run((context) -> {
6363
EndpointMediaTypes endpointMediaTypes = context.getBean(EndpointMediaTypes.class);
64-
assertThat(endpointMediaTypes.getConsumed()).containsExactly(ActuatorMediaType.V2_JSON, "application/json");
64+
assertThat(endpointMediaTypes.getConsumed()).containsExactly(ActuatorMediaType.V3_JSON,
65+
ActuatorMediaType.V2_JSON, "application/json");
6566
});
6667
}
6768

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@
1818

1919
import java.util.Map;
2020

21+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2122
import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker;
2223
import org.springframework.util.Assert;
2324

2425
/**
2526
* The context for the {@link OperationInvoker invocation of an operation}.
2627
*
2728
* @author Andy Wilkinson
29+
* @author Phillip Webb
2830
* @since 2.0.0
2931
*/
3032
public class InvocationContext {
@@ -33,23 +35,55 @@ public class InvocationContext {
3335

3436
private final Map<String, Object> arguments;
3537

38+
private final ApiVersion apiVersion;
39+
3640
/**
37-
* Creates a new context for an operation being invoked by the given {@code principal}
38-
* with the given available {@code arguments}.
41+
* Creates a new context for an operation being invoked by the given
42+
* {@code securityContext} with the given available {@code arguments}.
3943
* @param securityContext the current security context. Never {@code null}
4044
* @param arguments the arguments available to the operation. Never {@code null}
4145
*/
4246
public InvocationContext(SecurityContext securityContext, Map<String, Object> arguments) {
47+
this(null, securityContext, arguments);
48+
}
49+
50+
/**
51+
* Creates a new context for an operation being invoked by the given
52+
* {@code securityContext} with the given available {@code arguments}.
53+
* @param apiVersion the API version or {@code null} to use the latest
54+
* @param securityContext the current security context. Never {@code null}
55+
* @param arguments the arguments available to the operation. Never {@code null}
56+
* @since 2.2.0
57+
*/
58+
public InvocationContext(ApiVersion apiVersion, SecurityContext securityContext, Map<String, Object> arguments) {
4359
Assert.notNull(securityContext, "SecurityContext must not be null");
4460
Assert.notNull(arguments, "Arguments must not be null");
61+
this.apiVersion = (apiVersion != null) ? apiVersion : ApiVersion.LATEST;
4562
this.securityContext = securityContext;
4663
this.arguments = arguments;
4764
}
4865

66+
/**
67+
* Return the API version in use.
68+
* @return the apiVersion the API version
69+
* @since 2.2.0
70+
*/
71+
public ApiVersion getApiVersion() {
72+
return this.apiVersion;
73+
}
74+
75+
/**
76+
* Return the security context to use for the invocation.
77+
* @return the security context
78+
*/
4979
public SecurityContext getSecurityContext() {
5080
return this.securityContext;
5181
}
5282

83+
/**
84+
* Return the invocation arguments.
85+
* @return the arguments
86+
*/
5387
public Map<String, Object> getArguments() {
5488
return this.arguments;
5589
}

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ActuatorMediaType.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,21 @@ public final class ActuatorMediaType {
2727

2828
/**
2929
* Constant for the Actuator V1 media type.
30+
* @deprecated since 2.2.0 as the v1 format is no longer supported
3031
*/
32+
@Deprecated
3133
public static final String V1_JSON = "application/vnd.spring-boot.actuator.v1+json";
3234

3335
/**
34-
* Constant for the Actuator V2 media type.
36+
* Constant for the Actuator {@link ApiVersion#V2 v2} media type.
3537
*/
3638
public static final String V2_JSON = "application/vnd.spring-boot.actuator.v2+json";
3739

40+
/**
41+
* Constant for the Actuator {@link ApiVersion#V3 v3} media type.
42+
*/
43+
public static final String V3_JSON = "application/vnd.spring-boot.actuator.v3+json";
44+
3845
private ActuatorMediaType() {
3946
}
4047

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2012-2019 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.actuate.endpoint.http;
18+
19+
import java.util.List;
20+
import java.util.Map;
21+
22+
import org.springframework.util.CollectionUtils;
23+
import org.springframework.util.MimeTypeUtils;
24+
25+
/**
26+
* API versions supported for the actuator HTTP API. This enum may be injected into
27+
* actuator endpoints in order to return a response compatible with the requested version.
28+
*
29+
* @author Phillip Webb
30+
* @since 2.2.0
31+
*/
32+
public enum ApiVersion {
33+
34+
/**
35+
* Version 2 (supported by Spring Boot 2.0+).
36+
*/
37+
V2,
38+
39+
/**
40+
* Version 3 (supported by Spring Boot 2.2+).
41+
*/
42+
V3;
43+
44+
private static final String MEDIA_TYPE_PREFIX = "application/vnd.spring-boot.actuator.";
45+
46+
/**
47+
* The latest API version.
48+
*/
49+
public static final ApiVersion LATEST = ApiVersion.V3;
50+
51+
/**
52+
* Return the {@link ApiVersion} to use based on the HTTP request headers. The version
53+
* will be deduced based on the {@code Accept} header.
54+
* @param headers the HTTP headers
55+
* @return the API version to use
56+
*/
57+
public static ApiVersion fromHttpHeaders(Map<String, List<String>> headers) {
58+
ApiVersion version = null;
59+
List<String> accepts = headers.get("Accept");
60+
if (!CollectionUtils.isEmpty(accepts)) {
61+
for (String accept : accepts) {
62+
for (String type : MimeTypeUtils.tokenize(accept)) {
63+
version = mostRecent(version, forType(type));
64+
}
65+
}
66+
}
67+
return (version != null) ? version : LATEST;
68+
}
69+
70+
private static ApiVersion forType(String type) {
71+
if (type.startsWith(MEDIA_TYPE_PREFIX)) {
72+
type = type.substring(MEDIA_TYPE_PREFIX.length());
73+
int suffixIndex = type.indexOf("+");
74+
type = (suffixIndex != -1) ? type.substring(0, suffixIndex) : type;
75+
try {
76+
return valueOf(type.toUpperCase());
77+
}
78+
catch (IllegalArgumentException ex) {
79+
}
80+
}
81+
return null;
82+
}
83+
84+
private static ApiVersion mostRecent(ApiVersion existing, ApiVersion candidate) {
85+
int existingOrdinal = (existing != null) ? existing.ordinal() : -1;
86+
int candidateOrdinal = (candidate != null) ? candidate.ordinal() : -1;
87+
return (candidateOrdinal > existingOrdinal) ? candidate : existing;
88+
}
89+
90+
}

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
import org.springframework.boot.actuate.endpoint.InvocationContext;
2525
import org.springframework.boot.actuate.endpoint.SecurityContext;
26+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
2627
import org.springframework.boot.actuate.endpoint.invoke.MissingParametersException;
2728
import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker;
2829
import org.springframework.boot.actuate.endpoint.invoke.OperationParameter;
@@ -88,6 +89,9 @@ private boolean isMissing(InvocationContext context, OperationParameter paramete
8889
if (!parameter.isMandatory()) {
8990
return false;
9091
}
92+
if (ApiVersion.class.equals(parameter.getType())) {
93+
return false;
94+
}
9195
if (Principal.class.equals(parameter.getType())) {
9296
return context.getSecurityContext().getPrincipal() == null;
9397
}
@@ -103,6 +107,9 @@ private Object[] resolveArguments(InvocationContext context) {
103107
}
104108

105109
private Object resolveArgument(OperationParameter parameter, InvocationContext context) {
110+
if (ApiVersion.class.equals(parameter.getType())) {
111+
return context.getApiVersion();
112+
}
106113
if (Principal.class.equals(parameter.getType())) {
107114
return context.getSecurityContext().getPrincipal();
108115
}

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypes.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616

1717
package org.springframework.boot.actuate.endpoint.web;
1818

19+
import java.util.Arrays;
1920
import java.util.Collections;
2021
import java.util.List;
2122

23+
import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
2224
import org.springframework.util.Assert;
2325

2426
/**
@@ -29,10 +31,40 @@
2931
*/
3032
public class EndpointMediaTypes {
3133

34+
private static final String JSON_MEDIA_TYPE = "application/json";
35+
36+
/**
37+
* Default {@link EndpointMediaTypes} for this version of Spring Boot.
38+
*/
39+
public static final EndpointMediaTypes DEFAULT = new EndpointMediaTypes(ActuatorMediaType.V3_JSON,
40+
ActuatorMediaType.V2_JSON, JSON_MEDIA_TYPE);
41+
3242
private final List<String> produced;
3343

3444
private final List<String> consumed;
3545

46+
/**
47+
* Creates a new {@link EndpointMediaTypes} with the given {@code produced} and
48+
* {@code consumed} media types.
49+
* @param producedAndConsumed the default media types that are produced and consumed
50+
* by an endpoint. Must not be {@code null}.
51+
* @since 2.2.0
52+
*/
53+
public EndpointMediaTypes(String... producedAndConsumed) {
54+
this((producedAndConsumed != null) ? Arrays.asList(producedAndConsumed) : (List<String>) null);
55+
}
56+
57+
/**
58+
* Creates a new {@link EndpointMediaTypes} with the given {@code produced} and
59+
* {@code consumed} media types.
60+
* @param producedAndConsumed the default media types that are produced and consumed
61+
* by an endpoint. Must not be {@code null}.
62+
* @since 2.2.0
63+
*/
64+
public EndpointMediaTypes(List<String> producedAndConsumed) {
65+
this(producedAndConsumed, producedAndConsumed);
66+
}
67+
3668
/**
3769
* Creates a new {@link EndpointMediaTypes} with the given {@code produced} and
3870
* {@code consumed} media types.

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException;
4444
import org.springframework.boot.actuate.endpoint.InvocationContext;
4545
import org.springframework.boot.actuate.endpoint.SecurityContext;
46+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
4647
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
4748
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
4849
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
@@ -150,8 +151,10 @@ public Response apply(ContainerRequestContext data) {
150151
arguments.putAll(extractPathParameters(data));
151152
arguments.putAll(extractQueryParameters(data));
152153
try {
153-
Object response = this.operation
154-
.invoke(new InvocationContext(new JerseySecurityContext(data.getSecurityContext()), arguments));
154+
ApiVersion apiVersion = ApiVersion.fromHttpHeaders(data.getHeaders());
155+
JerseySecurityContext securityContext = new JerseySecurityContext(data.getSecurityContext());
156+
InvocationContext invocationContext = new InvocationContext(apiVersion, securityContext, arguments);
157+
Object response = this.operation.invoke(invocationContext);
155158
return convertToJaxRsResponse(response, data.getRequest().getMethod());
156159
}
157160
catch (InvalidEndpointRequestException ex) {

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import org.springframework.boot.actuate.endpoint.InvocationContext;
3434
import org.springframework.boot.actuate.endpoint.OperationType;
3535
import org.springframework.boot.actuate.endpoint.SecurityContext;
36+
import org.springframework.boot.actuate.endpoint.http.ApiVersion;
3637
import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker;
3738
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
3839
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
@@ -309,6 +310,7 @@ Mono<SecurityContext> emptySecurityContext() {
309310

310311
@Override
311312
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
313+
ApiVersion apiVersion = ApiVersion.fromHttpHeaders(exchange.getRequest().getHeaders());
312314
Map<String, Object> arguments = getArguments(exchange, body);
313315
String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate()
314316
.getMatchAllRemainingPathSegmentsVariable();
@@ -317,7 +319,7 @@ public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<Strin
317319
tokenizePathSegments((String) arguments.get(matchAllRemainingPathSegmentsVariable)));
318320
}
319321
return this.securityContextSupplier.get()
320-
.map((securityContext) -> new InvocationContext(securityContext, arguments))
322+
.map((securityContext) -> new InvocationContext(apiVersion, securityContext, arguments))
321323
.flatMap((invocationContext) -> handleResult((Publisher<?>) this.invoker.invoke(invocationContext),
322324
exchange.getRequest().getMethod()));
323325
}

0 commit comments

Comments
 (0)