Commit 1ec49cee authored by Andy Wilkinson's avatar Andy Wilkinson Committed by Phillip Webb

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
parent 663fd8ce
/*
* 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<String, Object> arguments;
private final ApiVersion apiVersion;
private final List<OperationArgumentResolver> 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<String, Object> 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<String, Object> arguments,
List<OperationArgumentResolver> 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 <T> 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> T resolveArgument(Class<T> 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<T> implements OperationArgumentResolver {
private final Class<T> argumentType;
private final T value;
private FixedValueArgumentResolver(Class<T> argumentType, T value) {
this.argumentType = argumentType;
this.value = value;
}
@SuppressWarnings("unchecked")
@Override
public <U> U resolve(Class<U> type) {
return (U) this.value;
}
@Override
public boolean canResolve(Class<?> type) {
return this.argumentType.equals(type);
}
}
private static final class SuppliedValueArgumentResolver<T> implements OperationArgumentResolver {
private final Class<T> argumentType;
private final Supplier<T> value;
private SuppliedValueArgumentResolver(Class<T> argumentType, Supplier<T> value) {
this.argumentType = argumentType;
this.value = value;
}
@SuppressWarnings("unchecked")
@Override
public <U> U resolve(Class<U> type) {
return (U) this.value.get();
}
@Override
public boolean canResolve(Class<?> type) {
return this.argumentType.equals(type);
}
}
}
/*
* 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 <T> required type of the argument
* @param type argument type
* @return an argument of the required type, or {@code null}
*/
<T> T resolve(Class<T> 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);
}
/*
* 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<ApiVersion> {
/**
* 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;
}
}
/*
* 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 <E> enum type that implements this interface
* @author Andy Wilkinson
* @since 2.5.0
*/
public interface Producible<E extends Enum<E> & Producible<E>> {
/**
* Mime type that can be produced.
* @return the producible mime type
*/
MimeType getMimeType();
}
/*
* 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<String, List<String>> httpHeaders;
public ProducibleOperationArgumentResolver(Map<String, List<String>> httpHeaders) {
this.httpHeaders = httpHeaders;
}
@Override
public boolean canResolve(Class<?> type) {
return Producible.class.isAssignableFrom(type) && Enum.class.isAssignableFrom(type);
}
@SuppressWarnings("unchecked")
@Override
public <T> T resolve(Class<T> type) {
return (T) resolveProducible((Class<Enum<? extends Producible<?>>>) type);
}
private Enum<? extends Producible<?>> resolveProducible(Class<Enum<? extends Producible<?>>> type) {
List<String> accepts = this.httpHeaders.get("Accept");
List<Enum<? extends Producible<?>>> values = Arrays.asList(type.getEnumConstants());
Collections.reverse(values);
if (CollectionUtils.isEmpty(accepts)) {
return values.get(0);
}
Enum<? extends Producible<?>> 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<? extends Producible<?>> mostRecent(Enum<? extends Producible<?>> existing,
Enum<? extends Producible<?>> 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<? extends Producible<?>> forType(List<Enum<? extends Producible<?>>> candidates,
MimeType mimeType) {
for (Enum<? extends Producible<?>> candidate : candidates) {
if (mimeType.isCompatibleWith(((Producible<?>) candidate).getMimeType())) {
return candidate;
}
}
return null;
}
}
/*
* 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);
......
......@@ -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);
......
/*
* 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<T> {
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<T> {
}
/**
* 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<T> {
}
/**
* 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;
}
/**
......
/*
* 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) {
......
......@@ -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<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
ApiVersion apiVersion = ApiVersion.fromHttpHeaders(exchange.getRequest().getHeaders());
Map<String, Object> 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
......
/*
* 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<String, Object> 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());
}
}
......
/*
* 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<String, Object> 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);
......
/*
* 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<String, List<String>> acceptHeader(String... types) {
List<String> value = Arrays.asList(types);
return value.isEmpty() ? Collections.emptyMap() : Collections.singletonMap("Accept", value);
......
/*
* 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<String, List<String>> acceptHeader(String... types) {
List<String> value = Arrays.asList(types);
return value.isEmpty() ? Collections.emptyMap() : Collections.singletonMap("Accept", value);
}
private ApiVersion resolve(Map<String, List<String>> httpHeaders) {
return new ProducibleOperationArgumentResolver(httpHeaders).resolve(ApiVersion.class);
}
}
/*
* 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> T resolve(Class<T> type) {
return (T) this.apiVersion;
}
@Override
public boolean canResolve(Class<?> type) {
return ApiVersion.class.equals(type);
}
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment