From 4f2e4ada4550ca7698e4bce278e2dc221c7e3ef4 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 20 Jul 2017 16:30:24 +0100 Subject: [PATCH 1/3] Introduce the foundations of the new endpoint infrastructure This commit introduces a new annotation-based programming model for implementing endpoints. An endpoint is identified by a type-level `@Endpoint` annotation. Method-level `@ReadOperation` and `@WriteOperation` are used to identify methods on an endpoint class that implement an operation and should be exposed (via JMX for exmaple) to remote clients. See gh-9946 --- .../AnnotationEndpointDiscoverer.java | 408 ++++++++++++++++++ .../boot/endpoint/CachingConfiguration.java | 45 ++ .../endpoint/CachingOperationInvoker.java | 95 ++++ ...ersionServiceOperationParameterMapper.java | 56 +++ .../boot/endpoint/Endpoint.java | 56 +++ .../boot/endpoint/EndpointDiscoverer.java | 38 ++ .../boot/endpoint/EndpointInfo.java | 73 ++++ .../boot/endpoint/EndpointOperation.java | 73 ++++ .../boot/endpoint/EndpointOperationType.java | 37 ++ .../boot/endpoint/EndpointType.java | 37 ++ .../boot/endpoint/OperationInvoker.java | 37 ++ .../endpoint/OperationParameterMapper.java | 39 ++ .../endpoint/ParameterMappingException.java | 66 +++ .../boot/endpoint/ReadOperation.java | 36 ++ .../endpoint/ReflectiveOperationInvoker.java | 76 ++++ .../boot/endpoint/Selector.java | 37 ++ .../boot/endpoint/WriteOperation.java | 36 ++ .../boot/endpoint/package-info.java | 20 + .../AnnotationEndpointDiscovererTests.java | 347 +++++++++++++++ .../CachingOperationInvokerTests.java | 77 ++++ 20 files changed, 1689 insertions(+) create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/AnnotationEndpointDiscoverer.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/CachingConfiguration.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/CachingOperationInvoker.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/ConversionServiceOperationParameterMapper.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/Endpoint.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointDiscoverer.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointInfo.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperation.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperationType.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointType.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/OperationInvoker.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/OperationParameterMapper.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/ParameterMappingException.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/ReadOperation.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/ReflectiveOperationInvoker.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/Selector.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/WriteOperation.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/package-info.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/AnnotationEndpointDiscovererTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/CachingOperationInvokerTests.java diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/AnnotationEndpointDiscoverer.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/AnnotationEndpointDiscoverer.java new file mode 100644 index 00000000000..94eb2d9554b --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/AnnotationEndpointDiscoverer.java @@ -0,0 +1,408 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.springframework.context.ApplicationContext; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.ObjectUtils; + +/** + * A base {@link EndpointDiscoverer} implementation that discovers {@link Endpoint} beans + * in an application context. + * + * @param the type of the operation + * @param the type of the operation key + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 2.0.0 + */ +public abstract class AnnotationEndpointDiscoverer + implements EndpointDiscoverer { + + private final ApplicationContext applicationContext; + + private final EndpointOperationFactory operationFactory; + + private final Function operationKeyFactory; + + private final Function cachingConfigurationFactory; + + protected AnnotationEndpointDiscoverer(ApplicationContext applicationContext, + EndpointOperationFactory operationFactory, + Function operationKeyFactory, + Function cachingConfigurationFactory) { + this.applicationContext = applicationContext; + this.operationFactory = operationFactory; + this.operationKeyFactory = operationKeyFactory; + this.cachingConfigurationFactory = cachingConfigurationFactory; + } + + /** + * Perform endpoint discovery, including discovery and merging of extensions. + * @param extensionType the annotation type of the extension + * @param endpointType the {@link EndpointType} that should be considered + * @return the list of {@link EndpointInfo EndpointInfos} that describes the + * discovered endpoints matching the specified {@link EndpointType} + */ + protected Collection> discoverEndpointsWithExtension( + Class extensionType, EndpointType endpointType) { + Map, EndpointInfo> endpoints = discoverGenericEndpoints(endpointType); + Map, EndpointExtensionInfo> extensions = discoverExtensions(endpoints, + extensionType, endpointType); + Collection> result = new ArrayList<>(); + endpoints.forEach((endpointClass, endpointInfo) -> { + EndpointExtensionInfo extension = extensions.remove(endpointClass); + result.add(createDescriptor(endpointClass, endpointInfo, extension)); + }); + return result; + } + + private EndpointInfoDescriptor createDescriptor(Class endpointType, + EndpointInfo endpoint, EndpointExtensionInfo extension) { + Map, List> endpointOperations = indexOperations( + endpoint.getId(), endpointType, endpoint.getOperations()); + if (extension != null) { + endpointOperations.putAll(indexOperations(endpoint.getId(), + extension.getEndpointExtensionType(), extension.getOperations())); + return new EndpointInfoDescriptor<>(mergeEndpoint(endpoint, extension), + endpointOperations); + } + else { + return new EndpointInfoDescriptor<>(endpoint, endpointOperations); + } + } + + private EndpointInfo mergeEndpoint(EndpointInfo endpoint, + EndpointExtensionInfo extension) { + Map operations = new HashMap<>(); + Consumer operationConsumer = (operation) -> operations + .put(this.operationKeyFactory.apply(operation), operation); + endpoint.getOperations().forEach(operationConsumer); + extension.getOperations().forEach(operationConsumer); + return new EndpointInfo<>(endpoint.getId(), endpoint.isEnabledByDefault(), + operations.values()); + } + + private Map, List> indexOperations(String endpointId, + Class target, Collection operations) { + LinkedMultiValueMap, T> operationByKey = new LinkedMultiValueMap<>(); + operations + .forEach( + (operation) -> operationByKey.add( + new OperationKey<>(endpointId, target, + this.operationKeyFactory.apply(operation)), + operation)); + return operationByKey; + } + + private Map, EndpointInfo> discoverGenericEndpoints( + EndpointType endpointType) { + String[] endpointBeanNames = this.applicationContext + .getBeanNamesForAnnotation(Endpoint.class); + Map> endpointsById = new HashMap<>(); + Map, EndpointInfo> endpointsByClass = new HashMap<>(); + for (String beanName : endpointBeanNames) { + Class beanType = this.applicationContext.getType(beanName); + AnnotationAttributes endpointAttributes = AnnotatedElementUtils + .findMergedAnnotationAttributes(beanType, Endpoint.class, true, true); + String endpointId = endpointAttributes.getString("id"); + if (matchEndpointType(endpointAttributes, endpointType)) { + EndpointInfo endpointInfo = createEndpointInfo(endpointsById, beanName, + beanType, endpointAttributes, endpointId); + endpointsByClass.put(beanType, endpointInfo); + } + } + return endpointsByClass; + } + + private EndpointInfo createEndpointInfo(Map> endpointsById, + String beanName, Class beanType, AnnotationAttributes endpointAttributes, + String endpointId) { + Map operationMethods = discoverOperations(endpointId, beanName, + beanType); + EndpointInfo endpointInfo = new EndpointInfo<>(endpointId, + endpointAttributes.getBoolean("enabledByDefault"), + operationMethods.values()); + EndpointInfo previous = endpointsById.putIfAbsent(endpointInfo.getId(), + endpointInfo); + if (previous != null) { + throw new IllegalStateException("Found two endpoints with the id '" + + endpointInfo.getId() + "': " + endpointInfo + " and " + previous); + } + return endpointInfo; + } + + private Map, EndpointExtensionInfo> discoverExtensions( + Map, EndpointInfo> endpoints, + Class extensionType, EndpointType endpointType) { + if (extensionType == null) { + return Collections.emptyMap(); + } + String[] extensionBeanNames = this.applicationContext + .getBeanNamesForAnnotation(extensionType); + Map, EndpointExtensionInfo> extensionsByEndpoint = new HashMap<>(); + for (String beanName : extensionBeanNames) { + Class beanType = this.applicationContext.getType(beanName); + AnnotationAttributes extensionAttributes = AnnotatedElementUtils + .getMergedAnnotationAttributes(beanType, extensionType); + Class endpointClass = (Class) extensionAttributes.get("endpoint"); + AnnotationAttributes endpointAttributes = AnnotatedElementUtils + .getMergedAnnotationAttributes(endpointClass, Endpoint.class); + if (!matchEndpointType(endpointAttributes, endpointType)) { + throw new IllegalStateException(String.format( + "Invalid extension %s': " + + "endpoint '%s' does not support such extension", + beanType.getName(), endpointClass.getName())); + } + EndpointInfo endpoint = endpoints.get(endpointClass); + if (endpoint == null) { + throw new IllegalStateException(String.format( + "Invalid extension '%s': no endpoint found with type '%s'", + beanType.getName(), endpointClass.getName())); + } + Map operationMethods = discoverOperations(endpoint.getId(), + beanName, beanType); + EndpointExtensionInfo extension = new EndpointExtensionInfo<>(beanType, + operationMethods.values()); + EndpointExtensionInfo previous = extensionsByEndpoint + .putIfAbsent(endpointClass, extension); + if (previous != null) { + throw new IllegalStateException( + "Found two extensions for the same endpoint '" + + endpointClass.getName() + "': " + + extension.getEndpointExtensionType().getName() + " and " + + previous.getEndpointExtensionType().getName()); + } + } + return extensionsByEndpoint; + } + + private boolean matchEndpointType(AnnotationAttributes endpointAttributes, + EndpointType endpointType) { + if (endpointType == null) { + return true; + } + Object types = endpointAttributes.get("types"); + if (ObjectUtils.isEmpty(types)) { + return true; + } + return Arrays.stream((EndpointType[]) types) + .anyMatch((t) -> t.equals(endpointType)); + } + + private Map discoverOperations(String endpointId, String beanName, + Class beanType) { + return MethodIntrospector.selectMethods(beanType, + (MethodIntrospector.MetadataLookup) ( + method) -> createOperationIfPossible(endpointId, beanName, + method)); + } + + private T createOperationIfPossible(String endpointId, String beanName, + Method method) { + T readOperation = createReadOperationIfPossible(endpointId, beanName, method); + return readOperation != null ? readOperation + : createWriteOperationIfPossible(endpointId, beanName, method); + } + + private T createReadOperationIfPossible(String endpointId, String beanName, + Method method) { + return createOperationIfPossible(endpointId, beanName, method, + ReadOperation.class, EndpointOperationType.READ); + } + + private T createWriteOperationIfPossible(String endpointId, String beanName, + Method method) { + return createOperationIfPossible(endpointId, beanName, method, + WriteOperation.class, EndpointOperationType.WRITE); + } + + private T createOperationIfPossible(String endpointId, String beanName, Method method, + Class operationAnnotation, + EndpointOperationType operationType) { + AnnotationAttributes operationAttributes = AnnotatedElementUtils + .getMergedAnnotationAttributes(method, operationAnnotation); + if (operationAttributes == null) { + return null; + } + CachingConfiguration cachingConfiguration = this.cachingConfigurationFactory + .apply(endpointId); + return this.operationFactory.createOperation(endpointId, operationAttributes, + this.applicationContext.getBean(beanName), method, operationType, + determineTimeToLive(cachingConfiguration, operationType, method)); + } + + private long determineTimeToLive(CachingConfiguration cachingConfiguration, + EndpointOperationType operationType, Method method) { + if (cachingConfiguration != null && cachingConfiguration.getTimeToLive() > 0 + && operationType == EndpointOperationType.READ + && method.getParameters().length == 0) { + return cachingConfiguration.getTimeToLive(); + } + return 0; + } + + /** + * An {@code EndpointOperationFactory} creates an {@link EndpointOperation} for an + * operation on an endpoint. + * + * @param the {@link EndpointOperation} type + */ + @FunctionalInterface + protected interface EndpointOperationFactory { + + /** + * Creates an {@code EndpointOperation} for an operation on an endpoint. + * @param endpointId the id of the endpoint + * @param operationAttributes the annotation attributes for the operation + * @param target the target that implements the operation + * @param operationMethod the method on the bean that implements the operation + * @param operationType the type of the operation + * @param timeToLive the caching period in milliseconds + * @return the operation info that describes the operation + */ + T createOperation(String endpointId, AnnotationAttributes operationAttributes, + Object target, Method operationMethod, + EndpointOperationType operationType, long timeToLive); + + } + + /** + * Describes a tech-specific extension of an endpoint. + * @param the type of the operation + */ + private static final class EndpointExtensionInfo { + + private final Class endpointExtensionType; + + private final Collection operations; + + private EndpointExtensionInfo(Class endpointExtensionType, + Collection operations) { + this.endpointExtensionType = endpointExtensionType; + this.operations = operations; + } + + private Class getEndpointExtensionType() { + return this.endpointExtensionType; + } + + private Collection getOperations() { + return this.operations; + } + + } + + /** + * Describes an {@link EndpointInfo endpoint} and whether or not it is valid. + * + * @param the type of the operation + * @param the type of the operation key + */ + protected static class EndpointInfoDescriptor { + + private final EndpointInfo endpointInfo; + + private final Map, List> operations; + + protected EndpointInfoDescriptor(EndpointInfo endpointInfo, + Map, List> operations) { + this.endpointInfo = endpointInfo; + this.operations = operations; + } + + public EndpointInfo getEndpointInfo() { + return this.endpointInfo; + } + + public Map, List> findDuplicateOperations() { + Map, List> duplicateOperations = new HashMap<>(); + this.operations.forEach((k, list) -> { + if (list.size() > 1) { + duplicateOperations.put(k, list); + } + }); + return duplicateOperations; + } + + } + + /** + * Define the key of an operation in the context of an operation's implementation. + * + * @param the type of the key + */ + protected static final class OperationKey { + + private final String endpointId; + + private final Class endpointType; + + private final K key; + + public OperationKey(String endpointId, Class endpointType, K key) { + this.endpointId = endpointId; + this.endpointType = endpointType; + this.key = key; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + OperationKey that = (OperationKey) o; + + if (!this.endpointId.equals(that.endpointId)) { + return false; + } + if (!this.endpointType.equals(that.endpointType)) { + return false; + } + return this.key.equals(that.key); + } + + @Override + public int hashCode() { + int result = this.endpointId.hashCode(); + result = 31 * result + this.endpointType.hashCode(); + result = 31 * result + this.key.hashCode(); + return result; + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/CachingConfiguration.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/CachingConfiguration.java new file mode 100644 index 00000000000..06d5ae264e7 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/CachingConfiguration.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +/** + * The caching configuration of an endpoint. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +public class CachingConfiguration { + + private final long timeToLive; + + /** + * Create a new instance with the given {@code timeToLive}. + * @param timeToLive the time to live of an operation result in milliseconds + */ + public CachingConfiguration(long timeToLive) { + this.timeToLive = timeToLive; + } + + /** + * Returns the time to live of a cached operation result. + * @return the time to live of an operation result + */ + public long getTimeToLive() { + return this.timeToLive; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/CachingOperationInvoker.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/CachingOperationInvoker.java new file mode 100644 index 00000000000..2a80c6cbc09 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/CachingOperationInvoker.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.util.Map; + +import org.springframework.util.Assert; + +/** + * An {@link OperationInvoker} that caches the response of an operation with a + * configurable time to live. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +public class CachingOperationInvoker implements OperationInvoker { + + private final OperationInvoker target; + + private final long timeToLive; + + private volatile CachedResponse cachedResponse; + + /** + * Create a new instance with the target {@link OperationInvoker} to use to compute + * the response and the time to live for the cache. + * @param target the {@link OperationInvoker} this instance wraps + * @param timeToLive the maximum time in milliseconds that a response can be cached + */ + public CachingOperationInvoker(OperationInvoker target, long timeToLive) { + Assert.state(timeToLive > 0, "TimeToLive must be strictly positive"); + this.target = target; + this.timeToLive = timeToLive; + } + + /** + * Return the maximum time in milliseconds that a response can be cached. + * @return the time to live of a response + */ + public long getTimeToLive() { + return this.timeToLive; + } + + @Override + public Object invoke(Map arguments) { + long accessTime = System.currentTimeMillis(); + CachedResponse cached = this.cachedResponse; + if (cached == null || cached.isStale(accessTime, this.timeToLive)) { + Object response = this.target.invoke(arguments); + this.cachedResponse = new CachedResponse(response, accessTime); + return response; + } + return cached.getResponse(); + } + + /** + * A cached response that encapsulates the response itself and the time at which it + * was created. + */ + static class CachedResponse { + + private final Object response; + + private final long creationTime; + + CachedResponse(Object response, long creationTime) { + this.response = response; + this.creationTime = creationTime; + } + + public boolean isStale(long accessTime, long timeToLive) { + return (accessTime - this.creationTime) >= timeToLive; + } + + public Object getResponse() { + return this.response; + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/ConversionServiceOperationParameterMapper.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/ConversionServiceOperationParameterMapper.java new file mode 100644 index 00000000000..064e40436a5 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/ConversionServiceOperationParameterMapper.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import org.springframework.boot.context.properties.bind.convert.BinderConversionService; +import org.springframework.core.convert.ConversionService; + +/** + * {@link OperationParameterMapper} that uses a {@link ConversionService} to map parameter + * values if necessary. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +public class ConversionServiceOperationParameterMapper + implements OperationParameterMapper { + + private final ConversionService conversionService; + + /** + * Create a new instance with the {@link ConversionService} to use. + * @param conversionService the conversion service + */ + public ConversionServiceOperationParameterMapper( + ConversionService conversionService) { + this.conversionService = new BinderConversionService(conversionService); + } + + @Override + public T mapParameter(Object input, Class parameterType) { + if (input == null || parameterType.isAssignableFrom(input.getClass())) { + return parameterType.cast(input); + } + try { + return this.conversionService.convert(input, parameterType); + } + catch (Exception ex) { + throw new ParameterMappingException(input, parameterType, ex); + } + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/Endpoint.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/Endpoint.java new file mode 100644 index 00000000000..5483ecadf17 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/Endpoint.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Identifies a type as being an endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + * @see EndpointDiscoverer + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Endpoint { + + /** + * The id of the endpoint. + * @return the id + */ + String id(); + + /** + * Defines the endpoint {@link EndpointType types} that should be exposed. By default, + * all types are exposed. + * @return the endpoint types to expose + */ + EndpointType[] types() default {}; + + /** + * Whether or not the endpoint is enabled by default. + * @return {@code true} if the endpoint is enabled by default, otherwise {@code false} + */ + boolean enabledByDefault() default true; + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointDiscoverer.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointDiscoverer.java new file mode 100644 index 00000000000..0013da9a028 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointDiscoverer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.util.Collection; + +/** + * Discovers endpoints and provides an {@link EndpointInfo} for each of them. + * + * @param the type of the operation + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 2.0.0 + */ +@FunctionalInterface +public interface EndpointDiscoverer { + + /** + * Perform endpoint discovery. + * @return the discovered endpoints + */ + Collection> discoverEndpoints(); + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointInfo.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointInfo.java new file mode 100644 index 00000000000..50a852bb6f4 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointInfo.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.util.Collection; + +/** + * Information describing an endpoint. + * + * @param the type of the endpoint's operations + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class EndpointInfo { + + private final String id; + + private final boolean enabledByDefault; + + private final Collection operations; + + /** + * Creates a new {@code EndpointInfo} describing an endpoint with the given {@code id} + * and {@code operations}. + * @param id the id of the endpoint + * @param enabledByDefault whether or not the endpoint is enabled by default + * @param operations the operations of the endpoint + */ + public EndpointInfo(String id, boolean enabledByDefault, Collection operations) { + this.id = id; + this.enabledByDefault = enabledByDefault; + this.operations = operations; + } + + /** + * Returns the id of the endpoint. + * @return the id + */ + public String getId() { + return this.id; + } + + /** + * Returns whether or not this endpoint is enabled by default. + * @return {@code true} if it is enabled by default, otherwise {@code false} + */ + public boolean isEnabledByDefault() { + return this.enabledByDefault; + } + + /** + * Returns the operations of the endpoint. + * @return the operations + */ + public Collection getOperations() { + return this.operations; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperation.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperation.java new file mode 100644 index 00000000000..f4583e05daf --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperation.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +/** + * An operation on an endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class EndpointOperation { + + private final EndpointOperationType type; + + private final OperationInvoker operationInvoker; + + private final boolean blocking; + + /** + * Creates a new {@code EndpointOperation} for an operation of the given {@code type}. + * The operation can be performed using the given {@code operationInvoker}. + * @param type the type of the operation + * @param operationInvoker used to perform the operation + * @param blocking whether or not this is a blocking operation + */ + public EndpointOperation(EndpointOperationType type, + OperationInvoker operationInvoker, boolean blocking) { + this.type = type; + this.operationInvoker = operationInvoker; + this.blocking = blocking; + } + + /** + * Returns the {@link EndpointOperationType type} of the operation. + * @return the type + */ + public EndpointOperationType getType() { + return this.type; + } + + /** + * Returns the {@code OperationInvoker} that can be used to invoke this endpoint + * operation. + * @return the operation invoker + */ + public OperationInvoker getOperationInvoker() { + return this.operationInvoker; + } + + /** + * Whether or not this is a blocking operation. + * + * @return {@code true} if it is a blocking operation, otherwise {@code false}. + */ + public boolean isBlocking() { + return this.blocking; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperationType.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperationType.java new file mode 100644 index 00000000000..1cfabdfcacb --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointOperationType.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +/** + * An enumeration of the different types of operation supported by an endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public enum EndpointOperationType { + + /** + * A read operation. + */ + READ, + + /** + * A write operation. + */ + WRITE + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointType.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointType.java new file mode 100644 index 00000000000..657a3357a49 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/EndpointType.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +/** + * An enumeration of the available {@link Endpoint} types. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +public enum EndpointType { + + /** + * Expose the endpoint as a JMX MBean. + */ + JMX, + + /** + * Expose the endpoint as a Web endpoint. + */ + WEB + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/OperationInvoker.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/OperationInvoker.java new file mode 100644 index 00000000000..6de256a8fc5 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/OperationInvoker.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.util.Map; + +/** + * An {@code OperationInvoker} is used to invoke an operation on an endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +@FunctionalInterface +public interface OperationInvoker { + + /** + * Invoke the underlying operation using the given {@code arguments}. + * @param arguments the arguments to pass to the operation + * @return the result of the operation, may be {@code null} + */ + Object invoke(Map arguments); + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/OperationParameterMapper.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/OperationParameterMapper.java new file mode 100644 index 00000000000..ef8d0fa3e79 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/OperationParameterMapper.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +/** + * An {@code OperationParameterMapper} is used to map parameters to the required type when + * invoking an endpoint. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +@FunctionalInterface +public interface OperationParameterMapper { + + /** + * Map the specified {@code input} parameter to the given {@code parameterType}. + * @param input a parameter value + * @param parameterType the required type of the parameter + * @return a value suitable for that parameter + * @param the actual type of the parameter + * @throws ParameterMappingException when a mapping failure occurs + */ + T mapParameter(Object input, Class parameterType); + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/ParameterMappingException.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/ParameterMappingException.java new file mode 100644 index 00000000000..c7371217555 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/ParameterMappingException.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +/** + * A {@code ParameterMappingException} is thrown when a failure occurs during + * {@link OperationParameterMapper#mapParameter(Object, Class) operation parameter + * mapping}. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class ParameterMappingException extends RuntimeException { + + private final Object input; + + private final Class type; + + /** + * Creates a new {@code ParameterMappingException} for a failure that occurred when + * trying to map the given {@code input} to the given {@code type}. + * + * @param input the input that was being mapped + * @param type the type that was being mapped to + * @param cause the cause of the mapping failure + */ + public ParameterMappingException(Object input, Class type, Throwable cause) { + super("Failed to map " + input + " of type " + input.getClass() + " to type " + + type, cause); + this.input = input; + this.type = type; + } + + /** + * Returns the input that was to be mapped. + * + * @return the input + */ + public Object getInput() { + return this.input; + } + + /** + * Returns the type to be mapped to. + * + * @return the type + */ + public Class getType() { + return this.type; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/ReadOperation.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/ReadOperation.java new file mode 100644 index 00000000000..c68397f8005 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/ReadOperation.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Identifies a method on an {@link Endpoint} as being a read operation. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ReadOperation { + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/ReflectiveOperationInvoker.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/ReflectiveOperationInvoker.java new file mode 100644 index 00000000000..ded4079bc8e --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/ReflectiveOperationInvoker.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.util.ReflectionUtils; + +/** + * An {@code OperationInvoker} that invokes an operation using reflection. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class ReflectiveOperationInvoker implements OperationInvoker { + + private final OperationParameterMapper parameterMapper; + + private final Object target; + + private final Method method; + + /** + * Creates a new {code ReflectiveOperationInvoker} that will invoke the given + * {@code method} on the given {@code target}. The given {@code parameterMapper} will + * be used to map parameters to the required types. + * + * @param parameterMapper the parameter mapper + * @param target the target of the reflective call + * @param method the method to call + */ + public ReflectiveOperationInvoker(OperationParameterMapper parameterMapper, + Object target, Method method) { + this.parameterMapper = parameterMapper; + this.target = target; + ReflectionUtils.makeAccessible(method); + this.method = method; + } + + @Override + public Object invoke(Map arguments) { + return ReflectionUtils.invokeMethod(this.method, this.target, + resolveArguments(arguments)); + } + + private Object[] resolveArguments(Map arguments) { + return Stream.of(this.method.getParameters()) + .map((parameter) -> resolveArgument(parameter, arguments)) + .collect(Collectors.collectingAndThen(Collectors.toList(), + (list) -> list.toArray(new Object[list.size()]))); + } + + private Object resolveArgument(Parameter parameter, Map arguments) { + Object resolved = arguments.get(parameter.getName()); + return this.parameterMapper.mapParameter(resolved, parameter.getType()); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/Selector.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/Selector.java new file mode 100644 index 00000000000..215459e86be --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/Selector.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * A {@code Selector} can be used on a parameter of an {@link Endpoint} method to indicate + * that the parameter is used to select a subset of the endpoint's data. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Selector { + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/WriteOperation.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/WriteOperation.java new file mode 100644 index 00000000000..eab3b30eef0 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/WriteOperation.java @@ -0,0 +1,36 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Identifies a method on an {@link Endpoint} as being a write operation. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface WriteOperation { + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/package-info.java new file mode 100644 index 00000000000..ae47aa6e4e6 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2017 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 + * + * http://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. + */ + +/** + * Endpoint support. + */ +package org.springframework.boot.endpoint; diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/AnnotationEndpointDiscovererTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/AnnotationEndpointDiscovererTests.java new file mode 100644 index 00000000000..0b680354fbd --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/AnnotationEndpointDiscovererTests.java @@ -0,0 +1,347 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.util.ReflectionUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link AnnotationEndpointDiscoverer}. + * + * @author Andy Wilkinson + * @author Stephane Nicoll + */ +public class AnnotationEndpointDiscovererTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void discoverWorksWhenThereAreNoEndpoints() { + load(EmptyConfiguration.class, + (context) -> assertThat(new TestAnnotationEndpointDiscoverer(context) + .discoverEndpoints().isEmpty())); + } + + @Test + public void endpointIsDiscovered() { + load(TestEndpointConfiguration.class, (context) -> { + Map> endpoints = mapEndpoints( + new TestAnnotationEndpointDiscoverer(context).discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + Map operations = mapOperations( + endpoints.get("test")); + assertThat(operations).hasSize(3); + assertThat(operations).containsKeys( + ReflectionUtils.findMethod(TestEndpoint.class, "getAll"), + ReflectionUtils.findMethod(TestEndpoint.class, "getOne", + String.class), + ReflectionUtils.findMethod(TestEndpoint.class, "update", String.class, + String.class)); + }); + } + + @Test + public void subclassedEndpointIsDiscovered() { + load(TestEndpointSubclassConfiguration.class, (context) -> { + Map> endpoints = mapEndpoints( + new TestAnnotationEndpointDiscoverer(context).discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + Map operations = mapOperations( + endpoints.get("test")); + assertThat(operations).hasSize(4); + assertThat(operations).containsKeys( + ReflectionUtils.findMethod(TestEndpoint.class, "getAll"), + ReflectionUtils.findMethod(TestEndpoint.class, "getOne", + String.class), + ReflectionUtils.findMethod(TestEndpoint.class, "update", String.class, + String.class), + ReflectionUtils.findMethod(TestEndpointSubclass.class, + "updateWithMoreArguments", String.class, String.class, + String.class)); + }); + } + + @Test + public void discoveryFailsWhenTwoEndpointsHaveTheSameId() { + load(ClashingEndpointConfiguration.class, (context) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found two endpoints with the id 'test': "); + new TestAnnotationEndpointDiscoverer(context).discoverEndpoints(); + }); + } + + @Test + public void endpointMainReadOperationIsNotCachedWithTtlSetToZero() { + load(TestEndpointConfiguration.class, (context) -> { + Map> endpoints = mapEndpoints( + new TestAnnotationEndpointDiscoverer(context, + (endpointId) -> new CachingConfiguration(0)) + .discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + Map operations = mapOperations( + endpoints.get("test")); + assertThat(operations).hasSize(3); + operations.values() + .forEach(operation -> assertThat(operation.getOperationInvoker()) + .isNotInstanceOf(CachingOperationInvoker.class)); + }); + } + + @Test + public void endpointMainReadOperationIsNotCachedWithNonMatchingId() { + Function cachingConfigurationFactory = ( + endpointId) -> (endpointId.equals("foo") ? new CachingConfiguration(500) + : new CachingConfiguration(0)); + load(TestEndpointConfiguration.class, (context) -> { + Map> endpoints = mapEndpoints( + new TestAnnotationEndpointDiscoverer(context, + cachingConfigurationFactory).discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + Map operations = mapOperations( + endpoints.get("test")); + assertThat(operations).hasSize(3); + operations.values() + .forEach(operation -> assertThat(operation.getOperationInvoker()) + .isNotInstanceOf(CachingOperationInvoker.class)); + }); + } + + @Test + public void endpointMainReadOperationIsCachedWithMatchingId() { + Function cachingConfigurationFactory = ( + endpointId) -> (endpointId.equals("test") ? new CachingConfiguration(500) + : new CachingConfiguration(0)); + load(TestEndpointConfiguration.class, (context) -> { + Map> endpoints = mapEndpoints( + new TestAnnotationEndpointDiscoverer(context, + cachingConfigurationFactory).discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + Map operations = mapOperations( + endpoints.get("test")); + OperationInvoker getAllOperationInvoker = operations + .get(ReflectionUtils.findMethod(TestEndpoint.class, "getAll")) + .getOperationInvoker(); + assertThat(getAllOperationInvoker) + .isInstanceOf(CachingOperationInvoker.class); + assertThat(((CachingOperationInvoker) getAllOperationInvoker).getTimeToLive()) + .isEqualTo(500); + assertThat(operations.get(ReflectionUtils.findMethod(TestEndpoint.class, + "getOne", String.class)).getOperationInvoker()) + .isNotInstanceOf(CachingOperationInvoker.class); + assertThat(operations.get(ReflectionUtils.findMethod(TestEndpoint.class, + "update", String.class, String.class)).getOperationInvoker()) + .isNotInstanceOf(CachingOperationInvoker.class); + }); + } + + private Map> mapEndpoints( + Collection> endpoints) { + Map> endpointById = new LinkedHashMap<>(); + endpoints.forEach((endpoint) -> { + EndpointInfo existing = endpointById + .put(endpoint.getId(), endpoint); + if (existing != null) { + throw new AssertionError(String.format( + "Found endpoints with duplicate id '%s'", endpoint.getId())); + } + }); + return endpointById; + } + + private Map mapOperations( + EndpointInfo endpoint) { + Map operationByMethod = new HashMap<>(); + endpoint.getOperations().forEach((operation) -> { + EndpointOperation existing = operationByMethod + .put(operation.getOperationMethod(), operation); + if (existing != null) { + throw new AssertionError(String.format( + "Found endpoint with duplicate operation method '%s'", + operation.getOperationMethod())); + } + }); + return operationByMethod; + } + + private void load(Class configuration, + Consumer consumer) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + configuration); + try { + consumer.accept(context); + } + finally { + context.close(); + } + } + + @Configuration + static class EmptyConfiguration { + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getOne(@Selector String id) { + return null; + } + + @WriteOperation + public void update(String foo, String bar) { + + } + + public void someOtherMethod() { + + } + + } + + static class TestEndpointSubclass extends TestEndpoint { + + @WriteOperation + public void updateWithMoreArguments(String foo, String bar, String baz) { + + } + + } + + @Configuration + static class TestEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + } + + @Configuration + static class TestEndpointSubclassConfiguration { + + @Bean + public TestEndpointSubclass testEndpointSubclass() { + return new TestEndpointSubclass(); + } + + } + + @Configuration + static class ClashingEndpointConfiguration { + + @Bean + public TestEndpoint testEndpointTwo() { + return new TestEndpoint(); + } + + @Bean + public TestEndpoint testEndpointOne() { + return new TestEndpoint(); + } + } + + private static final class TestEndpointOperation extends EndpointOperation { + + private final Method operationMethod; + + private TestEndpointOperation(EndpointOperationType type, + OperationInvoker operationInvoker, Method operationMethod) { + super(type, operationInvoker, true); + this.operationMethod = operationMethod; + } + + private Method getOperationMethod() { + return this.operationMethod; + } + + } + + private static class TestAnnotationEndpointDiscoverer + extends AnnotationEndpointDiscoverer { + + TestAnnotationEndpointDiscoverer(ApplicationContext applicationContext, + Function cachingConfigurationFactory) { + super(applicationContext, endpointOperationFactory(), + TestEndpointOperation::getOperationMethod, + cachingConfigurationFactory); + } + + TestAnnotationEndpointDiscoverer(ApplicationContext applicationContext) { + this(applicationContext, (id) -> null); + } + + @Override + public Collection> discoverEndpoints() { + return discoverEndpointsWithExtension(null, null).stream() + .map(EndpointInfoDescriptor::getEndpointInfo) + .collect(Collectors.toList()); + } + + private static EndpointOperationFactory endpointOperationFactory() { + return new EndpointOperationFactory() { + + @Override + public TestEndpointOperation createOperation(String endpointId, + AnnotationAttributes operationAttributes, Object target, + Method operationMethod, EndpointOperationType operationType, + long timeToLive) { + return new TestEndpointOperation(operationType, + createOperationInvoker(timeToLive), operationMethod); + } + + private OperationInvoker createOperationInvoker(long timeToLive) { + OperationInvoker invoker = (arguments) -> null; + if (timeToLive > 0) { + return new CachingOperationInvoker(invoker, timeToLive); + } + else { + return invoker; + } + } + }; + } + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/CachingOperationInvokerTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/CachingOperationInvokerTests.java new file mode 100644 index 00000000000..ec0cca0319b --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/CachingOperationInvokerTests.java @@ -0,0 +1,77 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +/** + * Tests for {@link CachingOperationInvoker}. + * + * @author Stephane Nicoll + */ +public class CachingOperationInvokerTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void createInstanceWithTllSetToZero() { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("TimeToLive"); + new CachingOperationInvoker(mock(OperationInvoker.class), 0); + } + + @Test + public void cacheInTtlRange() { + Object expected = new Object(); + OperationInvoker target = mock(OperationInvoker.class); + Map parameters = new HashMap<>(); + given(target.invoke(parameters)).willReturn(expected); + CachingOperationInvoker invoker = new CachingOperationInvoker(target, 500L); + Object response = invoker.invoke(parameters); + assertThat(response).isSameAs(expected); + verify(target, times(1)).invoke(parameters); + Object cachedResponse = invoker.invoke(parameters); + assertThat(cachedResponse).isSameAs(response); + verifyNoMoreInteractions(target); + } + + @Test + public void targetInvokedWhenCacheExpires() throws InterruptedException { + OperationInvoker target = mock(OperationInvoker.class); + Map parameters = new HashMap<>(); + given(target.invoke(parameters)).willReturn(new Object()); + CachingOperationInvoker invoker = new CachingOperationInvoker(target, 50L); + invoker.invoke(parameters); + Thread.sleep(55); + invoker.invoke(parameters); + verify(target, times(2)).invoke(parameters); + } + +} From 4592e071db411dcc1bed5511f7df52fcf3879eef Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Thu, 20 Jul 2017 16:33:19 +0100 Subject: [PATCH 2/3] Add support for making endpoints accessible via JMX This commit adds support to the new endpoint infrastructure for exposing endpoint operations via JMX. It also introduces support for JMX-specific extensions to a general-purpose endpoint. Such an extension is identified by the `@JmxEndpointExtension` annotation. See gh-9946 --- .../boot/endpoint/jmx/EndpointMBean.java | 130 ++++ .../boot/endpoint/jmx/EndpointMBeanInfo.java | 60 ++ .../jmx/EndpointMBeanInfoAssembler.java | 127 ++++ .../endpoint/jmx/EndpointMBeanRegistrar.java | 119 ++++ .../jmx/EndpointObjectNameFactory.java | 39 ++ .../jmx/JmxAnnotationEndpointDiscoverer.java | 187 ++++++ .../endpoint/jmx/JmxEndpointExtension.java | 45 ++ .../endpoint/jmx/JmxEndpointMBeanFactory.java | 61 ++ .../endpoint/jmx/JmxEndpointOperation.java | 95 +++ .../JmxEndpointOperationParameterInfo.java | 56 ++ .../jmx/JmxOperationResponseMapper.java | 42 ++ .../boot/endpoint/jmx/package-info.java | 20 + .../jmx/EndpointMBeanInfoAssemblerTests.java | 132 +++++ .../jmx/EndpointMBeanRegistrarTests.java | 134 +++++ .../boot/endpoint/jmx/EndpointMBeanTests.java | 362 ++++++++++++ .../JmxAnnotationEndpointDiscovererTests.java | 555 ++++++++++++++++++ 16 files changed, 2164 insertions(+) create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBean.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfo.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssembler.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrar.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointObjectNameFactory.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscoverer.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointExtension.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointMBeanFactory.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperation.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperationParameterInfo.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxOperationResponseMapper.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/package-info.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssemblerTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrarTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscovererTests.java diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBean.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBean.java new file mode 100644 index 00000000000..bedb1e63ca2 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBean.java @@ -0,0 +1,130 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import javax.management.Attribute; +import javax.management.AttributeList; +import javax.management.AttributeNotFoundException; +import javax.management.DynamicMBean; +import javax.management.InvalidAttributeValueException; +import javax.management.MBeanException; +import javax.management.MBeanInfo; +import javax.management.ReflectionException; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.util.ClassUtils; + +/** + * A {@link DynamicMBean} that invokes operations on an {@link EndpointInfo endpoint}. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + * @since 2.0.0 + * @see EndpointMBeanInfoAssembler + */ +public class EndpointMBean implements DynamicMBean { + + private static final boolean REACTOR_PRESENT = ClassUtils.isPresent( + "reactor.core.publisher.Mono", EndpointMBean.class.getClassLoader()); + + private final Function operationResponseConverter; + + private final EndpointMBeanInfo endpointInfo; + + EndpointMBean(Function operationResponseConverter, + EndpointMBeanInfo endpointInfo) { + this.operationResponseConverter = operationResponseConverter; + this.endpointInfo = endpointInfo; + } + + /** + * Return the id of the related endpoint. + * @return the endpoint id + */ + public String getEndpointId() { + return this.endpointInfo.getEndpointId(); + } + + @Override + public MBeanInfo getMBeanInfo() { + return this.endpointInfo.getMbeanInfo(); + } + + @Override + public Object invoke(String actionName, Object[] params, String[] signature) + throws MBeanException, ReflectionException { + JmxEndpointOperation operationInfo = this.endpointInfo.getOperations() + .get(actionName); + if (operationInfo != null) { + Map arguments = new HashMap<>(); + List parameters = operationInfo + .getParameters(); + for (int i = 0; i < params.length; i++) { + arguments.put(parameters.get(i).getName(), params[i]); + } + Object result = operationInfo.getOperationInvoker().invoke(arguments); + if (REACTOR_PRESENT) { + result = ReactiveHandler.handle(result); + } + return this.operationResponseConverter.apply(result); + } + throw new ReflectionException(new IllegalArgumentException( + String.format("Endpoint with id '%s' has no operation named %s", + this.endpointInfo.getEndpointId(), actionName))); + } + + @Override + public Object getAttribute(String attribute) + throws AttributeNotFoundException, MBeanException, ReflectionException { + throw new AttributeNotFoundException(); + } + + @Override + public void setAttribute(Attribute attribute) throws AttributeNotFoundException, + InvalidAttributeValueException, MBeanException, ReflectionException { + throw new AttributeNotFoundException(); + } + + @Override + public AttributeList getAttributes(String[] attributes) { + return new AttributeList(); + } + + @Override + public AttributeList setAttributes(AttributeList attributes) { + return new AttributeList(); + } + + private static class ReactiveHandler { + + public static Object handle(Object result) { + if (result instanceof Mono) { + return ((Mono) result).block(); + } + return result; + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfo.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfo.java new file mode 100644 index 00000000000..d457dbd2e44 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfo.java @@ -0,0 +1,60 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.Map; + +import javax.management.MBeanInfo; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperation; + +/** + * The {@link MBeanInfo} for a particular {@link EndpointInfo endpoint}. Maps operation + * names to an {@link EndpointOperation}. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +public final class EndpointMBeanInfo { + + private final String endpointId; + + private final MBeanInfo mBeanInfo; + + private final Map operations; + + public EndpointMBeanInfo(String endpointId, MBeanInfo mBeanInfo, + Map operations) { + this.endpointId = endpointId; + this.mBeanInfo = mBeanInfo; + this.operations = operations; + } + + public String getEndpointId() { + return this.endpointId; + } + + public MBeanInfo getMbeanInfo() { + return this.mBeanInfo; + } + + public Map getOperations() { + return this.operations; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssembler.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssembler.java new file mode 100644 index 00000000000..237e97641c4 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssembler.java @@ -0,0 +1,127 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.management.MBeanInfo; +import javax.management.MBeanOperationInfo; +import javax.management.MBeanParameterInfo; +import javax.management.modelmbean.ModelMBeanAttributeInfo; +import javax.management.modelmbean.ModelMBeanConstructorInfo; +import javax.management.modelmbean.ModelMBeanInfoSupport; +import javax.management.modelmbean.ModelMBeanNotificationInfo; +import javax.management.modelmbean.ModelMBeanOperationInfo; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; + +/** + * Gathers the management operations of a particular {@link EndpointInfo endpoint}. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + */ +class EndpointMBeanInfoAssembler { + + private final JmxOperationResponseMapper responseMapper; + + EndpointMBeanInfoAssembler(JmxOperationResponseMapper responseMapper) { + this.responseMapper = responseMapper; + } + + /** + * Creates the {@link EndpointMBeanInfo} for the specified {@link EndpointInfo + * endpoint}. + * @param endpointInfo the endpoint to handle + * @return the mbean info for the endpoint + */ + EndpointMBeanInfo createEndpointMBeanInfo( + EndpointInfo endpointInfo) { + Map operationsMapping = getOperationInfo(endpointInfo); + ModelMBeanOperationInfo[] operationsMBeanInfo = operationsMapping.values() + .stream().map((t) -> t.mBeanOperationInfo).collect(Collectors.toList()) + .toArray(new ModelMBeanOperationInfo[] {}); + Map operationsInfo = new LinkedHashMap<>(); + operationsMapping.forEach((name, t) -> operationsInfo.put(name, t.operation)); + + MBeanInfo info = new ModelMBeanInfoSupport(EndpointMBean.class.getName(), + getDescription(endpointInfo), new ModelMBeanAttributeInfo[0], + new ModelMBeanConstructorInfo[0], operationsMBeanInfo, + new ModelMBeanNotificationInfo[0]); + return new EndpointMBeanInfo(endpointInfo.getId(), info, operationsInfo); + } + + private String getDescription(EndpointInfo endpointInfo) { + return "MBean operations for endpoint " + endpointInfo.getId(); + } + + private Map getOperationInfo( + EndpointInfo endpointInfo) { + Map operationInfos = new HashMap<>(); + endpointInfo.getOperations().forEach((operationInfo) -> { + String name = operationInfo.getOperationName(); + ModelMBeanOperationInfo mBeanOperationInfo = new ModelMBeanOperationInfo( + operationInfo.getOperationName(), operationInfo.getDescription(), + getMBeanParameterInfos(operationInfo), this.responseMapper + .mapResponseType(operationInfo.getOutputType()).getName(), + mapOperationType(operationInfo.getType())); + operationInfos.put(name, + new OperationInfos(mBeanOperationInfo, operationInfo)); + }); + return operationInfos; + } + + private MBeanParameterInfo[] getMBeanParameterInfos(JmxEndpointOperation operation) { + return operation.getParameters().stream() + .map((operationParameter) -> new MBeanParameterInfo( + operationParameter.getName(), + operationParameter.getType().getName(), + operationParameter.getDescription())) + .collect(Collectors.collectingAndThen(Collectors.toList(), + (parameterInfos) -> parameterInfos + .toArray(new MBeanParameterInfo[parameterInfos.size()]))); + } + + private int mapOperationType(EndpointOperationType type) { + if (type == EndpointOperationType.READ) { + return MBeanOperationInfo.INFO; + } + if (type == EndpointOperationType.WRITE) { + return MBeanOperationInfo.ACTION; + } + return MBeanOperationInfo.UNKNOWN; + } + + private static class OperationInfos { + + private final ModelMBeanOperationInfo mBeanOperationInfo; + + private final JmxEndpointOperation operation; + + OperationInfos(ModelMBeanOperationInfo mBeanOperationInfo, + JmxEndpointOperation operation) { + this.mBeanOperationInfo = mBeanOperationInfo; + this.operation = operation; + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrar.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrar.java new file mode 100644 index 00000000000..887ee965068 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrar.java @@ -0,0 +1,119 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import javax.management.InstanceNotFoundException; +import javax.management.MBeanRegistrationException; +import javax.management.MBeanServer; +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.jmx.JmxException; +import org.springframework.jmx.export.MBeanExportException; +import org.springframework.jmx.export.MBeanExporter; +import org.springframework.util.Assert; + +/** + * JMX Registrar for {@link EndpointMBean}. + * + * @author Stephane Nicoll + * @since 2.0.0 + * @see EndpointObjectNameFactory + */ +public class EndpointMBeanRegistrar { + + private static final Log logger = LogFactory.getLog(EndpointMBeanRegistrar.class); + + private final MBeanServer mBeanServer; + + private final EndpointObjectNameFactory objectNameFactory; + + /** + * Create a new instance with the {@link MBeanExporter} and + * {@link EndpointObjectNameFactory} to use. + * @param mBeanServer the mbean exporter + * @param objectNameFactory the {@link ObjectName} factory + */ + public EndpointMBeanRegistrar(MBeanServer mBeanServer, + EndpointObjectNameFactory objectNameFactory) { + Assert.notNull(mBeanServer, "MBeanServer must not be null"); + Assert.notNull(objectNameFactory, "ObjectNameFactory must not be null"); + this.mBeanServer = mBeanServer; + this.objectNameFactory = objectNameFactory; + } + + /** + * Register the specified {@link EndpointMBean} and return its {@link ObjectName}. + * @param endpoint the endpoint to register + * @return the {@link ObjectName} used to register the {@code endpoint} + */ + public ObjectName registerEndpointMBean(EndpointMBean endpoint) { + Assert.notNull(endpoint, "Endpoint must not be null"); + try { + if (logger.isDebugEnabled()) { + logger.debug(String.format( + "Registering endpoint with id '%s' to " + "the JMX domain", + endpoint.getEndpointId())); + } + ObjectName objectName = this.objectNameFactory.generate(endpoint); + this.mBeanServer.registerMBean(endpoint, objectName); + return objectName; + } + catch (MalformedObjectNameException ex) { + throw new IllegalStateException( + String.format("Invalid ObjectName for " + "endpoint with id '%s'", + endpoint.getEndpointId()), + ex); + } + catch (Exception ex) { + throw new MBeanExportException( + String.format("Failed to register MBean for endpoint with id '%s'", + endpoint.getEndpointId()), + ex); + } + } + + /** + * Unregister the specified {@link ObjectName} if necessary. + * @param objectName the {@link ObjectName} of the endpoint to unregister + * @return {@code true} if the endpoint was unregistered, {@code false} if no such + * endpoint was found + */ + public boolean unregisterEndpointMbean(ObjectName objectName) { + try { + if (logger.isDebugEnabled()) { + logger.debug(String.format("Unregister endpoint with ObjectName '%s' " + + "from the JMX domain", objectName)); + } + this.mBeanServer.unregisterMBean(objectName); + return true; + } + catch (InstanceNotFoundException ex) { + return false; + } + catch (MBeanRegistrationException ex) { + throw new JmxException( + String.format("Failed to unregister MBean with" + "ObjectName '%s'", + objectName), + ex); + } + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointObjectNameFactory.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointObjectNameFactory.java new file mode 100644 index 00000000000..2647e827081 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointObjectNameFactory.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; + +/** + * A factory to create an {@link ObjectName} for an {@link EndpointMBean}. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +@FunctionalInterface +public interface EndpointObjectNameFactory { + + /** + * Generate an {@link ObjectName} for the specified {@link EndpointMBean endpoint}. + * @param mBean the endpoint to handle + * @return the {@link ObjectName} to use for the endpoint + * @throws MalformedObjectNameException if the object name is invalid + */ + ObjectName generate(EndpointMBean mBean) throws MalformedObjectNameException; + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscoverer.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscoverer.java new file mode 100644 index 00000000000..11b0980a83d --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscoverer.java @@ -0,0 +1,187 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.springframework.boot.endpoint.AnnotationEndpointDiscoverer; +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.CachingOperationInvoker; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.EndpointType; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.OperationParameterMapper; +import org.springframework.boot.endpoint.ReflectiveOperationInvoker; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.jmx.export.annotation.AnnotationJmxAttributeSource; +import org.springframework.jmx.export.metadata.ManagedOperation; +import org.springframework.jmx.export.metadata.ManagedOperationParameter; +import org.springframework.util.StringUtils; + +/** + * Discovers the {@link Endpoint endpoints} in an {@link ApplicationContext} with + * {@link JmxEndpointExtension JMX extensions} applied to them. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class JmxAnnotationEndpointDiscoverer + extends AnnotationEndpointDiscoverer { + + private static final AnnotationJmxAttributeSource jmxAttributeSource = new AnnotationJmxAttributeSource(); + + /** + * Creates a new {@link JmxAnnotationEndpointDiscoverer} that will discover + * {@link Endpoint endpoints} and {@link JmxEndpointExtension jmx extensions} using + * the given {@link ApplicationContext}. + * + * @param applicationContext the application context + * @param parameterMapper the {@link OperationParameterMapper} used to convert + * arguments when an operation is invoked + * @param cachingConfigurationFactory the {@link CachingConfiguration} factory to use + */ + public JmxAnnotationEndpointDiscoverer(ApplicationContext applicationContext, + OperationParameterMapper parameterMapper, + Function cachingConfigurationFactory) { + super(applicationContext, new JmxEndpointOperationFactory(parameterMapper), + JmxEndpointOperation::getOperationName, cachingConfigurationFactory); + } + + @Override + public Collection> discoverEndpoints() { + Collection> endpointDescriptors = discoverEndpointsWithExtension( + JmxEndpointExtension.class, EndpointType.JMX); + verifyThatOperationsHaveDistinctName(endpointDescriptors); + return endpointDescriptors.stream().map(EndpointInfoDescriptor::getEndpointInfo) + .collect(Collectors.toList()); + } + + private void verifyThatOperationsHaveDistinctName( + Collection> endpointDescriptors) { + List> clashes = new ArrayList<>(); + endpointDescriptors.forEach((descriptor) -> clashes + .addAll(descriptor.findDuplicateOperations().values())); + if (!clashes.isEmpty()) { + StringBuilder message = new StringBuilder(); + message.append( + String.format("Found multiple JMX operations with the same name:%n")); + clashes.forEach((clash) -> { + message.append(" ").append(clash.get(0).getOperationName()) + .append(String.format(":%n")); + clash.forEach((operation) -> message.append(" ") + .append(String.format("%s%n", operation))); + }); + throw new IllegalStateException(message.toString()); + } + } + + private static class JmxEndpointOperationFactory + implements EndpointOperationFactory { + + private final OperationParameterMapper parameterMapper; + + JmxEndpointOperationFactory(OperationParameterMapper parameterMapper) { + this.parameterMapper = parameterMapper; + } + + @Override + public JmxEndpointOperation createOperation(String endpointId, + AnnotationAttributes operationAttributes, Object target, Method method, + EndpointOperationType type, long timeToLive) { + String operationName = method.getName(); + Class outputType = mapParameterType(method.getReturnType()); + String description = getDescription(method, + () -> "Invoke " + operationName + " for endpoint " + endpointId); + List parameters = getParameters(method); + OperationInvoker invoker = new ReflectiveOperationInvoker( + this.parameterMapper, target, method); + if (timeToLive > 0) { + invoker = new CachingOperationInvoker(invoker, timeToLive); + } + return new JmxEndpointOperation(type, invoker, operationName, outputType, + description, parameters); + } + + private String getDescription(Method method, Supplier fallback) { + ManagedOperation managedOperation = jmxAttributeSource + .getManagedOperation(method); + if (managedOperation != null + && StringUtils.hasText(managedOperation.getDescription())) { + return managedOperation.getDescription(); + } + return fallback.get(); + } + + private List getParameters(Method method) { + List parameters = new ArrayList<>(); + Parameter[] methodParameters = method.getParameters(); + if (methodParameters.length == 0) { + return parameters; + } + ManagedOperationParameter[] managedOperationParameters = jmxAttributeSource + .getManagedOperationParameters(method); + if (managedOperationParameters.length > 0) { + for (int i = 0; i < managedOperationParameters.length; i++) { + ManagedOperationParameter mBeanParameter = managedOperationParameters[i]; + Parameter methodParameter = methodParameters[i]; + parameters.add(new JmxEndpointOperationParameterInfo( + mBeanParameter.getName(), + mapParameterType(methodParameter.getType()), + mBeanParameter.getDescription())); + } + } + else { + for (Parameter parameter : methodParameters) { + parameters.add( + new JmxEndpointOperationParameterInfo(parameter.getName(), + mapParameterType(parameter.getType()), null)); + } + } + return parameters; + } + + private Class mapParameterType(Class parameter) { + if (parameter.isEnum()) { + return String.class; + } + if (Date.class.isAssignableFrom(parameter)) { + return String.class; + } + if (parameter.getName().startsWith("java.")) { + return parameter; + } + if (parameter.equals(Void.TYPE)) { + return parameter; + } + return Object.class; + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointExtension.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointExtension.java new file mode 100644 index 00000000000..aaacf887150 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointExtension.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.endpoint.Endpoint; + +/** + * Identifies a type as being a JMX-specific extension of an {@link Endpoint}. + * + * @author Stephane Nicoll + * @since 2.0.0 + * @see Endpoint + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface JmxEndpointExtension { + + /** + * The {@link Endpoint endpoint} class to which this JMX extension relates. + * @return the endpoint class + */ + Class endpoint(); + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointMBeanFactory.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointMBeanFactory.java new file mode 100644 index 00000000000..abfa353a7c8 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointMBeanFactory.java @@ -0,0 +1,61 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.Collection; +import java.util.stream.Collectors; + +import org.springframework.boot.endpoint.EndpointInfo; + +/** + * A factory for creating JMX MBeans for endpoint operations. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class JmxEndpointMBeanFactory { + + private final EndpointMBeanInfoAssembler assembler; + + private final JmxOperationResponseMapper resultMapper; + + /** + * Create a new {@link JmxEndpointMBeanFactory} instance that will use the given + * {@code responseMapper} to convert an operation's response to a JMX-friendly form. + * @param responseMapper the response mapper + */ + public JmxEndpointMBeanFactory(JmxOperationResponseMapper responseMapper) { + this.assembler = new EndpointMBeanInfoAssembler(responseMapper); + this.resultMapper = responseMapper; + } + + /** + * Creates MBeans for the given {@code endpoints}. + * @param endpoints the endpoints + * @return the MBeans + */ + public Collection createMBeans( + Collection> endpoints) { + return endpoints.stream().map((endpointInfo) -> { + EndpointMBeanInfo endpointMBeanInfo = this.assembler + .createEndpointMBeanInfo(endpointInfo); + return new EndpointMBean(this.resultMapper::mapResponse, endpointMBeanInfo); + }).collect(Collectors.toList()); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperation.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperation.java new file mode 100644 index 00000000000..e8dc4f51883 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperation.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.Collections; +import java.util.List; + +import org.springframework.boot.endpoint.EndpointOperation; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.OperationInvoker; + +/** + * An operation on a JMX endpoint. + * + * @author Stephane Nicoll + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class JmxEndpointOperation extends EndpointOperation { + + private final String operationName; + + private final Class outputType; + + private final String description; + + private final List parameters; + + /** + * Creates a new {@code JmxEndpointOperation} for an operation of the given + * {@code type}. The operation can be performed using the given {@code invoker}. + * @param type the type of the operation + * @param invoker used to perform the operation + * @param operationName the name of the operation + * @param outputType the type of the output of the operation + * @param description the description of the operation + * @param parameters the parameters of the operation + */ + public JmxEndpointOperation(EndpointOperationType type, OperationInvoker invoker, + String operationName, Class outputType, String description, + List parameters) { + super(type, invoker, true); + this.operationName = operationName; + this.outputType = outputType; + this.description = description; + this.parameters = parameters; + } + + /** + * Returns the name of the operation. + * @return the operation name + */ + public String getOperationName() { + return this.operationName; + } + + /** + * Returns the type of the output of the operation. + * @return the output type + */ + public Class getOutputType() { + return this.outputType; + } + + /** + * Returns the description of the operation. + * @return the operation description + */ + public String getDescription() { + return this.description; + } + + /** + * Returns the parameters of the operation. + * @return the operation parameters + */ + public List getParameters() { + return Collections.unmodifiableList(this.parameters); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperationParameterInfo.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperationParameterInfo.java new file mode 100644 index 00000000000..5da2b862c4d --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperationParameterInfo.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +/** + * Describes the parameters of an operation on a JMX endpoint. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +public class JmxEndpointOperationParameterInfo { + + private final String name; + + private final Class type; + + private final String description; + + public JmxEndpointOperationParameterInfo(String name, Class type, + String description) { + this.name = name; + this.type = type; + this.description = description; + } + + public String getName() { + return this.name; + } + + public Class getType() { + return this.type; + } + + /** + * Return the description of the parameter or {@code null} if none is available. + * @return the description or {@code null} + */ + public String getDescription() { + return this.description; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxOperationResponseMapper.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxOperationResponseMapper.java new file mode 100644 index 00000000000..74695742c6d --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxOperationResponseMapper.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +/** + * A {@code JmxOperationResponseMapper} maps an operation's response to a JMX-friendly + * form. + * + * @author Stephane Nicoll + * @since 2.0.0 + */ +public interface JmxOperationResponseMapper { + + /** + * Map the operation's response so that it can be consumed by a JMX compliant client. + * @param response the operation's response + * @return the {@code response}, in a JMX compliant format + */ + Object mapResponse(Object response); + + /** + * Map the response type to its JMX compliant counterpart. + * @param responseType the operation's response type + * @return the JMX compliant type + */ + Class mapResponseType(Class responseType); + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/package-info.java new file mode 100644 index 00000000000..ecfde0e231d --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2017 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 + * + * http://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. + */ + +/** + * JMX endpoint support. + */ +package org.springframework.boot.endpoint.jmx; diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssemblerTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssemblerTests.java new file mode 100644 index 00000000000..34fc5caacb0 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssemblerTests.java @@ -0,0 +1,132 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.Collections; +import java.util.Map; + +import javax.management.MBeanInfo; +import javax.management.MBeanOperationInfo; +import javax.management.MBeanParameterInfo; + +import org.junit.Test; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.OperationInvoker; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link EndpointMBeanInfoAssembler}. + * + * @author Stephane Nicoll + */ +public class EndpointMBeanInfoAssemblerTests { + + private final EndpointMBeanInfoAssembler mBeanInfoAssembler = new EndpointMBeanInfoAssembler( + new DummyOperationResponseMapper()); + + @Test + public void exposeSimpleReadOperation() { + JmxEndpointOperation operation = new JmxEndpointOperation( + EndpointOperationType.READ, new DummyOperationInvoker(), "getAll", + Object.class, "Test operation", Collections.emptyList()); + EndpointInfo endpoint = new EndpointInfo<>("test", true, + Collections.singletonList(operation)); + EndpointMBeanInfo endpointMBeanInfo = this.mBeanInfoAssembler + .createEndpointMBeanInfo(endpoint); + assertThat(endpointMBeanInfo).isNotNull(); + assertThat(endpointMBeanInfo.getEndpointId()).isEqualTo("test"); + assertThat(endpointMBeanInfo.getOperations()) + .containsOnly(entry("getAll", operation)); + MBeanInfo mbeanInfo = endpointMBeanInfo.getMbeanInfo(); + assertThat(mbeanInfo).isNotNull(); + assertThat(mbeanInfo.getClassName()).isEqualTo(EndpointMBean.class.getName()); + assertThat(mbeanInfo.getDescription()) + .isEqualTo("MBean operations for endpoint test"); + assertThat(mbeanInfo.getAttributes()).isEmpty(); + assertThat(mbeanInfo.getNotifications()).isEmpty(); + assertThat(mbeanInfo.getConstructors()).isEmpty(); + assertThat(mbeanInfo.getOperations()).hasSize(1); + MBeanOperationInfo mBeanOperationInfo = mbeanInfo.getOperations()[0]; + assertThat(mBeanOperationInfo.getName()).isEqualTo("getAll"); + assertThat(mBeanOperationInfo.getReturnType()).isEqualTo(Object.class.getName()); + assertThat(mBeanOperationInfo.getImpact()).isEqualTo(MBeanOperationInfo.INFO); + assertThat(mBeanOperationInfo.getSignature()).hasSize(0); + } + + @Test + public void exposeSimpleWriteOperation() { + JmxEndpointOperation operation = new JmxEndpointOperation( + EndpointOperationType.WRITE, new DummyOperationInvoker(), "update", + Object.class, "Update operation", + Collections.singletonList(new JmxEndpointOperationParameterInfo("test", + String.class, "Test argument"))); + EndpointInfo endpoint = new EndpointInfo<>("another", true, + Collections.singletonList(operation)); + EndpointMBeanInfo endpointMBeanInfo = this.mBeanInfoAssembler + .createEndpointMBeanInfo(endpoint); + assertThat(endpointMBeanInfo).isNotNull(); + assertThat(endpointMBeanInfo.getEndpointId()).isEqualTo("another"); + assertThat(endpointMBeanInfo.getOperations()) + .containsOnly(entry("update", operation)); + MBeanInfo mbeanInfo = endpointMBeanInfo.getMbeanInfo(); + assertThat(mbeanInfo).isNotNull(); + assertThat(mbeanInfo.getClassName()).isEqualTo(EndpointMBean.class.getName()); + assertThat(mbeanInfo.getDescription()) + .isEqualTo("MBean operations for endpoint another"); + assertThat(mbeanInfo.getAttributes()).isEmpty(); + assertThat(mbeanInfo.getNotifications()).isEmpty(); + assertThat(mbeanInfo.getConstructors()).isEmpty(); + assertThat(mbeanInfo.getOperations()).hasSize(1); + MBeanOperationInfo mBeanOperationInfo = mbeanInfo.getOperations()[0]; + assertThat(mBeanOperationInfo.getName()).isEqualTo("update"); + assertThat(mBeanOperationInfo.getReturnType()).isEqualTo(Object.class.getName()); + assertThat(mBeanOperationInfo.getImpact()).isEqualTo(MBeanOperationInfo.ACTION); + assertThat(mBeanOperationInfo.getSignature()).hasSize(1); + MBeanParameterInfo mBeanParameterInfo = mBeanOperationInfo.getSignature()[0]; + assertThat(mBeanParameterInfo.getName()).isEqualTo("test"); + assertThat(mBeanParameterInfo.getType()).isEqualTo(String.class.getName()); + assertThat(mBeanParameterInfo.getDescription()).isEqualTo("Test argument"); + } + + private static class DummyOperationInvoker implements OperationInvoker { + + @Override + public Object invoke(Map arguments) { + return null; + } + + } + + private static class DummyOperationResponseMapper + implements JmxOperationResponseMapper { + + @Override + public Object mapResponse(Object response) { + return response; + } + + @Override + public Class mapResponseType(Class responseType) { + return responseType; + } + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrarTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrarTests.java new file mode 100644 index 00000000000..1e44c415a35 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrarTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import javax.management.InstanceNotFoundException; +import javax.management.MBeanRegistrationException; +import javax.management.MBeanServer; +import javax.management.MalformedObjectNameException; +import javax.management.ObjectName; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.jmx.JmxException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link EndpointMBeanRegistrar}. + * + * @author Stephane Nicoll + */ +public class EndpointMBeanRegistrarTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private MBeanServer mBeanServer = mock(MBeanServer.class); + + @Test + public void mBeanServerMustNotBeNull() { + this.thrown.expect(IllegalArgumentException.class); + new EndpointMBeanRegistrar(null, (e) -> new ObjectName("foo")); + } + + @Test + public void objectNameFactoryMustNotBeNull() { + this.thrown.expect(IllegalArgumentException.class); + new EndpointMBeanRegistrar(this.mBeanServer, null); + } + + @Test + public void endpointMustNotBeNull() { + EndpointMBeanRegistrar registrar = new EndpointMBeanRegistrar(this.mBeanServer, + (e) -> new ObjectName("foo")); + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Endpoint must not be null"); + registrar.registerEndpointMBean(null); + } + + @Test + public void registerEndpointInvokesObjectNameFactory() + throws MalformedObjectNameException { + EndpointObjectNameFactory factory = mock(EndpointObjectNameFactory.class); + EndpointMBean endpointMBean = mock(EndpointMBean.class); + ObjectName objectName = mock(ObjectName.class); + given(factory.generate(endpointMBean)).willReturn(objectName); + EndpointMBeanRegistrar registrar = new EndpointMBeanRegistrar(this.mBeanServer, + factory); + ObjectName actualObjectName = registrar.registerEndpointMBean(endpointMBean); + assertThat(actualObjectName).isSameAs(objectName); + verify(factory).generate(endpointMBean); + } + + @Test + public void registerEndpointInvalidObjectName() throws MalformedObjectNameException { + EndpointMBean endpointMBean = mock(EndpointMBean.class); + given(endpointMBean.getEndpointId()).willReturn("test"); + EndpointObjectNameFactory factory = mock(EndpointObjectNameFactory.class); + given(factory.generate(endpointMBean)) + .willThrow(new MalformedObjectNameException()); + EndpointMBeanRegistrar registrar = new EndpointMBeanRegistrar(this.mBeanServer, + factory); + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Invalid ObjectName for endpoint with id 'test'"); + registrar.registerEndpointMBean(endpointMBean); + } + + @Test + public void registerEndpointFailure() throws Exception { + EndpointMBean endpointMBean = mock(EndpointMBean.class); + given(endpointMBean.getEndpointId()).willReturn("test"); + EndpointObjectNameFactory factory = mock(EndpointObjectNameFactory.class); + ObjectName objectName = mock(ObjectName.class); + given(factory.generate(endpointMBean)).willReturn(objectName); + given(this.mBeanServer.registerMBean(endpointMBean, objectName)) + .willThrow(MBeanRegistrationException.class); + EndpointMBeanRegistrar registrar = new EndpointMBeanRegistrar(this.mBeanServer, + factory); + this.thrown.expect(JmxException.class); + this.thrown.expectMessage("Failed to register MBean for endpoint with id 'test'"); + registrar.registerEndpointMBean(endpointMBean); + } + + @Test + public void unregisterEndpoint() throws Exception { + ObjectName objectName = mock(ObjectName.class); + EndpointMBeanRegistrar registrar = new EndpointMBeanRegistrar(this.mBeanServer, + mock(EndpointObjectNameFactory.class)); + assertThat(registrar.unregisterEndpointMbean(objectName)).isTrue(); + verify(this.mBeanServer).unregisterMBean(objectName); + } + + @Test + public void unregisterUnknownEndpoint() throws Exception { + ObjectName objectName = mock(ObjectName.class); + willThrow(InstanceNotFoundException.class).given(this.mBeanServer) + .unregisterMBean(objectName); + EndpointMBeanRegistrar registrar = new EndpointMBeanRegistrar(this.mBeanServer, + mock(EndpointObjectNameFactory.class)); + assertThat(registrar.unregisterEndpointMbean(objectName)).isFalse(); + verify(this.mBeanServer).unregisterMBean(objectName); + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanTests.java new file mode 100644 index 00000000000..d41d5906b64 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanTests.java @@ -0,0 +1,362 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Consumer; + +import javax.management.Attribute; +import javax.management.AttributeList; +import javax.management.AttributeNotFoundException; +import javax.management.InstanceNotFoundException; +import javax.management.MBeanException; +import javax.management.MBeanInfo; +import javax.management.MBeanOperationInfo; +import javax.management.MBeanParameterInfo; +import javax.management.MBeanServer; +import javax.management.MBeanServerFactory; +import javax.management.ObjectName; +import javax.management.ReflectionException; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.ReadOperation; +import org.springframework.boot.endpoint.WriteOperation; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.core.convert.support.DefaultConversionService; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link EndpointMBean}. + * + * @author Stephane Nicoll + */ +public class EndpointMBeanTests { + + private final JmxEndpointMBeanFactory jmxEndpointMBeanFactory = new JmxEndpointMBeanFactory( + new TestJmxOperationResponseMapper()); + + private MBeanServer server; + + private EndpointMBeanRegistrar endpointMBeanRegistrar; + + private EndpointObjectNameFactory objectNameFactory = (endpoint) -> new ObjectName( + String.format("org.springframework.boot.test:type=Endpoint,name=%s", + UUID.randomUUID().toString())); + + @Before + public void createMBeanServer() { + this.server = MBeanServerFactory.createMBeanServer(); + this.endpointMBeanRegistrar = new EndpointMBeanRegistrar(this.server, + this.objectNameFactory); + } + + @After + public void disposeMBeanServer() { + if (this.server != null) { + MBeanServerFactory.releaseMBeanServer(this.server); + } + } + + @Test + public void invokeSimpleEndpoint() { + load(FooEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "foo"); + try { + // getAll + Object allResponse = this.server.invoke(objectName, "getAll", + new Object[0], new String[0]); + assertThat(allResponse).isEqualTo("[ONE, TWO]"); + + // getOne + Object oneResponse = this.server.invoke(objectName, "getOne", + new Object[] { "one" }, new String[] { String.class.getName() }); + assertThat(oneResponse).isEqualTo("ONE"); + + // update + Object updateResponse = this.server.invoke(objectName, "update", + new Object[] { "one", "1" }, + new String[] { String.class.getName(), String.class.getName() }); + assertThat(updateResponse).isNull(); + + // getOne validation after update + Object updatedOneResponse = this.server.invoke(objectName, "getOne", + new Object[] { "one" }, new String[] { String.class.getName() }); + assertThat(updatedOneResponse).isEqualTo("1"); + + } + catch (Exception ex) { + throw new AssertionError("Failed to invoke method on FooEndpoint", ex); + } + }); + } + + @Test + public void jmxTypesAreProperlyMapped() { + load(FooEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "foo"); + try { + MBeanInfo mBeanInfo = this.server.getMBeanInfo(objectName); + Map operations = mapOperations(mBeanInfo); + assertThat(operations).containsOnlyKeys("getAll", "getOne", "update"); + assertOperation(operations.get("getAll"), String.class, + MBeanOperationInfo.INFO, new Class[0]); + assertOperation(operations.get("getOne"), String.class, + MBeanOperationInfo.INFO, new Class[] { String.class }); + assertOperation(operations.get("update"), Void.TYPE, + MBeanOperationInfo.ACTION, + new Class[] { String.class, String.class }); + } + catch (Exception ex) { + throw new AssertionError("Failed to retrieve MBeanInfo of FooEndpoint", + ex); + } + }); + } + + private void assertOperation(MBeanOperationInfo operation, Class returnType, + int impact, Class[] types) { + assertThat(operation.getReturnType()).isEqualTo(returnType.getName()); + assertThat(operation.getImpact()).isEqualTo(impact); + MBeanParameterInfo[] signature = operation.getSignature(); + assertThat(signature).hasSize(types.length); + for (int i = 0; i < types.length; i++) { + assertThat(signature[i].getType()).isEqualTo(types[0].getName()); + } + } + + @Test + public void invokeReactiveOperation() { + load(ReactiveEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "reactive"); + try { + Object allResponse = this.server.invoke(objectName, "getInfo", + new Object[0], new String[0]); + assertThat(allResponse).isInstanceOf(String.class); + assertThat(allResponse).isEqualTo("HELLO WORLD"); + } + catch (Exception ex) { + throw new AssertionError("Failed to invoke getInfo method", ex); + } + }); + + } + + @Test + public void invokeUnknownOperation() { + load(FooEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "foo"); + try { + this.server.invoke(objectName, "doesNotExist", new Object[0], + new String[0]); + throw new AssertionError( + "Should have failed to invoke unknown operation"); + } + catch (ReflectionException ex) { + assertThat(ex.getCause()).isInstanceOf(IllegalArgumentException.class); + assertThat(ex.getCause().getMessage()).contains("doesNotExist", "foo"); + } + catch (MBeanException | InstanceNotFoundException ex) { + throw new IllegalStateException(ex); + } + + }); + } + + @Test + public void dynamicMBeanCannotReadAttribute() { + load(FooEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "foo"); + try { + this.server.getAttribute(objectName, "foo"); + throw new AssertionError("Should have failed to read attribute foo"); + } + catch (Exception ex) { + assertThat(ex).isInstanceOf(AttributeNotFoundException.class); + } + }); + } + + @Test + public void dynamicMBeanCannotWriteAttribute() { + load(FooEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "foo"); + try { + this.server.setAttribute(objectName, new Attribute("foo", "bar")); + throw new AssertionError("Should have failed to write attribute foo"); + } + catch (Exception ex) { + assertThat(ex).isInstanceOf(AttributeNotFoundException.class); + } + }); + } + + @Test + public void dynamicMBeanCannotReadAttributes() { + load(FooEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "foo"); + try { + AttributeList attributes = this.server.getAttributes(objectName, + new String[] { "foo", "bar" }); + assertThat(attributes).isNotNull(); + assertThat(attributes).isEmpty(); + } + catch (Exception ex) { + throw new AssertionError("Failed to invoke getAttributes", ex); + } + }); + } + + @Test + public void dynamicMBeanCannotWriteAttributes() { + load(FooEndpoint.class, (discoverer) -> { + ObjectName objectName = registerEndpoint(discoverer, "foo"); + try { + AttributeList attributes = new AttributeList(); + attributes.add(new Attribute("foo", 1)); + attributes.add(new Attribute("bar", 42)); + AttributeList attributesSet = this.server.setAttributes(objectName, + attributes); + assertThat(attributesSet).isNotNull(); + assertThat(attributesSet).isEmpty(); + } + catch (Exception ex) { + throw new AssertionError("Failed to invoke setAttributes", ex); + } + }); + } + + private ObjectName registerEndpoint(JmxAnnotationEndpointDiscoverer discoverer, + String endpointId) { + Collection mBeans = this.jmxEndpointMBeanFactory + .createMBeans(discoverer.discoverEndpoints()); + assertThat(mBeans).hasSize(1); + EndpointMBean endpointMBean = mBeans.iterator().next(); + assertThat(endpointMBean.getEndpointId()).isEqualTo(endpointId); + return this.endpointMBeanRegistrar.registerEndpointMBean(endpointMBean); + } + + private Map mapOperations(MBeanInfo info) { + Map operations = new HashMap<>(); + for (MBeanOperationInfo mBeanOperationInfo : info.getOperations()) { + operations.put(mBeanOperationInfo.getName(), mBeanOperationInfo); + } + return operations; + } + + private void load(Class configuration, + Consumer consumer) { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + configuration)) { + consumer.accept(new JmxAnnotationEndpointDiscoverer(context, + new ConversionServiceOperationParameterMapper( + DefaultConversionService.getSharedInstance()), + (id) -> new CachingConfiguration(0))); + } + } + + @Endpoint(id = "foo") + static class FooEndpoint { + + private final Map all = new LinkedHashMap<>(); + + FooEndpoint() { + this.all.put(FooName.ONE, new Foo("one")); + this.all.put(FooName.TWO, new Foo("two")); + } + + @ReadOperation + public Collection getAll() { + return this.all.values(); + } + + @ReadOperation + public Foo getOne(FooName name) { + return this.all.get(name); + } + + @WriteOperation + public void update(FooName name, String value) { + this.all.put(name, new Foo(value)); + } + + } + + @Endpoint(id = "reactive") + static class ReactiveEndpoint { + + @ReadOperation + public Mono getInfo() { + return Mono.defer(() -> Mono.just("Hello World")); + } + + } + + enum FooName { + + ONE, TWO, THREE + + } + + static class Foo { + + private final String name; + + Foo(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + @Override + public String toString() { + return this.name; + } + + } + + private static class TestJmxOperationResponseMapper + implements JmxOperationResponseMapper { + + @Override + public Object mapResponse(Object response) { + return (response != null ? response.toString().toUpperCase() : null); + } + + @Override + public Class mapResponseType(Class responseType) { + if (responseType == Void.TYPE) { + return Void.TYPE; + } + return String.class; + } + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscovererTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscovererTests.java new file mode 100644 index 00000000000..0090ac27e28 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscovererTests.java @@ -0,0 +1,555 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.jmx; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.CachingOperationInvoker; +import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointType; +import org.springframework.boot.endpoint.ReadOperation; +import org.springframework.boot.endpoint.ReflectiveOperationInvoker; +import org.springframework.boot.endpoint.WriteOperation; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.jmx.export.annotation.ManagedOperation; +import org.springframework.jmx.export.annotation.ManagedOperationParameter; +import org.springframework.jmx.export.annotation.ManagedOperationParameters; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JmxAnnotationEndpointDiscoverer}. + * + * @author Stephane Nicoll + */ +public class JmxAnnotationEndpointDiscovererTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void discoveryWorksWhenThereAreNoEndpoints() { + load(EmptyConfiguration.class, + (discoverer) -> assertThat(discoverer.discoverEndpoints()).isEmpty()); + } + + @Test + public void standardEndpointIsDiscovered() { + load(TestEndpoint.class, (discoverer) -> { + Map> endpoints = discover( + discoverer); + assertThat(endpoints).containsOnlyKeys("test"); + Map operationByName = mapOperations( + endpoints.get("test").getOperations()); + assertThat(operationByName).containsOnlyKeys("getAll", "getSomething", + "update"); + JmxEndpointOperation getAll = operationByName.get("getAll"); + assertThat(getAll.getDescription()) + .isEqualTo("Invoke getAll for endpoint test"); + assertThat(getAll.getOutputType()).isEqualTo(Object.class); + assertThat(getAll.getParameters()).isEmpty(); + assertThat(getAll.getOperationInvoker()) + .isInstanceOf(ReflectiveOperationInvoker.class); + JmxEndpointOperation getSomething = operationByName.get("getSomething"); + assertThat(getSomething.getDescription()) + .isEqualTo("Invoke getSomething for endpoint test"); + assertThat(getSomething.getOutputType()).isEqualTo(String.class); + assertThat(getSomething.getParameters()).hasSize(1); + hasDefaultParameter(getSomething, 0, String.class); + JmxEndpointOperation update = operationByName.get("update"); + assertThat(update.getDescription()) + .isEqualTo("Invoke update for endpoint test"); + assertThat(update.getOutputType()).isEqualTo(Void.TYPE); + assertThat(update.getParameters()).hasSize(2); + hasDefaultParameter(update, 0, String.class); + hasDefaultParameter(update, 1, String.class); + }); + + } + + @Test + public void onlyJmxEndpointsAreDiscovered() { + load(MultipleEndpointsConfiguration.class, (discoverer) -> { + Map> endpoints = discover( + discoverer); + assertThat(endpoints).containsOnlyKeys("test", "jmx"); + }); + } + + @Test + public void jmxExtensionMustHaveEndpoint() { + load(TestJmxEndpointExtension.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Invalid extension"); + this.thrown.expectMessage(TestJmxEndpointExtension.class.getName()); + this.thrown.expectMessage("no endpoint found"); + this.thrown.expectMessage(TestEndpoint.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void jmxEndpointOverridesStandardEndpoint() { + load(OverriddenOperationJmxEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = discover( + discoverer); + assertThat(endpoints).containsOnlyKeys("test"); + assertJmxTestEndpoint(endpoints.get("test")); + }); + } + + @Test + public void jmxEndpointAddsExtraOperation() { + load(AdditionalOperationJmxEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = discover( + discoverer); + assertThat(endpoints).containsOnlyKeys("test"); + Map operationByName = mapOperations( + endpoints.get("test").getOperations()); + assertThat(operationByName).containsOnlyKeys("getAll", "getSomething", + "update", "getAnother"); + JmxEndpointOperation getAnother = operationByName.get("getAnother"); + assertThat(getAnother.getDescription()).isEqualTo("Get another thing"); + assertThat(getAnother.getOutputType()).isEqualTo(Object.class); + assertThat(getAnother.getParameters()).isEmpty(); + }); + } + + @Test + public void endpointMainReadOperationIsCachedWithMatchingId() { + load(TestEndpoint.class, (id) -> new CachingConfiguration(500), (discoverer) -> { + Map> endpoints = discover( + discoverer); + assertThat(endpoints).containsOnlyKeys("test"); + Map operationByName = mapOperations( + endpoints.get("test").getOperations()); + assertThat(operationByName).containsOnlyKeys("getAll", "getSomething", + "update"); + JmxEndpointOperation getAll = operationByName.get("getAll"); + assertThat(getAll.getOperationInvoker()) + .isInstanceOf(CachingOperationInvoker.class); + assertThat(((CachingOperationInvoker) getAll.getOperationInvoker()) + .getTimeToLive()).isEqualTo(500); + }); + } + + @Test + public void extraReadOperationsAreCached() { + load(AdditionalOperationJmxEndpointConfiguration.class, + (id) -> new CachingConfiguration(500), (discoverer) -> { + Map> endpoints = discover( + discoverer); + assertThat(endpoints).containsOnlyKeys("test"); + Map operationByName = mapOperations( + endpoints.get("test").getOperations()); + assertThat(operationByName).containsOnlyKeys("getAll", "getSomething", + "update", "getAnother"); + JmxEndpointOperation getAll = operationByName.get("getAll"); + assertThat(getAll.getOperationInvoker()) + .isInstanceOf(CachingOperationInvoker.class); + assertThat(((CachingOperationInvoker) getAll.getOperationInvoker()) + .getTimeToLive()).isEqualTo(500); + JmxEndpointOperation getAnother = operationByName.get("getAnother"); + assertThat(getAnother.getOperationInvoker()) + .isInstanceOf(CachingOperationInvoker.class); + assertThat( + ((CachingOperationInvoker) getAnother.getOperationInvoker()) + .getTimeToLive()).isEqualTo(500); + }); + } + + @Test + public void discoveryFailsWhenTwoExtensionsHaveTheSameEndpointType() { + load(ClashingJmxEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found two extensions for the same endpoint"); + this.thrown.expectMessage(TestEndpoint.class.getName()); + this.thrown.expectMessage(TestJmxEndpointExtension.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenTwoStandardEndpointsHaveTheSameId() { + load(ClashingStandardEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found two endpoints with the id 'test': "); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenEndpointHasTwoOperationsWithTheSameName() { + load(ClashingOperationsEndpoint.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found multiple JMX operations with the same name"); + this.thrown.expectMessage("getAll"); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenExtensionHasTwoOperationsWithTheSameName() { + load(AdditionalClashingOperationsConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found multiple JMX operations with the same name"); + this.thrown.expectMessage("getAll"); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenExtensionIsNotCompatibleWithTheEndpointType() { + load(InvalidJmxExtensionConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Invalid extension"); + this.thrown.expectMessage(NonJmxJmxEndpointExtension.class.getName()); + this.thrown.expectMessage(NonJmxEndpoint.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + private void assertJmxTestEndpoint(EndpointInfo endpoint) { + Map operationByName = mapOperations( + endpoint.getOperations()); + assertThat(operationByName).containsOnlyKeys("getAll", "getSomething", "update"); + JmxEndpointOperation getAll = operationByName.get("getAll"); + assertThat(getAll.getDescription()).isEqualTo("Get all the things"); + assertThat(getAll.getOutputType()).isEqualTo(Object.class); + assertThat(getAll.getParameters()).isEmpty(); + JmxEndpointOperation getSomething = operationByName.get("getSomething"); + assertThat(getSomething.getDescription()) + .isEqualTo("Get something based on a timeUnit"); + assertThat(getSomething.getOutputType()).isEqualTo(String.class); + assertThat(getSomething.getParameters()).hasSize(1); + hasDocumentedParameter(getSomething, 0, "unitMs", Long.class, + "Number of milliseconds"); + JmxEndpointOperation update = operationByName.get("update"); + assertThat(update.getDescription()).isEqualTo("Update something based on bar"); + assertThat(update.getOutputType()).isEqualTo(Void.TYPE); + assertThat(update.getParameters()).hasSize(2); + hasDocumentedParameter(update, 0, "foo", String.class, "Foo identifier"); + hasDocumentedParameter(update, 1, "bar", String.class, "Bar value"); + } + + private void hasDefaultParameter(JmxEndpointOperation operation, int index, + Class type) { + assertThat(index).isLessThan(operation.getParameters().size()); + JmxEndpointOperationParameterInfo parameter = operation.getParameters() + .get(index); + assertThat(parameter.getType()).isEqualTo(type); + assertThat(parameter.getDescription()).isNull(); + } + + private void hasDocumentedParameter(JmxEndpointOperation operation, int index, + String name, Class type, String description) { + assertThat(index).isLessThan(operation.getParameters().size()); + JmxEndpointOperationParameterInfo parameter = operation.getParameters() + .get(index); + assertThat(parameter.getName()).isEqualTo(name); + assertThat(parameter.getType()).isEqualTo(type); + assertThat(parameter.getDescription()).isEqualTo(description); + } + + private Map> discover( + JmxAnnotationEndpointDiscoverer discoverer) { + Map> endpointsById = new HashMap<>(); + discoverer.discoverEndpoints() + .forEach((endpoint) -> endpointsById.put(endpoint.getId(), endpoint)); + return endpointsById; + } + + private Map mapOperations( + Collection operations) { + Map operationByName = new HashMap<>(); + operations.forEach((operation) -> operationByName + .put(operation.getOperationName(), operation)); + return operationByName; + } + + private void load(Class configuration, + Consumer consumer) { + load(configuration, (id) -> null, consumer); + } + + private void load(Class configuration, + Function cachingConfigurationFactory, + Consumer consumer) { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + configuration)) { + consumer.accept(new JmxAnnotationEndpointDiscoverer(context, + new ConversionServiceOperationParameterMapper( + DefaultConversionService.getSharedInstance()), + cachingConfigurationFactory)); + } + } + + @Endpoint(id = "test") + private static class TestEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public String getSomething(TimeUnit timeUnit) { + return null; + } + + @WriteOperation + public void update(String foo, String bar) { + + } + + } + + @Endpoint(id = "jmx", types = EndpointType.JMX) + private static class TestJmxEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + } + + @JmxEndpointExtension(endpoint = TestEndpoint.class) + private static class TestJmxEndpointExtension { + + @ManagedOperation(description = "Get all the things") + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + @ManagedOperation(description = "Get something based on a timeUnit") + @ManagedOperationParameters({ + @ManagedOperationParameter(name = "unitMs", description = "Number of milliseconds") }) + public String getSomething(Long timeUnit) { + return null; + } + + @WriteOperation + @ManagedOperation(description = "Update something based on bar") + @ManagedOperationParameters({ + @ManagedOperationParameter(name = "foo", description = "Foo identifier"), + @ManagedOperationParameter(name = "bar", description = "Bar value") }) + public void update(String foo, String bar) { + + } + + } + + @JmxEndpointExtension(endpoint = TestEndpoint.class) + private static class AdditionalOperationJmxEndpointExtension { + + @ManagedOperation(description = "Get another thing") + @ReadOperation + public Object getAnother() { + return null; + } + + } + + @Endpoint(id = "test") + static class ClashingOperationsEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getAll(String param) { + return null; + } + + } + + @JmxEndpointExtension(endpoint = TestEndpoint.class) + static class ClashingOperationsJmxEndpointExtension { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getAll(String param) { + return null; + } + + } + + @Endpoint(id = "nonjmx", types = EndpointType.WEB) + private static class NonJmxEndpoint { + + @ReadOperation + public Object getData() { + return null; + } + + } + + @JmxEndpointExtension(endpoint = NonJmxEndpoint.class) + private static class NonJmxJmxEndpointExtension { + + @ReadOperation + public Object getSomething() { + return null; + } + + } + + @Configuration + static class EmptyConfiguration { + + } + + @Configuration + static class MultipleEndpointsConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public TestJmxEndpoint testJmxEndpoint() { + return new TestJmxEndpoint(); + } + + @Bean + public NonJmxEndpoint nonJmxEndpoint() { + return new NonJmxEndpoint(); + } + + } + + @Configuration + static class OverriddenOperationJmxEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public TestJmxEndpointExtension testJmxEndpointExtension() { + return new TestJmxEndpointExtension(); + } + + } + + @Configuration + static class AdditionalOperationJmxEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public AdditionalOperationJmxEndpointExtension additionalOperationJmxEndpointExtension() { + return new AdditionalOperationJmxEndpointExtension(); + } + + } + + @Configuration + static class AdditionalClashingOperationsConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public ClashingOperationsJmxEndpointExtension clashingOperationsJmxEndpointExtension() { + return new ClashingOperationsJmxEndpointExtension(); + } + + } + + @Configuration + static class ClashingJmxEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public TestJmxEndpointExtension testExtensionOne() { + return new TestJmxEndpointExtension(); + } + + @Bean + public TestJmxEndpointExtension testExtensionTwo() { + return new TestJmxEndpointExtension(); + } + + } + + @Configuration + static class ClashingStandardEndpointConfiguration { + + @Bean + public TestEndpoint testEndpointTwo() { + return new TestEndpoint(); + } + + @Bean + public TestEndpoint testEndpointOne() { + return new TestEndpoint(); + } + + } + + @Configuration + static class InvalidJmxExtensionConfiguration { + + @Bean + public NonJmxEndpoint nonJmxEndpoint() { + return new NonJmxEndpoint(); + } + + @Bean + public NonJmxJmxEndpointExtension nonJmxJmxEndpointExtension() { + return new NonJmxJmxEndpointExtension(); + } + + } + +} From 9687a5041ed658da3ef9994d001b602e6de08a6c Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 20 Jul 2017 16:35:44 +0100 Subject: [PATCH 3/3] Add support for making endpoints accessible via HTTP This commit adds support for exposing endpoint operations over HTTP. Jersey, Spring MVC, and WebFlux are all supported but the programming model remains web framework agnostic. When using WebFlux, blocking operations are automatically performed on a separate thread using Reactor's scheduler support. Support for web-specific extensions is provided via a new `@WebEndpointExtension` annotation. Closes gh-7970 Closes gh-9946 Closes gh-9947 --- eclipse/org.eclipse.jdt.core.prefs | 1 + .../src/checkstyle/import-control.xml | 18 +- spring-boot/pom.xml | 22 +- .../endpoint/web/EndpointLinksResolver.java | 68 ++ .../boot/endpoint/web/Link.java | 66 ++ .../web/OperationRequestPredicate.java | 136 ++++ .../web/WebAnnotationEndpointDiscoverer.java | 219 ++++++ .../endpoint/web/WebEndpointExtension.java | 46 ++ .../endpoint/web/WebEndpointHttpMethod.java | 37 ++ .../endpoint/web/WebEndpointOperation.java | 69 ++ .../endpoint/web/WebEndpointResponse.java | 86 +++ .../jersey/JerseyEndpointResourceFactory.java | 208 ++++++ .../endpoint/web/jersey/package-info.java | 20 + .../mvc/WebEndpointServletHandlerMapping.java | 243 +++++++ .../boot/endpoint/web/mvc/package-info.java | 20 + .../boot/endpoint/web/package-info.java | 20 + .../WebEndpointReactiveHandlerMapping.java | 254 +++++++ .../endpoint/web/reactive/package-info.java | 20 + .../AbstractWebEndpointIntegrationTests.java | 481 ++++++++++++++ .../web/EndpointLinksResolverTests.java | 88 +++ .../web/OperationRequestPredicateTests.java | 71 ++ .../WebAnnotationEndpointDiscovererTests.java | 628 ++++++++++++++++++ .../JerseyWebEndpointIntegrationTests.java | 108 +++ .../mvc/MvcWebEndpointIntegrationTests.java | 96 +++ .../ReactiveWebEndpointIntegrationTests.java | 107 +++ 25 files changed, 3130 insertions(+), 2 deletions(-) create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java diff --git a/eclipse/org.eclipse.jdt.core.prefs b/eclipse/org.eclipse.jdt.core.prefs index e44b6fc3667..3ec70345dfe 100644 --- a/eclipse/org.eclipse.jdt.core.prefs +++ b/eclipse/org.eclipse.jdt.core.prefs @@ -10,6 +10,7 @@ org.eclipse.jdt.core.codeComplete.staticFieldSuffixes= org.eclipse.jdt.core.codeComplete.staticFinalFieldPrefixes= org.eclipse.jdt.core.codeComplete.staticFinalFieldSuffixes= org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled +org.eclipse.jdt.core.compiler.codegen.methodParameters=generate org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve org.eclipse.jdt.core.compiler.compliance=1.6 diff --git a/spring-boot-parent/src/checkstyle/import-control.xml b/spring-boot-parent/src/checkstyle/import-control.xml index b73b7cbd407..cc52288841b 100644 --- a/spring-boot-parent/src/checkstyle/import-control.xml +++ b/spring-boot-parent/src/checkstyle/import-control.xml @@ -29,6 +29,22 @@ + + + + + + + + + + + + + + + + @@ -109,4 +125,4 @@ - + \ No newline at end of file diff --git a/spring-boot/pom.xml b/spring-boot/pom.xml index 56f060e6201..21d22b2c0e7 100644 --- a/spring-boot/pom.xml +++ b/spring-boot/pom.xml @@ -159,6 +159,11 @@ jetty-webapp true + + org.glassfish.jersey.core + jersey-server + true + org.hamcrest hamcrest-library @@ -271,6 +276,11 @@ h2 test + + com.jayway.jsonpath + json-path + test + com.microsoft.sqlserver mssql-jdbc @@ -316,6 +326,16 @@ jaybird-jdk18 test + + org.glassfish.jersey.containers + jersey-container-servlet-core + test + + + org.glassfish.jersey.media + jersey-media-json-jackson + test + org.hsqldb hsqldb @@ -352,4 +372,4 @@ test - + \ No newline at end of file diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java new file mode 100644 index 00000000000..647a59c69fd --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/EndpointLinksResolver.java @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.endpoint.EndpointInfo; + +/** + * A resolver for {@link Link links} to web endpoints. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class EndpointLinksResolver { + + /** + * Resolves links to the operations of the given {code webEndpoints} based on a + * request with the given {@code requestUrl}. + * + * @param webEndpoints the web endpoints + * @param requestUrl the url of the request for the endpoint links + * @return the links + */ + public Map resolveLinks( + Collection> webEndpoints, + String requestUrl) { + String normalizedUrl = normalizeRequestUrl(requestUrl); + Map links = new LinkedHashMap(); + links.put("self", new Link(normalizedUrl)); + for (EndpointInfo endpoint : webEndpoints) { + for (WebEndpointOperation operation : endpoint.getOperations()) { + webEndpoints.stream().map(EndpointInfo::getId).forEach((id) -> links + .put(operation.getId(), createLink(normalizedUrl, operation))); + } + } + return links; + } + + private String normalizeRequestUrl(String requestUrl) { + if (requestUrl.endsWith("/")) { + return requestUrl.substring(0, requestUrl.length() - 1); + } + return requestUrl; + } + + private Link createLink(String requestUrl, WebEndpointOperation operation) { + String path = operation.getRequestPredicate().getPath(); + return new Link(requestUrl + (path.startsWith("/") ? path : "/" + path)); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java new file mode 100644 index 00000000000..9b2ecec8b70 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/Link.java @@ -0,0 +1,66 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import org.springframework.core.style.ToStringCreator; + +/** + * Details for a link in a + * HAL-formatted + * response. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class Link { + + private final String href; + + private final boolean templated; + + /** + * Creates a new {@link Link} with the given {@code href}. + * @param href the href + */ + public Link(String href) { + this.href = href; + this.templated = href.contains("{"); + + } + + /** + * Returns the href of the link. + * @return the href + */ + public String getHref() { + return this.href; + } + + /** + * Returns whether or not the {@link #getHref() href} is templated. + * @return {@code true} if the href is templated, otherwise {@code false} + */ + public boolean isTemplated() { + return this.templated; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("href", this.href).toString(); + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java new file mode 100644 index 00000000000..269877976bf --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/OperationRequestPredicate.java @@ -0,0 +1,136 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.core.style.ToStringCreator; + +/** + * A predicate for a request to an operation on a web endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class OperationRequestPredicate { + + private final String path; + + private final String canonicalPath; + + private final WebEndpointHttpMethod httpMethod; + + private final Collection consumes; + + private final Collection produces; + + /** + * Creates a new {@code WebEndpointRequestPredict}. + * + * @param path the path for the operation + * @param httpMethod the HTTP method that the operation supports + * @param produces the media types that the operation produces + * @param consumes the media types that the operation consumes + */ + public OperationRequestPredicate(String path, WebEndpointHttpMethod httpMethod, + Collection consumes, Collection produces) { + this.path = path; + this.canonicalPath = path.replaceAll("\\{.*?}", "{*}"); + this.httpMethod = httpMethod; + this.consumes = consumes; + this.produces = produces; + } + + /** + * Returns the path for the operation. + * @return the path + */ + public String getPath() { + return this.path; + } + + /** + * Returns the HTTP method for the operation. + * @return the HTTP method + */ + public WebEndpointHttpMethod getHttpMethod() { + return this.httpMethod; + } + + /** + * Returns the media types that the operation consumes. + * @return the consumed media types + */ + public Collection getConsumes() { + return Collections.unmodifiableCollection(this.consumes); + } + + /** + * Returns the media types that the operation produces. + * @return the produced media types + */ + public Collection getProduces() { + return Collections.unmodifiableCollection(this.produces); + } + + @Override + public String toString() { + return new ToStringCreator(this).append("httpMethod", this.httpMethod) + .append("path", this.path).append("consumes", this.consumes) + .append("produces", this.produces).toString(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + this.consumes.hashCode(); + result = prime * result + this.httpMethod.hashCode(); + result = prime * result + this.canonicalPath.hashCode(); + result = prime * result + this.produces.hashCode(); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + OperationRequestPredicate other = (OperationRequestPredicate) obj; + if (!this.consumes.equals(other.consumes)) { + return false; + } + if (this.httpMethod != other.httpMethod) { + return false; + } + if (!this.canonicalPath.equals(other.canonicalPath)) { + return false; + } + if (!this.produces.equals(other.produces)) { + return false; + } + return true; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java new file mode 100644 index 00000000000..35390beb12d --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscoverer.java @@ -0,0 +1,219 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.reactivestreams.Publisher; + +import org.springframework.boot.endpoint.AnnotationEndpointDiscoverer; +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.CachingOperationInvoker; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.EndpointType; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.OperationParameterMapper; +import org.springframework.boot.endpoint.ReflectiveOperationInvoker; +import org.springframework.boot.endpoint.Selector; +import org.springframework.context.ApplicationContext; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.io.Resource; +import org.springframework.util.ClassUtils; + +/** + * Discovers the {@link Endpoint endpoints} in an {@link ApplicationContext} with + * {@link WebEndpointExtension web extensions} applied to them. + * + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 2.0.0 + */ +public class WebAnnotationEndpointDiscoverer extends + AnnotationEndpointDiscoverer { + + /** + * Creates a new {@link WebAnnotationEndpointDiscoverer} that will discover + * {@link Endpoint endpoints} and {@link WebEndpointExtension web extensions} using + * the given {@link ApplicationContext}. + * @param applicationContext the application context + * @param operationParameterMapper the {@link OperationParameterMapper} used to + * convert arguments when an operation is invoked + * @param cachingConfigurationFactory the {@link CachingConfiguration} factory to use + * @param consumedMediaTypes the media types consumed by web endpoint operations + * @param producedMediaTypes the media types produced by web endpoint operations + */ + public WebAnnotationEndpointDiscoverer(ApplicationContext applicationContext, + OperationParameterMapper operationParameterMapper, + Function cachingConfigurationFactory, + Collection consumedMediaTypes, + Collection producedMediaTypes) { + super(applicationContext, + new WebEndpointOperationFactory(operationParameterMapper, + consumedMediaTypes, producedMediaTypes), + WebEndpointOperation::getRequestPredicate, cachingConfigurationFactory); + } + + @Override + public Collection> discoverEndpoints() { + Collection> endpoints = discoverEndpointsWithExtension( + WebEndpointExtension.class, EndpointType.WEB); + verifyThatOperationsHaveDistinctPredicates(endpoints); + return endpoints.stream().map(EndpointInfoDescriptor::getEndpointInfo) + .collect(Collectors.toList()); + } + + private void verifyThatOperationsHaveDistinctPredicates( + Collection> endpointDescriptors) { + List> clashes = new ArrayList<>(); + endpointDescriptors.forEach((descriptor) -> clashes + .addAll(descriptor.findDuplicateOperations().values())); + if (!clashes.isEmpty()) { + StringBuilder message = new StringBuilder(); + message.append(String.format( + "Found multiple web operations with matching request predicates:%n")); + clashes.forEach((clash) -> { + message.append(" ").append(clash.get(0).getRequestPredicate()) + .append(String.format(":%n")); + clash.forEach((operation) -> message.append(" ") + .append(String.format("%s%n", operation))); + }); + throw new IllegalStateException(message.toString()); + } + } + + private static final class WebEndpointOperationFactory + implements EndpointOperationFactory { + + private static final boolean REACTIVE_STREAMS_PRESENT = ClassUtils.isPresent( + "org.reactivestreams.Publisher", + WebEndpointOperationFactory.class.getClassLoader()); + + private final OperationParameterMapper parameterMapper; + + private final Collection consumedMediaTypes; + + private final Collection producedMediaTypes; + + private WebEndpointOperationFactory(OperationParameterMapper parameterMapper, + Collection consumedMediaTypes, + Collection producedMediaTypes) { + this.parameterMapper = parameterMapper; + this.consumedMediaTypes = consumedMediaTypes; + this.producedMediaTypes = producedMediaTypes; + } + + @Override + public WebEndpointOperation createOperation(String endpointId, + AnnotationAttributes operationAttributes, Object target, Method method, + EndpointOperationType type, long timeToLive) { + WebEndpointHttpMethod httpMethod = determineHttpMethod(type); + OperationRequestPredicate requestPredicate = new OperationRequestPredicate( + determinePath(endpointId, method), httpMethod, + determineConsumedMediaTypes(httpMethod, method), + determineProducedMediaTypes(method)); + OperationInvoker invoker = new ReflectiveOperationInvoker( + this.parameterMapper, target, method); + if (timeToLive > 0) { + invoker = new CachingOperationInvoker(invoker, timeToLive); + } + return new WebEndpointOperation(type, invoker, determineBlocking(method), + requestPredicate, determineId(endpointId, method)); + } + + private String determinePath(String endpointId, Method operationMethod) { + StringBuilder path = new StringBuilder(endpointId); + Stream.of(operationMethod.getParameters()) + .filter(( + parameter) -> parameter.getAnnotation(Selector.class) != null) + .map((parameter) -> "/{" + parameter.getName() + "}") + .forEach(path::append); + return path.toString(); + } + + private String determineId(String endpointId, Method operationMethod) { + StringBuilder path = new StringBuilder(endpointId); + Stream.of(operationMethod.getParameters()) + .filter(( + parameter) -> parameter.getAnnotation(Selector.class) != null) + .map((parameter) -> "-" + parameter.getName()).forEach(path::append); + return path.toString(); + } + + private Collection determineConsumedMediaTypes( + WebEndpointHttpMethod httpMethod, Method method) { + if (WebEndpointHttpMethod.POST == httpMethod && consumesRequestBody(method)) { + return this.consumedMediaTypes; + } + return Collections.emptyList(); + } + + private Collection determineProducedMediaTypes(Method method) { + if (Void.class.equals(method.getReturnType()) + || void.class.equals(method.getReturnType())) { + return Collections.emptyList(); + } + if (producesResourceResponseBody(method)) { + return Collections.singletonList("application/octet-stream"); + } + return this.producedMediaTypes; + } + + private boolean producesResourceResponseBody(Method method) { + if (Resource.class.equals(method.getReturnType())) { + return true; + } + if (WebEndpointResponse.class.isAssignableFrom(method.getReturnType())) { + ResolvableType returnType = ResolvableType.forMethodReturnType(method); + if (ResolvableType.forClass(Resource.class) + .isAssignableFrom(returnType.getGeneric(0))) { + return true; + } + } + return false; + } + + private boolean consumesRequestBody(Method method) { + return Stream.of(method.getParameters()).anyMatch( + (parameter) -> parameter.getAnnotation(Selector.class) == null); + } + + private WebEndpointHttpMethod determineHttpMethod( + EndpointOperationType operationType) { + if (operationType == EndpointOperationType.WRITE) { + return WebEndpointHttpMethod.POST; + } + return WebEndpointHttpMethod.GET; + } + + private boolean determineBlocking(Method method) { + return !REACTIVE_STREAMS_PRESENT + || !Publisher.class.isAssignableFrom(method.getReturnType()); + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java new file mode 100644 index 00000000000..f89416fb23a --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointExtension.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.endpoint.Endpoint; + +/** + * Identifies a type as being a Web-specific extension of an {@link Endpoint}. + * + * @author Andy Wilkinson + * @author Stephane Nicoll + * @since 2.0.0 + * @see Endpoint + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface WebEndpointExtension { + + /** + * The {@link Endpoint endpoint} class to which this Web extension relates. + * @return the endpoint class + */ + Class endpoint(); + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java new file mode 100644 index 00000000000..d43d41b872a --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointHttpMethod.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +/** + * An enumeration of HTTP methods supported by web endpoint operations. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public enum WebEndpointHttpMethod { + + /** + * An HTTP GET request. + */ + GET, + + /** + * An HTTP POST request. + */ + POST + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java new file mode 100644 index 00000000000..df87213729c --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointOperation.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import org.springframework.boot.endpoint.EndpointOperation; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.OperationInvoker; + +/** + * An operation on a web endpoint. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class WebEndpointOperation extends EndpointOperation { + + private final OperationRequestPredicate requestPredicate; + + private final String id; + + /** + * Creates a new {@code WebEndpointOperation} with the given {@code type}. The + * operation can be performed using the given {@code operationInvoker}. The operation + * can handle requests that match the given {@code requestPredicate}. + * @param type the type of the operation + * @param operationInvoker used to perform the operation + * @param blocking whether or not this is a blocking operation + * @param requestPredicate the predicate for requests that can be handled by the + * @param id the id of the operation, unique within its endpoint operation + */ + public WebEndpointOperation(EndpointOperationType type, + OperationInvoker operationInvoker, boolean blocking, + OperationRequestPredicate requestPredicate, String id) { + super(type, operationInvoker, blocking); + this.requestPredicate = requestPredicate; + this.id = id; + } + + /** + * Returns the predicate for requests that can be handled by this operation. + * @return the predicate + */ + public OperationRequestPredicate getRequestPredicate() { + return this.requestPredicate; + } + + /** + * Returns the ID of the operation that uniquely identifies it within its endpoint. + * @return the ID + */ + public String getId() { + return this.id; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java new file mode 100644 index 00000000000..ae50b1c14ea --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/WebEndpointResponse.java @@ -0,0 +1,86 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +/** + * A {@code WebEndpointResponse} can be returned by an operation on a + * {@link WebEndpointExtension} to provide additional, web-specific information such as + * the HTTP status code. + * + * @param the type of the response body + * @author Stephane Nicoll + * @author Andy Wilkinson + * @since 2.0.0 + */ +public final class WebEndpointResponse { + + private final T body; + + private final int status; + + /** + * Creates a new {@code WebEndpointResponse} with no body and a 200 (OK) status. + */ + public WebEndpointResponse() { + this(null); + } + + /** + * Creates a new {@code WebEndpointResponse} with no body and the given + * {@code status}. + * @param status the HTTP status + */ + public WebEndpointResponse(int status) { + this(null, status); + } + + /** + * Creates a new {@code WebEndpointResponse} with then given body and a 200 (OK) + * status. + * @param body the body + */ + public WebEndpointResponse(T body) { + this(body, 200); + } + + /** + * Creates a new {@code WebEndpointResponse} with then given body and status. + * @param body the body + * @param status the HTTP status + */ + public WebEndpointResponse(T body, int status) { + this.body = body; + this.status = status; + } + + /** + * Returns the body for the response. + * @return the body + */ + public T getBody() { + return this.body; + } + + /** + * Returns the status for the response. + * @return the status + */ + public int getStatus() { + return this.status; + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java new file mode 100644 index 00000000000..eb49a5a58ca --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/JerseyEndpointResourceFactory.java @@ -0,0 +1,208 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web.jersey; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.ws.rs.HttpMethod; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; + +import org.glassfish.jersey.process.Inflector; +import org.glassfish.jersey.server.ContainerRequest; +import org.glassfish.jersey.server.model.Resource; +import org.glassfish.jersey.server.model.Resource.Builder; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ParameterMappingException; +import org.springframework.boot.endpoint.web.EndpointLinksResolver; +import org.springframework.boot.endpoint.web.Link; +import org.springframework.boot.endpoint.web.OperationRequestPredicate; +import org.springframework.boot.endpoint.web.WebEndpointOperation; +import org.springframework.boot.endpoint.web.WebEndpointResponse; +import org.springframework.util.CollectionUtils; + +/** + * A factory for creating Jersey {@link Resource Resources} for web endpoint operations. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class JerseyEndpointResourceFactory { + + private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); + + /** + * Creates {@link Resource Resources} for the operations of the given + * {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param webEndpoints the web endpoints + * @return the resources for the operations + */ + public Collection createEndpointResources(String endpointPath, + Collection> webEndpoints) { + List resources = new ArrayList<>(); + webEndpoints.stream() + .flatMap((endpointInfo) -> endpointInfo.getOperations().stream()) + .map((operation) -> createResource(endpointPath, operation)) + .forEach(resources::add); + resources.add(createEndpointLinksResource(endpointPath, webEndpoints)); + return resources; + } + + private Resource createResource(String endpointPath, WebEndpointOperation operation) { + OperationRequestPredicate requestPredicate = operation.getRequestPredicate(); + Builder resourceBuilder = Resource.builder() + .path(endpointPath + "/" + requestPredicate.getPath()); + resourceBuilder.addMethod(requestPredicate.getHttpMethod().name()) + .consumes(toStringArray(requestPredicate.getConsumes())) + .produces(toStringArray(requestPredicate.getProduces())) + .handledBy(new EndpointInvokingInflector(operation.getOperationInvoker(), + !requestPredicate.getConsumes().isEmpty())); + return resourceBuilder.build(); + } + + private String[] toStringArray(Collection collection) { + return collection.toArray(new String[collection.size()]); + } + + private Resource createEndpointLinksResource(String endpointPath, + Collection> webEndpoints) { + Builder resourceBuilder = Resource.builder().path(endpointPath); + resourceBuilder.addMethod("GET").handledBy( + new EndpointLinksInflector(webEndpoints, this.endpointLinksResolver)); + return resourceBuilder.build(); + } + + private static final class EndpointInvokingInflector + implements Inflector { + + private final OperationInvoker operationInvoker; + + private final boolean readBody; + + private EndpointInvokingInflector(OperationInvoker operationInvoker, + boolean readBody) { + this.operationInvoker = operationInvoker; + this.readBody = readBody; + } + + @SuppressWarnings("unchecked") + @Override + public Response apply(ContainerRequestContext data) { + Map arguments = new HashMap<>(); + if (this.readBody) { + Map body = ((ContainerRequest) data) + .readEntity(Map.class); + if (body != null) { + arguments.putAll(body); + } + } + arguments.putAll(extractPathParmeters(data)); + arguments.putAll(extractQueryParmeters(data)); + try { + return convertToJaxRsResponse(this.operationInvoker.invoke(arguments), + data.getRequest().getMethod()); + } + catch (ParameterMappingException ex) { + return Response.status(Status.BAD_REQUEST).build(); + } + } + + private Map extractPathParmeters( + ContainerRequestContext requestContext) { + return extract(requestContext.getUriInfo().getPathParameters()); + } + + private Map extractQueryParmeters( + ContainerRequestContext requestContext) { + return extract(requestContext.getUriInfo().getQueryParameters()); + } + + private Map extract( + MultivaluedMap multivaluedMap) { + Map result = new HashMap<>(); + multivaluedMap.forEach((name, values) -> { + if (!CollectionUtils.isEmpty(values)) { + result.put(name, values.size() == 1 ? values.get(0) : values); + } + }); + return result; + } + + private Response convertToJaxRsResponse(Object response, String httpMethod) { + if (response == null) { + return Response.status(HttpMethod.GET.equals(httpMethod) + ? Status.NOT_FOUND : Status.NO_CONTENT).build(); + } + try { + if (!(response instanceof WebEndpointResponse)) { + return Response.status(Status.OK).entity(convertIfNecessary(response)) + .build(); + } + WebEndpointResponse webEndpointResponse = (WebEndpointResponse) response; + return Response.status(webEndpointResponse.getStatus()) + .entity(convertIfNecessary(webEndpointResponse.getBody())) + .build(); + } + catch (IOException ex) { + return Response.status(Status.INTERNAL_SERVER_ERROR).build(); + } + } + + private Object convertIfNecessary(Object body) throws IOException { + if (body instanceof org.springframework.core.io.Resource) { + return ((org.springframework.core.io.Resource) body).getInputStream(); + } + return body; + } + + } + + private static final class EndpointLinksInflector + implements Inflector { + + private final Collection> endpoints; + + private final EndpointLinksResolver linksResolver; + + private EndpointLinksInflector( + Collection> endpoints, + EndpointLinksResolver linksResolver) { + this.endpoints = endpoints; + this.linksResolver = linksResolver; + } + + @Override + public Response apply(ContainerRequestContext request) { + Map links = this.linksResolver.resolveLinks(this.endpoints, + request.getUriInfo().getAbsolutePath().toString()); + return Response.ok(Collections.singletonMap("_links", links)).build(); + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java new file mode 100644 index 00000000000..65469b9973c --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/jersey/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2017 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 + * + * http://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. + */ + +/** + * Jersey web endpoint support. + */ +package org.springframework.boot.endpoint.web.jersey; diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java new file mode 100644 index 00000000000..77065dd8c1c --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java @@ -0,0 +1,243 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web.mvc; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ParameterMappingException; +import org.springframework.boot.endpoint.web.EndpointLinksResolver; +import org.springframework.boot.endpoint.web.Link; +import org.springframework.boot.endpoint.web.OperationRequestPredicate; +import org.springframework.boot.endpoint.web.WebEndpointOperation; +import org.springframework.boot.endpoint.web.WebEndpointResponse; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.accept.PathExtensionContentNegotiationStrategy; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; +import org.springframework.web.servlet.mvc.condition.ConsumesRequestCondition; +import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; +import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition; +import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping; + +/** + * A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available + * over HTTP using Spring MVC. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class WebEndpointServletHandlerMapping extends RequestMappingInfoHandlerMapping + implements InitializingBean { + + private final Method handle = ReflectionUtils.findMethod(OperationHandler.class, + "handle", HttpServletRequest.class, Map.class); + + private final Method links = ReflectionUtils.findMethod( + WebEndpointServletHandlerMapping.class, "links", HttpServletRequest.class); + + private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); + + private final String endpointPath; + + private final Collection> webEndpoints; + + private final CorsConfiguration corsConfiguration; + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param collection the web endpoints operations + */ + public WebEndpointServletHandlerMapping(String endpointPath, + Collection> collection) { + this(endpointPath, collection, null); + } + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param webEndpoints the web endpoints + * @param corsConfiguration the CORS configuraton for the endpoints + */ + public WebEndpointServletHandlerMapping(String endpointPath, + Collection> webEndpoints, + CorsConfiguration corsConfiguration) { + this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath; + this.webEndpoints = webEndpoints; + this.corsConfiguration = corsConfiguration; + setOrder(-100); + } + + @Override + protected void initHandlerMethods() { + this.webEndpoints.stream() + .flatMap((webEndpoint) -> webEndpoint.getOperations().stream()) + .forEach(this::registerMappingForOperation); + registerMapping(new RequestMappingInfo(patternsRequestConditionForPattern(""), + new RequestMethodsRequestCondition(RequestMethod.GET), null, null, null, + null, null), this, this.links); + } + + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mapping) { + return this.corsConfiguration; + } + + private void registerMappingForOperation(WebEndpointOperation operation) { + registerMapping(createRequestMappingInfo(operation), + new OperationHandler(operation.getOperationInvoker()), this.handle); + } + + private RequestMappingInfo createRequestMappingInfo( + WebEndpointOperation operationInfo) { + OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate(); + return new RequestMappingInfo(null, + patternsRequestConditionForPattern(requestPredicate.getPath()), + new RequestMethodsRequestCondition( + RequestMethod.valueOf(requestPredicate.getHttpMethod().name())), + null, null, + new ConsumesRequestCondition( + toStringArray(requestPredicate.getConsumes())), + new ProducesRequestCondition( + toStringArray(requestPredicate.getProduces())), + null); + } + + private PatternsRequestCondition patternsRequestConditionForPattern(String path) { + return new PatternsRequestCondition( + new String[] { this.endpointPath + + (StringUtils.hasText(path) ? "/" + path : "") }, + null, null, false, false); + } + + private String[] toStringArray(Collection collection) { + return collection.toArray(new String[collection.size()]); + } + + @Override + protected boolean isHandler(Class beanType) { + return false; + } + + @Override + protected RequestMappingInfo getMappingForMethod(Method method, + Class handlerType) { + return null; + } + + @Override + protected void extendInterceptors(List interceptors) { + interceptors.add(new SkipPathExtensionContentNegotiation()); + } + + @ResponseBody + private Map> links(HttpServletRequest request) { + return Collections.singletonMap("_links", this.endpointLinksResolver + .resolveLinks(this.webEndpoints, request.getRequestURL().toString())); + } + + /** + * A handler for an endpoint operation. + */ + final class OperationHandler { + + private final OperationInvoker operationInvoker; + + OperationHandler(OperationInvoker operationInvoker) { + this.operationInvoker = operationInvoker; + } + + @SuppressWarnings("unchecked") + @ResponseBody + public Object handle(HttpServletRequest request, + @RequestBody(required = false) Map body) { + Map arguments = new HashMap<>((Map) request + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)); + HttpMethod httpMethod = HttpMethod.valueOf(request.getMethod()); + if (body != null && HttpMethod.POST == httpMethod) { + arguments.putAll(body); + } + request.getParameterMap().forEach((name, values) -> arguments.put(name, + values.length == 1 ? values[0] : Arrays.asList(values))); + try { + return handleResult(this.operationInvoker.invoke(arguments), httpMethod); + } + catch (ParameterMappingException ex) { + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + } + + private Object handleResult(Object result, HttpMethod httpMethod) { + if (result == null) { + return new ResponseEntity<>(httpMethod == HttpMethod.GET + ? HttpStatus.NOT_FOUND : HttpStatus.NO_CONTENT); + } + if (!(result instanceof WebEndpointResponse)) { + return result; + } + WebEndpointResponse response = (WebEndpointResponse) result; + return new ResponseEntity(response.getBody(), + HttpStatus.valueOf(response.getStatus())); + } + + } + + /** + * {@link HandlerInterceptorAdapter} to ensure that + * {@link PathExtensionContentNegotiationStrategy} is skipped for web endpoints. + */ + private static final class SkipPathExtensionContentNegotiation + extends HandlerInterceptorAdapter { + + private static final String SKIP_ATTRIBUTE = PathExtensionContentNegotiationStrategy.class + .getName() + ".SKIP"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + request.setAttribute(SKIP_ATTRIBUTE, Boolean.TRUE); + return true; + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java new file mode 100644 index 00000000000..f2874cd1ff6 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2017 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 + * + * http://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. + */ + +/** + * Spring MVC web endpoint support. + */ +package org.springframework.boot.endpoint.web.mvc; diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java new file mode 100644 index 00000000000..8c0c6c9d856 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2017 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 + * + * http://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. + */ + +/** + * Web endpoint support. + */ +package org.springframework.boot.endpoint.web; diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java new file mode 100644 index 00000000000..9db49432ad9 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/WebEndpointReactiveHandlerMapping.java @@ -0,0 +1,254 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web.reactive; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ParameterMappingException; +import org.springframework.boot.endpoint.web.EndpointLinksResolver; +import org.springframework.boot.endpoint.web.Link; +import org.springframework.boot.endpoint.web.OperationRequestPredicate; +import org.springframework.boot.endpoint.web.WebEndpointOperation; +import org.springframework.boot.endpoint.web.WebEndpointResponse; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.condition.ConsumesRequestCondition; +import org.springframework.web.reactive.result.condition.PatternsRequestCondition; +import org.springframework.web.reactive.result.condition.ProducesRequestCondition; +import org.springframework.web.reactive.result.condition.RequestMethodsRequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available + * over HTTP using Spring WebFlux. + * + * @author Andy Wilkinson + * @since 2.0.0 + */ +public class WebEndpointReactiveHandlerMapping extends RequestMappingInfoHandlerMapping + implements InitializingBean { + + private static final PathPatternParser pathPatternParser = new PathPatternParser(); + + private final Method handleRead = ReflectionUtils + .findMethod(ReadOperationHandler.class, "handle", ServerWebExchange.class); + + private final Method handleWrite = ReflectionUtils.findMethod( + WriteOperationHandler.class, "handle", ServerWebExchange.class, Map.class); + + private final Method links = ReflectionUtils.findMethod(getClass(), "links", + ServerHttpRequest.class); + + private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); + + private final String endpointPath; + + private final Collection> webEndpoints; + + private final CorsConfiguration corsConfiguration; + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param collection the web endpoints + */ + public WebEndpointReactiveHandlerMapping(String endpointPath, + Collection> collection) { + this(endpointPath, collection, null); + } + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointPath the path beneath which all endpoints should be mapped + * @param webEndpoints the web endpoints + * @param corsConfiguration the CORS configuraton for the endpoints + */ + public WebEndpointReactiveHandlerMapping(String endpointPath, + Collection> webEndpoints, + CorsConfiguration corsConfiguration) { + this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath; + this.webEndpoints = webEndpoints; + this.corsConfiguration = corsConfiguration; + setOrder(-100); + } + + @Override + protected void initHandlerMethods() { + this.webEndpoints.stream() + .flatMap((webEndpoint) -> webEndpoint.getOperations().stream()) + .forEach(this::registerMappingForOperation); + registerMapping(new RequestMappingInfo( + new PatternsRequestCondition(pathPatternParser.parse(this.endpointPath)), + new RequestMethodsRequestCondition(RequestMethod.GET), null, null, null, + null, null), this, this.links); + } + + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mapping) { + return this.corsConfiguration; + } + + private void registerMappingForOperation(WebEndpointOperation operation) { + EndpointOperationType operationType = operation.getType(); + registerMapping(createRequestMappingInfo(operation), + operationType == EndpointOperationType.WRITE + ? new WriteOperationHandler(operation.getOperationInvoker()) + : new ReadOperationHandler(operation.getOperationInvoker()), + operationType == EndpointOperationType.WRITE ? this.handleWrite + : this.handleRead); + } + + private RequestMappingInfo createRequestMappingInfo( + WebEndpointOperation operationInfo) { + OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate(); + return new RequestMappingInfo(null, + new PatternsRequestCondition(pathPatternParser + .parse(this.endpointPath + "/" + requestPredicate.getPath())), + new RequestMethodsRequestCondition( + RequestMethod.valueOf(requestPredicate.getHttpMethod().name())), + null, null, + new ConsumesRequestCondition( + toStringArray(requestPredicate.getConsumes())), + new ProducesRequestCondition( + toStringArray(requestPredicate.getProduces())), + null); + } + + private String[] toStringArray(Collection collection) { + return collection.toArray(new String[collection.size()]); + } + + @Override + protected boolean isHandler(Class beanType) { + return false; + } + + @Override + protected RequestMappingInfo getMappingForMethod(Method method, + Class handlerType) { + return null; + } + + @ResponseBody + private Map> links(ServerHttpRequest request) { + return Collections.singletonMap("_links", + this.endpointLinksResolver.resolveLinks(this.webEndpoints, + UriComponentsBuilder.fromUri(request.getURI()).replaceQuery(null) + .toUriString())); + } + + /** + * Base class for handlers for endpoint operations. + */ + abstract class AbstractOperationHandler { + + private final OperationInvoker operationInvoker; + + AbstractOperationHandler(OperationInvoker operationInvoker) { + this.operationInvoker = operationInvoker; + } + + @SuppressWarnings("unchecked") + ResponseEntity doHandle(ServerWebExchange exchange, Map body) { + Map arguments = new HashMap<>((Map) exchange + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)); + if (body != null) { + arguments.putAll(body); + } + exchange.getRequest().getQueryParams().forEach((name, values) -> arguments + .put(name, values.size() == 1 ? values.get(0) : values)); + try { + return handleResult(this.operationInvoker.invoke(arguments), + exchange.getRequest().getMethod()); + } + catch (ParameterMappingException ex) { + return new ResponseEntity(HttpStatus.BAD_REQUEST); + } + } + + private ResponseEntity handleResult(Object result, HttpMethod httpMethod) { + if (result == null) { + return new ResponseEntity<>(httpMethod == HttpMethod.GET + ? HttpStatus.NOT_FOUND : HttpStatus.NO_CONTENT); + } + if (!(result instanceof WebEndpointResponse)) { + return new ResponseEntity<>(result, HttpStatus.OK); + } + WebEndpointResponse response = (WebEndpointResponse) result; + return new ResponseEntity(response.getBody(), + HttpStatus.valueOf(response.getStatus())); + } + + } + + /** + * A handler for an endpoint write operation. + */ + final class WriteOperationHandler extends AbstractOperationHandler { + + WriteOperationHandler(OperationInvoker operationInvoker) { + super(operationInvoker); + } + + @ResponseBody + public ResponseEntity handle(ServerWebExchange exchange, + @RequestBody(required = false) Map body) { + return doHandle(exchange, body); + } + + } + + /** + * A handler for an endpoint write operation. + */ + final class ReadOperationHandler extends AbstractOperationHandler { + + ReadOperationHandler(OperationInvoker operationInvoker) { + super(operationInvoker); + } + + @ResponseBody + public ResponseEntity handle(ServerWebExchange exchange) { + return doHandle(exchange, null); + } + + } + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java new file mode 100644 index 00000000000..98c77dc6f97 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/reactive/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2017 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 + * + * http://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. + */ + +/** + * Reactive web endpoint support. + */ +package org.springframework.boot.endpoint.web.reactive; diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java new file mode 100644 index 00000000000..0c4fe80aee1 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/AbstractWebEndpointIntegrationTests.java @@ -0,0 +1,481 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.junit.Test; + +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.OperationParameterMapper; +import org.springframework.boot.endpoint.ReadOperation; +import org.springframework.boot.endpoint.Selector; +import org.springframework.boot.endpoint.WriteOperation; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Abstract base class for web endpoint integration tests. + * + * @param the type of application context used by the tests + * @author Andy Wilkinson + */ +public abstract class AbstractWebEndpointIntegrationTests { + + private final Class exporterConfiguration; + + protected AbstractWebEndpointIntegrationTests(Class exporterConfiguration) { + this.exporterConfiguration = exporterConfiguration; + } + + @Test + public void readOperation() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/test").accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk().expectBody().jsonPath("All") + .isEqualTo(true)); + } + + @Test + public void readOperationWithSelector() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/test/one") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("part").isEqualTo("one")); + } + + @Test + public void readOperationWithSelectorContainingADot() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/test/foo.bar") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("part").isEqualTo("foo.bar")); + } + + @Test + public void linksToOtherEndpointsAreProvided() { + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("").accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk().expectBody() + .jsonPath("_links.length()").isEqualTo(3) + .jsonPath("_links.self.href").isNotEmpty() + .jsonPath("_links.self.templated").isEqualTo(false) + .jsonPath("_links.test.href").isNotEmpty() + .jsonPath("_links.test.templated").isEqualTo(false) + .jsonPath("_links.test-part.href").isNotEmpty() + .jsonPath("_links.test-part.templated").isEqualTo(true)); + } + + @Test + public void readOperationWithSingleQueryParameters() { + load(QueryEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1 2")); + } + + @Test + public void readOperationWithSingleQueryParametersAndMultipleValues() { + load(QueryEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&one=1&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1,1 2")); + } + + @Test + public void readOperationWithListQueryParameterAndSingleValue() { + load(QueryWithListEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1 [2]")); + } + + @Test + public void readOperationWithListQueryParameterAndMultipleValues() { + load(QueryWithListEndpointConfiguration.class, + (client) -> client.get().uri("/query?one=1&two=2&two=2") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isOk().expectBody().jsonPath("query").isEqualTo("1 [2, 2]")); + } + + @Test + public void readOperationWithMappingFailureProducesBadRequestResponse() { + load(QueryEndpointConfiguration.class, + (client) -> client.get().uri("/query?two=two") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isBadRequest()); + } + + @Test + public void writeOperation() { + load(TestEndpointConfiguration.class, (client) -> { + Map body = new HashMap<>(); + body.put("foo", "one"); + body.put("bar", "two"); + client.post().uri("/test").syncBody(body).accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isNoContent().expectBody().isEmpty(); + }); + } + + @Test + public void writeOperationWithVoidResponse() { + load(VoidWriteResponseEndpointConfiguration.class, (context, client) -> { + client.post().uri("/voidwrite").accept(MediaType.APPLICATION_JSON).exchange() + .expectStatus().isNoContent().expectBody().isEmpty(); + verify(context.getBean(EndpointDelegate.class)).write(); + }); + } + + @Test + public void nullIsPassedToTheOperationWhenArgumentIsNotFoundInPostRequestBody() { + load(TestEndpointConfiguration.class, (context, client) -> { + Map body = new HashMap<>(); + body.put("foo", "one"); + client.post().uri("/test").syncBody(body).accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isNoContent().expectBody().isEmpty(); + verify(context.getBean(EndpointDelegate.class)).write("one", null); + }); + } + + @Test + public void nullsArePassedToTheOperationWhenPostRequestHasNoBody() { + load(TestEndpointConfiguration.class, (context, client) -> { + client.post().uri("/test").contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isNoContent().expectBody().isEmpty(); + verify(context.getBean(EndpointDelegate.class)).write(null, null); + }); + } + + @Test + public void nullResponseFromReadOperationResultsInNotFoundResponseStatus() { + load(NullReadResponseEndpointConfiguration.class, + (context, client) -> client.get().uri("/nullread") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isNotFound()); + } + + @Test + public void nullResponseFromWriteOperationResultsInNoContentResponseStatus() { + load(NullWriteResponseEndpointConfiguration.class, + (context, client) -> client.post().uri("/nullwrite") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isNoContent()); + } + + @Test + public void readOperationWithResourceResponse() { + load(ResourceEndpointConfiguration.class, (context, client) -> { + byte[] responseBody = client.get().uri("/resource").exchange().expectStatus() + .isOk().expectHeader().contentType(MediaType.APPLICATION_OCTET_STREAM) + .returnResult(byte[].class).getResponseBodyContent(); + assertThat(responseBody).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9); + }); + } + + @Test + public void readOperationWithResourceWebOperationResponse() { + load(ResourceWebEndpointResponseEndpointConfiguration.class, + (context, client) -> { + byte[] responseBody = client.get().uri("/resource").exchange() + .expectStatus().isOk().expectHeader() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .returnResult(byte[].class).getResponseBodyContent(); + assertThat(responseBody).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, + 9); + }); + } + + protected abstract T createApplicationContext(Class... config); + + protected abstract int getPort(T context); + + private void load(Class configuration, + BiConsumer consumer) { + T context = createApplicationContext(configuration, this.exporterConfiguration); + try { + consumer.accept(context, + WebTestClient.bindToServer() + .baseUrl( + "http://localhost:" + getPort(context) + "/endpoints") + .build()); + } + finally { + context.close(); + } + } + + protected void load(Class configuration, Consumer clientConsumer) { + load(configuration, (context, client) -> clientConsumer.accept(client)); + } + + @Configuration + static class BaseConfiguration { + + @Bean + public EndpointDelegate endpointDelegate() { + return mock(EndpointDelegate.class); + } + + @Bean + public WebAnnotationEndpointDiscoverer webEndpointDiscoverer( + ApplicationContext applicationContext) { + OperationParameterMapper parameterMapper = new ConversionServiceOperationParameterMapper( + DefaultConversionService.getSharedInstance()); + return new WebAnnotationEndpointDiscoverer(applicationContext, + parameterMapper, (id) -> new CachingConfiguration(0), + Collections.singletonList("application/json"), + Collections.singletonList("application/json")); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + protected static class TestEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint(EndpointDelegate endpointDelegate) { + return new TestEndpoint(endpointDelegate); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class QueryEndpointConfiguration { + + @Bean + public QueryEndpoint queryEndpoint() { + return new QueryEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class QueryWithListEndpointConfiguration { + + @Bean + public QueryWithListEndpoint queryEndpoint() { + return new QueryWithListEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class VoidWriteResponseEndpointConfiguration { + + @Bean + public VoidWriteResponseEndpoint voidWriteResponseEndpoint( + EndpointDelegate delegate) { + return new VoidWriteResponseEndpoint(delegate); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class NullWriteResponseEndpointConfiguration { + + @Bean + public NullWriteResponseEndpoint nullWriteResponseEndpoint( + EndpointDelegate delegate) { + return new NullWriteResponseEndpoint(delegate); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class NullReadResponseEndpointConfiguration { + + @Bean + public NullReadResponseEndpoint nullResponseEndpoint() { + return new NullReadResponseEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class ResourceEndpointConfiguration { + + @Bean + public ResourceEndpoint resourceEndpoint() { + return new ResourceEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class ResourceWebEndpointResponseEndpointConfiguration { + + @Bean + public ResourceWebEndpointResponseEndpoint resourceEndpoint() { + return new ResourceWebEndpointResponseEndpoint(); + } + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + private final EndpointDelegate endpointDelegate; + + TestEndpoint(EndpointDelegate endpointDelegate) { + this.endpointDelegate = endpointDelegate; + } + + @ReadOperation + public Map readAll() { + return Collections.singletonMap("All", true); + } + + @ReadOperation + public Map readPart(@Selector String part) { + return Collections.singletonMap("part", part); + } + + @WriteOperation + public void write(String foo, String bar) { + this.endpointDelegate.write(foo, bar); + } + + } + + @Endpoint(id = "query") + static class QueryEndpoint { + + @ReadOperation + public Map query(String one, Integer two) { + return Collections.singletonMap("query", one + " " + two); + } + + @ReadOperation + public Map queryWithParameterList(@Selector String list, + String one, List two) { + return Collections.singletonMap("query", list + " " + one + " " + two); + } + + } + + @Endpoint(id = "query") + static class QueryWithListEndpoint { + + @ReadOperation + public Map queryWithParameterList(String one, List two) { + return Collections.singletonMap("query", one + " " + two); + } + + } + + @Endpoint(id = "voidwrite") + static class VoidWriteResponseEndpoint { + + private final EndpointDelegate delegate; + + VoidWriteResponseEndpoint(EndpointDelegate delegate) { + this.delegate = delegate; + } + + @WriteOperation + public void write() { + this.delegate.write(); + } + + } + + @Endpoint(id = "nullwrite") + static class NullWriteResponseEndpoint { + + private final EndpointDelegate delegate; + + NullWriteResponseEndpoint(EndpointDelegate delegate) { + this.delegate = delegate; + } + + @WriteOperation + public Object write() { + this.delegate.write(); + return null; + } + + } + + @Endpoint(id = "nullread") + static class NullReadResponseEndpoint { + + @ReadOperation + public String readReturningNull() { + return null; + } + + } + + @Endpoint(id = "resource") + static class ResourceEndpoint { + + @ReadOperation + public Resource read() { + return new ByteArrayResource(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + } + + } + + @Endpoint(id = "resource") + static class ResourceWebEndpointResponseEndpoint { + + @ReadOperation + public WebEndpointResponse read() { + return new WebEndpointResponse( + new ByteArrayResource(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }), + 200); + } + + } + + public interface EndpointDelegate { + + void write(); + + void write(String foo, String bar); + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java new file mode 100644 index 00000000000..efd3f6bb10a --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/EndpointLinksResolverTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import org.assertj.core.api.Condition; +import org.junit.Test; + +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointOperationType; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link EndpointLinksResolver}. + * + * @author Andy Wilkinson + */ +public class EndpointLinksResolverTests { + + private final EndpointLinksResolver linksResolver = new EndpointLinksResolver(); + + @Test + public void linkResolutionWithTrailingSlashStripsSlashOnSelfLink() { + Map links = this.linksResolver.resolveLinks(Collections.emptyList(), + "https://api.example.com/application/"); + assertThat(links).hasSize(1); + assertThat(links).hasEntrySatisfying("self", + linkWithHref("https://api.example.com/application")); + } + + @Test + public void linkResolutionWithoutTrailingSlash() { + Map links = this.linksResolver.resolveLinks(Collections.emptyList(), + "https://api.example.com/application"); + assertThat(links).hasSize(1); + assertThat(links).hasEntrySatisfying("self", + linkWithHref("https://api.example.com/application")); + } + + @Test + public void resolvedLinksContainsALinkForEachEndpointOperation() { + Map links = this.linksResolver + .resolveLinks( + Arrays.asList(new EndpointInfo<>("alpha", true, + Arrays.asList(operationWithPath("/alpha", "alpha"), + operationWithPath("/alpha/{name}", + "alpha-name")))), + "https://api.example.com/application"); + assertThat(links).hasSize(3); + assertThat(links).hasEntrySatisfying("self", + linkWithHref("https://api.example.com/application")); + assertThat(links).hasEntrySatisfying("alpha", + linkWithHref("https://api.example.com/application/alpha")); + assertThat(links).hasEntrySatisfying("alpha-name", + linkWithHref("https://api.example.com/application/alpha/{name}")); + } + + private WebEndpointOperation operationWithPath(String path, String id) { + return new WebEndpointOperation(EndpointOperationType.READ, null, false, + new OperationRequestPredicate(path, WebEndpointHttpMethod.GET, + Collections.emptyList(), Collections.emptyList()), + id); + } + + private Condition linkWithHref(String href) { + return new Condition<>((link) -> href.equals(link.getHref()), + "Link with href '%s'", href); + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java new file mode 100644 index 00000000000..177c41a23fa --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/OperationRequestPredicateTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.util.Collections; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OperationRequestPredicate}. + * + * @author Andy Wilkinson + */ +public class OperationRequestPredicateTests { + + @Test + public void predicatesWithIdenticalPathsAreEqual() { + assertThat(predicateWithPath("/path")).isEqualTo(predicateWithPath("/path")); + } + + @Test + public void predicatesWithDifferentPathsAreNotEqual() { + assertThat(predicateWithPath("/one")).isNotEqualTo(predicateWithPath("/two")); + } + + @Test + public void predicatesWithIdenticalPathsWithVariablesAreEqual() { + assertThat(predicateWithPath("/path/{foo}")) + .isEqualTo(predicateWithPath("/path/{foo}")); + } + + @Test + public void predicatesWhereOneHasAPathAndTheOtherHasAVariableAreNotEqual() { + assertThat(predicateWithPath("/path/{foo}")) + .isNotEqualTo(predicateWithPath("/path/foo")); + } + + @Test + public void predicatesWithSinglePathVariablesInTheSamplePlaceAreEqual() { + assertThat(predicateWithPath("/path/{foo1}")) + .isEqualTo(predicateWithPath("/path/{foo2}")); + } + + @Test + public void predicatesWithMultiplePathVariablesInTheSamplePlaceAreEqual() { + assertThat(predicateWithPath("/path/{foo1}/more/{bar1}")) + .isEqualTo(predicateWithPath("/path/{foo2}/more/{bar2}")); + } + + private OperationRequestPredicate predicateWithPath(String path) { + return new OperationRequestPredicate(path, WebEndpointHttpMethod.GET, + Collections.emptyList(), Collections.emptyList()); + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java new file mode 100644 index 00000000000..2af16b2be74 --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/WebAnnotationEndpointDiscovererTests.java @@ -0,0 +1,628 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.assertj.core.api.Condition; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.boot.endpoint.CachingConfiguration; +import org.springframework.boot.endpoint.CachingOperationInvoker; +import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper; +import org.springframework.boot.endpoint.Endpoint; +import org.springframework.boot.endpoint.EndpointInfo; +import org.springframework.boot.endpoint.EndpointType; +import org.springframework.boot.endpoint.OperationInvoker; +import org.springframework.boot.endpoint.ReadOperation; +import org.springframework.boot.endpoint.Selector; +import org.springframework.boot.endpoint.WriteOperation; +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests.BaseConfiguration; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link WebAnnotationEndpointDiscoverer}. + * + * @author Andy Wilkinson + * @author Stephane Nicoll + */ +public class WebAnnotationEndpointDiscovererTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void discoveryWorksWhenThereAreNoEndpoints() { + load(EmptyConfiguration.class, + (discoverer) -> assertThat(discoverer.discoverEndpoints()).isEmpty()); + } + + @Test + public void webExtensionMustHaveEndpoint() { + load(TestWebEndpointExtensionConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Invalid extension"); + this.thrown.expectMessage(TestWebEndpointExtension.class.getName()); + this.thrown.expectMessage("no endpoint found"); + this.thrown.expectMessage(TestEndpoint.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void onlyWebEndpointsAreDiscovered() { + load(MultipleEndpointsConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + }); + } + + @Test + public void oneOperationIsDiscoveredWhenExtensionOverridesOperation() { + load(OverriddenOperationWebEndpointExtensionConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + EndpointInfo endpoint = endpoints.get("test"); + assertThat(requestPredicates(endpoint)).has( + requestPredicates(path("test").httpMethod(WebEndpointHttpMethod.GET) + .consumes().produces("application/json"))); + }); + } + + @Test + public void twoOperationsAreDiscoveredWhenExtensionAddsOperation() { + load(AdditionalOperationWebEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + EndpointInfo endpoint = endpoints.get("test"); + assertThat(requestPredicates(endpoint)).has(requestPredicates( + path("test").httpMethod(WebEndpointHttpMethod.GET).consumes() + .produces("application/json"), + path("test/{id}").httpMethod(WebEndpointHttpMethod.GET).consumes() + .produces("application/json"))); + }); + } + + @Test + public void predicateForWriteOperationThatReturnsVoidHasNoProducedMediaTypes() { + load(VoidWriteOperationEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("voidwrite"); + EndpointInfo endpoint = endpoints.get("voidwrite"); + assertThat(requestPredicates(endpoint)).has(requestPredicates( + path("voidwrite").httpMethod(WebEndpointHttpMethod.POST).produces() + .consumes("application/json"))); + }); + } + + @Test + public void discoveryFailsWhenTwoExtensionsHaveTheSameEndpointType() { + load(ClashingWebEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found two extensions for the same endpoint"); + this.thrown.expectMessage(TestEndpoint.class.getName()); + this.thrown.expectMessage(TestWebEndpointExtension.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenTwoStandardEndpointsHaveTheSameId() { + load(ClashingStandardEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Found two endpoints with the id 'test': "); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenEndpointHasClashingOperations() { + load(ClashingOperationsEndpointConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage( + "Found multiple web operations with matching request predicates:"); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void discoveryFailsWhenExtensionIsNotCompatibleWithTheEndpointType() { + load(InvalidWebExtensionConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("Invalid extension"); + this.thrown.expectMessage(NonWebWebEndpointExtension.class.getName()); + this.thrown.expectMessage(NonWebEndpoint.class.getName()); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void twoOperationsOnSameEndpointClashWhenSelectorsHaveDifferentNames() { + load(ClashingSelectorsWebEndpointExtensionConfiguration.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage( + "Found multiple web operations with matching request predicates:"); + discoverer.discoverEndpoints(); + }); + } + + @Test + public void endpointMainReadOperationIsCachedWithMatchingId() { + load((id) -> new CachingConfiguration(500), TestEndpointConfiguration.class, + (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("test"); + EndpointInfo endpoint = endpoints.get("test"); + assertThat(endpoint.getOperations()).hasSize(1); + OperationInvoker operationInvoker = endpoint.getOperations() + .iterator().next().getOperationInvoker(); + assertThat(operationInvoker) + .isInstanceOf(CachingOperationInvoker.class); + assertThat( + ((CachingOperationInvoker) operationInvoker).getTimeToLive()) + .isEqualTo(500); + }); + } + + @Test + public void operationsThatReturnResourceProduceApplicationOctetStream() { + load(ResourceEndpointConfiguration.class, (discoverer) -> { + Map> endpoints = mapEndpoints( + discoverer.discoverEndpoints()); + assertThat(endpoints).containsOnlyKeys("resource"); + EndpointInfo endpoint = endpoints.get("resource"); + assertThat(requestPredicates(endpoint)).has(requestPredicates( + path("resource").httpMethod(WebEndpointHttpMethod.GET).consumes() + .produces("application/octet-stream"))); + }); + } + + private void load(Class configuration, + Consumer consumer) { + this.load((id) -> null, configuration, consumer); + } + + private void load(Function cachingConfigurationFactory, + Class configuration, Consumer consumer) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + configuration); + try { + consumer.accept(new WebAnnotationEndpointDiscoverer(context, + new ConversionServiceOperationParameterMapper( + DefaultConversionService.getSharedInstance()), + cachingConfigurationFactory, + Collections.singletonList("application/json"), + Collections.singletonList("application/json"))); + } + finally { + context.close(); + } + } + + private Map> mapEndpoints( + Collection> endpoints) { + Map> endpointById = new HashMap<>(); + endpoints.forEach((endpoint) -> endpointById.put(endpoint.getId(), endpoint)); + return endpointById; + } + + private List requestPredicates( + EndpointInfo endpoint) { + return endpoint.getOperations().stream() + .map(WebEndpointOperation::getRequestPredicate) + .collect(Collectors.toList()); + } + + private Condition> requestPredicates( + RequestPredicateMatcher... matchers) { + return new Condition<>((predicates) -> { + if (predicates.size() != matchers.length) { + return false; + } + Map matchCounts = new HashMap<>(); + for (OperationRequestPredicate predicate : predicates) { + matchCounts.put(predicate, Stream.of(matchers) + .filter(matcher -> matcher.matches(predicate)).count()); + } + return matchCounts.values().stream().noneMatch(count -> count != 1); + }, Arrays.toString(matchers)); + } + + private RequestPredicateMatcher path(String path) { + return new RequestPredicateMatcher(path); + } + + @Configuration + static class EmptyConfiguration { + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class TestWebEndpointExtension { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getOne(@Selector String id) { + return null; + } + + @WriteOperation + public void update(String foo, String bar) { + + } + + public void someOtherMethod() { + + } + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class OverriddenOperationWebEndpointExtension { + + @ReadOperation + public Object getAll() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class AdditionalOperationWebEndpointExtension { + + @ReadOperation + public Object getOne(@Selector String id) { + return null; + } + + } + + @Endpoint(id = "test") + static class ClashingOperationsEndpoint { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getAgain() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class ClashingOperationsWebEndpointExtension { + + @ReadOperation + public Object getAll() { + return null; + } + + @ReadOperation + public Object getAgain() { + return null; + } + + } + + @WebEndpointExtension(endpoint = TestEndpoint.class) + static class ClashingSelectorsWebEndpointExtension { + + @ReadOperation + public Object readOne(@Selector String oneA, @Selector String oneB) { + return null; + } + + @ReadOperation + public Object readTwo(@Selector String twoA, @Selector String twoB) { + return null; + } + + } + + @Endpoint(id = "nonweb", types = EndpointType.JMX) + static class NonWebEndpoint { + + @ReadOperation + public Object getData() { + return null; + } + + } + + @WebEndpointExtension(endpoint = NonWebEndpoint.class) + static class NonWebWebEndpointExtension { + + @ReadOperation + public Object getSomething(@Selector String name) { + return null; + } + + } + + @Endpoint(id = "voidwrite") + static class VoidWriteOperationEndpoint { + + @WriteOperation + public void write(String foo, String bar) { + + } + + } + + @Endpoint(id = "resource") + static class ResourceEndpoint { + + @ReadOperation + public Resource read() { + return new ByteArrayResource(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }); + } + + } + + @Configuration + static class MultipleEndpointsConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public NonWebEndpoint nonWebEndpoint() { + return new NonWebEndpoint(); + } + + } + + @Configuration + static class TestWebEndpointExtensionConfiguration { + + @Bean + public TestWebEndpointExtension endpointExtension() { + return new TestWebEndpointExtension(); + } + + } + + @Configuration + static class ClashingOperationsEndpointConfiguration { + + @Bean + public ClashingOperationsEndpoint clashingOperationsEndpoint() { + return new ClashingOperationsEndpoint(); + } + + } + + @Configuration + static class ClashingOperationsWebEndpointExtensionConfiguration { + + @Bean + public ClashingOperationsWebEndpointExtension clashingOperationsExtension() { + return new ClashingOperationsWebEndpointExtension(); + } + + } + + @Configuration + @Import(TestEndpointConfiguration.class) + static class OverriddenOperationWebEndpointExtensionConfiguration { + + @Bean + public OverriddenOperationWebEndpointExtension overriddenOperationExtension() { + return new OverriddenOperationWebEndpointExtension(); + } + + } + + @Configuration + @Import(TestEndpointConfiguration.class) + static class AdditionalOperationWebEndpointConfiguration { + + @Bean + public AdditionalOperationWebEndpointExtension additionalOperationExtension() { + return new AdditionalOperationWebEndpointExtension(); + } + + } + + @Configuration + static class TestEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + } + + @Configuration + static class ClashingWebEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public TestWebEndpointExtension testExtensionOne() { + return new TestWebEndpointExtension(); + } + + @Bean + public TestWebEndpointExtension testExtensionTwo() { + return new TestWebEndpointExtension(); + } + + } + + @Configuration + static class ClashingStandardEndpointConfiguration { + + @Bean + public TestEndpoint testEndpointTwo() { + return new TestEndpoint(); + } + + @Bean + public TestEndpoint testEndpointOne() { + return new TestEndpoint(); + } + + } + + @Configuration + static class ClashingSelectorsWebEndpointExtensionConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + @Bean + public ClashingSelectorsWebEndpointExtension clashingSelectorsExtension() { + return new ClashingSelectorsWebEndpointExtension(); + } + + } + + @Configuration + static class InvalidWebExtensionConfiguration { + + @Bean + public NonWebEndpoint nonWebEndpoint() { + return new NonWebEndpoint(); + } + + @Bean + public NonWebWebEndpointExtension nonWebWebEndpointExtension() { + return new NonWebWebEndpointExtension(); + } + + } + + @Configuration + static class VoidWriteOperationEndpointConfiguration { + + @Bean + public VoidWriteOperationEndpoint voidWriteOperationEndpoint() { + return new VoidWriteOperationEndpoint(); + } + + } + + @Configuration + @Import(BaseConfiguration.class) + static class ResourceEndpointConfiguration { + + @Bean + public ResourceEndpoint resourceEndpoint() { + return new ResourceEndpoint(); + } + + } + + private static final class RequestPredicateMatcher { + + private final String path; + + private List produces; + + private List consumes; + + private WebEndpointHttpMethod httpMethod; + + private RequestPredicateMatcher(String path) { + this.path = path; + } + + public RequestPredicateMatcher produces(String... mediaTypes) { + this.produces = Arrays.asList(mediaTypes); + return this; + } + + public RequestPredicateMatcher consumes(String... mediaTypes) { + this.consumes = Arrays.asList(mediaTypes); + return this; + } + + private RequestPredicateMatcher httpMethod(WebEndpointHttpMethod httpMethod) { + this.httpMethod = httpMethod; + return this; + } + + private boolean matches(OperationRequestPredicate predicate) { + return (this.path == null || this.path.equals(predicate.getPath())) + && (this.httpMethod == null + || this.httpMethod == predicate.getHttpMethod()) + && (this.produces == null || this.produces + .equals(new ArrayList<>(predicate.getProduces()))) + && (this.consumes == null || this.consumes + .equals(new ArrayList<>(predicate.getConsumes()))); + } + + @Override + public String toString() { + return "Request predicate with path = '" + this.path + "', httpMethod = '" + + this.httpMethod + "', produces = '" + this.produces + "'"; + } + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java new file mode 100644 index 00000000000..c184103a40d --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web.jersey; + +import java.util.Collection; +import java.util.HashSet; + +import javax.ws.rs.ext.ContextResolver; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.server.model.Resource; +import org.glassfish.jersey.servlet.ServletContainer; + +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests; +import org.springframework.boot.endpoint.web.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Integration tests for web endpoints exposed using Jersey. + * + * @author Andy Wilkinson + */ +public class JerseyWebEndpointIntegrationTests extends + AbstractWebEndpointIntegrationTests { + + public JerseyWebEndpointIntegrationTests() { + super(JerseyConfiguration.class); + } + + @Override + protected AnnotationConfigServletWebServerApplicationContext createApplicationContext( + Class... config) { + return new AnnotationConfigServletWebServerApplicationContext(config); + } + + @Override + protected int getPort(AnnotationConfigServletWebServerApplicationContext context) { + return context.getWebServer().getPort(); + } + + @Configuration + static class JerseyConfiguration { + + @Bean + public TomcatServletWebServerFactory tomcat() { + return new TomcatServletWebServerFactory(0); + } + + @Bean + public ServletRegistrationBean servletContainer( + ResourceConfig resourceConfig) { + return new ServletRegistrationBean( + new ServletContainer(resourceConfig), "/*"); + } + + @Bean + public ResourceConfig resourceConfig( + WebAnnotationEndpointDiscoverer endpointDiscoverer) { + ResourceConfig resourceConfig = new ResourceConfig(); + Collection resources = new JerseyEndpointResourceFactory() + .createEndpointResources("endpoints", + endpointDiscoverer.discoverEndpoints()); + resourceConfig.registerResources(new HashSet(resources)); + resourceConfig.register(JacksonFeature.class); + resourceConfig.register(new ObjectMapperContextResolver(new ObjectMapper()), + ContextResolver.class); + return resourceConfig; + } + + } + + private static final class ObjectMapperContextResolver + implements ContextResolver { + + private final ObjectMapper objectMapper; + + private ObjectMapperContextResolver(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public ObjectMapper getContext(Class type) { + return this.objectMapper; + } + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java new file mode 100644 index 00000000000..1f9c77f6c0e --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/mvc/MvcWebEndpointIntegrationTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web.mvc; + +import java.util.Arrays; + +import org.junit.Test; + +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests; +import org.springframework.boot.endpoint.web.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +/** + * Integration tests for web endpoints exposed using Spring MVC. + * + * @author Andy Wilkinson + */ +public class MvcWebEndpointIntegrationTests extends + AbstractWebEndpointIntegrationTests { + + public MvcWebEndpointIntegrationTests() { + super(WebMvcConfiguration.class); + } + + @Test + public void responseToOptionsRequestIncludesCorsHeaders() { + load(TestEndpointConfiguration.class, + (client) -> client.options().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .header("Access-Control-Request-Method", "POST") + .header("Origin", "http://example.com").exchange().expectStatus() + .isOk().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "http://example.com") + .expectHeader() + .valueEquals("Access-Control-Allow-Methods", "GET,POST")); + } + + @Override + protected AnnotationConfigServletWebServerApplicationContext createApplicationContext( + Class... config) { + return new AnnotationConfigServletWebServerApplicationContext(config); + } + + @Override + protected int getPort(AnnotationConfigServletWebServerApplicationContext context) { + return context.getWebServer().getPort(); + } + + @Configuration + @EnableWebMvc + static class WebMvcConfiguration { + + @Bean + public TomcatServletWebServerFactory tomcat() { + return new TomcatServletWebServerFactory(0); + } + + @Bean + public DispatcherServlet dispatcherServlet() { + return new DispatcherServlet(); + } + + @Bean + public WebEndpointServletHandlerMapping webEndpointHandlerMapping( + WebAnnotationEndpointDiscoverer webEndpointDiscoverer) { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedOrigins(Arrays.asList("http://example.com")); + corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST")); + return new WebEndpointServletHandlerMapping("/endpoints", + webEndpointDiscoverer.discoverEndpoints(), corsConfiguration); + } + + } + +} diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java new file mode 100644 index 00000000000..2335671daea --- /dev/null +++ b/spring-boot/src/test/java/org/springframework/boot/endpoint/web/reactive/ReactiveWebEndpointIntegrationTests.java @@ -0,0 +1,107 @@ +/* + * Copyright 2012-2017 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 + * + * http://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.endpoint.web.reactive; + +import java.util.Arrays; + +import org.junit.Test; + +import org.springframework.boot.endpoint.web.AbstractWebEndpointIntegrationTests; +import org.springframework.boot.endpoint.web.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; +import org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext; +import org.springframework.boot.web.reactive.context.ReactiveWebServerInitializedEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +/** + * Integration tests for web endpoints exposed using WebFlux. + * + * @author Andy Wilkinson + */ +public class ReactiveWebEndpointIntegrationTests + extends AbstractWebEndpointIntegrationTests { + + public ReactiveWebEndpointIntegrationTests() { + super(ReactiveConfiguration.class); + } + + @Test + public void responseToOptionsRequestIncludesCorsHeaders() { + load(TestEndpointConfiguration.class, + (client) -> client.options().uri("/test") + .accept(MediaType.APPLICATION_JSON) + .header("Access-Control-Request-Method", "POST") + .header("Origin", "http://example.com").exchange().expectStatus() + .isOk().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "http://example.com") + .expectHeader() + .valueEquals("Access-Control-Allow-Methods", "GET,POST")); + } + + @Override + protected ReactiveWebServerApplicationContext createApplicationContext( + Class... config) { + return new ReactiveWebServerApplicationContext(config); + } + + @Override + protected int getPort(ReactiveWebServerApplicationContext context) { + return context.getBean(ReactiveConfiguration.class).port; + } + + @Configuration + @EnableWebFlux + static class ReactiveConfiguration { + + private int port; + + @Bean + public NettyReactiveWebServerFactory netty() { + return new NettyReactiveWebServerFactory(0); + } + + @Bean + public HttpHandler httpHandler(ApplicationContext applicationContext) { + return WebHttpHandlerBuilder.applicationContext(applicationContext).build(); + } + + @Bean + public WebEndpointReactiveHandlerMapping webEndpointHandlerMapping( + WebAnnotationEndpointDiscoverer endpointDiscoverer) { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedOrigins(Arrays.asList("http://example.com")); + corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST")); + return new WebEndpointReactiveHandlerMapping("endpoints", + endpointDiscoverer.discoverEndpoints(), corsConfiguration); + } + + @Bean + public ApplicationListener serverInitializedListener() { + return (event) -> this.port = event.getWebServer().getPort(); + } + + } + +}