From deb9d67cef7fe55bd08ab292659b611419031403 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Wed, 25 Sep 2019 22:53:55 -0700 Subject: [PATCH] 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 --- .../web/WebEndpointAutoConfiguration.java | 7 +- ...FoundryActuatorAutoConfigurationTests.java | 2 +- .../WebEndpointAutoConfigurationTests.java | 3 +- .../actuate/endpoint/InvocationContext.java | 38 +++++++- .../endpoint/http/ActuatorMediaType.java | 9 +- .../actuate/endpoint/http/ApiVersion.java | 90 +++++++++++++++++++ .../reflect/ReflectiveOperationInvoker.java | 7 ++ .../endpoint/web/EndpointMediaTypes.java | 32 +++++++ .../jersey/JerseyEndpointResourceFactory.java | 7 +- ...AbstractWebFluxEndpointHandlerMapping.java | 4 +- .../AbstractWebMvcEndpointHandlerMapping.java | 11 ++- .../endpoint/InvocationContextTests.java | 77 ++++++++++++++++ .../endpoint/http/ApiVersionTests.java | 90 +++++++++++++++++++ .../ReflectiveOperationInvokerTests.java | 17 ++-- .../endpoint/web/EndpointMediaTypesTests.java | 17 ++++ ...EndpointTestInvocationContextProvider.java | 13 +-- 16 files changed, 391 insertions(+), 33 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ApiVersion.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/InvocationContextTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/http/ApiVersionTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java index e54837ad14..ea5eac25d2 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java @@ -16,10 +16,8 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.web; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.List; import java.util.stream.Collectors; import org.springframework.beans.factory.ObjectProvider; @@ -28,7 +26,6 @@ import org.springframework.boot.actuate.autoconfigure.endpoint.ExposeExcludeProp import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointsSupplier; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; -import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType; import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -67,8 +64,6 @@ import org.springframework.context.annotation.Configuration; @EnableConfigurationProperties(WebEndpointProperties.class) public class WebEndpointAutoConfiguration { - private static final List MEDIA_TYPES = Arrays.asList(ActuatorMediaType.V2_JSON, "application/json"); - private final ApplicationContext applicationContext; private final WebEndpointProperties properties; @@ -86,7 +81,7 @@ public class WebEndpointAutoConfiguration { @Bean @ConditionalOnMissingBean public EndpointMediaTypes endpointMediaTypes() { - return new EndpointMediaTypes(MEDIA_TYPES, MEDIA_TYPES); + return EndpointMediaTypes.DEFAULT; } @Bean diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java index dea8001a8d..eaeed62e86 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java @@ -98,7 +98,7 @@ class CloudFoundryActuatorAutoConfigurationTests { "vcap.application.cf_api:https://my-cloud-controller.com").run((context) -> { MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); mockMvc.perform(get("/cloudfoundryapplication")) - .andExpect(header().string("Content-Type", ActuatorMediaType.V2_JSON)); + .andExpect(header().string("Content-Type", ActuatorMediaType.V3_JSON)); }); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java index f5b564d50d..89fc5ae40c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfigurationTests.java @@ -61,7 +61,8 @@ class WebEndpointAutoConfigurationTests { void webApplicationConfiguresEndpointMediaTypes() { this.contextRunner.run((context) -> { EndpointMediaTypes endpointMediaTypes = context.getBean(EndpointMediaTypes.class); - assertThat(endpointMediaTypes.getConsumed()).containsExactly(ActuatorMediaType.V2_JSON, "application/json"); + assertThat(endpointMediaTypes.getConsumed()).containsExactly(ActuatorMediaType.V3_JSON, + ActuatorMediaType.V2_JSON, "application/json"); }); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java index 1ea4430f6f..8c80225412 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/InvocationContext.java @@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint; import java.util.Map; +import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.util.Assert; @@ -25,6 +26,7 @@ import org.springframework.util.Assert; * The context for the {@link OperationInvoker invocation of an operation}. * * @author Andy Wilkinson + * @author Phillip Webb * @since 2.0.0 */ public class InvocationContext { @@ -33,23 +35,55 @@ public class InvocationContext { private final Map arguments; + private final ApiVersion apiVersion; + /** - * Creates a new context for an operation being invoked by the given {@code principal} - * with the given available {@code arguments}. + * Creates a new context for an operation being invoked by the given + * {@code securityContext} with the given available {@code arguments}. * @param securityContext the current security context. Never {@code null} * @param arguments the arguments available to the operation. Never {@code null} */ public InvocationContext(SecurityContext securityContext, Map arguments) { + this(null, securityContext, arguments); + } + + /** + * Creates a new context for an operation being invoked by the given + * {@code securityContext} with the given available {@code arguments}. + * @param apiVersion the API version or {@code null} to use the latest + * @param securityContext the current security context. Never {@code null} + * @param arguments the arguments available to the operation. Never {@code null} + * @since 2.2.0 + */ + public InvocationContext(ApiVersion apiVersion, SecurityContext securityContext, Map arguments) { Assert.notNull(securityContext, "SecurityContext must not be null"); Assert.notNull(arguments, "Arguments must not be null"); + this.apiVersion = (apiVersion != null) ? apiVersion : ApiVersion.LATEST; this.securityContext = securityContext; this.arguments = arguments; } + /** + * Return the API version in use. + * @return the apiVersion the API version + * @since 2.2.0 + */ + public ApiVersion getApiVersion() { + return this.apiVersion; + } + + /** + * Return the security context to use for the invocation. + * @return the security context + */ public SecurityContext getSecurityContext() { return this.securityContext; } + /** + * Return the invocation arguments. + * @return the arguments + */ public Map getArguments() { return this.arguments; } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ActuatorMediaType.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ActuatorMediaType.java index 596cb8dd09..831f4e0c4b 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ActuatorMediaType.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ActuatorMediaType.java @@ -27,14 +27,21 @@ public final class ActuatorMediaType { /** * Constant for the Actuator V1 media type. + * @deprecated since 2.2.0 as the v1 format is no longer supported */ + @Deprecated public static final String V1_JSON = "application/vnd.spring-boot.actuator.v1+json"; /** - * Constant for the Actuator V2 media type. + * Constant for the Actuator {@link ApiVersion#V2 v2} media type. */ public static final String V2_JSON = "application/vnd.spring-boot.actuator.v2+json"; + /** + * Constant for the Actuator {@link ApiVersion#V3 v3} media type. + */ + public static final String V3_JSON = "application/vnd.spring-boot.actuator.v3+json"; + private ActuatorMediaType() { } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ApiVersion.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ApiVersion.java new file mode 100644 index 0000000000..6267ef8f85 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ApiVersion.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.endpoint.http; + +import java.util.List; +import java.util.Map; + +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeTypeUtils; + +/** + * API versions supported for the actuator HTTP API. This enum may be injected into + * actuator endpoints in order to return a response compatible with the requested version. + * + * @author Phillip Webb + * @since 2.2.0 + */ +public enum ApiVersion { + + /** + * Version 2 (supported by Spring Boot 2.0+). + */ + V2, + + /** + * Version 3 (supported by Spring Boot 2.2+). + */ + V3; + + private static final String MEDIA_TYPE_PREFIX = "application/vnd.spring-boot.actuator."; + + /** + * The latest API version. + */ + public static final ApiVersion LATEST = ApiVersion.V3; + + /** + * Return the {@link ApiVersion} to use based on the HTTP request headers. The version + * will be deduced based on the {@code Accept} header. + * @param headers the HTTP headers + * @return the API version to use + */ + public static ApiVersion fromHttpHeaders(Map> headers) { + ApiVersion version = null; + List accepts = headers.get("Accept"); + if (!CollectionUtils.isEmpty(accepts)) { + for (String accept : accepts) { + for (String type : MimeTypeUtils.tokenize(accept)) { + version = mostRecent(version, forType(type)); + } + } + } + return (version != null) ? version : LATEST; + } + + private static ApiVersion forType(String type) { + if (type.startsWith(MEDIA_TYPE_PREFIX)) { + type = type.substring(MEDIA_TYPE_PREFIX.length()); + int suffixIndex = type.indexOf("+"); + type = (suffixIndex != -1) ? type.substring(0, suffixIndex) : type; + try { + return valueOf(type.toUpperCase()); + } + catch (IllegalArgumentException ex) { + } + } + return null; + } + + private static ApiVersion mostRecent(ApiVersion existing, ApiVersion candidate) { + int existingOrdinal = (existing != null) ? existing.ordinal() : -1; + int candidateOrdinal = (candidate != null) ? candidate.ordinal() : -1; + return (candidateOrdinal > existingOrdinal) ? candidate : existing; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java index dbb74b3ce3..38137d9ee1 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvoker.java @@ -23,6 +23,7 @@ import java.util.stream.Collectors; import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.invoke.MissingParametersException; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.invoke.OperationParameter; @@ -88,6 +89,9 @@ public class ReflectiveOperationInvoker implements OperationInvoker { if (!parameter.isMandatory()) { return false; } + if (ApiVersion.class.equals(parameter.getType())) { + return false; + } if (Principal.class.equals(parameter.getType())) { return context.getSecurityContext().getPrincipal() == null; } @@ -103,6 +107,9 @@ public class ReflectiveOperationInvoker implements OperationInvoker { } private Object resolveArgument(OperationParameter parameter, InvocationContext context) { + if (ApiVersion.class.equals(parameter.getType())) { + return context.getApiVersion(); + } if (Principal.class.equals(parameter.getType())) { return context.getSecurityContext().getPrincipal(); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypes.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypes.java index 7141a3bddc..0a7e46d3a2 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypes.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypes.java @@ -16,9 +16,11 @@ package org.springframework.boot.actuate.endpoint.web; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType; import org.springframework.util.Assert; /** @@ -29,10 +31,40 @@ import org.springframework.util.Assert; */ public class EndpointMediaTypes { + private static final String JSON_MEDIA_TYPE = "application/json"; + + /** + * Default {@link EndpointMediaTypes} for this version of Spring Boot. + */ + public static final EndpointMediaTypes DEFAULT = new EndpointMediaTypes(ActuatorMediaType.V3_JSON, + ActuatorMediaType.V2_JSON, JSON_MEDIA_TYPE); + private final List produced; private final List consumed; + /** + * Creates a new {@link EndpointMediaTypes} with the given {@code produced} and + * {@code consumed} media types. + * @param producedAndConsumed the default media types that are produced and consumed + * by an endpoint. Must not be {@code null}. + * @since 2.2.0 + */ + public EndpointMediaTypes(String... producedAndConsumed) { + this((producedAndConsumed != null) ? Arrays.asList(producedAndConsumed) : (List) null); + } + + /** + * Creates a new {@link EndpointMediaTypes} with the given {@code produced} and + * {@code consumed} media types. + * @param producedAndConsumed the default media types that are produced and consumed + * by an endpoint. Must not be {@code null}. + * @since 2.2.0 + */ + public EndpointMediaTypes(List producedAndConsumed) { + this(producedAndConsumed, producedAndConsumed); + } + /** * Creates a new {@link EndpointMediaTypes} with the given {@code produced} and * {@code consumed} media types. diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java index bb6009e125..6c86907581 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java @@ -43,6 +43,7 @@ import reactor.core.publisher.Mono; import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException; import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -150,8 +151,10 @@ public class JerseyEndpointResourceFactory { arguments.putAll(extractPathParameters(data)); arguments.putAll(extractQueryParameters(data)); try { - Object response = this.operation - .invoke(new InvocationContext(new JerseySecurityContext(data.getSecurityContext()), arguments)); + ApiVersion apiVersion = ApiVersion.fromHttpHeaders(data.getHeaders()); + JerseySecurityContext securityContext = new JerseySecurityContext(data.getSecurityContext()); + InvocationContext invocationContext = new InvocationContext(apiVersion, securityContext, arguments); + Object response = this.operation.invoke(invocationContext); return convertToJaxRsResponse(response, data.getRequest().getMethod()); } catch (InvalidEndpointRequestException ex) { diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java index 0744608d2c..a9aaff3664 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java @@ -33,6 +33,7 @@ import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.OperationType; import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -309,6 +310,7 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi @Override public Mono> handle(ServerWebExchange exchange, Map body) { + ApiVersion apiVersion = ApiVersion.fromHttpHeaders(exchange.getRequest().getHeaders()); Map arguments = getArguments(exchange, body); String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate() .getMatchAllRemainingPathSegmentsVariable(); @@ -317,7 +319,7 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi tokenizePathSegments((String) arguments.get(matchAllRemainingPathSegmentsVariable))); } return this.securityContextSupplier.get() - .map((securityContext) -> new InvocationContext(securityContext, arguments)) + .map((securityContext) -> new InvocationContext(apiVersion, securityContext, arguments)) .flatMap((invocationContext) -> handleResult((Publisher) this.invoker.invoke(invocationContext), exchange.getRequest().getMethod())); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java index abbba127ec..0b7f997051 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java @@ -33,6 +33,7 @@ import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.actuate.endpoint.InvalidEndpointRequestException; import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -40,9 +41,11 @@ import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.WebOperation; import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.util.AntPathMatcher; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; @@ -293,11 +296,13 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin @Override public Object handle(HttpServletRequest request, @RequestBody(required = false) Map body) { + HttpHeaders headers = new ServletServerHttpRequest(request).getHeaders(); Map arguments = getArguments(request, body); try { - return handleResult( - this.operation.invoke(new InvocationContext(new ServletSecurityContext(request), arguments)), - HttpMethod.valueOf(request.getMethod())); + ApiVersion apiVersion = ApiVersion.fromHttpHeaders(headers); + ServletSecurityContext securityContext = new ServletSecurityContext(request); + InvocationContext invocationContext = new InvocationContext(apiVersion, securityContext, arguments); + return handleResult(this.operation.invoke(invocationContext), HttpMethod.resolve(request.getMethod())); } catch (InvalidEndpointRequestException ex) { throw new BadOperationRequestException(ex.getReason()); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/InvocationContextTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/InvocationContextTests.java new file mode 100644 index 0000000000..b71184c109 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/InvocationContextTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.endpoint; + +import java.util.Collections; +import java.util.Map; + +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; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link InvocationContext}. + * + * @author Phillip Webb + */ +class InvocationContextTests { + + private final SecurityContext securityContext = mock(SecurityContext.class); + + private final Map arguments = Collections.singletonMap("test", "value"); + + @Test + void createWhenApiVersionIsNullUsesLatestVersion() { + InvocationContext context = new InvocationContext(null, this.securityContext, this.arguments); + assertThat(context.getApiVersion()).isEqualTo(ApiVersion.LATEST); + } + + @Test + void createWhenSecurityContextIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new InvocationContext(null, this.arguments)) + .withMessage("SecurityContext must not be null"); + } + + @Test + void createWhenArgumentsIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new InvocationContext(this.securityContext, null)) + .withMessage("Arguments must not be null"); + } + + @Test + void getApiVersionReturnsApiVersion() { + InvocationContext context = new InvocationContext(ApiVersion.V2, this.securityContext, this.arguments); + assertThat(context.getApiVersion()).isEqualTo(ApiVersion.V2); + } + + @Test + void getSecurityContextReturnsSecurityContext() { + InvocationContext context = new InvocationContext(this.securityContext, this.arguments); + assertThat(context.getSecurityContext()).isEqualTo(this.securityContext); + } + + @Test + void getArgumentsReturnsArguments() { + InvocationContext context = new InvocationContext(this.securityContext, this.arguments); + assertThat(context.getArguments()).isEqualTo(this.arguments); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/http/ApiVersionTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/http/ApiVersionTests.java new file mode 100644 index 0000000000..45408d2158 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/http/ApiVersionTests.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.endpoint.http; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test for {@link ApiVersion}. + * + * @author Phillip Webb + */ +class ApiVersionTests { + + @Test + void latestIsLatestVersion() { + ApiVersion[] values = ApiVersion.values(); + assertThat(ApiVersion.LATEST).isEqualTo(values[values.length - 1]); + } + + @Test + void fromHttpHeadersWhenEmptyReturnsLatest() { + ApiVersion version = ApiVersion.fromHttpHeaders(Collections.emptyMap()); + assertThat(version).isEqualTo(ApiVersion.V3); + } + + @Test + void fromHttpHeadersWhenHasSingleV2HeaderReturnsV2() { + ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader(ActuatorMediaType.V2_JSON)); + assertThat(version).isEqualTo(ApiVersion.V2); + } + + @Test + void fromHttpHeadersWhenHasSingleV3HeaderReturnsV3() { + ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader(ActuatorMediaType.V3_JSON)); + assertThat(version).isEqualTo(ApiVersion.V3); + } + + @Test + void fromHttpHeadersWhenHasV2AndV3HeaderReturnsV3() { + ApiVersion version = ApiVersion + .fromHttpHeaders(acceptHeader(ActuatorMediaType.V2_JSON, ActuatorMediaType.V3_JSON)); + assertThat(version).isEqualTo(ApiVersion.V3); + } + + @Test + void fromHttpHeadersWhenHasV2AndV3AsOneHeaderReturnsV3() { + ApiVersion version = ApiVersion + .fromHttpHeaders(acceptHeader(ActuatorMediaType.V2_JSON + "," + ActuatorMediaType.V3_JSON)); + assertThat(version).isEqualTo(ApiVersion.V3); + } + + @Test + void fromHttpHeadersWhenHasSingleHeaderWithoutJsonReturnsHeader() { + ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader("application/vnd.spring-boot.actuator.v2")); + assertThat(version).isEqualTo(ApiVersion.V2); + } + + @Test + void fromHttpHeadersWhenHasUnknownVersionReturnsLatest() { + ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader("application/vnd.spring-boot.actuator.v200")); + assertThat(version).isEqualTo(ApiVersion.V3); + } + + private Map> acceptHeader(String... types) { + List value = Arrays.asList(types); + return value.isEmpty() ? Collections.emptyMap() : Collections.singletonMap("Accept", value); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java index 8fe3adc03e..11eb5bae88 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoke/reflect/ReflectiveOperationInvokerTests.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.endpoint.InvocationContext; import org.springframework.boot.actuate.endpoint.OperationType; import org.springframework.boot.actuate.endpoint.SecurityContext; +import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.invoke.MissingParametersException; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.lang.Nullable; @@ -50,8 +51,8 @@ class ReflectiveOperationInvokerTests { @BeforeEach void setup() { this.target = new Example(); - this.operationMethod = new OperationMethod(ReflectionUtils.findMethod(Example.class, "reverse", String.class), - OperationType.READ); + this.operationMethod = new OperationMethod(ReflectionUtils.findMethod(Example.class, "reverse", + ApiVersion.class, SecurityContext.class, String.class), OperationType.READ); this.parameterValueMapper = (parameter, value) -> (value != null) ? value.toString() : null; } @@ -95,8 +96,8 @@ class ReflectiveOperationInvokerTests { @Test void invokeWhenMissingNullableArgumentShouldInvoke() { - OperationMethod operationMethod = new OperationMethod( - ReflectionUtils.findMethod(Example.class, "reverseNullable", String.class), OperationType.READ); + OperationMethod operationMethod = new OperationMethod(ReflectionUtils.findMethod(Example.class, + "reverseNullable", ApiVersion.class, SecurityContext.class, String.class), OperationType.READ); ReflectiveOperationInvoker invoker = new ReflectiveOperationInvoker(this.target, operationMethod, this.parameterValueMapper); Object result = invoker @@ -115,11 +116,15 @@ class ReflectiveOperationInvokerTests { static class Example { - String reverse(String name) { + String reverse(ApiVersion apiVersion, SecurityContext securityContext, String name) { + assertThat(apiVersion).isEqualTo(ApiVersion.LATEST); + assertThat(securityContext).isNotNull(); return new StringBuilder(name).reverse().toString(); } - String reverseNullable(@Nullable String name) { + String reverseNullable(ApiVersion apiVersion, SecurityContext securityContext, @Nullable String name) { + assertThat(apiVersion).isEqualTo(ApiVersion.LATEST); + assertThat(securityContext).isNotNull(); return new StringBuilder(String.valueOf(name)).reverse().toString(); } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypesTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypesTests.java index 2874f85014..cee1d83bd9 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypesTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/EndpointMediaTypesTests.java @@ -22,6 +22,8 @@ import java.util.List; import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -32,6 +34,14 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException */ class EndpointMediaTypesTests { + @Test + void defaultReturnsExpectedProducedAndConsumedTypes() { + assertThat(EndpointMediaTypes.DEFAULT.getProduced()).containsExactly(ActuatorMediaType.V3_JSON, + ActuatorMediaType.V2_JSON, "application/json"); + assertThat(EndpointMediaTypes.DEFAULT.getConsumed()).containsExactly(ActuatorMediaType.V3_JSON, + ActuatorMediaType.V2_JSON, "application/json"); + } + @Test void createWhenProducedIsNullShouldThrowException() { assertThatIllegalArgumentException().isThrownBy(() -> new EndpointMediaTypes(null, Collections.emptyList())) @@ -44,6 +54,13 @@ class EndpointMediaTypesTests { .withMessageContaining("Consumed must not be null"); } + @Test + void createFromProducedAndConsumedUsesSameListForBoth() { + EndpointMediaTypes types = new EndpointMediaTypes("spring/framework", "spring/boot"); + assertThat(types.getProduced()).containsExactly("spring/framework", "spring/boot"); + assertThat(types.getConsumed()).containsExactly("spring/framework", "spring/boot"); + } + @Test void getProducedShouldReturnProduced() { List produced = Arrays.asList("a", "b", "c"); diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java index a409302b36..41bf39c07f 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/test/WebEndpointTestInvocationContextProvider.java @@ -17,7 +17,6 @@ package org.springframework.boot.actuate.endpoint.web.test; import java.time.Duration; -import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -38,7 +37,6 @@ import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestTemplateInvocationContext; import org.junit.jupiter.api.extension.TestTemplateInvocationContextProvider; -import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType; import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; @@ -68,7 +66,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; -import org.springframework.http.MediaType; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.util.ClassUtils; @@ -233,9 +230,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation } private void customize(ResourceConfig config) { - List mediaTypes = Arrays.asList(javax.ws.rs.core.MediaType.APPLICATION_JSON, - ActuatorMediaType.V2_JSON); - EndpointMediaTypes endpointMediaTypes = new EndpointMediaTypes(mediaTypes, mediaTypes); + EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT; WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext, new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList()); @@ -281,8 +276,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation @Bean WebFluxEndpointHandlerMapping webEndpointReactiveHandlerMapping() { - List mediaTypes = Arrays.asList(MediaType.APPLICATION_JSON_VALUE, ActuatorMediaType.V2_JSON); - EndpointMediaTypes endpointMediaTypes = new EndpointMediaTypes(mediaTypes, mediaTypes); + EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT; WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext, new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList()); @@ -311,8 +305,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation @Bean WebMvcEndpointHandlerMapping webEndpointServletHandlerMapping() { - List mediaTypes = Arrays.asList(MediaType.APPLICATION_JSON_VALUE, ActuatorMediaType.V2_JSON); - EndpointMediaTypes endpointMediaTypes = new EndpointMediaTypes(mediaTypes, mediaTypes); + EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT; WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext, new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList());