From 4f2e4ada4550ca7698e4bce278e2dc221c7e3ef4 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 20 Jul 2017 16:30:24 +0100 Subject: [PATCH] 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 0000000000..94eb2d9554 --- /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 0000000000..06d5ae264e --- /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 0000000000..2a80c6cbc0 --- /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 0000000000..064e40436a --- /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 0000000000..5483ecadf1 --- /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 0000000000..0013da9a02 --- /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 0000000000..50a852bb6f --- /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 0000000000..f4583e05da --- /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 0000000000..1cfabdfcac --- /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 0000000000..657a3357a4 --- /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 0000000000..6de256a8fc --- /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 0000000000..ef8d0fa3e7 --- /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 0000000000..c737121755 --- /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 0000000000..c68397f800 --- /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 0000000000..ded4079bc8 --- /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 0000000000..215459e86b --- /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 0000000000..eab3b30eef --- /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 0000000000..ae47aa6e4e --- /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 0000000000..0b680354fb --- /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 0000000000..ec0cca0319 --- /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); + } + +}