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/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/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/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/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/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); + } + +} 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(); + } + + } + +} 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(); + } + + } + +}