Expose media type mappings in ContentNegotiationManager

ContentNegotiationManagerFactoryBean now ensures that
ContentNegotiationManager contains the MediaType mappings even if the
path extension and the parameter strategies are off.

There are also minor fixes to ensure the media type mappings in
ContentNegotiationManagerFactoryBean aren't polluted when mapping keys
are not lowercase, and likewise MappingMediaTypeFileExtensionResolver
filters out duplicates in the list of all file extensions.

See gh-24179
This commit is contained in:
Rossen Stoyanchev 2020-01-22 12:32:52 +00:00
parent 214ba63127
commit 542e187831
5 changed files with 186 additions and 73 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2018 the original author or authors.
* Copyright 2002-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,13 +20,17 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.context.request.NativeWebRequest;
@ -132,11 +136,7 @@ public class ContentNegotiationManager implements ContentNegotiationStrategy, Me
@Override
public List<String> resolveFileExtensions(MediaType mediaType) {
Set<String> result = new LinkedHashSet<>();
for (MediaTypeFileExtensionResolver resolver : this.resolvers) {
result.addAll(resolver.resolveFileExtensions(mediaType));
}
return new ArrayList<>(result);
return doResolveExtensions(resolver -> resolver.resolveFileExtensions(mediaType));
}
/**
@ -152,11 +152,44 @@ public class ContentNegotiationManager implements ContentNegotiationStrategy, Me
*/
@Override
public List<String> getAllFileExtensions() {
Set<String> result = new LinkedHashSet<>();
return doResolveExtensions(MediaTypeFileExtensionResolver::getAllFileExtensions);
}
private List<String> doResolveExtensions(Function<MediaTypeFileExtensionResolver, List<String>> extractor) {
List<String> result = null;
for (MediaTypeFileExtensionResolver resolver : this.resolvers) {
result.addAll(resolver.getAllFileExtensions());
List<String> extensions = extractor.apply(resolver);
if (CollectionUtils.isEmpty(extensions)) {
continue;
}
result = (result != null ? result : new ArrayList<>(4));
for (String extension : extensions) {
if (!result.contains(extension)) {
result.add(extension);
}
}
}
return new ArrayList<>(result);
return (result != null ? result : Collections.emptyList());
}
/**
* Return all registered lookup key to media type mappings by iterating
* {@link MediaTypeFileExtensionResolver}s.
* @since 5.2.4
*/
public Map<String, MediaType> getMediaTypeMappings() {
Map<String, MediaType> result = null;
for (MediaTypeFileExtensionResolver resolver : this.resolvers) {
if (resolver instanceof MappingMediaTypeFileExtensionResolver) {
Map<String, MediaType> map = ((MappingMediaTypeFileExtensionResolver) resolver).getMediaTypes();
if (CollectionUtils.isEmpty(map)) {
continue;
}
result = (result != null ? result : new HashMap<>(4));
result.putAll(map);
}
}
return (result != null ? result : Collections.emptyMap());
}
}

View File

@ -89,7 +89,7 @@ import org.springframework.web.context.ServletContextAware;
* {@link #setFavorPathExtension(boolean) favorPathExtension} and
* {@link #setIgnoreUnknownPathExtensions(boolean) ignoreUnknownPathExtensions}
* are deprecated in order to discourage use of path extensions for content
* negotiation and for request mapping (with similar deprecations in
* negotiation as well as for request mapping (with similar deprecations in
* {@link org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
* RequestMappingHandlerMapping}). For further context, please read issue
* <a href="https://github.com/spring-projects/spring-framework/issues/24179">#24719</a>.
@ -157,46 +157,52 @@ public class ContentNegotiationManagerFactoryBean
}
/**
* Add a mapping from a key, extracted from a path extension or a query
* parameter, to a MediaType. This is required in order for the parameter
* strategy to work. Any extensions explicitly registered here are also
* whitelisted for the purpose of Reflected File Download attack detection
* (see Spring Framework reference documentation for more details on RFD
* attack protection).
* <p>The path extension strategy will also try to use
* Add a mapping from a key to a MediaType where the key are normalized to
* lowercase and may have been extracted from a path extension, a filename
* extension, or passed as a query parameter.
* <p>The {@link #setFavorParameter(boolean) parameter strategy} requires
* such mappings in order to work while the {@link #setFavorPathExtension(boolean)
* path extension strategy} can fall back on lookups via
* {@link ServletContext#getMimeType} and
* {@link org.springframework.http.MediaTypeFactory} to resolve path extensions.
* {@link org.springframework.http.MediaTypeFactory}.
* <p><strong>Note:</strong> Mappings registered here may be accessed via
* {@link ContentNegotiationManager#getMediaTypeMappings()} and may be used
* not only in the parameter and path extension strategies. For example,
* with the Spring MVC config, e.g. {@code @EnableWebMvc} or
* {@code <mvc:annotation-driven>}, the media type mappings are also plugged
* in to:
* <ul>
* <li>Determine the media type of static resources served with
* {@code ResourceHttpRequestHandler}.
* <li>Determine the media type of views rendered with
* {@code ContentNegotiatingViewResolver}.
* <li>Whitelist extensions for RFD attack detection (check the Spring
* Framework reference docs for details).
* </ul>
* @param mediaTypes media type mappings
* @see #addMediaType(String, MediaType)
* @see #addMediaTypes(Map)
*/
public void setMediaTypes(Properties mediaTypes) {
if (!CollectionUtils.isEmpty(mediaTypes)) {
mediaTypes.forEach((key, value) -> {
String extension = ((String) key).toLowerCase(Locale.ENGLISH);
MediaType mediaType = MediaType.valueOf((String) value);
this.mediaTypes.put(extension, mediaType);
});
mediaTypes.forEach((key, value) ->
addMediaType((String) key, MediaType.valueOf((String) value)));
}
}
/**
* An alternative to {@link #setMediaTypes} for use in Java code.
* @see #setMediaTypes
* @see #addMediaTypes
* An alternative to {@link #setMediaTypes} for programmatic registrations.
*/
public void addMediaType(String fileExtension, MediaType mediaType) {
this.mediaTypes.put(fileExtension, mediaType);
public void addMediaType(String key, MediaType mediaType) {
this.mediaTypes.put(key.toLowerCase(Locale.ENGLISH), mediaType);
}
/**
* An alternative to {@link #setMediaTypes} for use in Java code.
* @see #setMediaTypes
* @see #addMediaType
* An alternative to {@link #setMediaTypes} for programmatic registrations.
*/
public void addMediaTypes(@Nullable Map<String, MediaType> mediaTypes) {
if (mediaTypes != null) {
this.mediaTypes.putAll(mediaTypes);
mediaTypes.forEach(this::addMediaType);
}
}
@ -315,6 +321,7 @@ public class ContentNegotiationManagerFactoryBean
* Create and initialize a {@link ContentNegotiationManager} instance.
* @since 5.0
*/
@SuppressWarnings("deprecation")
public ContentNegotiationManager build() {
List<ContentNegotiationStrategy> strategies = new ArrayList<>();
@ -336,7 +343,6 @@ public class ContentNegotiationManagerFactoryBean
}
strategies.add(strategy);
}
if (this.favorParameter) {
ParameterContentNegotiationStrategy strategy = new ParameterContentNegotiationStrategy(this.mediaTypes);
strategy.setParameterName(this.parameterName);
@ -348,17 +354,24 @@ public class ContentNegotiationManagerFactoryBean
}
strategies.add(strategy);
}
if (!this.ignoreAcceptHeader) {
strategies.add(new HeaderContentNegotiationStrategy());
}
if (this.defaultNegotiationStrategy != null) {
strategies.add(this.defaultNegotiationStrategy);
}
}
this.contentNegotiationManager = new ContentNegotiationManager(strategies);
// Ensure media type mappings are available via ContentNegotiationManager#getMediaTypeMappings()
// independent of path extension or parameter strategies.
if (!CollectionUtils.isEmpty(this.mediaTypes) && !this.favorPathExtension && !this.favorParameter) {
this.contentNegotiationManager.addFileExtensionResolvers(
new MappingMediaTypeFileExtensionResolver(this.mediaTypes));
}
return this.contentNegotiationManager;
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -18,9 +18,11 @@ package org.springframework.web.accept;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
@ -53,7 +55,7 @@ public class MappingMediaTypeFileExtensionResolver implements MediaTypeFileExten
*/
public MappingMediaTypeFileExtensionResolver(@Nullable Map<String, MediaType> mediaTypes) {
if (mediaTypes != null) {
List<String> allFileExtensions = new ArrayList<>();
Set<String> allFileExtensions = new HashSet<>(mediaTypes.size());
mediaTypes.forEach((extension, mediaType) -> {
String lowerCaseExtension = extension.toLowerCase(Locale.ENGLISH);
this.mediaTypes.put(lowerCaseExtension, mediaType);

View File

@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2020 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -21,6 +21,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -70,21 +71,29 @@ public class ContentNegotiationManagerFactoryBeanTests {
this.servletRequest.setRequestURI("/flower.gif");
assertThat(manager.resolveMediaTypes(this.webRequest)).as("Should be able to resolve file extensions by default").isEqualTo(Collections.singletonList(MediaType.IMAGE_GIF));
assertThat(manager.resolveMediaTypes(this.webRequest))
.as("Should be able to resolve file extensions by default")
.isEqualTo(Collections.singletonList(MediaType.IMAGE_GIF));
this.servletRequest.setRequestURI("/flower.foobarbaz");
assertThat(manager.resolveMediaTypes(this.webRequest)).as("Should ignore unknown extensions by default").isEqualTo(ContentNegotiationStrategy.MEDIA_TYPE_ALL_LIST);
assertThat(manager.resolveMediaTypes(this.webRequest))
.as("Should ignore unknown extensions by default")
.isEqualTo(ContentNegotiationStrategy.MEDIA_TYPE_ALL_LIST);
this.servletRequest.setRequestURI("/flower");
this.servletRequest.setParameter("format", "gif");
assertThat(manager.resolveMediaTypes(this.webRequest)).as("Should not resolve request parameters by default").isEqualTo(ContentNegotiationStrategy.MEDIA_TYPE_ALL_LIST);
assertThat(manager.resolveMediaTypes(this.webRequest))
.as("Should not resolve request parameters by default")
.isEqualTo(ContentNegotiationStrategy.MEDIA_TYPE_ALL_LIST);
this.servletRequest.setRequestURI("/flower");
this.servletRequest.addHeader("Accept", MediaType.IMAGE_GIF_VALUE);
assertThat(manager.resolveMediaTypes(this.webRequest)).as("Should resolve Accept header by default").isEqualTo(Collections.singletonList(MediaType.IMAGE_GIF));
assertThat(manager.resolveMediaTypes(this.webRequest))
.as("Should resolve Accept header by default")
.isEqualTo(Collections.singletonList(MediaType.IMAGE_GIF));
}
@Test
@ -101,29 +110,33 @@ public class ContentNegotiationManagerFactoryBeanTests {
this.servletRequest.setRequestURI("/flower");
this.servletRequest.addParameter("format", "bar");
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(Collections.singletonList(new MediaType("application", "bar")));
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(Collections.singletonList(new MediaType("application", "bar")));
}
@Test
public void favorPath() throws Exception {
this.factoryBean.setFavorPathExtension(true);
this.factoryBean.addMediaTypes(Collections.singletonMap("bar", new MediaType("application", "bar")));
this.factoryBean.addMediaType("bar", new MediaType("application", "bar"));
this.factoryBean.afterPropertiesSet();
ContentNegotiationManager manager = this.factoryBean.getObject();
this.servletRequest.setRequestURI("/flower.foo");
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(Collections.singletonList(new MediaType("application", "foo")));
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(Collections.singletonList(new MediaType("application", "foo")));
this.servletRequest.setRequestURI("/flower.bar");
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(Collections.singletonList(new MediaType("application", "bar")));
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(Collections.singletonList(new MediaType("application", "bar")));
this.servletRequest.setRequestURI("/flower.gif");
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(Collections.singletonList(MediaType.IMAGE_GIF));
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(Collections.singletonList(MediaType.IMAGE_GIF));
}
@Test // SPR-10170
public void favorPathWithIgnoreUnknownPathExtensionTurnedOff() throws Exception {
public void favorPathWithIgnoreUnknownPathExtensionTurnedOff() {
this.factoryBean.setFavorPathExtension(true);
this.factoryBean.setIgnoreUnknownPathExtensions(false);
this.factoryBean.afterPropertiesSet();
@ -139,10 +152,7 @@ public class ContentNegotiationManagerFactoryBeanTests {
@Test
public void favorParameter() throws Exception {
this.factoryBean.setFavorParameter(true);
Map<String, MediaType> mediaTypes = new HashMap<>();
mediaTypes.put("json", MediaType.APPLICATION_JSON);
this.factoryBean.addMediaTypes(mediaTypes);
this.factoryBean.addMediaType("json", MediaType.APPLICATION_JSON);
this.factoryBean.afterPropertiesSet();
ContentNegotiationManager manager = this.factoryBean.getObject();
@ -150,11 +160,12 @@ public class ContentNegotiationManagerFactoryBeanTests {
this.servletRequest.setRequestURI("/flower");
this.servletRequest.addParameter("format", "json");
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(Collections.singletonList(MediaType.APPLICATION_JSON));
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(Collections.singletonList(MediaType.APPLICATION_JSON));
}
@Test // SPR-10170
public void favorParameterWithUnknownMediaType() throws HttpMediaTypeNotAcceptableException {
public void favorParameterWithUnknownMediaType() {
this.factoryBean.setFavorParameter(true);
this.factoryBean.afterPropertiesSet();
ContentNegotiationManager manager = this.factoryBean.getObject();
@ -162,8 +173,52 @@ public class ContentNegotiationManagerFactoryBeanTests {
this.servletRequest.setRequestURI("/flower");
this.servletRequest.setParameter("format", "invalid");
assertThatExceptionOfType(HttpMediaTypeNotAcceptableException.class).isThrownBy(() ->
manager.resolveMediaTypes(this.webRequest));
assertThatExceptionOfType(HttpMediaTypeNotAcceptableException.class)
.isThrownBy(() -> manager.resolveMediaTypes(this.webRequest));
}
@Test
public void mediaTypeMappingsWithoutPathAndParameterStrategies() {
this.factoryBean.setFavorPathExtension(false);
this.factoryBean.setFavorParameter(false);
Properties properties = new Properties();
properties.put("JSon", "application/json");
this.factoryBean.setMediaTypes(properties);
this.factoryBean.addMediaType("pdF", MediaType.APPLICATION_PDF);
this.factoryBean.addMediaTypes(Collections.singletonMap("xML", MediaType.APPLICATION_XML));
ContentNegotiationManager manager = this.factoryBean.build();
assertThat(manager.getMediaTypeMappings())
.hasSize(3)
.containsEntry("json", MediaType.APPLICATION_JSON)
.containsEntry("pdf", MediaType.APPLICATION_PDF)
.containsEntry("xml", MediaType.APPLICATION_XML);
}
@Test
public void fileExtensions() {
this.factoryBean.setFavorPathExtension(false);
this.factoryBean.setFavorParameter(false);
Properties properties = new Properties();
properties.put("json", "application/json");
properties.put("pdf", "application/pdf");
properties.put("xml", "application/xml");
this.factoryBean.setMediaTypes(properties);
this.factoryBean.addMediaType("jsON", MediaType.APPLICATION_JSON);
this.factoryBean.addMediaType("pdF", MediaType.APPLICATION_PDF);
this.factoryBean.addMediaTypes(Collections.singletonMap("JSon", MediaType.APPLICATION_JSON));
this.factoryBean.addMediaTypes(Collections.singletonMap("xML", MediaType.APPLICATION_XML));
ContentNegotiationManager manager = this.factoryBean.build();
assertThat(manager.getAllFileExtensions()).containsExactlyInAnyOrder("json", "xml", "pdf");
}
@Test
@ -175,7 +230,8 @@ public class ContentNegotiationManagerFactoryBeanTests {
this.servletRequest.setRequestURI("/flower");
this.servletRequest.addHeader("Accept", MediaType.IMAGE_GIF_VALUE);
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(ContentNegotiationStrategy.MEDIA_TYPE_ALL_LIST);
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(ContentNegotiationStrategy.MEDIA_TYPE_ALL_LIST);
}
@Test
@ -210,10 +266,12 @@ public class ContentNegotiationManagerFactoryBeanTests {
this.factoryBean.afterPropertiesSet();
ContentNegotiationManager manager = this.factoryBean.getObject();
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(Collections.singletonList(MediaType.APPLICATION_JSON));
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(Collections.singletonList(MediaType.APPLICATION_JSON));
this.servletRequest.addHeader("Accept", MediaType.ALL_VALUE);
assertThat(manager.resolveMediaTypes(this.webRequest)).isEqualTo(Collections.singletonList(MediaType.APPLICATION_JSON));
assertThat(manager.resolveMediaTypes(this.webRequest))
.isEqualTo(Collections.singletonList(MediaType.APPLICATION_JSON));
}

View File

@ -17,6 +17,7 @@
package org.springframework.web.accept;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -34,12 +35,14 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
public class MappingMediaTypeFileExtensionResolverTests {
private final Map<String, MediaType> mapping = Collections.singletonMap("json", MediaType.APPLICATION_JSON);
private final MappingMediaTypeFileExtensionResolver resolver = new MappingMediaTypeFileExtensionResolver(this.mapping);
private static final Map<String, MediaType> DEFAULT_MAPPINGS =
Collections.singletonMap("json", MediaType.APPLICATION_JSON);
@Test
public void resolveExtensions() {
List<String> extensions = this.resolver.resolveFileExtensions(MediaType.APPLICATION_JSON);
List<String> extensions = new MappingMediaTypeFileExtensionResolver(DEFAULT_MAPPINGS)
.resolveFileExtensions(MediaType.APPLICATION_JSON);
assertThat(extensions).hasSize(1);
assertThat(extensions.get(0)).isEqualTo("json");
@ -47,20 +50,24 @@ public class MappingMediaTypeFileExtensionResolverTests {
@Test
public void resolveExtensionsNoMatch() {
List<String> extensions = this.resolver.resolveFileExtensions(MediaType.TEXT_HTML);
assertThat(extensions).isEmpty();
assertThat(new MappingMediaTypeFileExtensionResolver(DEFAULT_MAPPINGS)
.resolveFileExtensions(MediaType.TEXT_HTML)).isEmpty();
}
/**
* Unit test for SPR-13747 - ensures that reverse lookup of media type from media
* type key is case-insensitive.
*/
@Test
@Test // SPR-13747
public void lookupMediaTypeCaseInsensitive() {
MediaType mediaType = this.resolver.lookupMediaType("JSON");
assertThat(mediaType).isEqualTo(MediaType.APPLICATION_JSON);
assertThat(new MappingMediaTypeFileExtensionResolver(DEFAULT_MAPPINGS).lookupMediaType("JSON"))
.isEqualTo(MediaType.APPLICATION_JSON);
}
@Test
public void allFileExtensions() {
Map<String, MediaType> mappings = new HashMap<>();
mappings.put("json", MediaType.APPLICATION_JSON);
mappings.put("JsOn", MediaType.APPLICATION_JSON);
mappings.put("jSoN", MediaType.APPLICATION_JSON);
MappingMediaTypeFileExtensionResolver resolver = new MappingMediaTypeFileExtensionResolver(mappings);
assertThat(resolver.getAllFileExtensions()).containsExactly("json");
}
}