From 4592e071db411dcc1bed5511f7df52fcf3879eef Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Thu, 20 Jul 2017 16:33:19 +0100 Subject: [PATCH] Add support for making endpoints accessible via JMX This commit adds support to the new endpoint infrastructure for exposing endpoint operations via JMX. It also introduces support for JMX-specific extensions to a general-purpose endpoint. Such an extension is identified by the `@JmxEndpointExtension` annotation. See gh-9946 --- .../boot/endpoint/jmx/EndpointMBean.java | 130 ++++ .../boot/endpoint/jmx/EndpointMBeanInfo.java | 60 ++ .../jmx/EndpointMBeanInfoAssembler.java | 127 ++++ .../endpoint/jmx/EndpointMBeanRegistrar.java | 119 ++++ .../jmx/EndpointObjectNameFactory.java | 39 ++ .../jmx/JmxAnnotationEndpointDiscoverer.java | 187 ++++++ .../endpoint/jmx/JmxEndpointExtension.java | 45 ++ .../endpoint/jmx/JmxEndpointMBeanFactory.java | 61 ++ .../endpoint/jmx/JmxEndpointOperation.java | 95 +++ .../JmxEndpointOperationParameterInfo.java | 56 ++ .../jmx/JmxOperationResponseMapper.java | 42 ++ .../boot/endpoint/jmx/package-info.java | 20 + .../jmx/EndpointMBeanInfoAssemblerTests.java | 132 +++++ .../jmx/EndpointMBeanRegistrarTests.java | 134 +++++ .../boot/endpoint/jmx/EndpointMBeanTests.java | 362 ++++++++++++ .../JmxAnnotationEndpointDiscovererTests.java | 555 ++++++++++++++++++ 16 files changed, 2164 insertions(+) create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBean.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfo.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssembler.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrar.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointObjectNameFactory.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscoverer.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointExtension.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointMBeanFactory.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperation.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxEndpointOperationParameterInfo.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/JmxOperationResponseMapper.java create mode 100644 spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/package-info.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssemblerTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanRegistrarTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanTests.java create mode 100644 spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/JmxAnnotationEndpointDiscovererTests.java diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBean.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/EndpointMBean.java new file mode 100644 index 0000000000..bedb1e63ca --- /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 0000000000..d457dbd2e4 --- /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 0000000000..237e97641c --- /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 0000000000..887ee96506 --- /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 0000000000..2647e82708 --- /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 0000000000..11b0980a83 --- /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 0000000000..aaacf88715 --- /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 0000000000..abfa353a7c --- /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 0000000000..e8dc4f5188 --- /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 0000000000..5da2b862c4 --- /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 0000000000..74695742c6 --- /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 0000000000..ecfde0e231 --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/jmx/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * JMX endpoint support. + */ +package org.springframework.boot.endpoint.jmx; diff --git a/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssemblerTests.java b/spring-boot/src/test/java/org/springframework/boot/endpoint/jmx/EndpointMBeanInfoAssemblerTests.java new file mode 100644 index 0000000000..34fc5caacb --- /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 0000000000..1e44c415a3 --- /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 0000000000..d41d5906b6 --- /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 0000000000..0090ac27e2 --- /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(); + } + + } + +}