Commit 97af0b2f authored by Brian Clozel's avatar Brian Clozel

Add actuator specific ObjectMapper

Prior to this commit, Actuator endpoints would use the application
ObjectMapper instance for serializing payloads as JSON. This was
problematic in several cases:

* application-specific configuration would change the actuator endpoint
output.
* choosing a different JSON mapper implementation in the application
would break completely some endpoints.

Spring Boot Actuator already has a hard dependency on Jackson, and this
commit uses that fact to configure a shared `ObjectMapper` instance that
will be used by the Actuator infrastructure consistently, without
polluting the application context.

This `ObjectMapper` is used in Actuator for:

* JMX endpoints
* Spring MVC endpoints with an HTTP message converter
* Spring WebFlux endpoints with an `Encoder`
* Jersey endpoints with a `ContextResolver<ObjectMapper>`

For all web endpoints, this configuration is limited to the
actuator-specific media types such as
`"application/vnd.spring-boot.actuator.v3+json"`.

Fixes gh-12951
parent 420af175
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -25,6 +25,7 @@ import org.springframework.boot.actuate.endpoint.annotation.EndpointConverter; ...@@ -25,6 +25,7 @@ import org.springframework.boot.actuate.endpoint.annotation.EndpointConverter;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper; import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvokerAdvisor; import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.json.ActuatorJsonMapperProvider;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.boot.convert.ApplicationConversionService;
...@@ -75,4 +76,9 @@ public class EndpointAutoConfiguration { ...@@ -75,4 +76,9 @@ public class EndpointAutoConfiguration {
return new CachingOperationInvokerAdvisor(new EndpointIdTimeToLivePropertyFunction(environment)); return new CachingOperationInvokerAdvisor(new EndpointIdTimeToLivePropertyFunction(environment));
} }
@Bean
public ActuatorJsonMapperProvider actuatorJsonMapperProvider() {
return new ActuatorJsonMapperProvider();
}
} }
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -20,8 +20,6 @@ import java.util.stream.Collectors; ...@@ -20,8 +20,6 @@ import java.util.stream.Collectors;
import javax.management.MBeanServer; import javax.management.MBeanServer;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.endpoint.ExposeExcludePropertyEndpointFilter; import org.springframework.boot.actuate.autoconfigure.endpoint.ExposeExcludePropertyEndpointFilter;
import org.springframework.boot.actuate.endpoint.EndpointFilter; import org.springframework.boot.actuate.endpoint.EndpointFilter;
...@@ -35,6 +33,7 @@ import org.springframework.boot.actuate.endpoint.jmx.JmxEndpointExporter; ...@@ -35,6 +33,7 @@ import org.springframework.boot.actuate.endpoint.jmx.JmxEndpointExporter;
import org.springframework.boot.actuate.endpoint.jmx.JmxEndpointsSupplier; import org.springframework.boot.actuate.endpoint.jmx.JmxEndpointsSupplier;
import org.springframework.boot.actuate.endpoint.jmx.JmxOperationResponseMapper; import org.springframework.boot.actuate.endpoint.jmx.JmxOperationResponseMapper;
import org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpointDiscoverer; import org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpointDiscoverer;
import org.springframework.boot.actuate.endpoint.json.ActuatorJsonMapperProvider;
import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
...@@ -85,12 +84,12 @@ public class JmxEndpointAutoConfiguration { ...@@ -85,12 +84,12 @@ public class JmxEndpointAutoConfiguration {
@Bean @Bean
@ConditionalOnSingleCandidate(MBeanServer.class) @ConditionalOnSingleCandidate(MBeanServer.class)
public JmxEndpointExporter jmxMBeanExporter(MBeanServer mBeanServer, Environment environment, public JmxEndpointExporter jmxMBeanExporter(MBeanServer mBeanServer, Environment environment,
ObjectProvider<ObjectMapper> objectMapper, JmxEndpointsSupplier jmxEndpointsSupplier) { ActuatorJsonMapperProvider actuatorJsonMapperProvider, JmxEndpointsSupplier jmxEndpointsSupplier) {
String contextId = ObjectUtils.getIdentityHexString(this.applicationContext); String contextId = ObjectUtils.getIdentityHexString(this.applicationContext);
EndpointObjectNameFactory objectNameFactory = new DefaultEndpointObjectNameFactory(this.properties, environment, EndpointObjectNameFactory objectNameFactory = new DefaultEndpointObjectNameFactory(this.properties, environment,
mBeanServer, contextId); mBeanServer, contextId);
JmxOperationResponseMapper responseMapper = new JacksonJmxOperationResponseMapper( JmxOperationResponseMapper responseMapper = new JacksonJmxOperationResponseMapper(
objectMapper.getIfAvailable()); actuatorJsonMapperProvider.getInstance());
return new JmxEndpointExporter(mBeanServer, objectNameFactory, responseMapper, return new JmxEndpointExporter(mBeanServer, objectNameFactory, responseMapper,
jmxEndpointsSupplier.getEndpoints()); jmxEndpointsSupplier.getEndpoints());
......
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -22,7 +22,10 @@ import java.util.HashSet; ...@@ -22,7 +22,10 @@ import java.util.HashSet;
import java.util.List; import java.util.List;
import javax.annotation.PostConstruct; import javax.annotation.PostConstruct;
import javax.ws.rs.Produces;
import javax.ws.rs.ext.ContextResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.server.model.Resource; import org.glassfish.jersey.server.model.Resource;
...@@ -32,6 +35,8 @@ import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfi ...@@ -32,6 +35,8 @@ import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfi
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
import org.springframework.boot.actuate.endpoint.json.ActuatorJsonMapperProvider;
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
...@@ -84,6 +89,12 @@ class JerseyWebEndpointManagementContextConfiguration { ...@@ -84,6 +89,12 @@ class JerseyWebEndpointManagementContextConfiguration {
|| ManagementPortType.get(environment).equals(ManagementPortType.DIFFERENT); || ManagementPortType.get(environment).equals(ManagementPortType.DIFFERENT);
} }
@Bean
ResourceConfigCustomizer actuatorResourceConfigCustomizer(ActuatorJsonMapperProvider jsonMapperProvider) {
return (ResourceConfig config) -> config.register(
new ActuatorJsonMapperContextResolver(jsonMapperProvider.getInstance()), ContextResolver.class);
}
/** /**
* Register endpoints with the {@link ResourceConfig}. The * Register endpoints with the {@link ResourceConfig}. The
* {@link ResourceConfigCustomizer} cannot be used because we don't want to apply * {@link ResourceConfigCustomizer} cannot be used because we don't want to apply
...@@ -145,4 +156,20 @@ class JerseyWebEndpointManagementContextConfiguration { ...@@ -145,4 +156,20 @@ class JerseyWebEndpointManagementContextConfiguration {
} }
@Produces({ ActuatorMediaType.V3_JSON, ActuatorMediaType.V2_JSON })
private static final class ActuatorJsonMapperContextResolver implements ContextResolver<ObjectMapper> {
private final ObjectMapper objectMapper;
private ActuatorJsonMapperContextResolver(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public ObjectMapper getContext(Class<?> type) {
return this.objectMapper;
}
}
} }
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -26,6 +26,8 @@ import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfi ...@@ -26,6 +26,8 @@ import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfi
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
import org.springframework.boot.actuate.endpoint.json.ActuatorJsonMapperProvider;
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
...@@ -40,8 +42,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean ...@@ -40,8 +42,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.codec.CodecCustomizer;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.http.MediaType;
import org.springframework.http.codec.CodecConfigurer;
import org.springframework.http.codec.json.Jackson2JsonEncoder;
import org.springframework.http.server.reactive.HttpHandler; import org.springframework.http.server.reactive.HttpHandler;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.reactive.DispatcherHandler; import org.springframework.web.reactive.DispatcherHandler;
...@@ -52,6 +59,7 @@ import org.springframework.web.reactive.DispatcherHandler; ...@@ -52,6 +59,7 @@ import org.springframework.web.reactive.DispatcherHandler;
* *
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Phillip Webb * @author Phillip Webb
* @author Brian Clozel
* @since 2.0.0 * @since 2.0.0
*/ */
@ManagementContextConfiguration(proxyBeanMethods = false) @ManagementContextConfiguration(proxyBeanMethods = false)
...@@ -93,4 +101,16 @@ public class WebFluxEndpointManagementContextConfiguration { ...@@ -93,4 +101,16 @@ public class WebFluxEndpointManagementContextConfiguration {
corsProperties.toCorsConfiguration()); corsProperties.toCorsConfiguration());
} }
@Bean
@Order(-1)
public CodecCustomizer actuatorJsonCodec(ActuatorJsonMapperProvider actuatorJsonMapperProvider) {
return (configurer) -> {
MediaType v3MediaType = MediaType.parseMediaType(ActuatorMediaType.V3_JSON);
MediaType v2MediaType = MediaType.parseMediaType(ActuatorMediaType.V2_JSON);
CodecConfigurer.CustomCodecs customCodecs = configurer.customCodecs();
customCodecs.register(
new Jackson2JsonEncoder(actuatorJsonMapperProvider.getInstance(), v3MediaType, v2MediaType));
};
}
} }
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -20,12 +20,16 @@ import java.util.ArrayList; ...@@ -20,12 +20,16 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties; import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration; import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint; import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType;
import org.springframework.boot.actuate.endpoint.json.ActuatorJsonMapperProvider;
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
...@@ -42,9 +46,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplicat ...@@ -42,9 +46,15 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplicat
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.env.Environment; import org.springframework.core.env.Environment;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** /**
* {@link ManagementContextConfiguration @ManagementContextConfiguration} for Spring MVC * {@link ManagementContextConfiguration @ManagementContextConfiguration} for Spring MVC
...@@ -91,4 +101,35 @@ public class WebMvcEndpointManagementContextConfiguration { ...@@ -91,4 +101,35 @@ public class WebMvcEndpointManagementContextConfiguration {
corsProperties.toCorsConfiguration()); corsProperties.toCorsConfiguration());
} }
@Configuration(proxyBeanMethods = false)
public static class JsonWebMvcConfigurer implements WebMvcConfigurer, Ordered {
private final ActuatorJsonMapperProvider actuatorJsonMapperProvider;
public JsonWebMvcConfigurer(ActuatorJsonMapperProvider objectMapperFactory) {
this.actuatorJsonMapperProvider = objectMapperFactory;
}
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new ActuatorJsonHttpMessageConverter(this.actuatorJsonMapperProvider.getInstance()));
}
// WebMvcAutoConfiguration is ordered at 0
@Override
public int getOrder() {
return -1;
}
}
static class ActuatorJsonHttpMessageConverter extends AbstractJackson2HttpMessageConverter {
ActuatorJsonHttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.parseMediaType(ActuatorMediaType.V3_JSON),
MediaType.parseMediaType(ActuatorMediaType.V2_JSON));
}
}
} }
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -16,9 +16,13 @@ ...@@ -16,9 +16,13 @@
package org.springframework.boot.actuate.autoconfigure.web.servlet; package org.springframework.boot.actuate.autoconfigure.web.servlet;
import java.util.List;
import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration; import org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.ManagementContextType; import org.springframework.boot.actuate.autoconfigure.web.ManagementContextType;
import org.springframework.boot.actuate.endpoint.json.ActuatorJsonMapperProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
...@@ -33,11 +37,15 @@ import org.springframework.boot.web.servlet.error.ErrorAttributes; ...@@ -33,11 +37,15 @@ import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.boot.web.servlet.filter.OrderedRequestContextFilter; import org.springframework.boot.web.servlet.filter.OrderedRequestContextFilter;
import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered; import org.springframework.core.Ordered;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.context.request.RequestContextListener; import org.springframework.web.context.request.RequestContextListener;
import org.springframework.web.filter.RequestContextFilter; import org.springframework.web.filter.RequestContextFilter;
import org.springframework.web.servlet.DispatcherServlet; import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/** /**
* {@link ManagementContextConfiguration @ManagementContextConfiguration} for Spring MVC * {@link ManagementContextConfiguration @ManagementContextConfiguration} for Spring MVC
...@@ -108,6 +116,29 @@ class WebMvcEndpointChildContextConfiguration { ...@@ -108,6 +116,29 @@ class WebMvcEndpointChildContextConfiguration {
return new OrderedRequestContextFilter(); return new OrderedRequestContextFilter();
} }
/**
* Since {@code WebMvcEndpointManagementContextConfiguration} is adding an
* actuator-specific JSON message converter, {@code @EnableWebMvc} will not register
* default converters. We need to register a JSON converter for plain
* {@code "application/json"} still.
* WebMvcEndpointChildContextConfigurationIntegrationTests
*/
@Configuration(proxyBeanMethods = false)
public static class FallbackJsonConverterConfigurer implements WebMvcConfigurer {
private final ActuatorJsonMapperProvider actuatorJsonMapperProvider;
FallbackJsonConverterConfigurer(ObjectProvider<ActuatorJsonMapperProvider> objectMapperSupplier) {
this.actuatorJsonMapperProvider = objectMapperSupplier.getIfAvailable(ActuatorJsonMapperProvider::new);
}
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new MappingJackson2HttpMessageConverter(this.actuatorJsonMapperProvider.getInstance()));
}
}
/** /**
* {@link WebServerFactoryCustomizer} to add an {@link ErrorPage} so that the * {@link WebServerFactoryCustomizer} to add an {@link ErrorPage} so that the
* {@link ManagementErrorEndpoint} can be used. * {@link ManagementErrorEndpoint} can be used.
......
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -52,7 +52,7 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat ...@@ -52,7 +52,7 @@ class ScheduledTasksEndpointDocumentationTests extends MockMvcEndpointDocumentat
@Test @Test
void scheduledTasks() throws Exception { void scheduledTasks() throws Exception {
this.mockMvc.perform(get("/actuator/scheduledtasks")).andExpect(status().isOk()) this.mockMvc.perform(get("/actuator/scheduledtasks").accept("application/json")).andExpect(status().isOk())
.andDo(document("scheduled-tasks", .andDo(document("scheduled-tasks",
preprocessResponse(replacePattern( preprocessResponse(replacePattern(
Pattern.compile("org.*\\.ScheduledTasksEndpointDocumentationTests\\$TestConfiguration"), Pattern.compile("org.*\\.ScheduledTasksEndpointDocumentationTests\\$TestConfiguration"),
......
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -21,6 +21,7 @@ import java.util.Collections; ...@@ -21,6 +21,7 @@ import java.util.Collections;
import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.ResourceConfig;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.jersey.JerseyWebEndpointManagementContextConfiguration.JerseyWebEndpointsResourcesRegistrar; import org.springframework.boot.actuate.autoconfigure.endpoint.web.jersey.JerseyWebEndpointManagementContextConfiguration.JerseyWebEndpointsResourcesRegistrar;
import org.springframework.boot.actuate.autoconfigure.web.jersey.JerseySameManagementContextConfiguration; import org.springframework.boot.actuate.autoconfigure.web.jersey.JerseySameManagementContextConfiguration;
...@@ -41,8 +42,8 @@ import static org.assertj.core.api.Assertions.assertThat; ...@@ -41,8 +42,8 @@ import static org.assertj.core.api.Assertions.assertThat;
class JerseyWebEndpointManagementContextConfigurationTests { class JerseyWebEndpointManagementContextConfigurationTests {
private final WebApplicationContextRunner runner = new WebApplicationContextRunner() private final WebApplicationContextRunner runner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(WebEndpointAutoConfiguration.class, .withConfiguration(AutoConfigurations.of(EndpointAutoConfiguration.class,
JerseyWebEndpointManagementContextConfiguration.class)) WebEndpointAutoConfiguration.class, JerseyWebEndpointManagementContextConfiguration.class))
.withBean(WebEndpointsSupplier.class, () -> Collections::emptyList); .withBean(WebEndpointsSupplier.class, () -> Collections::emptyList);
@Test @Test
......
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -20,7 +20,6 @@ import java.util.function.Supplier; ...@@ -20,7 +20,6 @@ import java.util.function.Supplier;
import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServlet;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.audit.AuditAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.audit.AuditAutoConfiguration;
...@@ -33,32 +32,23 @@ import org.springframework.boot.actuate.endpoint.web.EndpointServlet; ...@@ -33,32 +32,23 @@ import org.springframework.boot.actuate.endpoint.web.EndpointServlet;
import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint;
import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint;
import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint; import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.data.rest.RepositoryRestMvcAutoConfiguration; import org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration;
import org.springframework.boot.autoconfigure.hateoas.HypermediaAutoConfiguration;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebApplicationContext;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.test.context.TestSecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.test.web.servlet.setup.MockMvcConfigurer;
import static org.hamcrest.Matchers.both; import static org.hamcrest.Matchers.both;
import static org.hamcrest.Matchers.hasKey; import static org.hamcrest.Matchers.hasKey;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; import static org.hamcrest.Matchers.startsWith;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
...@@ -67,98 +57,39 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. ...@@ -67,98 +57,39 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* Integration tests for the Actuator's MVC endpoints. * Integration tests for the Actuator's MVC endpoints.
* *
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Brian Clozel
*/ */
class WebMvcEndpointIntegrationTests { public class WebMvcEndpointIntegrationTests {
private AnnotationConfigServletWebApplicationContext context; private WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, GsonAutoConfiguration.class,
@AfterEach HttpMessageConvertersAutoConfiguration.class, EndpointAutoConfiguration.class,
void close() { WebEndpointAutoConfiguration.class, ServletManagementContextAutoConfiguration.class,
TestSecurityContextHolder.clearContext(); AuditAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
this.context.close(); WebMvcAutoConfiguration.class, ManagementContextAutoConfiguration.class,
} AuditAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
BeansEndpointAutoConfiguration.class));
@Test
void endpointsAreSecureByDefault() throws Exception {
this.context = new AnnotationConfigServletWebApplicationContext();
this.context.register(SecureConfiguration.class);
MockMvc mockMvc = createSecureMockMvc();
mockMvc.perform(get("/actuator/beans").accept(MediaType.APPLICATION_JSON)).andExpect(status().isUnauthorized());
}
@Test
void endpointsAreSecureByDefaultWithCustomBasePath() throws Exception {
this.context = new AnnotationConfigServletWebApplicationContext();
this.context.register(SecureConfiguration.class);
TestPropertyValues.of("management.endpoints.web.base-path:/management").applyTo(this.context);
MockMvc mockMvc = createSecureMockMvc();
mockMvc.perform(get("/management/beans").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized());
}
@Test
void endpointsAreSecureWithActuatorRoleWithCustomBasePath() throws Exception {
TestSecurityContextHolder.getContext()
.setAuthentication(new TestingAuthenticationToken("user", "N/A", "ROLE_ACTUATOR"));
this.context = new AnnotationConfigServletWebApplicationContext();
this.context.register(SecureConfiguration.class);
TestPropertyValues
.of("management.endpoints.web.base-path:/management", "management.endpoints.web.exposure.include=*")
.applyTo(this.context);
MockMvc mockMvc = createSecureMockMvc();
mockMvc.perform(get("/management/beans")).andExpect(status().isOk());
}
@Test @Test
void linksAreProvidedToAllEndpointTypes() throws Exception { void linksAreProvidedToAllEndpointTypes() throws Exception {
this.context = new AnnotationConfigServletWebApplicationContext(); this.contextRunner.withUserConfiguration(EndpointsConfiguration.class)
this.context.register(DefaultConfiguration.class, EndpointsConfiguration.class); .withPropertyValues("management.endpoints.web.exposure.include=*").run((context) -> {
TestPropertyValues.of("management.endpoints.web.exposure.include=*").applyTo(this.context); MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
MockMvc mockMvc = doCreateMockMvc(); mockMvc.perform(get("/actuator").accept("*/*")).andExpect(status().isOk())
mockMvc.perform(get("/actuator").accept("*/*")).andExpect(status().isOk()).andExpect(jsonPath("_links", .andExpect(jsonPath("_links", both(hasKey("beans")).and(hasKey("servlet"))
both(hasKey("beans")).and(hasKey("servlet")).and(hasKey("restcontroller")).and(hasKey("controller")))); .and(hasKey("restcontroller")).and(hasKey("controller"))));
});
} }
private MockMvc createSecureMockMvc() { @Test
return doCreateMockMvc(springSecurity()); void dedicatedJsonMapperIsUsed() throws Exception {
} this.contextRunner.withPropertyValues("spring.mvc.converters.preferred-json-mapper:gson",
"management.endpoints.web.exposure.include=*").run((context) -> {
private MockMvc doCreateMockMvc(MockMvcConfigurer... configurers) { MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
this.context.setServletContext(new MockServletContext()); mockMvc.perform(get("/actuator/beans").accept("*/*")).andExpect(status().isOk())
this.context.refresh(); .andExpect(MockMvcResultMatchers.header().string("Content-Type",
DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(this.context); startsWith("application/vnd.spring-boot.actuator")));
for (MockMvcConfigurer configurer : configurers) { });
builder.apply(configurer);
}
return builder.build();
}
@ImportAutoConfiguration({ JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
ServletManagementContextAutoConfiguration.class, AuditAutoConfiguration.class,
PropertyPlaceholderAutoConfiguration.class, WebMvcAutoConfiguration.class,
ManagementContextAutoConfiguration.class, AuditAutoConfiguration.class,
DispatcherServletAutoConfiguration.class, BeansEndpointAutoConfiguration.class })
static class DefaultConfiguration {
}
@Import(SecureConfiguration.class)
@ImportAutoConfiguration({ HypermediaAutoConfiguration.class })
static class SpringHateoasConfiguration {
}
@Import(SecureConfiguration.class)
@ImportAutoConfiguration({ HypermediaAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class })
static class SpringDataRestConfiguration {
}
@Import(DefaultConfiguration.class)
@ImportAutoConfiguration({ SecurityAutoConfiguration.class })
static class SecureConfiguration {
} }
@ServletEndpoint(id = "servlet") @ServletEndpoint(id = "servlet")
......
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.autoconfigure.integrationtest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.autoconfigure.audit.AuditAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.beans.BeansEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebApplicationContext;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockServletContext;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.test.context.TestSecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.test.web.servlet.setup.MockMvcConfigurer;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* Integration tests for the Actuator's MVC endpoints security features.
*
* @author Andy Wilkinson
*/
class WebMvcEndpointSecurityIntegrationTests {
private AnnotationConfigServletWebApplicationContext context;
@AfterEach
void close() {
TestSecurityContextHolder.clearContext();
this.context.close();
}
@Test
void endpointsAreSecureByDefault() throws Exception {
this.context = new AnnotationConfigServletWebApplicationContext();
this.context.register(SecureConfiguration.class);
MockMvc mockMvc = createSecureMockMvc();
mockMvc.perform(get("/actuator/beans").accept(MediaType.APPLICATION_JSON)).andExpect(status().isUnauthorized());
}
@Test
void endpointsAreSecureByDefaultWithCustomBasePath() throws Exception {
this.context = new AnnotationConfigServletWebApplicationContext();
this.context.register(SecureConfiguration.class);
TestPropertyValues.of("management.endpoints.web.base-path:/management").applyTo(this.context);
MockMvc mockMvc = createSecureMockMvc();
mockMvc.perform(get("/management/beans").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isUnauthorized());
}
@Test
void endpointsAreSecureWithActuatorRoleWithCustomBasePath() throws Exception {
TestSecurityContextHolder.getContext()
.setAuthentication(new TestingAuthenticationToken("user", "N/A", "ROLE_ACTUATOR"));
this.context = new AnnotationConfigServletWebApplicationContext();
this.context.register(SecureConfiguration.class);
TestPropertyValues
.of("management.endpoints.web.base-path:/management", "management.endpoints.web.exposure.include=*")
.applyTo(this.context);
MockMvc mockMvc = createSecureMockMvc();
mockMvc.perform(get("/management/beans")).andExpect(status().isOk());
}
private MockMvc createSecureMockMvc() {
return doCreateMockMvc(springSecurity());
}
private MockMvc doCreateMockMvc(MockMvcConfigurer... configurers) {
this.context.setServletContext(new MockServletContext());
this.context.refresh();
DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(this.context);
for (MockMvcConfigurer configurer : configurers) {
builder.apply(configurer);
}
return builder.build();
}
@ImportAutoConfiguration({ JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
ServletManagementContextAutoConfiguration.class, AuditAutoConfiguration.class,
PropertyPlaceholderAutoConfiguration.class, WebMvcAutoConfiguration.class,
ManagementContextAutoConfiguration.class, AuditAutoConfiguration.class,
DispatcherServletAutoConfiguration.class, BeansEndpointAutoConfiguration.class })
static class DefaultConfiguration {
}
@Import(DefaultConfiguration.class)
@ImportAutoConfiguration({ SecurityAutoConfiguration.class })
static class SecureConfiguration {
}
}
/* /*
* Copyright 2012-2019 the original author or authors. * Copyright 2012-2020 the original author or authors.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
...@@ -59,6 +59,7 @@ class WebMvcEndpointChildContextConfigurationIntegrationTests { ...@@ -59,6 +59,7 @@ class WebMvcEndpointChildContextConfigurationIntegrationTests {
WebClient client = WebClient.create("http://localhost:" + port); WebClient client = WebClient.create("http://localhost:" + port);
ClientResponse response = client.get().uri("actuator/fail").accept(MediaType.APPLICATION_JSON) ClientResponse response = client.get().uri("actuator/fail").accept(MediaType.APPLICATION_JSON)
.exchange().block(); .exchange().block();
assertThat(response.headers().contentType().get()).isEqualTo(MediaType.APPLICATION_JSON);
assertThat(response.bodyToMono(String.class).block()).contains("message\":\"Epic Fail"); assertThat(response.bodyToMono(String.class).block()).contains("message\":\"Epic Fail");
}); });
} }
......
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.endpoint.json;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
/**
* Factory for an {@code ObjectMapper} instance to be shared within Actuator
* infrastructure.
*
* <p>
* The goal is to have a Jackson configuration separate from the rest of the application
* to keep a consistent serialization behavior between applications.
*
* @author Brian Clozel
* @since 2.3.0
*/
public class ActuatorJsonMapperProvider {
private ObjectMapper objectMapper;
public ObjectMapper getInstance() {
if (this.objectMapper == null) {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
builder.featuresToDisable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS);
this.objectMapper = builder.build();
}
return this.objectMapper;
}
}
/*
* Copyright 2012-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* JSON support for actuator endpoints.
*/
package org.springframework.boot.actuate.endpoint.json;
...@@ -46,6 +46,9 @@ ...@@ -46,6 +46,9 @@
<disallow pkg="org.springframework.web.servlet" /> <disallow pkg="org.springframework.web.servlet" />
</subpackage> </subpackage>
</subpackage> </subpackage>
<subpackage name="json">
<allow pkg="org.springframework.http.converter" />
</subpackage>
</subpackage> </subpackage>
<!-- Logging --> <!-- Logging -->
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment