diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfiguration.java new file mode 100644 index 000000000000..40565258873d --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2022 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.endpoint.jackson; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.boot.actuate.endpoint.jackson.EndpointObjectMapper; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for Endpoint Jackson support. + * + * @author Phillip Webb + * @since 3.0.0 + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigureAfter(JacksonAutoConfiguration.class) +public class JacksonEndpointAutoConfiguration { + + @Bean + @ConditionalOnProperty(name = "management.endpoints.jackson.isolated-object-mapper", matchIfMissing = true) + @ConditionalOnClass({ ObjectMapper.class, Jackson2ObjectMapperBuilder.class }) + public EndpointObjectMapper endpointObjectMapper() { + ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().build(); + return () -> objectMapper; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/package-info.java new file mode 100644 index 000000000000..3617bb35317b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2022 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. + */ + +/** + * Actuator Jackson auto-configuration. + */ +package org.springframework.boot.actuate.autoconfigure.endpoint.jackson; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java index 0ca941a4998e..faaf72312713 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/jersey/JerseyWebEndpointManagementContextConfiguration.java @@ -23,6 +23,10 @@ import java.util.List; import java.util.Objects; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Priority; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.ext.ContextResolver; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.model.Resource; @@ -35,7 +39,9 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.EndpointId; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; +import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.jackson.EndpointObjectMapper; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -53,6 +59,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.autoconfigure.jersey.ResourceConfigCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.core.env.Environment; import org.springframework.util.StringUtils; @@ -98,6 +105,13 @@ JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar jerseyDifferentP return new JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar(health, healthEndpointGroups); } + @Bean + @ConditionalOnBean(EndpointObjectMapper.class) + ResourceConfigCustomizer endpointObjectMapperResourceConfigCustomizer(EndpointObjectMapper endpointObjectMapper) { + return (config) -> config.register(new EndpointObjectMapperContextResolver(endpointObjectMapper), + ContextResolver.class); + } + private boolean shouldRegisterLinksMapping(WebEndpointProperties properties, Environment environment, String basePath) { return properties.getDiscovery().isEnabled() && (StringUtils.hasText(basePath) @@ -192,4 +206,24 @@ private void register(Collection resources, ResourceConfig config) { } + /** + * {@link ContextResolver} used to obtain the {@link ObjectMapper} that should be used + * for {@link OperationResponseBody} instances. + */ + @Priority(Priorities.USER - 100) + private static final class EndpointObjectMapperContextResolver implements ContextResolver { + + private final EndpointObjectMapper endpointObjectMapper; + + private EndpointObjectMapperContextResolver(EndpointObjectMapper endpointObjectMapper) { + this.endpointObjectMapper = endpointObjectMapper; + } + + @Override + public ObjectMapper getContext(Class type) { + return OperationResponseBody.class.isAssignableFrom(type) ? this.endpointObjectMapper.get() : null; + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java index 6d9d5dc3bcf4..433303ebcb94 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -17,9 +17,16 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.web.reactive; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.List; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure; import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties; @@ -28,7 +35,9 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; +import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.jackson.EndpointObjectMapper; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -48,7 +57,14 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Role; +import org.springframework.core.codec.Encoder; import org.springframework.core.env.Environment; +import org.springframework.http.MediaType; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.http.server.reactive.HttpHandler; import org.springframework.util.StringUtils; import org.springframework.web.reactive.DispatcherHandler; @@ -114,4 +130,55 @@ public ControllerEndpointHandlerMapping controllerEndpointHandlerMapping( corsProperties.toCorsConfiguration()); } + @Bean + @ConditionalOnBean(EndpointObjectMapper.class) + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static ServerCodecConfigurerEndpointObjectMapperBeanPostProcessor serverCodecConfigurerEndpointObjectMapperBeanPostProcessor( + EndpointObjectMapper endpointObjectMapper) { + return new ServerCodecConfigurerEndpointObjectMapperBeanPostProcessor(endpointObjectMapper); + } + + /** + * {@link BeanPostProcessor} to apply {@link EndpointObjectMapper} for + * {@link OperationResponseBody} to {@link Jackson2JsonEncoder} instances. + */ + private static class ServerCodecConfigurerEndpointObjectMapperBeanPostProcessor implements BeanPostProcessor { + + private static final List MEDIA_TYPES = Collections + .unmodifiableList(Arrays.asList(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"))); + + private final EndpointObjectMapper endpointObjectMapper; + + ServerCodecConfigurerEndpointObjectMapperBeanPostProcessor(EndpointObjectMapper endpointObjectMapper) { + this.endpointObjectMapper = endpointObjectMapper; + } + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof ServerCodecConfigurer) { + process((ServerCodecConfigurer) bean); + } + return bean; + } + + private void process(ServerCodecConfigurer configurer) { + for (HttpMessageWriter writer : configurer.getWriters()) { + if (writer instanceof EncoderHttpMessageWriter) { + process(((EncoderHttpMessageWriter) writer).getEncoder()); + } + } + } + + private void process(Encoder encoder) { + if (encoder instanceof Jackson2JsonEncoder) { + Jackson2JsonEncoder jackson2JsonEncoder = (Jackson2JsonEncoder) encoder; + jackson2JsonEncoder.registerObjectMappersForType(OperationResponseBody.class, (associations) -> { + ObjectMapper objectMapper = this.endpointObjectMapper.get(); + MEDIA_TYPES.forEach((mimeType) -> associations.put(mimeType, objectMapper)); + }); + } + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java index f60bd8ce8ffc..07d507aaab2e 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java @@ -17,9 +17,14 @@ package org.springframework.boot.actuate.autoconfigure.endpoint.web.servlet; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.List; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure; import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties; @@ -28,7 +33,9 @@ import org.springframework.boot.actuate.autoconfigure.web.server.ConditionalOnManagementPort; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType; import org.springframework.boot.actuate.endpoint.ExposableEndpoint; +import org.springframework.boot.actuate.endpoint.OperationResponseBody; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.jackson.EndpointObjectMapper; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -49,9 +56,14 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Role; import org.springframework.core.env.Environment; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.util.StringUtils; import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** * {@link ManagementContextConfiguration @ManagementContextConfiguration} for Spring MVC @@ -116,4 +128,46 @@ public ControllerEndpointHandlerMapping controllerEndpointHandlerMapping( corsProperties.toCorsConfiguration()); } + @Bean + @ConditionalOnBean(EndpointObjectMapper.class) + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static EndpointObjectMapperWebMvcConfigurer endpointObjectMapperWebMvcConfigurer( + EndpointObjectMapper endpointObjectMapper) { + return new EndpointObjectMapperWebMvcConfigurer(endpointObjectMapper); + } + + /** + * {@link WebMvcConfigurer} to apply {@link EndpointObjectMapper} for + * {@link OperationResponseBody} to {@link MappingJackson2HttpMessageConverter} + * instances. + */ + static class EndpointObjectMapperWebMvcConfigurer implements WebMvcConfigurer { + + private static final List MEDIA_TYPES = Collections + .unmodifiableList(Arrays.asList(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"))); + + private final EndpointObjectMapper endpointObjectMapper; + + EndpointObjectMapperWebMvcConfigurer(EndpointObjectMapper endpointObjectMapper) { + this.endpointObjectMapper = endpointObjectMapper; + } + + @Override + public void configureMessageConverters(List> converters) { + for (HttpMessageConverter converter : converters) { + if (converter instanceof MappingJackson2HttpMessageConverter) { + configure((MappingJackson2HttpMessageConverter) converter); + } + } + } + + private void configure(MappingJackson2HttpMessageConverter converter) { + converter.registerObjectMappersForType(OperationResponseBody.class, (associations) -> { + ObjectMapper objectMapper = this.endpointObjectMapper.get(); + MEDIA_TYPES.forEach((mimeType) -> associations.put(mimeType, objectMapper)); + }); + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 6cec380cd6a1..4785a610a662 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -60,6 +60,12 @@ "type": "java.lang.Boolean", "description": "Whether to enable or disable all endpoints by default." }, + { + "name": "management.endpoints.jackson.isolated-object-mapper", + "type": "java.lang.Boolean", + "description": "Whether to use an isolated object mapper to serialize endpoint JSON.", + "defaultValue": true + }, { "name": "management.endpoints.jmx.domain", "defaultValue": "org.springframework.boot" diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 99d0c2fb3573..a1a38524d95b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -17,6 +17,7 @@ org.springframework.boot.actuate.autoconfigure.couchbase.CouchbaseReactiveHealth org.springframework.boot.actuate.autoconfigure.data.elasticsearch.ElasticsearchReactiveHealthContributorAutoConfiguration org.springframework.boot.actuate.autoconfigure.elasticsearch.ElasticsearchRestHealthContributorAutoConfiguration org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration +org.springframework.boot.actuate.autoconfigure.endpoint.jackson.JacksonEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.endpoint.jmx.JmxEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration org.springframework.boot.actuate.autoconfigure.env.EnvironmentEndpointAutoConfiguration diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfigurationTests.java new file mode 100644 index 000000000000..2d76e97fe194 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/jackson/JacksonEndpointAutoConfigurationTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2022 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.endpoint.jackson; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.endpoint.jackson.EndpointObjectMapper; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JacksonEndpointAutoConfiguration}. + * + * @author Phillip Webb + */ +class JacksonEndpointAutoConfigurationTests { + + private ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonEndpointAutoConfiguration.class)); + + @Test + void endpointObjectMapperWhenNoProperty() { + this.runner.run((context) -> assertThat(context).hasSingleBean(EndpointObjectMapper.class)); + } + + @Test + void endpointObjectMapperWhenPropertyTrue() { + this.runner.withPropertyValues("management.endpoints.jackson.isolated-object-mapper=true") + .run((context) -> assertThat(context).hasSingleBean(EndpointObjectMapper.class)); + } + + @Test + void endpointObjectMapperWhenPropertyFalse() { + this.runner.withPropertyValues("management.endpoints.jackson.isolated-object-mapper=false") + .run((context) -> assertThat(context).doesNotHaveBean(EndpointObjectMapper.class)); + } + + @Configuration(proxyBeanMethods = false) + static class TestEndpointMapperConfiguration { + + @Bean + TestEndpointObjectMapper testEndpointObjectMapper() { + return new TestEndpointObjectMapper(); + } + + } + + static class TestEndpointObjectMapper implements EndpointObjectMapper { + + @Override + public ObjectMapper get() { + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/EndpointObjectMapperConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/EndpointObjectMapperConfiguration.java new file mode 100644 index 000000000000..261412258ac4 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/EndpointObjectMapperConfiguration.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-2022 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 java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.jsontype.TypeSerializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdScalarSerializer; + +import org.springframework.boot.actuate.endpoint.jackson.EndpointObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +/** + * {@link Configuration @Configuration} that creates an {@link EndpointObjectMapper} that + * reverses all strings. + * + * @author Phillip Webb + */ +@Configuration +class EndpointObjectMapperConfiguration { + + @Bean + EndpointObjectMapper endpointObjectMapper() { + SimpleModule module = new SimpleModule(); + module.addSerializer(String.class, new ReverseStringSerializer()); + ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json().modules(module).build(); + return () -> objectMapper; + } + + static class ReverseStringSerializer extends StdScalarSerializer { + + ReverseStringSerializer() { + super(String.class, false); + } + + @Override + public boolean isEmpty(SerializerProvider prov, Object value) { + return ((String) value).isEmpty(); + } + + @Override + public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException { + serialize(value, gen); + } + + @Override + public final void serializeWithType(Object value, JsonGenerator gen, SerializerProvider provider, + TypeSerializer typeSer) throws IOException { + serialize(value, gen); + } + + private void serialize(Object value, JsonGenerator gen) throws IOException { + StringBuilder builder = new StringBuilder((String) value); + gen.writeString(builder.reverse().toString()); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JerseyEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JerseyEndpointIntegrationTests.java index 1e8de09dc675..29d79fe72d52 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JerseyEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/JerseyEndpointIntegrationTests.java @@ -16,6 +16,7 @@ package org.springframework.boot.actuate.autoconfigure.integrationtest; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -44,6 +45,8 @@ import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.web.servlet.DispatcherServlet; +import static org.assertj.core.api.Assertions.assertThat; + /** * Integration tests for the Jersey actuator endpoints. * @@ -87,7 +90,22 @@ void actuatorEndpointsWhenSecurityAvailable() { .responseTimeout(Duration.ofMinutes(5)).build(); client.get().uri("/actuator").exchange().expectStatus().isUnauthorized(); }); + } + @Test + void endpointObjectMapperCanBeApplied() { + WebApplicationContextRunner contextRunner = getContextRunner(new Class[] { EndpointsConfiguration.class, + ResourceConfigConfiguration.class, EndpointObjectMapperConfiguration.class }); + contextRunner.run((context) -> { + int port = context.getSourceApplicationContext(AnnotationConfigServletWebServerApplicationContext.class) + .getWebServer().getPort(); + WebTestClient client = WebTestClient.bindToServer().baseUrl("http://localhost:" + port) + .responseTimeout(Duration.ofMinutes(5)).build(); + client.get().uri("/actuator/beans").exchange().expectStatus().isOk().expectBody().consumeWith((result) -> { + String json = new String(result.getResponseBody(), StandardCharsets.UTF_8); + assertThat(json).contains("\"scope\":\"notelgnis\""); + }); + }); } protected void testJerseyEndpoints(Class[] userConfigurations) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebFluxEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebFluxEndpointIntegrationTests.java index 65bf929442e0..d19256824076 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebFluxEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebFluxEndpointIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2021 the original author or authors. + * Copyright 2012-2022 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. @@ -16,6 +16,8 @@ package org.springframework.boot.actuate.autoconfigure.integrationtest; +import java.nio.charset.StandardCharsets; + import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.autoconfigure.beans.BeansEndpointAutoConfiguration; @@ -36,6 +38,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.test.web.reactive.server.WebTestClient; +import static org.assertj.core.api.Assertions.assertThat; + /** * Integration tests for the WebFlux actuator endpoints. * @@ -68,6 +72,19 @@ void linksPageIsNotAvailableWhenDisabled() { }); } + @Test + void endpointObjectMapperCanBeApplied() { + this.contextRunner.withUserConfiguration(EndpointObjectMapperConfiguration.class) + .withPropertyValues("management.endpoints.web.exposure.include:*").run((context) -> { + WebTestClient client = createWebTestClient(context); + client.get().uri("/actuator/beans").exchange().expectStatus().isOk().expectBody() + .consumeWith((result) -> { + String json = new String(result.getResponseBody(), StandardCharsets.UTF_8); + assertThat(json).contains("\"scope\":\"notelgnis\""); + }); + }); + } + private WebTestClient createWebTestClient(ApplicationContext context) { return WebTestClient.bindToApplicationContext(context).configureClient().baseUrl("https://spring.example.org") .build(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointIntegrationTests.java index ca6eea6b8239..0f13ee43017b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointIntegrationTests.java @@ -52,6 +52,7 @@ 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.MvcResult; import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.test.web.servlet.setup.MockMvcConfigurer; @@ -140,6 +141,16 @@ void linksPageIsNotAvailableWhenDisabled() throws Exception { mockMvc.perform(get("/actuator").accept("*/*")).andExpect(status().isNotFound()); } + @Test + void endpointObjectMapperCanBeApplied() throws Exception { + this.context = new AnnotationConfigServletWebApplicationContext(); + this.context.register(EndpointObjectMapperConfiguration.class, DefaultConfiguration.class); + TestPropertyValues.of("management.endpoints.web.exposure.include=*").applyTo(this.context); + MockMvc mockMvc = doCreateMockMvc(); + MvcResult result = mockMvc.perform(get("/actuator/beans")).andExpect(status().isOk()).andReturn(); + assertThat(result.getResponse().getContentAsString()).contains("\"scope\":\"notelgnis\""); + } + private MockMvc createSecureMockMvc() { return doCreateMockMvc(springSecurity()); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Operation.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Operation.java index d68856a078c2..d21f2fbfc64d 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Operation.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/Operation.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2019 the original author or authors. + * Copyright 2012-2022 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. @@ -32,7 +32,9 @@ public interface Operation { OperationType getType(); /** - * Invoke the underlying operation using the given {@code context}. + * Invoke the underlying operation using the given {@code context}. Results intended + * to be returned in the body of the response should additionally implement + * {@link OperationResponseBody}. * @param context the context in to use when invoking the operation * @return the result of the operation, may be {@code null} */ diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationResponseBody.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationResponseBody.java new file mode 100644 index 000000000000..6901e543559b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationResponseBody.java @@ -0,0 +1,45 @@ +/* + * Copyright 2012-2022 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; + +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Tagging interface used to indicate that an operation result is intended to be returned + * in the body of the response. Primarily intended to support JSON serialzation using an + * endpoint specific {@link ObjectMapper}. + * + * @author Phillip Webb + * @since 3.0.0 + */ +public interface OperationResponseBody { + + /** + * Return a {@link OperationResponseBody} {@link Map} instance containing entires from + * the given {@code map}. + * @param the key type + * @param the value type + * @param map the source map or {@code null} + * @return a {@link OperationResponseBody} version of the map or {@code null} + */ + static Map of(Map map) { + return (map != null) ? new OperationResponseBodyMap<>(map) : null; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationResponseBodyMap.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationResponseBodyMap.java new file mode 100644 index 000000000000..c1863686cbc8 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/OperationResponseBodyMap.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2022 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; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * {@link LinkedHashMap} to support {@link OperationResponseBody#of(java.util.Map)}. + * + * @param the key type + * @param the value type + * @author Phillip Webb + */ +class OperationResponseBodyMap extends LinkedHashMap implements OperationResponseBody { + + OperationResponseBodyMap(Map map) { + super(map); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/EndpointObjectMapper.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/EndpointObjectMapper.java new file mode 100644 index 000000000000..930011a25c4d --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/EndpointObjectMapper.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2022 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.jackson; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.boot.actuate.endpoint.OperationResponseBody; + +/** + * Interface used to supply the {@link ObjectMapper} that should be used when serializing + * {@link OperationResponseBody} endpoint results. + * + * @author Phillip Webb + * @since 3.0.0 + * @see OperationResponseBody + */ +public interface EndpointObjectMapper { + + /** + * Return the {@link ObjectMapper} that should be used to serialize + * {@link OperationResponseBody} endpoint results. + * @return the object mapper + */ + ObjectMapper get(); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/package-info.java new file mode 100644 index 000000000000..a2e42218d2e6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jackson/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2022 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. + */ + +/** + * Jackson support classes for actuator endpoints. + */ +package org.springframework.boot.actuate.endpoint.jackson; diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/OperationResponseBodyTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/OperationResponseBodyTests.java new file mode 100644 index 000000000000..7c62d7a8ecc7 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/OperationResponseBodyTests.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-2022 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; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link OperationResponseBody}. + * + * @author Phillip Webb + */ +class OperationResponseBodyTests { + + @Test + void ofMapReturnsOperationResponseBody() { + LinkedHashMap map = new LinkedHashMap<>(); + map.put("one", "1"); + map.put("two", "2"); + Map mapDescriptor = OperationResponseBody.of(map); + assertThat(mapDescriptor).containsExactly(entry("one", "1"), entry("two", "2")); + assertThat(mapDescriptor).isInstanceOf(OperationResponseBody.class); + } + +}