From 71987a87132092e066e411e5831a0cba25bfaaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Deleuze?= Date: Tue, 13 May 2025 12:41:35 +0200 Subject: [PATCH] Introduce Jackson 3 support for views This commit introduces Jackson 3 based variants of the following Jackson 2 classes (and related dependent classes). MappingJackson2JsonView -> JacksonJsonView MappingJackson2XmlView-> JacksonXmlView See gh-33798 --- .../standalone/ViewResolutionTests.java | 9 +- .../standalone/ViewResolutionTests.java | 3 +- .../web/servlet/view/AbstractJacksonView.java | 238 ++++++++++ .../view/json/AbstractJackson2View.java | 4 +- .../servlet/view/json/JacksonJsonView.java | 171 +++++++ .../web/servlet/view/xml/JacksonXmlView.java | 112 +++++ .../view/json/JacksonJsonViewTests.java | 441 ++++++++++++++++++ .../servlet/view/xml/JacksonXmlViewTests.java | 321 +++++++++++++ 8 files changed, 1292 insertions(+), 7 deletions(-) create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/view/AbstractJacksonView.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java create mode 100644 spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/JacksonXmlView.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java create mode 100644 spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/JacksonXmlViewTests.java diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ViewResolutionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ViewResolutionTests.java index 000c101385..6153ea0a0b 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ViewResolutionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/client/standalone/ViewResolutionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -37,7 +37,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.servlet.View; import org.springframework.web.servlet.view.ContentNegotiatingViewResolver; import org.springframework.web.servlet.view.InternalResourceViewResolver; -import org.springframework.web.servlet.view.json.MappingJackson2JsonView; +import org.springframework.web.servlet.view.json.JacksonJsonView; import org.springframework.web.servlet.view.xml.MarshallingView; import static org.hamcrest.Matchers.equalTo; @@ -51,6 +51,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * {@link org.springframework.test.web.servlet.samples.standalone.RequestParameterTests}. * * @author Rossen Stoyanchev + * @author Sebastien Deleuze */ class ViewResolutionTests { @@ -78,7 +79,7 @@ class ViewResolutionTests { void jsonOnly() { WebTestClient testClient = MockMvcWebTestClient.bindToController(new PersonController()) - .singleView(new MappingJackson2JsonView()) + .singleView(new JacksonJsonView()) .build(); testClient.get().uri("/person/Corea") @@ -111,7 +112,7 @@ class ViewResolutionTests { marshaller.setClassesToBeBound(Person.class); List viewList = new ArrayList<>(); - viewList.add(new MappingJackson2JsonView()); + viewList.add(new JacksonJsonView()); viewList.add(new MarshallingView(marshaller)); ContentNegotiationManager manager = new ContentNegotiationManager( diff --git a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ViewResolutionTests.java b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ViewResolutionTests.java index 7cfb36e874..1925ae9ffb 100644 --- a/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ViewResolutionTests.java +++ b/spring-test/src/test/java/org/springframework/test/web/servlet/samples/standalone/ViewResolutionTests.java @@ -35,6 +35,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.servlet.View; import org.springframework.web.servlet.view.ContentNegotiatingViewResolver; import org.springframework.web.servlet.view.InternalResourceViewResolver; +import org.springframework.web.servlet.view.json.JacksonJsonView; import org.springframework.web.servlet.view.json.MappingJackson2JsonView; import org.springframework.web.servlet.view.xml.MarshallingView; @@ -70,7 +71,7 @@ class ViewResolutionTests { @Test void jsonOnly() throws Exception { - standaloneSetup(new PersonController()).setSingleView(new MappingJackson2JsonView()).build() + standaloneSetup(new PersonController()).setSingleView(new JacksonJsonView()).build() .perform(get("/person/Corea")) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/AbstractJacksonView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/AbstractJacksonView.java new file mode 100644 index 0000000000..ae7c3203e9 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/AbstractJacksonView.java @@ -0,0 +1,238 @@ +/* + * Copyright 2002-2025 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.web.servlet.view; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonView; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JsonEncoding; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectWriter; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.ser.FilterProvider; + +import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; +import org.springframework.util.Assert; + +/** + * Abstract base class for Jackson 3.x based and content type independent + * {@link AbstractView} implementations. + * + *

The following special model entries are supported: + *

+ * + * @author Sebastien Deleuze + * @since 7.0 + */ +public abstract class AbstractJacksonView extends AbstractView { + + protected static final String JSON_VIEW_HINT = JsonView.class.getName(); + + protected static final String FILTER_PROVIDER_HINT = FilterProvider.class.getName(); + + private static volatile @Nullable List modules = null; + + private final ObjectMapper objectMapper; + + private JsonEncoding encoding = JsonEncoding.UTF8; + + private boolean disableCaching = true; + + protected boolean updateContentLength = false; + + + protected AbstractJacksonView(MapperBuilder builder, String contentType) { + this.objectMapper = builder.addModules(initModules()).build(); + setContentType(contentType); + setExposePathVariables(false); + } + + protected AbstractJacksonView(ObjectMapper objectMapper, String contentType) { + this.objectMapper = objectMapper; + setContentType(contentType); + setExposePathVariables(false); + } + + private List initModules() { + if (modules == null) { + modules = MapperBuilder.findModules(AbstractJacksonHttpMessageConverter.class.getClassLoader()); + + } + return Objects.requireNonNull(modules); + } + + /** + * Set the {@code JsonEncoding} for this view. + * By default, {@linkplain JsonEncoding#UTF8 UTF-8} is used. + */ + public void setEncoding(JsonEncoding encoding) { + Assert.notNull(encoding, "'encoding' must not be null"); + this.encoding = encoding; + } + + /** + * Return the {@code JsonEncoding} for this view. + */ + public final JsonEncoding getEncoding() { + return this.encoding; + } + + /** + * Disables caching of the generated JSON. + *

Default is {@code true}, which will prevent the client from caching the generated JSON. + */ + public void setDisableCaching(boolean disableCaching) { + this.disableCaching = disableCaching; + } + + /** + * Whether to update the 'Content-Length' header of the response. When set to + * {@code true}, the response is buffered in order to determine the content + * length and set the 'Content-Length' header of the response. + *

The default setting is {@code false}. + */ + public void setUpdateContentLength(boolean updateContentLength) { + this.updateContentLength = updateContentLength; + } + + @Override + protected void prepareResponse(HttpServletRequest request, HttpServletResponse response) { + setResponseContentType(request, response); + response.setCharacterEncoding(this.encoding.getJavaName()); + if (this.disableCaching) { + response.addHeader("Cache-Control", "no-store"); + } + } + + @Override + protected void renderMergedOutputModel(Map model, HttpServletRequest request, + HttpServletResponse response) throws Exception { + + ByteArrayOutputStream temporaryStream = null; + OutputStream stream; + + if (this.updateContentLength) { + temporaryStream = createTemporaryOutputStream(); + stream = temporaryStream; + } + else { + stream = response.getOutputStream(); + } + + Object value = filterModel(model, request); + Map hints = null; + boolean containsFilterProviderHint = model.containsKey(FILTER_PROVIDER_HINT); + if (model.containsKey(JSON_VIEW_HINT)) { + if (containsFilterProviderHint) { + hints = new HashMap<>(2); + hints.put(JSON_VIEW_HINT, model.get(JSON_VIEW_HINT)); + hints.put(FILTER_PROVIDER_HINT, model.get(FILTER_PROVIDER_HINT)); + } + else { + hints = Collections.singletonMap(JSON_VIEW_HINT, model.get(JSON_VIEW_HINT)); + } + } + else if (containsFilterProviderHint) { + hints = Collections.singletonMap(FILTER_PROVIDER_HINT, model.get(FILTER_PROVIDER_HINT)); + } + writeContent(stream, value, hints); + + if (temporaryStream != null) { + writeToResponse(response, temporaryStream); + } + } + + /** + * Write the actual JSON content to the stream. + * @param stream the output stream to use + * @param object the value to be rendered, as returned from {@link #filterModel} + * @param hints additional information about how to serialize the data + * @throws IOException if writing failed + */ + protected void writeContent(OutputStream stream, Object object, @Nullable Map hints) throws IOException { + try (JsonGenerator generator = this.objectMapper.createGenerator(stream, this.encoding)) { + writePrefix(generator, object); + + Class serializationView = null; + FilterProvider filters = null; + if (hints != null) { + serializationView = (Class) hints.get(JSON_VIEW_HINT); + filters = (FilterProvider) hints.get(FILTER_PROVIDER_HINT); + } + + ObjectWriter objectWriter = (serializationView != null ? + this.objectMapper.writerWithView(serializationView) : this.objectMapper.writer()); + if (filters != null) { + objectWriter = objectWriter.with(filters); + } + objectWriter.writeValue(generator, object); + + writeSuffix(generator, object); + generator.flush(); + } + } + + + /** + * Set the attribute in the model that should be rendered by this view. + * When set, all other model attributes will be ignored. + */ + public abstract void setModelKey(String modelKey); + + /** + * Filter out undesired attributes from the given model. + * The return value can be either another {@link Map} or a single value object. + * @param model the model, as passed on to {@link #renderMergedOutputModel} + * @param request current HTTP request + * @return the value to be rendered + */ + protected abstract Object filterModel(Map model, HttpServletRequest request); + + /** + * Write a prefix before the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writePrefix(JsonGenerator generator, Object object) throws IOException { + } + + /** + * Write a suffix after the main content. + * @param generator the generator to use for writing content. + * @param object the object to write to the output message. + */ + protected void writeSuffix(JsonGenerator generator, Object object) throws IOException { + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java index 52d167d931..eeedd9c18e 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/AbstractJackson2View.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2025 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. @@ -37,7 +37,7 @@ import org.springframework.util.Assert; import org.springframework.web.servlet.view.AbstractView; /** - * Abstract base class for Jackson based and content type independent + * Abstract base class for Jackson 2.x based and content type independent * {@link AbstractView} implementations. * * @author Jeremy Grelle diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java new file mode 100644 index 0000000000..0d08ac4924 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/json/JacksonJsonView.java @@ -0,0 +1,171 @@ +/* + * Copyright 2002-2025 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.web.servlet.view.json; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.util.CollectionUtils; +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.AbstractJacksonView; + +/** + * Spring MVC {@link View} that renders JSON content by serializing the model for the current request + * using Jackson 3's {@link ObjectMapper}. + * + *

By default, the entire contents of the model map (with the exception of framework-specific classes) + * will be encoded as JSON. If the model contains only one key, you can have it extracted encoded as JSON + * alone via {@link #setExtractValueFromSingleKeyModel}. + * + *

The following special model entries are supported: + *

    + *
  • A JSON view with a com.fasterxml.jackson.annotation.JsonView + * key and the class name of the JSON view as value.
  • + *
  • A filter provider with a tools.jackson.databind.ser.FilterProvider + * key and the filter provider class name as value.
  • + *
+ * + * @author Sebastien Deleuze + * @since 7.0 + */ +public class JacksonJsonView extends AbstractJacksonView { + + /** + * Default content type: "application/json". + * Overridable through {@link #setContentType}. + */ + public static final String DEFAULT_CONTENT_TYPE = "application/json"; + + private @Nullable String jsonPrefix; + + private @Nullable Set modelKeys; + + private boolean extractValueFromSingleKeyModel = false; + + + /** + * Construct a new instance with a {@link JsonMapper} customized with + * the {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)} and setting + * the content type to {@code application/json}. + */ + public JacksonJsonView() { + super(JsonMapper.builder(), DEFAULT_CONTENT_TYPE); + } + + /** + * Construct a new instance using the provided {@link ObjectMapper} + * and setting the content type to {@code application/json}. + */ + public JacksonJsonView(ObjectMapper objectMapper) { + super(objectMapper, DEFAULT_CONTENT_TYPE); + } + + + /** + * Specify a custom prefix to use for this view's JSON output. + * Default is none. + * @see #setPrefixJson + */ + public void setJsonPrefix(String jsonPrefix) { + this.jsonPrefix = jsonPrefix; + } + + /** + * Indicates whether the JSON output by this view should be prefixed with ")]}', ". + * Default is {@code false}. + *

Prefixing the JSON string in this manner is used to help prevent JSON Hijacking. + * The prefix renders the string syntactically invalid as a script so that it cannot be hijacked. + * This prefix should be stripped before parsing the string as JSON. + * @see #setJsonPrefix + */ + public void setPrefixJson(boolean prefixJson) { + this.jsonPrefix = (prefixJson ? ")]}', " : null); + } + + @Override + public void setModelKey(String modelKey) { + this.modelKeys = Collections.singleton(modelKey); + } + + /** + * Set the attributes in the model that should be rendered by this view. + * When set, all other model attributes will be ignored. + */ + public void setModelKeys(@Nullable Set modelKeys) { + this.modelKeys = modelKeys; + } + + /** + * Return the attributes in the model that should be rendered by this view. + */ + public final @Nullable Set getModelKeys() { + return this.modelKeys; + } + + /** + * Set whether to serialize models containing a single attribute as a map or + * whether to extract the single value from the model and serialize it directly. + *

The effect of setting this flag is similar to using + * {@code JacksonJsonHttpMessageConverter} with an {@code @ResponseBody} + * request-handling method. + *

Default is {@code false}. + */ + public void setExtractValueFromSingleKeyModel(boolean extractValueFromSingleKeyModel) { + this.extractValueFromSingleKeyModel = extractValueFromSingleKeyModel; + } + + /** + * Filter out undesired attributes from the given model. + * The return value can be either another {@link Map} or a single value object. + *

The default implementation removes {@link BindingResult} instances and entries + * not included in the {@link #setModelKeys modelKeys} property. + * @param model the model, as passed on to {@link #renderMergedOutputModel} + * @return the value to be rendered + */ + @Override + protected Object filterModel(Map model, HttpServletRequest request) { + Map result = CollectionUtils.newHashMap(model.size()); + Set modelKeys = (!CollectionUtils.isEmpty(this.modelKeys) ? this.modelKeys : model.keySet()); + model.forEach((clazz, value) -> { + if (!(value instanceof BindingResult) && modelKeys.contains(clazz) && + !clazz.equals(JSON_VIEW_HINT) && + !clazz.equals(FILTER_PROVIDER_HINT)) { + result.put(clazz, value); + } + }); + return (this.extractValueFromSingleKeyModel && result.size() == 1 ? result.values().iterator().next() : result); + } + + @Override + protected void writePrefix(JsonGenerator generator, Object object) throws IOException { + if (this.jsonPrefix != null) { + generator.writeRaw(this.jsonPrefix); + } + } + +} diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/JacksonXmlView.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/JacksonXmlView.java new file mode 100644 index 0000000000..aac5ad96d9 --- /dev/null +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/view/xml/JacksonXmlView.java @@ -0,0 +1,112 @@ +/* + * Copyright 2002-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.web.servlet.view.xml; + +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import org.jspecify.annotations.Nullable; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.util.Assert; +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.view.AbstractJacksonView; + +/** + * Spring MVC {@link View} that renders XML content by serializing the model for the current request + * using Jackson 3's {@link XmlMapper}. + * + *

The Object to be serialized is supplied as a parameter in the model. The first serializable + * entry is used. Users can either specify a specific entry in the model via the + * {@link #setModelKey(String) sourceKey} property. + * + *

The following special model entries are supported: + *

    + *
  • A JSON view with a com.fasterxml.jackson.annotation.JsonView + * key and the class name of the JSON view as value.
  • + *
  • A filter provider with a tools.jackson.databind.ser.FilterProvider + * key and the filter provider class name as value.
  • + *
+ * + * @author Sebastien Deleuze + * @since 7.0 + * @see org.springframework.web.servlet.view.json.JacksonJsonView + */ +public class JacksonXmlView extends AbstractJacksonView { + + /** + * The default content type for the view. + */ + public static final String DEFAULT_CONTENT_TYPE = "application/xml"; + + + private @Nullable String modelKey; + + + /** + * Construct a new instance with a {@link XmlMapper} customized with + * the {@link tools.jackson.databind.JacksonModule}s found by + * {@link MapperBuilder#findModules(ClassLoader)} and setting + * the content type to {@code application/xml}. + */ + public JacksonXmlView() { + super(XmlMapper.builder(), DEFAULT_CONTENT_TYPE); + } + + /** + * Construct a new instance using the provided {@link XmlMapper} + * and setting the content type to {@code application/xml}. + */ + public JacksonXmlView(XmlMapper xmlMapper) { + super(xmlMapper, DEFAULT_CONTENT_TYPE); + } + + + @Override + public void setModelKey(String modelKey) { + this.modelKey = modelKey; + } + + @Override + protected Object filterModel(Map model, HttpServletRequest request) { + Object value = null; + if (this.modelKey != null) { + value = model.get(this.modelKey); + if (value == null) { + throw new IllegalStateException( + "Model contains no object with key [" + this.modelKey + "]"); + } + } + else { + for (Map.Entry entry : model.entrySet()) { + if (!(entry.getValue() instanceof BindingResult) && + !entry.getKey().equals(JSON_VIEW_HINT) && + !entry.getKey().equals(FILTER_PROVIDER_HINT)) { + if (value != null) { + throw new IllegalStateException("Model contains more than one object to render, only one is supported"); + } + value = entry.getValue(); + } + } + } + Assert.state(value != null, "Model contains no object to render"); + return value; + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java new file mode 100644 index 0000000000..b3a8fdf985 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/json/JacksonJsonViewTests.java @@ -0,0 +1,441 @@ +/* + * Copyright 2002-2024 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.web.servlet.view.json; + +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import com.fasterxml.jackson.annotation.JsonFilter; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonView; +import org.junit.jupiter.api.Test; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; +import org.mozilla.javascript.ScriptableObject; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.cfg.SerializerFactoryConfig; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.ser.BeanSerializerFactory; +import tools.jackson.databind.ser.FilterProvider; +import tools.jackson.databind.ser.SerializerFactory; +import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter; +import tools.jackson.databind.ser.std.SimpleFilterProvider; + +import org.springframework.beans.DirectFieldAccessor; +import org.springframework.http.MediaType; +import org.springframework.ui.ModelMap; +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.View; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JacksonJsonView}. + * + * @author Sebastien Deleuze + */ +class JacksonJsonViewTests { + + private JacksonJsonView view = new JacksonJsonView(); + + private MockHttpServletRequest request = new MockHttpServletRequest(); + + private MockHttpServletResponse response = new MockHttpServletResponse(); + + private Context jsContext = ContextFactory.getGlobal().enterContext(); + + private ScriptableObject jsScope = jsContext.initStandardObjects(); + + + @Test + void isExposePathVars() { + assertThat(view.isExposePathVariables()).as("Must not expose path variables").isFalse(); + } + + @Test + void renderSimpleMap() throws Exception { + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", "bar"); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + assertThat(response.getHeader("Cache-Control")).isEqualTo("no-store"); + + MediaType mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.parseMediaType(MappingJackson2JsonView.DEFAULT_CONTENT_TYPE))).isTrue(); + + String jsonResult = response.getContentAsString(); + assertThat(jsonResult).isNotEmpty(); + assertThat(response.getContentLength()).isEqualTo(jsonResult.length()); + + validateResult(); + } + + @Test + void renderWithSelectedContentType() throws Exception { + Map model = new HashMap<>(); + model.put("foo", "bar"); + + view.render(model, request, response); + MediaType mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.APPLICATION_JSON)).isTrue(); + + request.setAttribute(View.SELECTED_CONTENT_TYPE, new MediaType("application", "vnd.example-v2+xml")); + view.render(model, request, response); + + mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.parseMediaType("application/vnd.example-v2+xml"))).isTrue(); + } + + @Test + void renderCaching() throws Exception { + view.setDisableCaching(false); + + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", "bar"); + + view.render(model, request, response); + + assertThat(response.getHeader("Cache-Control")).isNull(); + } + + @Test + void renderSimpleMapPrefixed() throws Exception { + view.setPrefixJson(true); + renderSimpleMap(); + } + + @Test + void renderSimpleBean() throws Exception { + Object bean = new TestBeanSimple(); + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", bean); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + assertThat(response.getContentAsString()).isNotEmpty(); + assertThat(response.getContentLength()).isEqualTo(response.getContentAsString().length()); + + validateResult(); + } + + @Test + void renderWithPrettyPrint() throws Exception { + ModelMap model = new ModelMap("foo", new TestBeanSimple()); + + view = new JacksonJsonView(JsonMapper.builder().enable(SerializationFeature.INDENT_OUTPUT).build()); + view.render(model, request, response); + + String result = response.getContentAsString().replace("\r\n", "\n"); + assertThat(result).as("Pretty printing not applied:\n" + result).startsWith("{\n \"foo\" : {\n "); + + validateResult(); + } + + @Test + void renderSimpleBeanPrefixed() throws Exception { + view.setPrefixJson(true); + renderSimpleBean(); + assertThat(response.getContentAsString()).startsWith(")]}', "); + } + + @Test + void renderSimpleBeanNotPrefixed() throws Exception { + view.setPrefixJson(false); + renderSimpleBean(); + assertThat(response.getContentAsString()).doesNotStartWith(")]}', "); + } + + @Test + void renderWithCustomSerializerLocatedByFactory() throws Exception { + SerializerFactory factory = new DelegatingSerializerFactory(null); + ObjectMapper mapper = JsonMapper.builder().serializerFactory(factory).build(); + view = new JacksonJsonView(mapper); + + Object bean = new TestBeanSimple(); + Map model = new HashMap<>(); + model.put("foo", bean); + model.put("bar", new TestChildBean()); + + view.render(model, request, response); + + String result = response.getContentAsString(); + assertThat(result).isNotEmpty(); + assertThat(result).contains("\"foo\":{\"testBeanSimple\":\"custom\"}"); + + validateResult(); + } + + @Test + void renderOnlyIncludedAttributes() throws Exception { + + Set attrs = new HashSet<>(); + attrs.add("foo"); + attrs.add("baz"); + attrs.add("nil"); + + view.setModelKeys(attrs); + Map model = new HashMap<>(); + model.put("foo", "foo"); + model.put("bar", "bar"); + model.put("baz", "baz"); + + view.render(model, request, response); + + String result = response.getContentAsString(); + assertThat(result).isNotEmpty(); + assertThat(result).contains("\"foo\":\"foo\""); + assertThat(result).contains("\"baz\":\"baz\""); + + validateResult(); + } + + @Test + void filterSingleKeyModel() { + view.setExtractValueFromSingleKeyModel(true); + + Map model = new HashMap<>(); + TestBeanSimple bean = new TestBeanSimple(); + model.put("foo", bean); + + Object actual = view.filterModel(model, request); + + assertThat(actual).isSameAs(bean); + } + + @SuppressWarnings("rawtypes") + @Test + void filterTwoKeyModel() { + view.setExtractValueFromSingleKeyModel(true); + + Map model = new HashMap<>(); + TestBeanSimple bean1 = new TestBeanSimple(); + TestBeanSimple bean2 = new TestBeanSimple(); + model.put("foo1", bean1); + model.put("foo2", bean2); + + Object actual = view.filterModel(model, request); + + assertThat(actual).isInstanceOf(Map.class); + assertThat(((Map) actual).get("foo1")).isSameAs(bean1); + assertThat(((Map) actual).get("foo2")).isSameAs(bean2); + } + + @Test + void renderSimpleBeanWithJsonView() throws Exception { + Object bean = new TestBeanSimple(); + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", bean); + model.put(JsonView.class.getName(), MyJacksonView1.class); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + String content = response.getContentAsString(); + assertThat(content).isNotEmpty(); + assertThat(response.getContentLength()).isEqualTo(content.length()); + assertThat(content).contains("foo"); + assertThat(content).doesNotContain("boo"); + assertThat(content).doesNotContain(JsonView.class.getName()); + } + + @Test + void renderSimpleBeanWithFilters() throws Exception { + TestSimpleBeanFiltered bean = new TestSimpleBeanFiltered(); + bean.setProperty1("value"); + bean.setProperty2("value"); + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", bean); + FilterProvider filters = new SimpleFilterProvider().addFilter("myJacksonFilter", + SimpleBeanPropertyFilter.serializeAllExcept("property2")); + model.put(FilterProvider.class.getName(), filters); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + String content = response.getContentAsString(); + assertThat(content).isNotEmpty(); + assertThat(response.getContentLength()).isEqualTo(content.length()); + assertThat(content).contains("\"property1\":\"value\""); + assertThat(content).doesNotContain("\"property2\":\"value\""); + assertThat(content).doesNotContain(FilterProvider.class.getName()); + } + + private void validateResult() throws Exception { + String json = response.getContentAsString(); + DirectFieldAccessor viewAccessor = new DirectFieldAccessor(view); + String jsonPrefix = (String)viewAccessor.getPropertyValue("jsonPrefix"); + if (jsonPrefix != null) { + json = json.substring(5); + } + Object jsResult = jsContext.evaluateString(jsScope, "(" + json + ")", "JSON Stream", 1, null); + assertThat(jsResult).as("Json Result did not eval as valid JavaScript").isNotNull(); + MediaType mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.APPLICATION_JSON)).isTrue(); + } + + + public interface MyJacksonView1 { + } + + + public interface MyJacksonView2 { + } + + + @SuppressWarnings("unused") + public static class TestBeanSimple { + + @JsonView(MyJacksonView1.class) + private String property1 = "foo"; + + private boolean test = false; + + @JsonView(MyJacksonView2.class) + private String property2 = "boo"; + + private TestChildBean child = new TestChildBean(); + + public String getProperty1() { + return property1; + } + + public boolean getTest() { + return test; + } + + public String getProperty2() { + return property2; + } + + public Date getNow() { + return new Date(); + } + + public TestChildBean getChild() { + return child; + } + } + + + public static class TestChildBean { + + private String value = "bar"; + + private String baz = null; + + private TestBeanSimple parent = null; + + public String getValue() { + return value; + } + + public String getBaz() { + return baz; + } + + public TestBeanSimple getParent() { + return parent; + } + + public void setParent(TestBeanSimple parent) { + this.parent = parent; + } + } + + + public static class TestBeanSimpleSerializer extends ValueSerializer { + + @Override + public void serialize(Object value, JsonGenerator jgen, SerializationContext ctxt) throws JacksonException { + jgen.writeStartObject(); + jgen.writeStringProperty("testBeanSimple", "custom"); + jgen.writeEndObject(); + } + } + + + @JsonFilter("myJacksonFilter") + @SuppressWarnings("unused") + private static class TestSimpleBeanFiltered { + + private String property1; + private String property2; + + public String getProperty1() { + return property1; + } + + public void setProperty1(String property1) { + this.property1 = property1; + } + + public String getProperty2() { + return property2; + } + + public void setProperty2(String property2) { + this.property2 = property2; + } + } + + + @SuppressWarnings("serial") + public static class DelegatingSerializerFactory extends BeanSerializerFactory { + + protected DelegatingSerializerFactory(SerializerFactoryConfig config) { + super(config); + } + + @Override + public ValueSerializer createSerializer(SerializationContext ctxt, JavaType origType, + BeanDescription.Supplier beanDescRef, JsonFormat.Value formatOverrides) { + if (origType.getRawClass() == TestBeanSimple.class) { + return new TestBeanSimpleSerializer(); + } + else { + return super.createSerializer(ctxt, origType, beanDescRef, formatOverrides); + } + } + + @Override + public SerializerFactory withConfig(SerializerFactoryConfig config) { + return this; + } + } + +} diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/JacksonXmlViewTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/JacksonXmlViewTests.java new file mode 100644 index 0000000000..f8e8782021 --- /dev/null +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/view/xml/JacksonXmlViewTests.java @@ -0,0 +1,321 @@ +/* + * Copyright 2002-2024 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.web.servlet.view.xml; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.TreeMap; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonView; +import org.junit.jupiter.api.Test; +import org.mozilla.javascript.Context; +import org.mozilla.javascript.ContextFactory; +import org.mozilla.javascript.ScriptableObject; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.databind.BeanDescription; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.cfg.SerializerFactoryConfig; +import tools.jackson.databind.ser.BeanSerializerFactory; +import tools.jackson.databind.ser.SerializerFactory; +import tools.jackson.dataformat.xml.XmlMapper; + +import org.springframework.http.MediaType; +import org.springframework.validation.BindingResult; +import org.springframework.web.servlet.View; +import org.springframework.web.testfixture.servlet.MockHttpServletRequest; +import org.springframework.web.testfixture.servlet.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link JacksonXmlView}. + * + * @author Sebastien Deleuze + */ +class JacksonXmlViewTests { + + private JacksonXmlView view = new JacksonXmlView(); + + private MockHttpServletRequest request = new MockHttpServletRequest(); + + private MockHttpServletResponse response = new MockHttpServletResponse(); + + private Context jsContext = ContextFactory.getGlobal().enterContext(); + + private ScriptableObject jsScope = jsContext.initStandardObjects(); + + + @Test + void isExposePathVars() { + assertThat(view.isExposePathVariables()).as("Must not expose path variables").isFalse(); + } + + @Test + void renderSimpleMap() throws Exception { + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", "bar"); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + assertThat(response.getHeader("Cache-Control")).isEqualTo("no-store"); + + MediaType mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.parseMediaType(MappingJackson2XmlView.DEFAULT_CONTENT_TYPE))).isTrue(); + + String jsonResult = response.getContentAsString(); + assertThat(jsonResult).isNotEmpty(); + assertThat(response.getContentLength()).isEqualTo(jsonResult.length()); + + validateResult(); + } + + @Test + void renderWithSelectedContentType() throws Exception { + Map model = new HashMap<>(); + model.put("foo", "bar"); + + view.render(model, request, response); + MediaType mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.APPLICATION_XML)).isTrue(); + + request.setAttribute(View.SELECTED_CONTENT_TYPE, new MediaType("application", "vnd.example-v2+xml")); + view.render(model, request, response); + + mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.parseMediaType("application/vnd.example-v2+xml"))).isTrue(); + } + + @Test + void renderCaching() throws Exception { + view.setDisableCaching(false); + + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", "bar"); + + view.render(model, request, response); + + assertThat(response.getHeader("Cache-Control")).isNull(); + } + + @Test + void renderSimpleBean() throws Exception { + Object bean = new TestBeanSimple(); + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", bean); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + assertThat(response.getContentAsString()).isNotEmpty(); + assertThat(response.getContentLength()).isEqualTo(response.getContentAsString().length()); + + validateResult(); + } + + @Test + void renderWithCustomSerializerLocatedByFactory() throws Exception { + SerializerFactory factory = new DelegatingSerializerFactory(null); + XmlMapper mapper = XmlMapper.builder().serializerFactory(factory).build(); + view = new JacksonXmlView(mapper); + + Object bean = new TestBeanSimple(); + Map model = new HashMap<>(); + model.put("foo", bean); + + view.render(model, request, response); + + String result = response.getContentAsString(); + assertThat(result).isNotEmpty(); + assertThat(result).contains("custom"); + + validateResult(); + } + + @Test + void renderOnlySpecifiedModelKey() throws Exception { + + view.setModelKey("bar"); + Map model = new HashMap<>(); + model.put("foo", "foo"); + model.put("bar", "bar"); + model.put("baz", "baz"); + + view.render(model, request, response); + + String result = response.getContentAsString(); + assertThat(result).isNotEmpty(); + assertThat(result).doesNotContain("foo"); + assertThat(result).contains("bar"); + assertThat(result).doesNotContain("baz"); + + validateResult(); + } + + @Test + void renderModelWithMultipleKeys() { + Map model = new TreeMap<>(); + model.put("foo", "foo"); + model.put("bar", "bar"); + + assertThatIllegalStateException().isThrownBy(() -> + view.render(model, request, response)); + } + + @Test + void renderSimpleBeanWithJsonView() throws Exception { + Object bean = new TestBeanSimple(); + Map model = new HashMap<>(); + model.put("bindingResult", mock(BindingResult.class, "binding_result")); + model.put("foo", bean); + model.put(JsonView.class.getName(), MyJacksonView1.class); + + view.setUpdateContentLength(true); + view.render(model, request, response); + + String content = response.getContentAsString(); + assertThat(content).isNotEmpty(); + assertThat(response.getContentLength()).isEqualTo(content.length()); + assertThat(content).contains("foo"); + assertThat(content).doesNotContain("boo"); + assertThat(content).doesNotContain(JsonView.class.getName()); + } + + private void validateResult() throws Exception { + Object xmlResult = + jsContext.evaluateString(jsScope, "(" + response.getContentAsString() + ")", "XML Stream", 1, null); + assertThat(xmlResult).as("XML Result did not eval as valid JavaScript").isNotNull(); + MediaType mediaType = MediaType.parseMediaType(response.getContentType()); + assertThat(mediaType.isCompatibleWith(MediaType.APPLICATION_XML)).isTrue(); + } + + + public interface MyJacksonView1 { + } + + + public interface MyJacksonView2 { + } + + + @SuppressWarnings("unused") + public static class TestBeanSimple { + + @JsonView(MyJacksonView1.class) + private String property1 = "foo"; + + private boolean test = false; + + @JsonView(MyJacksonView2.class) + private String property2 = "boo"; + + private TestChildBean child = new TestChildBean(); + + public String getProperty1() { + return property1; + } + + public boolean getTest() { + return test; + } + + public String getProperty2() { + return property2; + } + + public Date getNow() { + return new Date(); + } + + public TestChildBean getChild() { + return child; + } + } + + + public static class TestChildBean { + + private String value = "bar"; + + private String baz = null; + + private TestBeanSimple parent = null; + + public String getValue() { + return value; + } + + public String getBaz() { + return baz; + } + + public TestBeanSimple getParent() { + return parent; + } + + public void setParent(TestBeanSimple parent) { + this.parent = parent; + } + } + + + public static class TestBeanSimpleSerializer extends ValueSerializer { + + @Override + public void serialize(Object value, JsonGenerator jgen, SerializationContext ctxt) throws JacksonException { + jgen.writeStartObject(); + jgen.writeStringProperty("testBeanSimple", "custom"); + jgen.writeEndObject(); + } + } + + + @SuppressWarnings("serial") + public static class DelegatingSerializerFactory extends BeanSerializerFactory { + + protected DelegatingSerializerFactory(SerializerFactoryConfig config) { + super(config); + } + + @Override + public ValueSerializer createSerializer(SerializationContext ctxt, JavaType origType, + BeanDescription.Supplier beanDescRef, JsonFormat.Value formatOverrides) { + if (origType.getRawClass() == TestBeanSimple.class) { + return new TestBeanSimpleSerializer(); + } + else { + return super.createSerializer(ctxt, origType, beanDescRef, formatOverrides); + } + } + + @Override + public SerializerFactory withConfig(SerializerFactoryConfig config) { + return this; + } + } + +}