diff --git a/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManager.java b/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManager.java index 8ad0110deb..5f3b76d7a4 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManager.java +++ b/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManager.java @@ -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 resolveFileExtensions(MediaType mediaType) { - Set 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 getAllFileExtensions() { - Set result = new LinkedHashSet<>(); + return doResolveExtensions(MediaTypeFileExtensionResolver::getAllFileExtensions); + } + + private List doResolveExtensions(Function> extractor) { + List result = null; for (MediaTypeFileExtensionResolver resolver : this.resolvers) { - result.addAll(resolver.getAllFileExtensions()); + List 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 getMediaTypeMappings() { + Map result = null; + for (MediaTypeFileExtensionResolver resolver : this.resolvers) { + if (resolver instanceof MappingMediaTypeFileExtensionResolver) { + Map 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()); } } diff --git a/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java b/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java index 91c3193934..42f5159141 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java +++ b/spring-web/src/main/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBean.java @@ -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 * #24719. @@ -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). - *

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. + *

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}. + *

Note: 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 }, the media type mappings are also plugged + * in to: + *

    + *
  • Determine the media type of static resources served with + * {@code ResourceHttpRequestHandler}. + *
  • Determine the media type of views rendered with + * {@code ContentNegotiatingViewResolver}. + *
  • Whitelist extensions for RFD attack detection (check the Spring + * Framework reference docs for details). + *
* @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 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 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; } diff --git a/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java b/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java index 25147843b7..a0ee49a569 100644 --- a/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java +++ b/spring-web/src/main/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolver.java @@ -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 mediaTypes) { if (mediaTypes != null) { - List allFileExtensions = new ArrayList<>(); + Set allFileExtensions = new HashSet<>(mediaTypes.size()); mediaTypes.forEach((extension, mediaType) -> { String lowerCaseExtension = extension.toLowerCase(Locale.ENGLISH); this.mediaTypes.put(lowerCaseExtension, mediaType); diff --git a/spring-web/src/test/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBeanTests.java b/spring-web/src/test/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBeanTests.java index 1b5ac13d79..2c0169c997 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBeanTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/ContentNegotiationManagerFactoryBeanTests.java @@ -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 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)); } diff --git a/spring-web/src/test/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolverTests.java b/spring-web/src/test/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolverTests.java index 873f521a9e..7f1907dba5 100644 --- a/spring-web/src/test/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolverTests.java +++ b/spring-web/src/test/java/org/springframework/web/accept/MappingMediaTypeFileExtensionResolverTests.java @@ -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 mapping = Collections.singletonMap("json", MediaType.APPLICATION_JSON); - private final MappingMediaTypeFileExtensionResolver resolver = new MappingMediaTypeFileExtensionResolver(this.mapping); + private static final Map DEFAULT_MAPPINGS = + Collections.singletonMap("json", MediaType.APPLICATION_JSON); + @Test public void resolveExtensions() { - List extensions = this.resolver.resolveFileExtensions(MediaType.APPLICATION_JSON); + List 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 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 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"); + } }