From 1ec49cee8be3d3b48524b4b2264e3c5bd8d5cdc8 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 18 Mar 2021 19:11:14 -0700 Subject: [PATCH] Allow operations to produce different output Update the actuator @Enpoint` infrastructure code so that operations may inject enums that indicate the type of output to produce. A new `Producible` interface can be implemented by any enum that indicates the mime-type that an enum value produces. The new `OperationArgumentResolver` provides a general strategy for resolving operation arguments with `ProducibleOperationArgumentResolver` providing support for `Producible` enums. Existing injection support has been refactored to use the new resolver. See gh-25738 --- .../actuate/endpoint/InvocationContext.java | 130 ++++++++++++++++-- .../endpoint/OperationArgumentResolver.java | 43 ++++++ .../actuate/endpoint/http/ApiVersion.java | 20 ++- .../actuate/endpoint/http/Producible.java | 39 ++++++ .../ProducibleOperationArgumentResolver.java | 87 ++++++++++++ .../reflect/ReflectiveOperationInvoker.java | 24 +--- .../cache/CachingOperationInvoker.java | 3 +- .../endpoint/web/WebEndpointResponse.java | 40 +++++- .../jersey/JerseyEndpointResourceFactory.java | 10 +- ...AbstractWebFluxEndpointHandlerMapping.java | 13 +- .../AbstractWebMvcEndpointHandlerMapping.java | 13 +- .../endpoint/InvocationContextTests.java | 23 +++- .../endpoint/http/ApiVersionTests.java | 8 +- ...ducibleOperationArgumentResolverTests.java | 79 +++++++++++ .../cache/CachingOperationInvokerTests.java | 33 ++++- 15 files changed, 510 insertions(+), 55 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationArgumentResolver.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/Producible.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ProducibleOperationArgumentResolver.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/http/ProducibleOperationArgumentResolverTests.java 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 8c80225412..a5674c0536 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2021 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. @@ -16,7 +16,12 @@ package org.springframework.boot.actuate.endpoint; +import java.security.Principal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.function.Supplier; import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; @@ -31,11 +36,9 @@ import org.springframework.util.Assert; */ public class InvocationContext { - private final SecurityContext securityContext; - private final Map arguments; - private final ApiVersion apiVersion; + private final List argumentResolvers; /** * Creates a new context for an operation being invoked by the given @@ -54,13 +57,34 @@ public class InvocationContext { * @param securityContext the current security context. Never {@code null} * @param arguments the arguments available to the operation. Never {@code null} * @since 2.2.0 + * @deprecated since 2.5.0 in favor of + * {@link #InvocationContext(SecurityContext, Map, List)} */ + @Deprecated public InvocationContext(ApiVersion apiVersion, SecurityContext securityContext, Map arguments) { + this(securityContext, arguments, Arrays.asList(new FixedValueArgumentResolver<>(ApiVersion.class, apiVersion))); + } + + /** + * 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} + * @param argumentResolvers resolvers for additional arguments should be available to + * the operation. + */ + public InvocationContext(SecurityContext securityContext, Map arguments, + List argumentResolvers) { 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; + this.argumentResolvers = new ArrayList<>(); + if (argumentResolvers != null) { + this.argumentResolvers.addAll(argumentResolvers); + } + this.argumentResolvers.add(new FixedValueArgumentResolver<>(SecurityContext.class, securityContext)); + this.argumentResolvers.add(new SuppliedValueArgumentResolver<>(Principal.class, securityContext::getPrincipal)); + this.argumentResolvers.add(new FixedValueArgumentResolver<>(ApiVersion.class, ApiVersion.LATEST)); } /** @@ -69,15 +93,17 @@ public class InvocationContext { * @since 2.2.0 */ public ApiVersion getApiVersion() { - return this.apiVersion; + return resolveArgument(ApiVersion.class); } /** * Return the security context to use for the invocation. * @return the security context + * @deprecated since 2.5.0 in favor of {@link #resolveArgument(Class)} */ + @Deprecated public SecurityContext getSecurityContext() { - return this.securityContext; + return resolveArgument(SecurityContext.class); } /** @@ -88,4 +114,92 @@ public class InvocationContext { return this.arguments; } + /** + * Resolves an argument with the given {@code argumentType}. + * @param type of the argument + * @param argumentType type of the argument + * @return resolved argument of the required type or {@code null} + * @since 2.5.0 + * @see #canResolve(Class) + */ + public T resolveArgument(Class argumentType) { + for (OperationArgumentResolver argumentResolver : this.argumentResolvers) { + if (argumentResolver.canResolve(argumentType)) { + T result = argumentResolver.resolve(argumentType); + if (result != null) { + return result; + } + } + } + return null; + } + + /** + * Returns whether or not the context is capable of resolving an argument of the given + * {@code type}. Note that, even when {@code true} is returned, + * {@link #resolveArgument argument resolution} will return {@code null} if no + * argument of the required type is available. + * @param type argument type + * @return {@code true} if resolution of arguments of the given type is possible, + * otherwise {@code false}. + * @since 2.5.0 + * @see #resolveArgument(Class) + */ + public boolean canResolve(Class type) { + for (OperationArgumentResolver argumentResolver : this.argumentResolvers) { + if (argumentResolver.canResolve(type)) { + return true; + } + } + return false; + } + + private static final class FixedValueArgumentResolver implements OperationArgumentResolver { + + private final Class argumentType; + + private final T value; + + private FixedValueArgumentResolver(Class argumentType, T value) { + this.argumentType = argumentType; + this.value = value; + } + + @SuppressWarnings("unchecked") + @Override + public U resolve(Class type) { + return (U) this.value; + } + + @Override + public boolean canResolve(Class type) { + return this.argumentType.equals(type); + } + + } + + private static final class SuppliedValueArgumentResolver implements OperationArgumentResolver { + + private final Class argumentType; + + private final Supplier value; + + private SuppliedValueArgumentResolver(Class argumentType, Supplier value) { + this.argumentType = argumentType; + this.value = value; + } + + @SuppressWarnings("unchecked") + @Override + public U resolve(Class type) { + return (U) this.value.get(); + } + + @Override + public boolean canResolve(Class type) { + return this.argumentType.equals(type); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationArgumentResolver.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationArgumentResolver.java new file mode 100644 index 0000000000..1e7f0ae6a1 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationArgumentResolver.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2021 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; + +/** + * Resolver for an argument of an {@link Operation}. + * + * @author Andy Wilkinson + * @since 2.5.0 + */ +public interface OperationArgumentResolver { + + /** + * Resolves an argument of the given {@code type}. + * @param required type of the argument + * @param type argument type + * @return an argument of the required type, or {@code null} + */ + T resolve(Class type); + + /** + * Return whether an argument of the given {@code type} can be resolved. + * @param type argument type + * @return {@code true} if an argument of the required type can be resolved, otherwise + * {@code false} + */ + boolean canResolve(Class type); + +} 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 index a4531b65d3..5a5a7bcdeb 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 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. @@ -20,6 +20,7 @@ import java.util.List; import java.util.Map; import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; import org.springframework.util.MimeTypeUtils; /** @@ -29,17 +30,17 @@ import org.springframework.util.MimeTypeUtils; * @author Phillip Webb * @since 2.2.0 */ -public enum ApiVersion { +public enum ApiVersion implements Producible { /** * Version 2 (supported by Spring Boot 2.0+). */ - V2, + V2(ActuatorMediaType.V2_JSON), /** * Version 3 (supported by Spring Boot 2.2+). */ - V3; + V3(ActuatorMediaType.V3_JSON); private static final String MEDIA_TYPE_PREFIX = "application/vnd.spring-boot.actuator."; @@ -87,4 +88,15 @@ public enum ApiVersion { return (candidateOrdinal > existingOrdinal) ? candidate : existing; } + private final MimeType mimeType; + + ApiVersion(String mimeType) { + this.mimeType = MimeTypeUtils.parseMimeType(mimeType); + } + + @Override + public MimeType getMimeType() { + return this.mimeType; + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/Producible.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/Producible.java new file mode 100644 index 0000000000..3cfbc84f63 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/Producible.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2021 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 org.springframework.util.MimeType; + +/** + * Interface to be implemented by an {@link Enum} that can be injected into an operation + * on a web endpoint. The value of the {@code Producible} enum is resolved using the + * {@code Accept} header of the request. When multiple values are equally acceptable, the + * value with the highest {@link Enum#ordinal() ordinal} is used. + * + * @param enum type that implements this interface + * @author Andy Wilkinson + * @since 2.5.0 + */ +public interface Producible & Producible> { + + /** + * Mime type that can be produced. + * @return the producible mime type + */ + MimeType getMimeType(); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ProducibleOperationArgumentResolver.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ProducibleOperationArgumentResolver.java new file mode 100644 index 0000000000..7383197d7d --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/http/ProducibleOperationArgumentResolver.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2021 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.springframework.boot.actuate.endpoint.OperationArgumentResolver; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * An {@link OperationArgumentResolver} for {@link Producible producible enums}. + * + * @author Andy Wilkinson + * @since 2.5.0 + */ +public class ProducibleOperationArgumentResolver implements OperationArgumentResolver { + + private final Map> httpHeaders; + + public ProducibleOperationArgumentResolver(Map> httpHeaders) { + this.httpHeaders = httpHeaders; + } + + @Override + public boolean canResolve(Class type) { + return Producible.class.isAssignableFrom(type) && Enum.class.isAssignableFrom(type); + } + + @SuppressWarnings("unchecked") + @Override + public T resolve(Class type) { + return (T) resolveProducible((Class>>) type); + } + + private Enum> resolveProducible(Class>> type) { + List accepts = this.httpHeaders.get("Accept"); + List>> values = Arrays.asList(type.getEnumConstants()); + Collections.reverse(values); + if (CollectionUtils.isEmpty(accepts)) { + return values.get(0); + } + Enum> result = null; + for (String accept : accepts) { + for (String mimeType : MimeTypeUtils.tokenize(accept)) { + result = mostRecent(result, forType(values, MimeTypeUtils.parseMimeType(mimeType))); + } + } + return result; + } + + private static Enum> mostRecent(Enum> existing, + Enum> candidate) { + int existingOrdinal = (existing != null) ? ((Enum) existing).ordinal() : -1; + int candidateOrdinal = (candidate != null) ? ((Enum) candidate).ordinal() : -1; + return (candidateOrdinal > existingOrdinal) ? candidate : existing; + } + + private static Enum> forType(List>> candidates, + MimeType mimeType) { + for (Enum> candidate : candidates) { + if (mimeType.isCompatibleWith(((Producible) candidate).getMimeType())) { + return candidate; + } + } + return null; + } + +} 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 38137d9ee1..5a277da4b7 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2021 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. @@ -17,13 +17,10 @@ package org.springframework.boot.actuate.endpoint.invoke.reflect; import java.lang.reflect.Method; -import java.security.Principal; import java.util.Set; 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; @@ -89,13 +86,7 @@ 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; - } - if (SecurityContext.class.equals(parameter.getType())) { + if (context.canResolve(parameter.getType())) { return false; } return context.getArguments().get(parameter.getName()) == null; @@ -107,14 +98,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(); - } - if (SecurityContext.class.equals(parameter.getType())) { - return context.getSecurityContext(); + Object resolvedByType = context.resolveArgument(parameter.getType()); + if (resolvedByType != null) { + return resolvedByType; } Object value = context.getArguments().get(parameter.getName()); return this.parameterValueMapper.mapParameterValue(parameter, value); diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java index be27aa3c0b..d02e808283 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvoker.java @@ -79,7 +79,8 @@ public class CachingOperationInvoker implements OperationInvoker { } long accessTime = System.currentTimeMillis(); ApiVersion contextApiVersion = context.getApiVersion(); - CacheKey cacheKey = new CacheKey(contextApiVersion, context.getSecurityContext().getPrincipal()); + Principal principal = context.resolveArgument(Principal.class); + CacheKey cacheKey = new CacheKey(contextApiVersion, principal); CachedResponse cached = this.cachedResponses.get(cacheKey); if (cached == null || cached.isStale(accessTime, this.timeToLive)) { Object response = this.invoker.invoke(context); diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebEndpointResponse.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebEndpointResponse.java index 0f390e23da..385df34cd9 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebEndpointResponse.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebEndpointResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2021 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. @@ -17,6 +17,7 @@ package org.springframework.boot.actuate.endpoint.web; import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; +import org.springframework.util.MimeType; /** * A {@code WebEndpointResponse} can be returned by an operation on a @@ -70,6 +71,8 @@ public final class WebEndpointResponse { private final int status; + private final MimeType contentType; + /** * Creates a new {@code WebEndpointResponse} with no body and a 200 (OK) status. */ @@ -87,7 +90,7 @@ public final class WebEndpointResponse { } /** - * Creates a new {@code WebEndpointResponse} with then given body and a 200 (OK) + * Creates a new {@code WebEndpointResponse} with the given body and a 200 (OK) * status. * @param body the body */ @@ -96,13 +99,44 @@ public final class WebEndpointResponse { } /** - * Creates a new {@code WebEndpointResponse} with then given body and status. + * Creates a new {@code WebEndpointResponse} with the given body and content type and + * a 200 (OK) status. + * @param body the body + * @param contentType the content type of the response + * @since 2.5.0 + */ + public WebEndpointResponse(T body, MimeType contentType) { + this(body, STATUS_OK, contentType); + } + + /** + * Creates a new {@code WebEndpointResponse} with the given body and status. * @param body the body * @param status the HTTP status */ public WebEndpointResponse(T body, int status) { + this(body, status, null); + } + + /** + * Creates a new {@code WebEndpointResponse} with the given body and status. + * @param body the body + * @param status the HTTP status + * @param contentType the content type of the response + * @since 2.5.0 + */ + public WebEndpointResponse(T body, int status, MimeType contentType) { this.body = body; this.status = status; + this.contentType = contentType; + } + + /** + * Returns the content type of the response. + * @return the content type; + */ + public MimeType getContentType() { + return this.contentType; } /** 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 14904f1526..f38a435739 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 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. @@ -21,6 +21,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.Principal; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -43,7 +44,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.http.ProducibleOperationArgumentResolver; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -151,9 +152,9 @@ public class JerseyEndpointResourceFactory { arguments.putAll(extractPathParameters(data)); arguments.putAll(extractQueryParameters(data)); try { - ApiVersion apiVersion = ApiVersion.fromHttpHeaders(data.getHeaders()); JerseySecurityContext securityContext = new JerseySecurityContext(data.getSecurityContext()); - InvocationContext invocationContext = new InvocationContext(apiVersion, securityContext, arguments); + InvocationContext invocationContext = new InvocationContext(securityContext, arguments, + Arrays.asList(new ProducibleOperationArgumentResolver(data.getHeaders()))); Object response = this.operation.invoke(invocationContext); return convertToJaxRsResponse(response, data.getRequest().getMethod()); } @@ -215,6 +216,7 @@ public class JerseyEndpointResourceFactory { } WebEndpointResponse webEndpointResponse = (WebEndpointResponse) response; return Response.status(webEndpointResponse.getStatus()) + .header("Content-Type", webEndpointResponse.getContentType()) .entity(convertIfNecessary(webEndpointResponse.getBody())).build(); } catch (IOException 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 b7501b87a8..8bf3c3b907 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 @@ -19,6 +19,7 @@ package org.springframework.boot.actuate.endpoint.web.reactive; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.security.Principal; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; @@ -33,7 +34,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.http.ProducibleOperationArgumentResolver; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -43,6 +44,7 @@ import org.springframework.boot.actuate.endpoint.web.WebOperation; import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDecisionVoter; import org.springframework.security.access.SecurityConfig; @@ -297,7 +299,6 @@ 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(); @@ -306,7 +307,8 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi tokenizePathSegments((String) arguments.get(matchAllRemainingPathSegmentsVariable))); } return this.securityContextSupplier.get() - .map((securityContext) -> new InvocationContext(apiVersion, securityContext, arguments)) + .map((securityContext) -> new InvocationContext(securityContext, arguments, + Arrays.asList(new ProducibleOperationArgumentResolver(exchange.getRequest().getHeaders())))) .flatMap((invocationContext) -> handleResult((Publisher) this.invoker.invoke(invocationContext), exchange.getRequest().getMethod())); } @@ -348,7 +350,10 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi return new ResponseEntity<>(response, HttpStatus.OK); } WebEndpointResponse webEndpointResponse = (WebEndpointResponse) response; - return ResponseEntity.status(webEndpointResponse.getStatus()).body(webEndpointResponse.getBody()); + MediaType contentType = (webEndpointResponse.getContentType() != null) + ? new MediaType(webEndpointResponse.getContentType()) : null; + return ResponseEntity.status(webEndpointResponse.getStatus()).contentType(contentType) + .body(webEndpointResponse.getBody()); } @Override 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 78a3925b2e..9b330fc928 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 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. @@ -33,7 +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.http.ProducibleOperationArgumentResolver; import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -44,6 +44,7 @@ import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicat import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.util.AntPathMatcher; @@ -284,9 +285,9 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin HttpHeaders headers = new ServletServerHttpRequest(request).getHeaders(); Map arguments = getArguments(request, body); try { - ApiVersion apiVersion = ApiVersion.fromHttpHeaders(headers); ServletSecurityContext securityContext = new ServletSecurityContext(request); - InvocationContext invocationContext = new InvocationContext(apiVersion, securityContext, arguments); + InvocationContext invocationContext = new InvocationContext(securityContext, arguments, + Arrays.asList(new ProducibleOperationArgumentResolver(headers))); return handleResult(this.operation.invoke(invocationContext), HttpMethod.resolve(request.getMethod())); } catch (InvalidEndpointRequestException ex) { @@ -352,7 +353,9 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin return result; } WebEndpointResponse response = (WebEndpointResponse) result; - return ResponseEntity.status(response.getStatus()).body(response.getBody()); + MediaType contentType = (response.getContentType() != null) ? new MediaType(response.getContentType()) + : null; + return ResponseEntity.status(response.getStatus()).contentType(contentType).body(response.getBody()); } } 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 index b71184c109..17d74a31e0 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2021 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. @@ -39,11 +39,24 @@ class InvocationContextTests { private final Map arguments = Collections.singletonMap("test", "value"); @Test + @SuppressWarnings("deprecation") void createWhenApiVersionIsNullUsesLatestVersion() { InvocationContext context = new InvocationContext(null, this.securityContext, this.arguments); assertThat(context.getApiVersion()).isEqualTo(ApiVersion.LATEST); } + @Test + void whenCreatedWithoutApiVersionThenGetApiVersionReturnsLatestVersion() { + InvocationContext context = new InvocationContext(this.securityContext, this.arguments); + assertThat(context.getApiVersion()).isEqualTo(ApiVersion.LATEST); + } + + @Test + void whenCreatedWithoutApiVersionThenResolveApiVersionReturnsLatestVersion() { + InvocationContext context = new InvocationContext(this.securityContext, this.arguments); + assertThat(context.resolveArgument(ApiVersion.class)).isEqualTo(ApiVersion.LATEST); + } + @Test void createWhenSecurityContextIsNullThrowsException() { assertThatIllegalArgumentException().isThrownBy(() -> new InvocationContext(null, this.arguments)) @@ -57,17 +70,25 @@ class InvocationContextTests { } @Test + @SuppressWarnings("deprecation") void getApiVersionReturnsApiVersion() { InvocationContext context = new InvocationContext(ApiVersion.V2, this.securityContext, this.arguments); assertThat(context.getApiVersion()).isEqualTo(ApiVersion.V2); } @Test + @SuppressWarnings("deprecation") void getSecurityContextReturnsSecurityContext() { InvocationContext context = new InvocationContext(this.securityContext, this.arguments); assertThat(context.getSecurityContext()).isEqualTo(this.securityContext); } + @Test + void resolveSecurityContextReturnsSecurityContext() { + InvocationContext context = new InvocationContext(this.securityContext, this.arguments); + assertThat(context.resolveArgument(SecurityContext.class)).isEqualTo(this.securityContext); + } + @Test void getArgumentsReturnsArguments() { InvocationContext context = new InvocationContext(this.securityContext, 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 index 45408d2158..34f30643cf 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2021 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. @@ -82,6 +82,12 @@ class ApiVersionTests { assertThat(version).isEqualTo(ApiVersion.V3); } + @Test + void fromHttpHeadersWhenAcceptsEverythingReturnsLatest() { + ApiVersion version = ApiVersion.fromHttpHeaders(acceptHeader("*/*")); + 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/http/ProducibleOperationArgumentResolverTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/http/ProducibleOperationArgumentResolverTests.java new file mode 100644 index 0000000000..df1f7a3e96 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/http/ProducibleOperationArgumentResolverTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-2021 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 ProducibleOperationArgumentResolver}. + * + * @author Andy Wilkinson + */ +class ProducibleOperationArgumentResolverTests { + + @Test + void whenAcceptHeaderIsEmptyThenHighestOrdinalIsReturned() { + assertThat(resolve(acceptHeader())).isEqualTo(ApiVersion.V3); + } + + @Test + void whenEverythingIsAcceptableThenHighestOrdinalIsReturned() { + assertThat(resolve(acceptHeader("*/*"))).isEqualTo(ApiVersion.V3); + } + + @Test + void whenNothingIsAcceptableThenNullIsReturned() { + assertThat(resolve(acceptHeader("image/png"))).isEqualTo(null); + } + + @Test + void whenSingleValueIsAcceptableThenMatchingEnumValueIsReturned() { + assertThat(new ProducibleOperationArgumentResolver(acceptHeader(ActuatorMediaType.V2_JSON)) + .resolve(ApiVersion.class)).isEqualTo(ApiVersion.V2); + assertThat(new ProducibleOperationArgumentResolver(acceptHeader(ActuatorMediaType.V3_JSON)) + .resolve(ApiVersion.class)).isEqualTo(ApiVersion.V3); + } + + @Test + void whenMultipleValuesAreAcceptableThenHighestOrdinalIsReturned() { + assertThat(resolve(acceptHeader(ActuatorMediaType.V2_JSON, ActuatorMediaType.V3_JSON))) + .isEqualTo(ApiVersion.V3); + } + + @Test + void whenMultipleValuesAreAcceptableAsSingleHeaderThenHighestOrdinalIsReturned() { + assertThat(resolve(acceptHeader(ActuatorMediaType.V2_JSON + "," + ActuatorMediaType.V3_JSON))) + .isEqualTo(ApiVersion.V3); + } + + private Map> acceptHeader(String... types) { + List value = Arrays.asList(types); + return value.isEmpty() ? Collections.emptyMap() : Collections.singletonMap("Accept", value); + } + + private ApiVersion resolve(Map> httpHeaders) { + return new ProducibleOperationArgumentResolver(httpHeaders).resolve(ApiVersion.class); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java index bc6b74ed8e..9583036db9 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/invoker/cache/CachingOperationInvokerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 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. @@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.invoker.cache; import java.security.Principal; import java.time.Duration; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -28,6 +29,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.boot.actuate.endpoint.InvocationContext; +import org.springframework.boot.actuate.endpoint.OperationArgumentResolver; import org.springframework.boot.actuate.endpoint.SecurityContext; import org.springframework.boot.actuate.endpoint.http.ApiVersion; import org.springframework.boot.actuate.endpoint.invoke.MissingParametersException; @@ -200,10 +202,10 @@ class CachingOperationInvokerTests { OperationInvoker target = mock(OperationInvoker.class); Object expectedV2 = new Object(); Object expectedV3 = new Object(); - InvocationContext contextV2 = new InvocationContext(ApiVersion.V2, mock(SecurityContext.class), - Collections.emptyMap()); - InvocationContext contextV3 = new InvocationContext(ApiVersion.V3, mock(SecurityContext.class), - Collections.emptyMap()); + InvocationContext contextV2 = new InvocationContext(mock(SecurityContext.class), Collections.emptyMap(), + Arrays.asList(new ApiVersionArgumentResolver(ApiVersion.V2))); + InvocationContext contextV3 = new InvocationContext(mock(SecurityContext.class), Collections.emptyMap(), + Arrays.asList(new ApiVersionArgumentResolver(ApiVersion.V3))); given(target.invoke(contextV2)).willReturn(expectedV2); given(target.invoke(contextV3)).willReturn(expectedV3); CachingOperationInvoker invoker = new CachingOperationInvoker(target, CACHE_TTL); @@ -240,4 +242,25 @@ class CachingOperationInvokerTests { } + private static final class ApiVersionArgumentResolver implements OperationArgumentResolver { + + private final ApiVersion apiVersion; + + private ApiVersionArgumentResolver(ApiVersion apiVersion) { + this.apiVersion = apiVersion; + } + + @SuppressWarnings("unchecked") + @Override + public T resolve(Class type) { + return (T) this.apiVersion; + } + + @Override + public boolean canResolve(Class type) { + return ApiVersion.class.equals(type); + } + + } + }