diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/monitoring.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/monitoring.adoc index aed5a2acf0e4..8f04b6496d6d 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/monitoring.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/monitoring.adoc @@ -9,8 +9,7 @@ TIP: Actuator is supported natively with Spring MVC, Spring WebFlux, and Jersey. If both Jersey and Spring MVC are available, Spring MVC is used. NOTE: Jackson is a required dependency in order to get the correct JSON responses as documented in the xref:api:rest/actuator/index.adoc[API documentation]. -Jackson 3 should be used for Spring MVC and Spring WebFlux. -Jersey does not yet have a Jackson 3 module, so you will need to use Jackson 2. +Jackson 3 should be used for Spring MVC, Spring WebFlux, and Jersey. diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc index e2d91e36e288..f8d2ad10bb5d 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/servlet.adoc @@ -506,6 +506,14 @@ To avoid this limitation, the `packages` method should not be used, and endpoint For more advanced customizations, you can also register an arbitrary number of beans that implement javadoc:org.springframework.boot.jersey.autoconfigure.ResourceConfigCustomizer[]. +Jersey uses Jackson 3 for JSON serialization and deserialization by default. +The auto-configured javadoc:tools.jackson.databind.json.JsonMapper[] is registered with Jersey and can be customized using `spring.jackson.*` properties or javadoc:org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer[] beans. +See xref:features/json.adoc#features.json.jackson[] for more details. + +To use the deprecated Jackson 2 support, set configprop:spring.jersey.preferred-json-mapper[] to `jackson2`. +Jersey then uses the auto-configured Jackson 2 `ObjectMapper`, including `spring.jackson2.*` settings and Jackson 2 customizations. +Both Jackson versions are included in the starter; dependency exclusions are not required to select a mapper. + All the registered endpoints should be a javadoc:org.springframework.stereotype.Component[format=annotation] with HTTP resource annotations (`@GET` and others), as shown in the following example: include-code::MyEndpoint[] diff --git a/integration-test/spring-boot-actuator-integration-tests/build.gradle b/integration-test/spring-boot-actuator-integration-tests/build.gradle index 374e947ed93e..d6ff19e2cacc 100644 --- a/integration-test/spring-boot-actuator-integration-tests/build.gradle +++ b/integration-test/spring-boot-actuator-integration-tests/build.gradle @@ -46,5 +46,4 @@ dependencies { testImplementation("net.minidev:json-smart") testImplementation("org.springframework.security:spring-security-web") testRuntimeOnly("ch.qos.logback:logback-classic") - testRuntimeOnly("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") // Required for Jersey } diff --git a/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java b/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java index 42e73b807d74..b2aaa999114b 100644 --- a/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java +++ b/integration-test/spring-boot-actuator-integration-tests/src/test/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyWebEndpointIntegrationTests.java @@ -21,19 +21,19 @@ import java.util.Collection; import java.util.HashSet; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.ws.rs.ext.ContextResolver; -import org.glassfish.jersey.jackson.JacksonFeature; +import org.glassfish.jersey.jackson3.JacksonFeature; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.model.Resource; import org.glassfish.jersey.servlet.ServletContainer; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; @@ -61,6 +61,7 @@ * Integration tests for web endpoints exposed using Jersey. * * @author Andy Wilkinson + * @author Kristoffer Larsen Hopland * @see JerseyEndpointResourceFactory */ class JerseyWebEndpointIntegrationTests @@ -121,7 +122,7 @@ ResourceConfig resourceConfig(Environment environment, WebEndpointDiscoverer end new EndpointLinksResolver(endpointDiscoverer.getEndpoints()), StringUtils.hasText(endpointPath)); resourceConfig.registerResources(new HashSet<>(resources)); resourceConfig.register(JacksonFeature.class); - resourceConfig.register(new ObjectMapperContextResolver(new ObjectMapper()), ContextResolver.class); + resourceConfig.register(new JsonMapperContextResolver(JsonMapper.builder().build()), ContextResolver.class); return resourceConfig; } @@ -154,17 +155,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } - private static final class ObjectMapperContextResolver implements ContextResolver { + private static final class JsonMapperContextResolver implements ContextResolver { - private final ObjectMapper objectMapper; + private final JsonMapper jsonMapper; - private ObjectMapperContextResolver(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + private JsonMapperContextResolver(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; } @Override - public ObjectMapper getContext(Class type) { - return this.objectMapper; + public JsonMapper getContext(Class type) { + return this.jsonMapper; } } diff --git a/module/spring-boot-jersey/build.gradle b/module/spring-boot-jersey/build.gradle index 8f78e80f98b1..365ebf617879 100644 --- a/module/spring-boot-jersey/build.gradle +++ b/module/spring-boot-jersey/build.gradle @@ -31,6 +31,7 @@ dependencies { api("org.glassfish.jersey.core:jersey-server") api("org.glassfish.jersey.ext:jersey-spring6") api("org.glassfish.jersey.media:jersey-media-json-jackson") + api("org.glassfish.jersey.media:jersey-media-json-jackson3") compileOnly("jakarta.servlet:jakarta.servlet-api") compileOnly("com.google.code.findbugs:jsr305") @@ -40,6 +41,7 @@ dependencies { optional(project(":core:spring-boot-autoconfigure")) optional(project(":module:spring-boot-actuator-autoconfigure")) optional(project(":module:spring-boot-health")) + optional(project(":module:spring-boot-jackson")) optional(project(":module:spring-boot-jackson2")) optional(project(":module:spring-boot-micrometer-metrics")) optional(project(":module:spring-boot-micrometer-observation")) @@ -48,7 +50,7 @@ dependencies { testFixturesApi(testFixtures(project(":module:spring-boot-actuator"))) testFixturesImplementation(project(":module:spring-boot-tomcat")) - testFixturesRuntimeOnly(project(":module:spring-boot-jackson2")) + testFixturesRuntimeOnly(project(":module:spring-boot-jackson")) testImplementation(project(":core:spring-boot-test")) testImplementation(project(":module:spring-boot-restclient")) diff --git a/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfiguration.java b/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfiguration.java index ad0fb9308fea..d5711d41d391 100644 --- a/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfiguration.java +++ b/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfiguration.java @@ -23,18 +23,14 @@ import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; import jakarta.servlet.ServletRegistration; -import jakarta.ws.rs.ext.ContextResolver; -import jakarta.xml.bind.annotation.XmlElement; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.glassfish.jersey.jackson.JacksonFeature; import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.spring.SpringComponentProvider; import org.glassfish.jersey.servlet.ServletContainer; import org.glassfish.jersey.servlet.ServletProperties; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureOrder; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -43,7 +39,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingFilterBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -51,7 +46,6 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.util.ClassUtils; @@ -66,9 +60,11 @@ * @author Andy Wilkinson * @author Eddú Meléndez * @author Stephane Nicoll + * @author Kristoffer Larsen Hopland * @since 4.0.0 */ -@AutoConfiguration(afterName = { "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration" }) +@AutoConfiguration(afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration" }) @ConditionalOnClass({ SpringComponentProvider.class, ServletRegistration.class }) @ConditionalOnBean(type = "org.glassfish.jersey.server.ResourceConfig") @ConditionalOnWebApplication(type = Type.SERVLET) @@ -175,59 +171,4 @@ public void onStartup(ServletContext servletContext) throws ServletException { } - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(JacksonFeature.class) - @ConditionalOnSingleCandidate(com.fasterxml.jackson.databind.ObjectMapper.class) - static class Jackson2ResourceConfigCustomizerConfiguration { - - @Bean - ResourceConfigCustomizer jacksonResourceConfigCustomizer( - com.fasterxml.jackson.databind.ObjectMapper objectMapper) { - return (ResourceConfig config) -> { - config.register(JacksonFeature.class); - config.register(new ObjectMapperContextResolver(objectMapper), ContextResolver.class); - }; - } - - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass({ com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector.class, - XmlElement.class }) - static class JaxbJackson2ObjectMapperCustomizerConfiguration { - - @Autowired - void addJaxbAnnotationIntrospector(com.fasterxml.jackson.databind.ObjectMapper objectMapper) { - com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector jaxbAnnotationIntrospector = new com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector( - objectMapper.getTypeFactory()); - objectMapper.setAnnotationIntrospectors( - createPair(objectMapper.getSerializationConfig(), jaxbAnnotationIntrospector), - createPair(objectMapper.getDeserializationConfig(), jaxbAnnotationIntrospector)); - } - - private com.fasterxml.jackson.databind.AnnotationIntrospector createPair( - com.fasterxml.jackson.databind.cfg.MapperConfig config, - com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector jaxbAnnotationIntrospector) { - return com.fasterxml.jackson.databind.AnnotationIntrospector.pair(config.getAnnotationIntrospector(), - jaxbAnnotationIntrospector); - } - - } - - private static final class ObjectMapperContextResolver - implements ContextResolver { - - private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; - - private ObjectMapperContextResolver(com.fasterxml.jackson.databind.ObjectMapper objectMapper) { - this.objectMapper = objectMapper; - } - - @Override - public com.fasterxml.jackson.databind.ObjectMapper getContext(Class type) { - return this.objectMapper; - } - - } - - } - } diff --git a/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/JerseyJacksonAutoConfiguration.java b/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/JerseyJacksonAutoConfiguration.java new file mode 100644 index 000000000000..437d44c1be3e --- /dev/null +++ b/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/JerseyJacksonAutoConfiguration.java @@ -0,0 +1,246 @@ +/* + * Copyright 2012-present 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.jersey.autoconfigure; + +import jakarta.ws.rs.core.Feature; +import jakarta.ws.rs.core.FeatureContext; +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.xml.bind.annotation.XmlElement; +import org.glassfish.jersey.internal.InternalProperties; +import org.glassfish.jersey.internal.util.PropertiesHelper; +import org.glassfish.jersey.jackson3.JacksonFeature; +import org.glassfish.jersey.server.ResourceConfig; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationModule; +import tools.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationModule.Priority; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.autoconfigure.condition.AnyNestedCondition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +/** + * Jackson configuration shared by Jersey applications and management contexts. + * + * @author Kristoffer Larsen Hopland + * @since 4.2.0 + */ +@AutoConfiguration(before = JerseyAutoConfiguration.class, + beforeName = "org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration", + afterName = { "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration", + "org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration", + "org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration" }) +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +@ConditionalOnClass(ResourceConfig.class) +@ConditionalOnWebApplication(type = Type.SERVLET) +@Conditional(JerseyJacksonAutoConfiguration.JerseyResourcesAvailable.class) +public final class JerseyJacksonAutoConfiguration { + + private static void registerPreferredFeature(ResourceConfig config, Feature feature) { + Feature delegate = config.getInstances() + .stream() + .filter(feature.getClass()::isInstance) + .map(Feature.class::cast) + .findFirst() + .orElse(feature); + String propertyName = PropertiesHelper.getPropertyNameForRuntime(InternalProperties.JSON_FEATURE, + config.getRuntimeType()); + // Disable the discovered features before any of them can configure providers. + config.property(propertyName, PreferredJacksonFeature.class.getName()); + config.register(new PreferredJacksonFeature(delegate), Ordered.HIGHEST_PRECEDENCE); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ JacksonFeature.class, JsonMapper.class }) + @ConditionalOnProperty(name = "spring.jersey.preferred-json-mapper", havingValue = "jackson", matchIfMissing = true) + @ConditionalOnSingleCandidate(JsonMapper.class) + static class JacksonResourceConfigCustomizerConfiguration { + + @Bean + ResourceConfigCustomizer jacksonResourceConfigCustomizer(JsonMapper jsonMapper) { + return (ResourceConfig config) -> { + registerPreferredFeature(config, new JacksonFeature()); + config.register(new JsonMapperContextResolver(jsonMapper), ContextResolver.class); + }; + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ JsonMapperBuilderCustomizer.class, JakartaXmlBindAnnotationModule.class, + XmlElement.class }) + static class JaxbJsonMapperBuilderCustomizerConfiguration { + + @Bean + JsonMapperBuilderCustomizer jaxbJsonMapperBuilderCustomizer(ObjectProvider modules) { + return (builder) -> { + if (modules.stream().noneMatch(JakartaXmlBindAnnotationModule.class::isInstance)) { + builder.addModule(new JakartaXmlBindAnnotationModule().setPriority(Priority.SECONDARY)); + } + }; + } + + } + + private static final class JsonMapperContextResolver implements ContextResolver { + + private final JsonMapper jsonMapper; + + private JsonMapperContextResolver(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + } + + @Override + public JsonMapper getContext(Class type) { + return this.jsonMapper; + } + + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ org.glassfish.jersey.jackson.JacksonFeature.class, + com.fasterxml.jackson.databind.ObjectMapper.class }) + @Conditional(NoJacksonOrJackson2Preferred.class) + @SuppressWarnings("removal") + @ConditionalOnSingleCandidate(com.fasterxml.jackson.databind.ObjectMapper.class) + static class Jackson2ResourceConfigCustomizerConfiguration { + + @Bean + ResourceConfigCustomizer jackson2ResourceConfigCustomizer( + com.fasterxml.jackson.databind.ObjectMapper objectMapper) { + return (ResourceConfig config) -> { + registerPreferredFeature(config, new org.glassfish.jersey.jackson.JacksonFeature()); + config.register(new ObjectMapperContextResolver(objectMapper), ContextResolver.class); + }; + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector.class, + XmlElement.class }) + static class JaxbJackson2ObjectMapperCustomizerConfiguration { + + @Autowired + void addJaxbAnnotationIntrospector(com.fasterxml.jackson.databind.ObjectMapper objectMapper) { + com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector jaxbAnnotationIntrospector = new com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector( + objectMapper.getTypeFactory()); + objectMapper.setAnnotationIntrospectors( + createPair(objectMapper.getSerializationConfig(), jaxbAnnotationIntrospector), + createPair(objectMapper.getDeserializationConfig(), jaxbAnnotationIntrospector)); + } + + private com.fasterxml.jackson.databind.AnnotationIntrospector createPair( + com.fasterxml.jackson.databind.cfg.MapperConfig config, + com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector jaxbAnnotationIntrospector) { + return com.fasterxml.jackson.databind.AnnotationIntrospector.pair(config.getAnnotationIntrospector(), + jaxbAnnotationIntrospector); + } + + } + + private static final class ObjectMapperContextResolver + implements ContextResolver { + + private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; + + private ObjectMapperContextResolver(com.fasterxml.jackson.databind.ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public com.fasterxml.jackson.databind.ObjectMapper getContext(Class type) { + return this.objectMapper; + } + + } + + } + + static class JerseyResourcesAvailable extends AnyNestedCondition { + + JerseyResourcesAvailable() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnBean(ResourceConfig.class) + static class ApplicationResources { + + } + + @ConditionalOnBean(type = "org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier") + @ConditionalOnMissingClass("org.springframework.web.servlet.DispatcherServlet") + static class ManagementResources { + + } + + } + + static class NoJacksonOrJackson2Preferred extends AnyNestedCondition { + + NoJacksonOrJackson2Preferred() { + super(ConfigurationPhase.PARSE_CONFIGURATION); + } + + @ConditionalOnMissingClass("tools.jackson.databind.json.JsonMapper") + static class NoJackson { + + } + + @ConditionalOnProperty(name = "spring.jersey.preferred-json-mapper", havingValue = "jackson2") + static class Jackson2Preferred { + + } + + } + + static final class PreferredJacksonFeature implements Feature { + + private final Feature delegate; + + PreferredJacksonFeature(Feature delegate) { + this.delegate = delegate; + } + + @Override + public boolean configure(FeatureContext context) { + String propertyName = PropertiesHelper.getPropertyNameForRuntime(InternalProperties.JSON_FEATURE, + context.getConfiguration().getRuntimeType()); + context.property(propertyName, "JacksonFeature"); + boolean configured = this.delegate.configure(context); + // Both Jersey Jackson features use the same JSON feature name. Configure + // the preferred provider first, then prevent the other features from running, + // including features that the application has already registered. + context.property(propertyName, PreferredJacksonFeature.class.getName()); + return configured; + } + + } + +} diff --git a/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyWebEndpointManagementContextConfiguration.java b/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyWebEndpointManagementContextConfiguration.java index ed288b648a4f..4b96c8a40c7c 100644 --- a/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyWebEndpointManagementContextConfiguration.java +++ b/module/spring-boot-jersey/src/main/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyWebEndpointManagementContextConfiguration.java @@ -30,7 +30,10 @@ import org.glassfish.jersey.server.ResourceConfig; import org.glassfish.jersey.server.model.Resource; import org.jspecify.annotations.Nullable; +import tools.jackson.databind.json.JsonMapper; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; 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.WebEndpointProperties; @@ -41,6 +44,7 @@ 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.EndpointJsonMapper; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMapping; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; @@ -71,6 +75,7 @@ * @author Michael Simons * @author Madhura Bhave * @author HaiTao Zhang + * @author Kristoffer Larsen Hopland */ @ManagementContextConfiguration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = Type.SERVLET) @@ -93,13 +98,30 @@ JerseyWebEndpointsResourcesRegistrar jerseyWebEndpointsResourcesRegistrar(Enviro endpointMediaTypes, basePath, shouldRegisterLinks); } + @Bean + @ConditionalOnBean(EndpointJsonMapper.class) + EndpointMapperResourceConfigCustomizer endpointJsonMapperResourceConfigCustomizer( + EndpointJsonMapper endpointJsonMapper) { + return new EndpointMapperResourceConfigCustomizer(new EndpointJsonMapperContextResolver(endpointJsonMapper)); + } + @Bean @ConditionalOnBean(org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper.class) @SuppressWarnings("removal") - ResourceConfigCustomizer endpointJackson2ObjectMapperResourceConfigCustomizer( + EndpointMapperResourceConfigCustomizer endpointJackson2ObjectMapperResourceConfigCustomizer( org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper endpointJackson2ObjectMapper) { - return (config) -> config.register( - new EndpointJackson2ObjectMapperContextResolver(endpointJackson2ObjectMapper), ContextResolver.class); + return new EndpointMapperResourceConfigCustomizer( + new EndpointJackson2ObjectMapperContextResolver(endpointJackson2ObjectMapper)); + } + + @Bean + ManagementContextResourceConfigCustomizer managementJsonMapperResourceConfigCustomizer( + @Qualifier("jacksonResourceConfigCustomizer") ObjectProvider jacksonCustomizer, + @Qualifier("jackson2ResourceConfigCustomizer") ObjectProvider jackson2Customizer) { + return (config) -> { + jacksonCustomizer.ifAvailable((customizer) -> customizer.customize(config)); + jackson2Customizer.ifAvailable((customizer) -> customizer.customize(config)); + }; } private boolean shouldRegisterLinksMapping(WebEndpointProperties properties, Environment environment, @@ -108,6 +130,22 @@ private boolean shouldRegisterLinksMapping(WebEndpointProperties properties, Env || ManagementPortType.get(environment).equals(ManagementPortType.DIFFERENT)); } + static final class EndpointMapperResourceConfigCustomizer + implements ResourceConfigCustomizer, ManagementContextResourceConfigCustomizer { + + private final ContextResolver resolver; + + EndpointMapperResourceConfigCustomizer(ContextResolver resolver) { + this.resolver = resolver; + } + + @Override + public void customize(ResourceConfig config) { + config.register(this.resolver, ContextResolver.class); + } + + } + @Configuration(proxyBeanMethods = false) @ConditionalOnClass(HealthEndpoint.class) static class HealthConfiguration { @@ -223,6 +261,26 @@ private void register(Collection resources, ResourceConfig config) { } + /** + * {@link ContextResolver} used to obtain the {@link JsonMapper} that should be used + * for {@link OperationResponseBody} instances. + */ + @Priority(Priorities.USER - 100) + private static final class EndpointJsonMapperContextResolver implements ContextResolver { + + private final EndpointJsonMapper mapper; + + private EndpointJsonMapperContextResolver(EndpointJsonMapper mapper) { + this.mapper = mapper; + } + + @Override + public @Nullable JsonMapper getContext(Class type) { + return OperationResponseBody.class.isAssignableFrom(type) ? this.mapper.get() : null; + } + + } + /** * {@link ContextResolver} used to obtain the {@link ObjectMapper} that should be used * for {@link OperationResponseBody} instances. diff --git a/module/spring-boot-jersey/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-jersey/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 000000000000..579249069f2c --- /dev/null +++ b/module/spring-boot-jersey/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,23 @@ +{ + "properties": [ + { + "name": "spring.jersey.preferred-json-mapper", + "type": "java.lang.String", + "defaultValue": "jackson", + "description": "Preferred JSON mapper to use for Jersey. Supported values are 'jackson' and 'jackson2' (deprecated)." + } + ], + "hints": [ + { + "name": "spring.jersey.preferred-json-mapper", + "values": [ + { + "value": "jackson" + }, + { + "value": "jackson2" + } + ] + } + ] +} diff --git a/module/spring-boot-jersey/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/module/spring-boot-jersey/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 2c4bbf79ba8f..827c8aac54a7 100644 --- a/module/spring-boot-jersey/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/module/spring-boot-jersey/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ org.springframework.boot.jersey.autoconfigure.JerseyAutoConfiguration +org.springframework.boot.jersey.autoconfigure.JerseyJacksonAutoConfiguration org.springframework.boot.jersey.autoconfigure.actuate.endpoint.web.HealthEndpointJerseyExtensionAutoConfiguration org.springframework.boot.jersey.autoconfigure.metrics.JerseyServerMetricsAutoConfiguration diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationCustomJackson2ObjectMapperProviderTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationCustomJackson2ObjectMapperProviderTests.java new file mode 100644 index 000000000000..afbc989fd5a1 --- /dev/null +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationCustomJackson2ObjectMapperProviderTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-present 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.jersey.autoconfigure; + +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 com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import org.glassfish.jersey.message.MessageProperties; +import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.servlet.ServletContainer; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.http.server.LocalTestWebServer; +import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JerseyAutoConfiguration} when using a custom Jackson 2 ObjectMapper. + * + * @author Eddú Meléndez + * @author Kristoffer Larsen Hopland + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.jersey.preferred-json-mapper=jackson2", + "spring.jackson2.default-property-inclusion=always", + "spring.jackson.default-property-inclusion=always" }) +@DirtiesContext +@SuppressWarnings("removal") +class JerseyAutoConfigurationCustomJackson2ObjectMapperProviderTests { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void responseIsSerializedUsingCustomJackson2ObjectMapper() { + String uri = LocalTestWebServer.obtain(this.applicationContext).uri("/rest/message"); + ResponseEntity response = RestClient.create().get().uri(uri).retrieve().toEntity(String.class); + assertThat(this.applicationContext.getBeansOfType(ObjectMapper.class)).hasSize(1); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("{\"subject\":\"Jersey\"}"); + ServletContainer servlet = (ServletContainer) this.applicationContext + .getBean("jerseyServletRegistration", ServletRegistrationBean.class) + .getServlet(); + assertThat(servlet).isNotNull(); + assertThat(servlet.getApplicationHandler() + .getConfiguration() + .getProperty(MessageProperties.JSON_MAX_STRING_LENGTH)).isEqualTo(1024); + } + + @MinimalWebConfiguration + @ApplicationPath("/rest") + @Path("/message") + public static class Application extends ResourceConfig { + + Application() { + register(Application.class); + register(new org.glassfish.jersey.jackson.JacksonFeature().maxStringLength(1024)); + register(new org.glassfish.jersey.jackson3.JacksonFeature(), Ordered.HIGHEST_PRECEDENCE); + } + + @Bean + ObjectMapper objectMapper() { + return new ObjectMapper().setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL); + } + + @GET + public Message message() { + return new Message("Jersey", null); + } + + static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + } + + public static class Message { + + private String subject; + + private @Nullable String body; + + Message(String subject, @Nullable String body) { + this.subject = subject; + this.body = body; + } + + public String getSubject() { + return this.subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public @Nullable String getBody() { + return this.body; + } + + public void setBody(@Nullable String body) { + this.body = body; + } + + } + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @Configuration + @ImportAutoConfiguration({ TomcatServletWebServerAutoConfiguration.class, JacksonAutoConfiguration.class, + org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, + JerseyAutoConfiguration.class, JerseyJacksonAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class }) + protected @interface MinimalWebConfiguration { + + } + +} diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationCustomObjectMapperProviderTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationCustomObjectMapperProviderTests.java index caa2cb0605c0..8984fc4bd601 100644 --- a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationCustomObjectMapperProviderTests.java +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationCustomObjectMapperProviderTests.java @@ -22,23 +22,31 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import org.glassfish.jersey.message.MessageProperties; import org.glassfish.jersey.server.ResourceConfig; +import org.glassfish.jersey.servlet.ServletContainer; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.http.server.LocalTestWebServer; import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; +import org.springframework.boot.web.servlet.ServletRegistrationBean; import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.annotation.DirtiesContext; @@ -47,24 +55,34 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link JerseyAutoConfiguration} when using custom ObjectMapper. + * Tests for {@link JerseyAutoConfiguration} when using a custom JsonMapper. * * @author Eddú Meléndez + * @author Kristoffer Larsen Hopland */ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, - properties = "spring.jackson2.default-property-inclusion=non_null") + properties = "spring.jackson.default-property-inclusion=always") @DirtiesContext +@SuppressWarnings("removal") class JerseyAutoConfigurationCustomObjectMapperProviderTests { @Autowired private ApplicationContext applicationContext; @Test - void contextLoads() { + void responseIsSerializedUsingCustomJsonMapper() { String uri = LocalTestWebServer.obtain(this.applicationContext).uri("/rest/message"); ResponseEntity response = RestClient.create().get().uri(uri).retrieve().toEntity(String.class); + assertThat(this.applicationContext.getBeansOfType(JsonMapper.class)).hasSize(1); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isEqualTo("{\"subject\":\"Jersey\"}"); + ServletContainer servlet = (ServletContainer) this.applicationContext + .getBean("jerseyServletRegistration", ServletRegistrationBean.class) + .getServlet(); + assertThat(servlet).isNotNull(); + assertThat(servlet.getApplicationHandler() + .getConfiguration() + .getProperty(MessageProperties.JSON_MAX_STRING_LENGTH)).isEqualTo(1024); } @MinimalWebConfiguration @@ -74,6 +92,16 @@ public static class Application extends ResourceConfig { Application() { register(Application.class); + register(new org.glassfish.jersey.jackson3.JacksonFeature().maxStringLength(1024)); + register(new org.glassfish.jersey.jackson.JacksonFeature(), Ordered.HIGHEST_PRECEDENCE); + } + + @Bean + JsonMapper jsonMapper() { + return JsonMapper.builder() + .changeDefaultPropertyInclusion( + (inclusion) -> inclusion.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); } @GET @@ -116,14 +144,14 @@ public void setBody(@Nullable String body) { } - @SuppressWarnings("removal") @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration - @Import({ TomcatServletWebServerAutoConfiguration.class, + @ImportAutoConfiguration({ TomcatServletWebServerAutoConfiguration.class, JacksonAutoConfiguration.class, org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, - JerseyAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class }) + JerseyAutoConfiguration.class, JerseyJacksonAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class }) protected @interface MinimalWebConfiguration { } diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationJackson2ObjectMapperProviderTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationJackson2ObjectMapperProviderTests.java new file mode 100644 index 000000000000..5fa26cd97813 --- /dev/null +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationJackson2ObjectMapperProviderTests.java @@ -0,0 +1,152 @@ +/* + * Copyright 2012-present 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.jersey.autoconfigure; + +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 com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlTransient; +import org.glassfish.jersey.server.ResourceConfig; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.http.server.LocalTestWebServer; +import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link JerseyAutoConfiguration} with a Jackson 2 ObjectMapper. + * + * @author Eddú Meléndez + * @author Andy Wilkinson + * @author Kristoffer Larsen Hopland + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.jersey.preferred-json-mapper=jackson2", + "spring.jackson2.default-property-inclusion=non-null", + "spring.jackson.default-property-inclusion=always" }) +@DirtiesContext +@SuppressWarnings("removal") +class JerseyAutoConfigurationJackson2ObjectMapperProviderTests { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void responseIsSerializedUsingAutoConfiguredJackson2ObjectMapper() { + String uri = LocalTestWebServer.obtain(this.applicationContext).uri("/rest/message"); + ResponseEntity response = RestClient.create().get().uri(uri).retrieve().toEntity(String.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo("{\"subject\":\"Jersey\"}"); + } + + @MinimalWebConfiguration + @ApplicationPath("/rest") + @Path("/message") + public static class Application extends ResourceConfig { + + Application() { + register(Application.class); + register(org.glassfish.jersey.jackson3.JacksonFeature.class, Ordered.HIGHEST_PRECEDENCE); + } + + @GET + public Message message() { + return new Message("Jersey", null); + } + + static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + + } + + public static class Message { + + private @Nullable String subject; + + private @Nullable String body; + + Message() { + } + + Message(@Nullable String subject, @Nullable String body) { + this.subject = subject; + this.body = body; + } + + @JsonProperty("subject") + @XmlElement(name = "title") + public @Nullable String getSubject() { + return this.subject; + } + + public void setSubject(@Nullable String subject) { + this.subject = subject; + } + + public @Nullable String getBody() { + return this.body; + } + + public void setBody(@Nullable String body) { + this.body = body; + } + + @XmlTransient + public String getFoo() { + return "foo"; + } + + } + + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @Configuration + @Import({ TomcatServletWebServerAutoConfiguration.class, + org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration.class, + org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, + JerseyAutoConfiguration.class, JerseyJacksonAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class }) + protected @interface MinimalWebConfiguration { + + } + +} diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationObjectMapperProviderTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationObjectMapperProviderTests.java index ce145bf2461b..d942b5ff98d1 100644 --- a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationObjectMapperProviderTests.java +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationObjectMapperProviderTests.java @@ -22,9 +22,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import jakarta.xml.bind.annotation.XmlElement; import jakarta.xml.bind.annotation.XmlTransient; import org.glassfish.jersey.server.ResourceConfig; import org.jspecify.annotations.Nullable; @@ -40,6 +42,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.annotation.DirtiesContext; @@ -48,21 +51,23 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link JerseyAutoConfiguration} with an ObjectMapper. + * Tests for {@link JerseyAutoConfiguration} with a JsonMapper. * * @author Eddú Meléndez * @author Andy Wilkinson + * @author Kristoffer Larsen Hopland */ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, - properties = "spring.jackson2.default-property-inclusion:non-null") + properties = "spring.jackson.default-property-inclusion:non-null") @DirtiesContext +@SuppressWarnings("removal") class JerseyAutoConfigurationObjectMapperProviderTests { @Autowired private ApplicationContext applicationContext; @Test - void responseIsSerializedUsingAutoConfiguredObjectMapper() { + void responseIsSerializedUsingAutoConfiguredJsonMapper() { String uri = LocalTestWebServer.obtain(this.applicationContext).uri("/rest/message"); ResponseEntity response = RestClient.create().get().uri(uri).retrieve().toEntity(String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); @@ -76,6 +81,7 @@ public static class Application extends ResourceConfig { Application() { register(Application.class); + register(org.glassfish.jersey.jackson.JacksonFeature.class, Ordered.HIGHEST_PRECEDENCE); } @GET @@ -103,6 +109,8 @@ public static class Message { this.body = body; } + @JsonProperty("subject") + @XmlElement(name = "title") public @Nullable String getSubject() { return this.subject; } @@ -130,10 +138,11 @@ public String getFoo() { @Retention(RetentionPolicy.RUNTIME) @Documented @Configuration - @SuppressWarnings("removal") @Import({ TomcatServletWebServerAutoConfiguration.class, + org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration.class, org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, - JerseyAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class }) + JerseyAutoConfiguration.class, JerseyJacksonAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class }) protected @interface MinimalWebConfiguration { } diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationTests.java index 871a55aacd71..cce0e28fe354 100644 --- a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationTests.java +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/JerseyAutoConfigurationTests.java @@ -18,14 +18,24 @@ import java.util.Collections; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletException; +import jakarta.ws.rs.ext.ContextResolver; +import jakarta.xml.bind.annotation.XmlElement; +import org.glassfish.jersey.jackson3.JacksonFeature; import org.glassfish.jersey.server.ResourceConfig; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationIntrospector; +import tools.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationModule; +import tools.jackson.module.jakarta.xmlbind.JakartaXmlBindAnnotationModule.Priority; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; import org.springframework.boot.jersey.autoconfigure.JerseyAutoConfiguration.JerseyWebApplicationInitializer; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; @@ -33,6 +43,7 @@ import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.mock.web.MockServletContext; import org.springframework.web.filter.RequestContextFilter; @@ -42,11 +53,12 @@ * Tests for {@link JerseyAutoConfiguration}. * * @author Andy Wilkinson + * @author Kristoffer Larsen Hopland */ class JerseyAutoConfigurationTests { private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(JerseyAutoConfiguration.class)) + .withConfiguration(AutoConfigurations.of(JerseyAutoConfiguration.class, JerseyJacksonAutoConfiguration.class)) .withUserConfiguration(ResourceConfigConfiguration.class); @Test @@ -74,32 +86,210 @@ void whenUserDefinesARequestContextFilterRegistrationTheAutoConfiguredRegistrati }); } + @Test + void jacksonCustomizationBacksOffWithoutApplicationOrManagementResources() { + new WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(JerseyJacksonAutoConfiguration.class, JacksonAutoConfiguration.class)) + .run((context) -> { + assertThat(context).hasSingleBean(JsonMapper.class); + assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class); + }); + } + + @Test + void jsonMapperIsRegisteredWithJersey() { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)).run((context) -> { + ResourceConfig config = context.getBean(ResourceConfig.class); + assertThat(config.isRegistered(JerseyJacksonAutoConfiguration.PreferredJacksonFeature.class)).isTrue(); + ContextResolver resolver = (ContextResolver) config.getInstances() + .stream() + .filter(ContextResolver.class::isInstance) + .findFirst() + .orElseThrow(); + assertThat(resolver.getContext(Object.class)).isSameAs(context.getBean(JsonMapper.class)); + }); + } + + @Test + @SuppressWarnings("removal") + void jackson3IsPreferredWhenBothMappersAreAvailable() { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, + org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .run((context) -> { + assertThat(context).hasSingleBean(ResourceConfigCustomizer.class); + ResourceConfig config = context.getBean(ResourceConfig.class); + assertThat(config.getContracts(JerseyJacksonAutoConfiguration.PreferredJacksonFeature.class)) + .containsKey(jakarta.ws.rs.core.Feature.class); + assertThat(config.getContracts(org.glassfish.jersey.jackson.JacksonFeature.class)).isEmpty(); + assertThat(config.getInstances()).filteredOn(ContextResolver.class::isInstance) + .singleElement() + .satisfies((resolver) -> assertThat(((ContextResolver) resolver).getContext(Object.class)) + .isSameAs(context.getBean(JsonMapper.class))); + }); + } + @Test @SuppressWarnings("removal") - void whenJaxbIsAvailableTheObjectMapperIsCustomizedWithAnAnnotationIntrospector() { + void jackson2CanBePreferredWhenBothMappersAreAvailable() { + this.contextRunner + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, + org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .withPropertyValues("spring.jersey.preferred-json-mapper=jackson2") + .run((context) -> { + assertThat(context).hasSingleBean(ResourceConfigCustomizer.class); + ResourceConfig config = context.getBean(ResourceConfig.class); + assertThat(config.getContracts(JerseyJacksonAutoConfiguration.PreferredJacksonFeature.class)) + .containsKey(jakarta.ws.rs.core.Feature.class); + assertThat(config.getContracts(JacksonFeature.class)).isEmpty(); + assertThat(config.getInstances()).filteredOn(ContextResolver.class::isInstance) + .singleElement() + .satisfies((resolver) -> assertThat(((ContextResolver) resolver).getContext(Object.class)) + .isSameAs(context.getBean(com.fasterxml.jackson.databind.ObjectMapper.class))); + }); + } + + @Test + @SuppressWarnings("removal") + void jackson2IsUsedWhenJackson3IsNotAvailable() { this.contextRunner .withConfiguration(AutoConfigurations .of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader("tools.jackson", "org.glassfish.jersey.jackson3")) .run((context) -> { - ObjectMapper objectMapper = context.getBean(ObjectMapper.class); - assertThat(objectMapper.getSerializationConfig() - .getAnnotationIntrospector() - .allIntrospectors() - .stream() - .filter(JakartaXmlBindAnnotationIntrospector.class::isInstance)).hasSize(1); + assertThat(context).hasSingleBean(ResourceConfigCustomizer.class); + assertThat(context.getBean(ResourceConfig.class) + .isRegistered(JerseyJacksonAutoConfiguration.PreferredJacksonFeature.class)).isTrue(); }); } + @Test + void jackson2PreferenceDoesNotUseJackson3WhenJackson2IsMissing() { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withPropertyValues("spring.jersey.preferred-json-mapper=jackson2") + .withClassLoader(new FilteredClassLoader("com.fasterxml.jackson.databind")) + .run((context) -> assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class)); + } + + @Test + void jackson2CustomizerBacksOffWithoutObjectMapper() { + this.contextRunner.withPropertyValues("spring.jersey.preferred-json-mapper=jackson2") + .run((context) -> assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class)); + } + @Test @SuppressWarnings("removal") - void whenJaxbIsNotAvailableTheObjectMapperCustomizationBacksOff() { + void jackson2CustomizerBacksOffWithoutJacksonFeature() { this.contextRunner .withConfiguration(AutoConfigurations .of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) + .withPropertyValues("spring.jersey.preferred-json-mapper=jackson2") + .withClassLoader(new FilteredClassLoader(org.glassfish.jersey.jackson.JacksonFeature.class)) + .run((context) -> assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class)); + } + + @Test + void jackson2CustomizerBacksOffWithMultipleObjectMappers() { + this.contextRunner.withPropertyValues("spring.jersey.preferred-json-mapper=jackson2") + .withBean("first", com.fasterxml.jackson.databind.ObjectMapper.class, + com.fasterxml.jackson.databind.ObjectMapper::new) + .withBean("second", com.fasterxml.jackson.databind.ObjectMapper.class, + com.fasterxml.jackson.databind.ObjectMapper::new) + .run((context) -> assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class)); + } + + @Test + void primaryJackson2ObjectMapperIsRegisteredWithJersey() { + this.contextRunner.withPropertyValues("spring.jersey.preferred-json-mapper=jackson2") + .withBean("first", com.fasterxml.jackson.databind.ObjectMapper.class, + com.fasterxml.jackson.databind.ObjectMapper::new) + .withBean("primary", com.fasterxml.jackson.databind.ObjectMapper.class, + com.fasterxml.jackson.databind.ObjectMapper::new, (definition) -> definition.setPrimary(true)) + .run((context) -> { + ResourceConfig config = context.getBean(ResourceConfig.class); + assertThat(config.getInstances()).filteredOn(ContextResolver.class::isInstance) + .singleElement() + .satisfies((resolver) -> assertThat(((ContextResolver) resolver).getContext(Object.class)) + .isSameAs(context.getBean("primary"))); + }); + } + + @Test + void jsonMapperCustomizerBacksOffWithoutJsonMapper() { + this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class)); + } + + @Test + void jsonMapperCustomizerBacksOffWithoutJacksonFeature() { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withClassLoader(new FilteredClassLoader(JacksonFeature.class)) + .run((context) -> assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class)); + } + + @Test + void jsonMapperCustomizerBacksOffWithMultipleJsonMappers() { + this.contextRunner.withUserConfiguration(MultipleJsonMappersConfiguration.class) + .run((context) -> assertThat(context).doesNotHaveBean(ResourceConfigCustomizer.class)); + } + + @Test + void primaryJsonMapperIsRegisteredWithJersey() { + this.contextRunner + .withUserConfiguration(MultipleJsonMappersConfiguration.class, PrimaryJsonMapperConfiguration.class) + .run((context) -> { + ResourceConfig config = context.getBean(ResourceConfig.class); + ContextResolver resolver = (ContextResolver) config.getInstances() + .stream() + .filter(ContextResolver.class::isInstance) + .findFirst() + .orElseThrow(); + assertThat(resolver.getContext(Object.class)).isSameAs(context.getBean("primaryJsonMapper")); + }); + } + + @Test + void jsonMapperIsRegisteredWithoutJacksonAutoConfigurationModule() { + this.contextRunner.withUserConfiguration(PrimaryJsonMapperConfiguration.class) + .withClassLoader(new FilteredClassLoader("org.springframework.boot.jackson")) + .run((context) -> { + assertThat(context).hasSingleBean(ResourceConfigCustomizer.class); + assertThat(context.getBean(ResourceConfig.class) + .isRegistered(JerseyJacksonAutoConfiguration.PreferredJacksonFeature.class)).isTrue(); + }); + } + + @Test + void whenJaxbIsAvailableTheJsonMapperIsCustomizedWithAnAnnotationIntrospector() { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)).run((context) -> { + JsonMapper jsonMapper = context.getBean(JsonMapper.class); + assertThat(jsonMapper.serializationConfig() + .getAnnotationIntrospector() + .allIntrospectors() + .stream() + .filter(JakartaXmlBindAnnotationIntrospector.class::isInstance)).hasSize(1); + }); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void userProvidedJaxbModuleIsUsed(boolean findAndAddModules) { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withUserConfiguration(CustomJaxbModuleConfiguration.class) + .withPropertyValues("spring.jackson.find-and-add-modules=" + findAndAddModules) + .run((context) -> { + JsonMapper jsonMapper = context.getBean(JsonMapper.class); + assertThat(jsonMapper.writeValueAsString(new JaxbAnnotatedBean())).isEqualTo("{\"jaxb\":\"value\"}"); + }); + } + + @Test + void whenJaxbIsNotAvailableTheJsonMapperCustomizationBacksOff() { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withPropertyValues("spring.jackson.find-and-add-modules=false") .withClassLoader(new FilteredClassLoader("jakarta.xml.bind.annotation")) .run((context) -> { - ObjectMapper objectMapper = context.getBean(ObjectMapper.class); - assertThat(objectMapper.getSerializationConfig() + JsonMapper jsonMapper = context.getBean(JsonMapper.class); + assertThat(jsonMapper.serializationConfig() .getAnnotationIntrospector() .allIntrospectors() .stream() @@ -108,15 +298,13 @@ void whenJaxbIsNotAvailableTheObjectMapperCustomizationBacksOff() { } @Test - @SuppressWarnings("removal") - void whenJacksonJaxbModuleIsNotAvailableTheObjectMapperCustomizationBacksOff() { - this.contextRunner - .withConfiguration(AutoConfigurations - .of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class)) - .withClassLoader(new FilteredClassLoader(JakartaXmlBindAnnotationIntrospector.class)) + void whenJacksonJaxbModuleIsNotAvailableTheJsonMapperCustomizationBacksOff() { + this.contextRunner.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withPropertyValues("spring.jackson.find-and-add-modules=false") + .withClassLoader(new FilteredClassLoader("tools.jackson.module.jakarta.xmlbind")) .run((context) -> { - ObjectMapper objectMapper = context.getBean(ObjectMapper.class); - assertThat(objectMapper.getSerializationConfig() + JsonMapper jsonMapper = context.getBean(JsonMapper.class); + assertThat(jsonMapper.serializationConfig() .getAnnotationIntrospector() .allIntrospectors() .stream() @@ -139,6 +327,52 @@ void webApplicationInitializerHasNoEffectWhenJerseyIsAbsent() throws ServletExce assertThat(Collections.list(context.getInitParameterNames())).isEmpty(); } + @Configuration(proxyBeanMethods = false) + static class CustomJaxbModuleConfiguration { + + @Bean + JacksonModule customJaxbModule() { + return new JakartaXmlBindAnnotationModule().setPriority(Priority.PRIMARY); + } + + } + + static class JaxbAnnotatedBean { + + @JsonProperty("jackson") + @XmlElement(name = "jaxb") + String getValue() { + return "value"; + } + + } + + @Configuration(proxyBeanMethods = false) + static class MultipleJsonMappersConfiguration { + + @Bean + JsonMapper firstJsonMapper() { + return JsonMapper.builder().build(); + } + + @Bean + JsonMapper secondJsonMapper() { + return JsonMapper.builder().build(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class PrimaryJsonMapperConfiguration { + + @Bean + @Primary + JsonMapper primaryJsonMapper() { + return JsonMapper.builder().build(); + } + + } + @Configuration(proxyBeanMethods = false) static class ResourceConfigConfiguration { diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointAccessIntegrationTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointAccessIntegrationTests.java index d5553485311f..fcc0409ee181 100644 --- a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointAccessIntegrationTests.java +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointAccessIntegrationTests.java @@ -31,7 +31,9 @@ import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; import org.springframework.boot.jersey.autoconfigure.JerseyAutoConfiguration; +import org.springframework.boot.jersey.autoconfigure.JerseyJacksonAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; @@ -53,14 +55,12 @@ */ class JerseyEndpointAccessIntegrationTests { - @SuppressWarnings("removal") private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner( AnnotationConfigServletWebServerApplicationContext::new) - .withConfiguration( - AutoConfigurations.of(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, - JerseyAutoConfiguration.class, EndpointAutoConfiguration.class, - TomcatServletWebServerAutoConfiguration.class, WebEndpointAutoConfiguration.class, - ManagementContextAutoConfiguration.class, BeansEndpointAutoConfiguration.class)) + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, JerseyAutoConfiguration.class, + JerseyJacksonAutoConfiguration.class, EndpointAutoConfiguration.class, + TomcatServletWebServerAutoConfiguration.class, WebEndpointAutoConfiguration.class, + ManagementContextAutoConfiguration.class, BeansEndpointAutoConfiguration.class)) .withUserConfiguration(CustomServletEndpoint.class) .withPropertyValues("server.port:0"); diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointIntegrationTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointIntegrationTests.java index 9f89556e6fec..71ec6918c047 100644 --- a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointIntegrationTests.java +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/endpoint/web/JerseyEndpointIntegrationTests.java @@ -16,34 +16,45 @@ package org.springframework.boot.jersey.autoconfigure.actuate.endpoint.web; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -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.glassfish.jersey.server.ResourceConfig; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.jsontype.TypeSerializer; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.std.StdScalarSerializer; import org.springframework.boot.actuate.autoconfigure.beans.BeansEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.jackson.JacksonEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.actuate.endpoint.OperationResponseBody; +import org.springframework.boot.actuate.endpoint.jackson.EndpointJsonMapper; import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; import org.springframework.boot.jersey.autoconfigure.JerseyAutoConfiguration; +import org.springframework.boot.jersey.autoconfigure.JerseyJacksonAutoConfiguration; +import org.springframework.boot.servlet.autoconfigure.actuate.web.ServletManagementContextAutoConfiguration; import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.tomcat.autoconfigure.actuate.web.server.TomcatServletManagementContextAutoConfiguration; import org.springframework.boot.tomcat.autoconfigure.servlet.TomcatServletWebServerAutoConfiguration; import org.springframework.boot.web.server.WebServer; +import org.springframework.boot.web.server.context.ServerPortInfoApplicationContextInitializer; import org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.test.json.JsonCompareMode; import org.springframework.test.web.reactive.server.WebTestClient; import static org.assertj.core.api.Assertions.assertThat; @@ -53,6 +64,7 @@ * * @author Andy Wilkinson * @author Madhura Bhave + * @author Kristoffer Larsen Hopland */ class JerseyEndpointIntegrationTests { @@ -85,9 +97,9 @@ void actuatorEndpointsWhenUserProvidedResourceConfigBeanNotAvailable() { } @Test - void endpointObjectMapperCanBeApplied() { + void endpointJsonMapperCanBeApplied() { WebApplicationContextRunner contextRunner = getContextRunner(new Class[] { EndpointsConfiguration.class, - ResourceConfigConfiguration.class, EndpointObjectMapperConfiguration.class }); + ResourceConfigConfiguration.class, EndpointJsonMapperConfiguration.class }); contextRunner.run((context) -> { WebServer webServer = context .getSourceApplicationContext(AnnotationConfigServletWebServerApplicationContext.class) @@ -105,6 +117,55 @@ void endpointObjectMapperCanBeApplied() { }); } + @ParameterizedTest + @CsvSource({ "jackson,false,false", "jackson,false,true", "jackson,true,false", "jackson,true,true", + "jackson2,false,false", "jackson2,false,true", "jackson2,true,false", "jackson2,true,true" }) + @SuppressWarnings("removal") + void actuatorOnlyApplicationUsesPreferredJsonMapper(String preferredMapper, boolean separateManagementPort, + boolean isolatedMapper) { + getContextRunner(new Class[] { MapperEndpointConfiguration.class }, + org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, + JacksonEndpointAutoConfiguration.class, + org.springframework.boot.actuate.autoconfigure.endpoint.jackson.Jackson2EndpointAutoConfiguration.class, + TomcatServletManagementContextAutoConfiguration.class, ServletManagementContextAutoConfiguration.class) + .withInitializer(new ServerPortInfoApplicationContextInitializer()) + .withPropertyValues("management.server.port=" + (separateManagementPort ? "0" : ""), + "management.endpoints.jackson.isolated-json-mapper=" + isolatedMapper, + "management.endpoints.jackson2.isolated-object-mapper=" + isolatedMapper, + "spring.jackson.default-property-inclusion=non-null", + "spring.jackson2.default-property-inclusion=always", + "spring.jersey.preferred-json-mapper=" + preferredMapper) + .run((context) -> { + assertThat(context).doesNotHaveBean(JerseyAutoConfiguration.class); + assertThat(context).hasBean(preferredMapper.equals("jackson") ? "jacksonResourceConfigCustomizer" + : "jackson2ResourceConfigCustomizer"); + WebServer webServer = context + .getSourceApplicationContext(AnnotationConfigServletWebServerApplicationContext.class) + .getWebServer(); + assertThat(webServer).isNotNull(); + Integer port = separateManagementPort + ? context.getEnvironment().getProperty("local.management.port", Integer.class) + : webServer.getPort(); + assertThat(port).isNotNull(); + String property = preferredMapper.equals("jackson") ? "first_name" : "FirstName"; + String json = "{\"" + property + "\":\"Jersey\""; + if (preferredMapper.equals("jackson2") && !isolatedMapper) { + json += ",\"Body\":null"; + } + json += "}"; + WebTestClient.bindToServer() + .baseUrl("http://localhost:" + port) + .build() + .get() + .uri("/actuator/mapper") + .exchange() + .expectStatus() + .isOk() + .expectBody() + .json(json, JsonCompareMode.STRICT); + }); + } + protected void testJerseyEndpoints(Class[] userConfigurations) { getContextRunner(userConfigurations).run((context) -> { WebServer webServer = context @@ -139,17 +200,41 @@ WebApplicationContextRunner getContextRunner(Class[] userConfigurations, .withPropertyValues("management.endpoints.web.exposure.include:*", "server.port:0"); } - @SuppressWarnings("removal") private Class[] getAutoconfigurations(Class... additional) { - List> autoconfigurations = new ArrayList<>( - Arrays.asList(org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, - JerseyAutoConfiguration.class, EndpointAutoConfiguration.class, - TomcatServletWebServerAutoConfiguration.class, WebEndpointAutoConfiguration.class, - ManagementContextAutoConfiguration.class, BeansEndpointAutoConfiguration.class)); + List> autoconfigurations = new ArrayList<>(Arrays.asList(JacksonAutoConfiguration.class, + JerseyAutoConfiguration.class, JerseyJacksonAutoConfiguration.class, EndpointAutoConfiguration.class, + TomcatServletWebServerAutoConfiguration.class, WebEndpointAutoConfiguration.class, + ManagementContextAutoConfiguration.class, BeansEndpointAutoConfiguration.class)); autoconfigurations.addAll(Arrays.asList(additional)); return autoconfigurations.toArray(new Class[0]); } + @Configuration(proxyBeanMethods = false) + static class MapperEndpointConfiguration { + + @Bean + MapperEndpoint mapperEndpoint() { + return new MapperEndpoint(); + } + + } + + @org.springframework.boot.actuate.endpoint.annotation.Endpoint(id = "mapper") + static class MapperEndpoint { + + @org.springframework.boot.actuate.endpoint.annotation.ReadOperation + MapperResponse response() { + return new MapperResponse("Jersey", null); + } + + } + + @tools.jackson.databind.annotation.JsonNaming(tools.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy.class) + @com.fasterxml.jackson.databind.annotation.JsonNaming(com.fasterxml.jackson.databind.PropertyNamingStrategies.UpperCamelCaseStrategy.class) + record MapperResponse(String firstName, @Nullable String body) implements OperationResponseBody { + + } + @org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint(id = "controller") @SuppressWarnings("removal") static class TestControllerEndpoint { @@ -188,16 +273,14 @@ ResourceConfig testResourceConfig() { } @Configuration - @SuppressWarnings({ "deprecation", "removal" }) - static class EndpointObjectMapperConfiguration { + static class EndpointJsonMapperConfiguration { @Bean - org.springframework.boot.actuate.endpoint.jackson.EndpointJackson2ObjectMapper endpointJackson2ObjectMapper() { + EndpointJsonMapper endpointJsonMapper() { SimpleModule module = new SimpleModule(); module.addSerializer(String.class, new ReverseStringSerializer()); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(module); - return () -> objectMapper; + JsonMapper jsonMapper = JsonMapper.builder().addModule(module).build(); + return () -> jsonMapper; } static class ReverseStringSerializer extends StdScalarSerializer { @@ -207,22 +290,22 @@ static class ReverseStringSerializer extends StdScalarSerializer { } @Override - public boolean isEmpty(SerializerProvider provider, Object value) { + public boolean isEmpty(SerializationContext provider, Object value) { return ((String) value).isEmpty(); } @Override - public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException { + public void serialize(Object value, JsonGenerator gen, SerializationContext provider) { serialize(value, gen); } @Override - public final void serializeWithType(Object value, JsonGenerator gen, SerializerProvider provider, - TypeSerializer typeSer) throws IOException { + public final void serializeWithType(Object value, JsonGenerator gen, SerializationContext provider, + TypeSerializer typeSer) { serialize(value, gen); } - private void serialize(Object value, JsonGenerator gen) throws IOException { + private void serialize(Object value, JsonGenerator gen) { StringBuilder builder = new StringBuilder((String) value); gen.writeString(builder.reverse().toString()); } diff --git a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyHealthEndpointAdditionalPathIntegrationTests.java b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyHealthEndpointAdditionalPathIntegrationTests.java index 98fdbeb7f549..794bf392399d 100644 --- a/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyHealthEndpointAdditionalPathIntegrationTests.java +++ b/module/spring-boot-jersey/src/test/java/org/springframework/boot/jersey/autoconfigure/actuate/web/JerseyHealthEndpointAdditionalPathIntegrationTests.java @@ -24,7 +24,9 @@ import org.springframework.boot.health.autoconfigure.actuate.endpoint.HealthEndpointAutoConfiguration; import org.springframework.boot.health.autoconfigure.application.DiskSpaceHealthContributorAutoConfiguration; import org.springframework.boot.health.autoconfigure.registry.HealthContributorRegistryAutoConfiguration; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; import org.springframework.boot.jersey.autoconfigure.JerseyAutoConfiguration; +import org.springframework.boot.jersey.autoconfigure.JerseyJacksonAutoConfiguration; import org.springframework.boot.jersey.autoconfigure.actuate.endpoint.web.HealthEndpointJerseyExtensionAutoConfiguration; import org.springframework.boot.servlet.autoconfigure.actuate.web.ServletManagementContextAutoConfiguration; import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; @@ -43,17 +45,14 @@ class JerseyHealthEndpointAdditionalPathIntegrationTests extends AbstractHealthEndpointAdditionalPathIntegrationTests { - @SuppressWarnings("removal") JerseyHealthEndpointAdditionalPathIntegrationTests() { super(new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new) - .withConfiguration(AutoConfigurations.of( - org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, - JerseyAutoConfiguration.class, EndpointAutoConfiguration.class, + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, JerseyAutoConfiguration.class, + JerseyJacksonAutoConfiguration.class, EndpointAutoConfiguration.class, TomcatServletWebServerAutoConfiguration.class, TomcatServletManagementContextAutoConfiguration.class, WebEndpointAutoConfiguration.class, - JerseyAutoConfiguration.class, ManagementContextAutoConfiguration.class, - ServletManagementContextAutoConfiguration.class, HealthEndpointAutoConfiguration.class, - HealthContributorRegistryAutoConfiguration.class, + ManagementContextAutoConfiguration.class, ServletManagementContextAutoConfiguration.class, + HealthEndpointAutoConfiguration.class, HealthContributorRegistryAutoConfiguration.class, HealthEndpointJerseyExtensionAutoConfiguration.class, DiskSpaceHealthContributorAutoConfiguration.class)) .withInitializer(new ServerPortInfoApplicationContextInitializer()) diff --git a/module/spring-boot-jersey/src/testFixtures/java/org/springframework/boot/jersey/actuate/endpoint/web/test/JerseyEndpointConfiguration.java b/module/spring-boot-jersey/src/testFixtures/java/org/springframework/boot/jersey/actuate/endpoint/web/test/JerseyEndpointConfiguration.java index 7426e9b9eb45..596ce6f56fa0 100644 --- a/module/spring-boot-jersey/src/testFixtures/java/org/springframework/boot/jersey/actuate/endpoint/web/test/JerseyEndpointConfiguration.java +++ b/module/spring-boot-jersey/src/testFixtures/java/org/springframework/boot/jersey/actuate/endpoint/web/test/JerseyEndpointConfiguration.java @@ -29,8 +29,10 @@ import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; import org.springframework.boot.jersey.actuate.endpoint.web.JerseyEndpointResourceFactory; import org.springframework.boot.jersey.autoconfigure.JerseyAutoConfiguration; +import org.springframework.boot.jersey.autoconfigure.JerseyJacksonAutoConfiguration; import org.springframework.boot.jersey.autoconfigure.ResourceConfigCustomizer; import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; import org.springframework.context.ApplicationContext; @@ -43,10 +45,9 @@ * @author Andy Wilkinson * @author Stephane Nicoll */ -@SuppressWarnings("removal") @Configuration(proxyBeanMethods = false) -@ImportAutoConfiguration({ org.springframework.boot.jackson2.autoconfigure.Jackson2AutoConfiguration.class, - JerseyAutoConfiguration.class }) +@ImportAutoConfiguration({ JacksonAutoConfiguration.class, JerseyAutoConfiguration.class, + JerseyJacksonAutoConfiguration.class }) class JerseyEndpointConfiguration { private final ApplicationContext applicationContext; diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index 5da8069319fc..7fafb4317697 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -1165,7 +1165,7 @@ bom { releaseNotes("https://github.com/redis/jedis/releases/tag/v{version}") } } - library("Jersey", "4.0.2") { + library("Jersey", "4.0.3") { group("org.glassfish.jersey") { bom("jersey-bom") } diff --git a/smoke-test/spring-boot-smoke-test-jersey/src/main/resources/application.properties b/smoke-test/spring-boot-smoke-test-jersey/src/main/resources/application.properties index 0b88415b6aba..ec76bb32a5a3 100644 --- a/smoke-test/spring-boot-smoke-test-jersey/src/main/resources/application.properties +++ b/smoke-test/spring-boot-smoke-test-jersey/src/main/resources/application.properties @@ -1,3 +1,3 @@ management.endpoints.web.exposure.include=* management.endpoints.jackson.isolated-json-mapper=true -spring.jackson2.visibility.field=any +spring.jackson.visibility.field=any diff --git a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/AbstractJerseyApplicationTests.java b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/AbstractJerseyApplicationTests.java index 4cb5fa7158b5..2cef37f9ef74 100644 --- a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/AbstractJerseyApplicationTests.java +++ b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/AbstractJerseyApplicationTests.java @@ -22,6 +22,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.http.server.LocalTestWebServer; +import org.springframework.boot.test.json.JsonContent; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -61,7 +62,8 @@ void validation() { void actuatorStatus() { ResponseEntity entity = getForEntity("/actuator/health"); assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(entity.getBody()).isEqualTo("{\"status\":\"UP\",\"groups\":[\"liveness\",\"readiness\"]}"); + assertThat(new JsonContent<>(getClass(), null, entity.getBody())) + .isEqualToJson("{\"status\":\"UP\",\"groups\":[\"liveness\",\"readiness\"]}"); } private ResponseEntity getForEntity(String path) { diff --git a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperFalseTests.java b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperFalseTests.java index d4ec7be37b57..a6cf559812d7 100644 --- a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperFalseTests.java +++ b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperFalseTests.java @@ -16,15 +16,14 @@ package smoketest.jersey; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.boot.test.web.server.LocalManagementPort; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.http.HttpStatus; -import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ContextConfiguration; import org.springframework.web.client.RestClient; @@ -32,12 +31,14 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Integration test for Jersey actuator when not using an isolated {@link ObjectMapper}. + * Integration test for Jersey actuator when not using an isolated {@link JsonMapper}. * * @author Phillip Webb + * @author Kristoffer Larsen Hopland */ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, - properties = "management.endpoints.jackson2.isolated-object-mapper=false") + properties = { "management.endpoints.jackson.isolated-json-mapper=false", + "spring.jackson.mapper.require-setters-for-getters=true", "spring.jackson.visibility.field=none" }) @ContextConfiguration(loader = ApplicationStartupSpringBootContextLoader.class) class JerseyActuatorIsolatedObjectMapperFalseTests { @@ -48,18 +49,14 @@ class JerseyActuatorIsolatedObjectMapperFalseTests { private int managementPort; @Test - void resourceShouldBeAvailableOnMainPort() { + void bodyIsEmptyDueToMainJsonMapperRequiringSettersForGetters() { ResponseEntity entity = RestClient.create() .get() .uri("http://localhost:" + this.port + "/actuator/startup") .retrieve() - .onStatus(HttpStatusCode::isError, (request, response) -> { - }) .toEntity(String.class); - System.out.println(entity.getBody()); - assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(entity.getBody()) - .contains("Java 8 date/time type `java.time.Clock$SystemClock` not supported by default"); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).isEqualTo("{}"); } } diff --git a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperTrueTests.java b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperTrueTests.java index 03397d4dc3e9..5d5cac48094b 100644 --- a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperTrueTests.java +++ b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorIsolatedObjectMapperTrueTests.java @@ -16,8 +16,8 @@ package smoketest.jersey; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; @@ -31,12 +31,14 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Integration test for Jersey actuator when using an isolated {@link ObjectMapper}. + * Integration test for Jersey actuator when using an isolated {@link JsonMapper}. * * @author Phillip Webb + * @author Kristoffer Larsen Hopland */ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, - properties = "management.endpoints.jackson.isolated-json-mapper=true") + properties = { "management.endpoints.jackson.isolated-json-mapper=true", + "spring.jackson.mapper.require-setters-for-getters=true", "spring.jackson.visibility.field=none" }) @ContextConfiguration(loader = ApplicationStartupSpringBootContextLoader.class) class JerseyActuatorIsolatedObjectMapperTrueTests { diff --git a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2IsolatedObjectMapperFalseTests.java b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2IsolatedObjectMapperFalseTests.java new file mode 100644 index 000000000000..5dc482b307f1 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2IsolatedObjectMapperFalseTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-present 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 smoketest.jersey; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for Jersey actuator when not using an isolated {@link ObjectMapper}. + * + * @author Phillip Webb + * @author Kristoffer Larsen Hopland + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.jersey.preferred-json-mapper=jackson2", + "management.endpoints.jackson2.isolated-object-mapper=false", + "spring.jackson2.mapper.require-setters-for-getters=true", "spring.jackson2.visibility.field=none" }) +@ContextConfiguration(loader = ApplicationStartupSpringBootContextLoader.class) +class JerseyActuatorJackson2IsolatedObjectMapperFalseTests { + + @LocalServerPort + private int port; + + @LocalManagementPort + private int managementPort; + + @Test + void startupStepsAreOmittedDueToMainObjectMapperRequiringSettersForGetters() { + ResponseEntity entity = RestClient.create() + .get() + .uri("http://localhost:" + this.port + "/actuator/startup") + .retrieve() + .toEntity(String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).contains("\"endTime\":").doesNotContain("\"startupStep\":"); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2IsolatedObjectMapperTrueTests.java b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2IsolatedObjectMapperTrueTests.java new file mode 100644 index 000000000000..3dc9ecdc222a --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2IsolatedObjectMapperTrueTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-present 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 smoketest.jersey; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for Jersey actuator when using an isolated {@link ObjectMapper}. + * + * @author Phillip Webb + * @author Kristoffer Larsen Hopland + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "spring.jersey.preferred-json-mapper=jackson2", + "management.endpoints.jackson2.isolated-object-mapper=true", + "spring.jackson2.mapper.require-setters-for-getters=true", "spring.jackson2.visibility.field=none" }) +@ContextConfiguration(loader = ApplicationStartupSpringBootContextLoader.class) +class JerseyActuatorJackson2IsolatedObjectMapperTrueTests { + + @LocalServerPort + private int port; + + @LocalManagementPort + private int managementPort; + + @Test + void resourceShouldBeAvailableOnMainPort() { + ResponseEntity entity = RestClient.create() + .get() + .uri("http://localhost:" + this.port + "/actuator/startup") + .retrieve() + .toEntity(String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).contains("\"timeline\":", "\"startupStep\":"); + } + +} diff --git a/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2ManagementPortTests.java b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2ManagementPortTests.java new file mode 100644 index 000000000000..02f5177a6df4 --- /dev/null +++ b/smoke-test/spring-boot-smoke-test-jersey/src/test/java/smoketest/jersey/JerseyActuatorJackson2ManagementPortTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-present 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 smoketest.jersey; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.server.LocalManagementPort; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test for Jersey actuator on a separate management port using an isolated + * {@link ObjectMapper}. + * + * @author Phillip Webb + * @author Kristoffer Larsen Hopland + */ +@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, + properties = { "management.server.port=0", "spring.jersey.preferred-json-mapper=jackson2", + "management.endpoints.jackson2.isolated-object-mapper=true", + "spring.jackson2.mapper.require-setters-for-getters=true", "spring.jackson2.visibility.field=none" }) +@ContextConfiguration(loader = ApplicationStartupSpringBootContextLoader.class) +class JerseyActuatorJackson2ManagementPortTests { + + @LocalServerPort + private int port; + + @LocalManagementPort + private int managementPort; + + @Test + void resourceShouldBeAvailableOnManagementPort() { + ResponseEntity entity = RestClient.create() + .get() + .uri("http://localhost:" + this.managementPort + "/actuator/startup") + .retrieve() + .toEntity(String.class); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).contains("\"timeline\":", "\"startupStep\":"); + } + +} diff --git a/starter/spring-boot-starter-jersey/build.gradle b/starter/spring-boot-starter-jersey/build.gradle index 6d1ced041da8..a7c5fd7fe833 100644 --- a/starter/spring-boot-starter-jersey/build.gradle +++ b/starter/spring-boot-starter-jersey/build.gradle @@ -25,6 +25,7 @@ dependencies { api(project(":starter:spring-boot-starter-tomcat")) api(project(":starter:spring-boot-starter-validation")) + api(project(":module:spring-boot-jackson")) api(project(":module:spring-boot-jackson2")) api(project(":module:spring-boot-jersey"))