SPR-5452: Content negotiation
This commit is contained in:
parent
b96a7a7a8c
commit
df563dfcd6
|
@ -0,0 +1,310 @@
|
|||
/*
|
||||
* Copyright 2002-2009 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
|
||||
*
|
||||
* http://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.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
import javax.activation.FileTypeMap;
|
||||
import javax.activation.MimetypesFileTypeMap;
|
||||
import javax.servlet.ServletContext;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.beans.factory.BeanFactoryUtils;
|
||||
import org.springframework.core.OrderComparator;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.MediaType;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.context.support.WebApplicationObjectSupport;
|
||||
import org.springframework.web.servlet.View;
|
||||
import org.springframework.web.servlet.ViewResolver;
|
||||
import org.springframework.web.util.UrlPathHelper;
|
||||
import org.springframework.web.util.WebUtils;
|
||||
|
||||
/**
|
||||
* Implementation of {@link ViewResolver} that resolves a view based on the request file name or {@code Accept} header.
|
||||
*
|
||||
* <p>The {@code ContentNegotiatingViewResolver} does not resolve views itself, but delegates to other {@link
|
||||
* ViewResolver}s. By default, these other view resolvers are picked up automatically from the application context,
|
||||
* though they can also be set explicitely by using the {@link #setViewResolvers(List) viewResolvers} property.
|
||||
* <strong>Note</strong> that in order for this view resolver to work properly, the {@link #setOrder(int) order}
|
||||
* property needs to be set to a higher precedence than the others (the default is {@link Ordered#HIGHEST_PRECEDENCE}.)
|
||||
*
|
||||
* <p>This view resolver uses the requested {@linkplain MediaType media type} to select a suitable {@link View} for a
|
||||
* request. This media type is determined by using the following criteria:
|
||||
* <ol>
|
||||
* <li>If the requested path has a file extension and if the {@link #setFavorPathExtension(boolean)} property is
|
||||
* <code>true</code>, the {@link #setMediaTypes(Map) mediaTypes} property is inspected for a matching media type.</li>
|
||||
* <li>If there is no match and if the Java Activation Framework (JAF) is present on the class path,
|
||||
* {@link FileTypeMap#getContentType(String)} is used.</li>
|
||||
* <li>If the previous steps did not result in a media type, the request {@code Accept} header is used.</li>
|
||||
* </ol>
|
||||
* Once the requested media type has been determined, this resolver queries each delegate view resolver for a
|
||||
* {@link View} and determines if the requested media type is {@linkplain MediaType#includes(MediaType) compatible} with
|
||||
* the view's {@linkplain View#getContentType() content type}). The most compatible view is returned.
|
||||
*
|
||||
* <p>For example, if the request path is {@code /view.html}, this view resolver will look for a view that has the
|
||||
* {@code text/html} content type (based on the {@code html} file extension). A request for {@code /view} with a {@code
|
||||
* text/html} request {@code Accept} header has the same result.
|
||||
*
|
||||
* @author Arjen Poutsma
|
||||
* @see ViewResolver
|
||||
* @see InternalResourceViewResolver
|
||||
* @see BeanNameViewResolver
|
||||
* @since 3.0
|
||||
*/
|
||||
public class ContentNegotiatingViewResolver extends WebApplicationObjectSupport implements ViewResolver, Ordered {
|
||||
|
||||
private static final boolean jafPresent =
|
||||
ClassUtils.isPresent("javax.activation.FileTypeMap", ContentNegotiatingViewResolver.class.getClassLoader());
|
||||
|
||||
private static final String ACCEPT_HEADER = "Accept";
|
||||
|
||||
private UrlPathHelper urlPathHelper = new UrlPathHelper();
|
||||
|
||||
private boolean favorPathExtension = true;
|
||||
|
||||
private int order = Ordered.HIGHEST_PRECEDENCE;
|
||||
|
||||
private Map<String, MediaType> mediaTypes = new HashMap<String, MediaType>();
|
||||
|
||||
private List<ViewResolver> viewResolvers;
|
||||
|
||||
public void setOrder(int order) {
|
||||
this.order = order;
|
||||
}
|
||||
|
||||
public int getOrder() {
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the extension of the request path should be used to determine the requested media type, in favor
|
||||
* of looking at the {@code Accept} header.
|
||||
*
|
||||
* <p>For instance, when this flag is <code>true</code> (the default), a request for {@code /hotels.pdf} will result in
|
||||
* an {@code AbstractPdfView} being resolved, while the {@code Accept} header can be the browser-defined {@code
|
||||
* text/html,application/xhtml+xml}.
|
||||
*/
|
||||
public void setFavorPathExtension(boolean favorPathExtension) {
|
||||
this.favorPathExtension = favorPathExtension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the mapping from file extensions to media types.
|
||||
*
|
||||
* <p>When this mapping is not set or when an extension is not present, this view resolver will fall back to using a
|
||||
* {@link FileTypeMap} when the Java Action Framework is available.
|
||||
*/
|
||||
public void setMediaTypes(Map<String, String> mediaTypes) {
|
||||
Assert.notNull(mediaTypes, "'mediaTypes' must not be null");
|
||||
for (Map.Entry<String, String> entry : mediaTypes.entrySet()) {
|
||||
String extension = entry.getKey().toLowerCase(Locale.ENGLISH);
|
||||
MediaType mediaType = MediaType.parseMediaType(entry.getValue());
|
||||
this.mediaTypes.put(extension, mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the view resolvers to be wrapped by this view resolver.
|
||||
*
|
||||
* <p>If this property is not set, view resolvers will be detected automatically.
|
||||
*/
|
||||
public void setViewResolvers(List<ViewResolver> viewResolvers) {
|
||||
this.viewResolvers = viewResolvers;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initServletContext(ServletContext servletContext) {
|
||||
if (viewResolvers == null) {
|
||||
Map<String, ViewResolver> matchingBeans = BeanFactoryUtils
|
||||
.beansOfTypeIncludingAncestors(getApplicationContext(), ViewResolver.class, true, false);
|
||||
this.viewResolvers = new ArrayList<ViewResolver>(matchingBeans.size());
|
||||
for (ViewResolver viewResolver : matchingBeans.values()) {
|
||||
if (this != viewResolver) {
|
||||
this.viewResolvers.add(viewResolver);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.viewResolvers.isEmpty()) {
|
||||
logger.warn("Did not find any ViewResolvers to delegate to; please configure them using the " +
|
||||
"'viewResolvers' property on the ContentNegotiatingViewResolver");
|
||||
}
|
||||
Collections.sort(this.viewResolvers, new OrderComparator());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the list of {@link MediaType} for the given {@link HttpServletRequest}.
|
||||
*
|
||||
* <p>The default implementation invokes {@link #getMediaTypeFromFilename(String)} if {@linkplain
|
||||
* #setFavorPathExtension(boolean) favorPathExtension} property is <code>true</code>. If the property is
|
||||
* <code>false</code>, or when a media type cannot be determined from the request path, this method will inspect the
|
||||
* {@code Accept} header of the request.
|
||||
*
|
||||
* <p>This method can be overriden to provide a different algorithm.
|
||||
*
|
||||
* @param request the current servlet request
|
||||
* @return the list of media types requested, if any
|
||||
*/
|
||||
protected List<MediaType> getMediaTypes(HttpServletRequest request) {
|
||||
if (favorPathExtension) {
|
||||
String requestUri = urlPathHelper.getRequestUri(request);
|
||||
String filename = WebUtils.extractFullFilenameFromUrlPath(requestUri);
|
||||
MediaType mediaType = getMediaTypeFromFilename(filename);
|
||||
if (mediaType != null) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Requested media type is '" + mediaType + "' (based on filename '" + filename + "')");
|
||||
}
|
||||
List<MediaType> mediaTypes = new ArrayList<MediaType>();
|
||||
mediaTypes.add(mediaType);
|
||||
return mediaTypes;
|
||||
}
|
||||
}
|
||||
String acceptHeader = request.getHeader(ACCEPT_HEADER);
|
||||
if (StringUtils.hasText(acceptHeader)) {
|
||||
List<MediaType> mediaTypes = MediaType.parseMediaTypes(acceptHeader);
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Requested media types are " + mediaTypes + " (based on Accept header)");
|
||||
}
|
||||
return mediaTypes;
|
||||
}
|
||||
else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the {@link MediaType} for the given filename.
|
||||
*
|
||||
* <p>The default implementation will check the {@linkplain #setMediaTypes(Map) media types} property first for a
|
||||
* defined mapping. If not present, and if the Java Activation Framework can be found on the class path, it will call
|
||||
* {@link FileTypeMap#getContentType(String)}
|
||||
*
|
||||
* <p>This method can be overriden to provide a different algorithm.
|
||||
*
|
||||
* @param filename the current request file name (i.e. {@code hotels.html})
|
||||
* @return the media type, if any
|
||||
*/
|
||||
protected MediaType getMediaTypeFromFilename(String filename) {
|
||||
String extension = StringUtils.getFilenameExtension(filename);
|
||||
if (!StringUtils.hasText(extension)) {
|
||||
return null;
|
||||
}
|
||||
extension = extension.toLowerCase(Locale.ENGLISH);
|
||||
MediaType mediaType = mediaTypes.get(extension);
|
||||
if (mediaType == null && jafPresent) {
|
||||
mediaType = ActivationMediaTypeFactory.getMediaType(filename);
|
||||
if (mediaType != null) {
|
||||
mediaTypes.put(extension, mediaType);
|
||||
}
|
||||
}
|
||||
return mediaType;
|
||||
}
|
||||
|
||||
public View resolveViewName(String viewName, Locale locale) throws Exception {
|
||||
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
|
||||
Assert.isInstanceOf(ServletRequestAttributes.class, attrs);
|
||||
ServletRequestAttributes servletAttrs = (ServletRequestAttributes) attrs;
|
||||
List<MediaType> requestedMediaTypes = getMediaTypes(servletAttrs.getRequest());
|
||||
Collections.sort(requestedMediaTypes);
|
||||
|
||||
SortedMap<MediaType, View> views = new TreeMap<MediaType, View>();
|
||||
for (ViewResolver viewResolver : viewResolvers) {
|
||||
View view = viewResolver.resolveViewName(viewName, locale);
|
||||
if (view != null) {
|
||||
MediaType viewMediaType = MediaType.parseMediaType(view.getContentType());
|
||||
for (MediaType requestedMediaType : requestedMediaTypes) {
|
||||
if (requestedMediaType.includes(viewMediaType)) {
|
||||
if (!views.containsKey(requestedMediaType)) {
|
||||
views.put(requestedMediaType, view);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!views.isEmpty()) {
|
||||
MediaType mediaType = views.firstKey();
|
||||
View view = views.get(mediaType);
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Returning [" + view + "] based on requested media type '" + mediaType + "'");
|
||||
}
|
||||
return view;
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner class to avoid hard-coded JAF dependency.
|
||||
*/
|
||||
private static class ActivationMediaTypeFactory {
|
||||
|
||||
private static final FileTypeMap fileTypeMap;
|
||||
|
||||
static {
|
||||
fileTypeMap = loadFileTypeMapFromContextSupportModule();
|
||||
}
|
||||
|
||||
private static FileTypeMap loadFileTypeMapFromContextSupportModule() {
|
||||
// see if we can find the extended mime.types from the context-support module
|
||||
Resource mappingLocation = new ClassPathResource("org/springframework/mail/javamail/mime.types");
|
||||
if (mappingLocation.exists()) {
|
||||
InputStream inputStream = null;
|
||||
try {
|
||||
inputStream = mappingLocation.getInputStream();
|
||||
return new MimetypesFileTypeMap(inputStream);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
// ignore
|
||||
}
|
||||
finally {
|
||||
if (inputStream != null) {
|
||||
try {
|
||||
inputStream.close();
|
||||
}
|
||||
catch (IOException ex) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return FileTypeMap.getDefaultFileTypeMap();
|
||||
}
|
||||
|
||||
public static MediaType getMediaType(String fileName) {
|
||||
String mediaType = fileTypeMap.getContentType(fileName);
|
||||
return StringUtils.hasText(mediaType) ? MediaType.parseMediaType(mediaType) : null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright 2002-2009 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
|
||||
*
|
||||
* http://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.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.easymock.EasyMock.*;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.util.MediaType;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.View;
|
||||
import org.springframework.web.servlet.ViewResolver;
|
||||
|
||||
/** @author Arjen Poutsma */
|
||||
public class ContentNegotiatingViewResolverTests {
|
||||
|
||||
private ContentNegotiatingViewResolver viewResolver;
|
||||
|
||||
@Before
|
||||
public void createViewResolver() {
|
||||
viewResolver = new ContentNegotiatingViewResolver();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMediaTypeFromFilename() {
|
||||
assertEquals("Invalid content type", new MediaType("text", "html"),
|
||||
viewResolver.getMediaTypeFromFilename("test.html"));
|
||||
viewResolver.setMediaTypes(Collections.singletonMap("HTML", "application/xhtml+xml"));
|
||||
assertEquals("Invalid content type", new MediaType("application", "xhtml+xml"),
|
||||
viewResolver.getMediaTypeFromFilename("test.html"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMediaTypeFilename() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/test.html?foo=bar");
|
||||
List<MediaType> result = viewResolver.getMediaTypes(request);
|
||||
assertEquals("Invalid content type", Collections.singletonList(new MediaType("text", "html")), result);
|
||||
viewResolver.setMediaTypes(Collections.singletonMap("html", "application/xhtml+xml"));
|
||||
result = viewResolver.getMediaTypes(request);
|
||||
assertEquals("Invalid content type", Collections.singletonList(new MediaType("application", "xhtml+xml")),
|
||||
result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMediaTypeAcceptHeader() {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/test");
|
||||
request.addHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||
List<MediaType> result = viewResolver.getMediaTypes(request);
|
||||
assertEquals("Invalid amount of media types", 4, result.size());
|
||||
assertEquals("Invalid content type", new MediaType("text", "html"), result.get(0));
|
||||
assertEquals("Invalid content type", new MediaType("application", "xhtml+xml"), result.get(1));
|
||||
assertEquals("Invalid content type", new MediaType("application", "xml", Collections.singletonMap("q", "0.9")),
|
||||
result.get(2));
|
||||
assertEquals("Invalid content type", new MediaType("*", "*", Collections.singletonMap("q", "0.8")),
|
||||
result.get(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveViewNameAcceptHeader() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/test");
|
||||
request.addHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
ViewResolver viewResolverMock1 = createMock(ViewResolver.class);
|
||||
ViewResolver viewResolverMock2 = createMock(ViewResolver.class);
|
||||
List<ViewResolver> viewResolverMocks = new ArrayList<ViewResolver>();
|
||||
viewResolverMocks.add(viewResolverMock1);
|
||||
viewResolverMocks.add(viewResolverMock2);
|
||||
viewResolver.setViewResolvers(viewResolverMocks);
|
||||
|
||||
View viewMock1 = createMock("application_xml", View.class);
|
||||
View viewMock2 = createMock("text_html", View.class);
|
||||
|
||||
String viewName = "view";
|
||||
Locale locale = Locale.ENGLISH;
|
||||
|
||||
expect(viewResolverMock1.resolveViewName(viewName, locale)).andReturn(viewMock1);
|
||||
expect(viewResolverMock2.resolveViewName(viewName, locale)).andReturn(viewMock2);
|
||||
expect(viewMock1.getContentType()).andReturn("application/xml");
|
||||
expect(viewMock2.getContentType()).andReturn("text/html;charset=ISO-8859-1");
|
||||
|
||||
replay(viewResolverMock1, viewResolverMock2, viewMock1, viewMock2);
|
||||
|
||||
View result = viewResolver.resolveViewName(viewName, locale);
|
||||
assertSame("Invalid view", viewMock2, result);
|
||||
|
||||
verify(viewResolverMock1, viewResolverMock2, viewMock1, viewMock2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolveViewNameFilename() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/test.html");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
|
||||
ViewResolver viewResolverMock1 = createMock(ViewResolver.class);
|
||||
ViewResolver viewResolverMock2 = createMock(ViewResolver.class);
|
||||
List<ViewResolver> viewResolverMocks = new ArrayList<ViewResolver>();
|
||||
viewResolverMocks.add(viewResolverMock1);
|
||||
viewResolverMocks.add(viewResolverMock2);
|
||||
viewResolver.setViewResolvers(viewResolverMocks);
|
||||
|
||||
View viewMock1 = createMock("application_xml", View.class);
|
||||
View viewMock2 = createMock("text_html", View.class);
|
||||
|
||||
String viewName = "view";
|
||||
Locale locale = Locale.ENGLISH;
|
||||
|
||||
expect(viewResolverMock1.resolveViewName(viewName, locale)).andReturn(viewMock1);
|
||||
expect(viewResolverMock2.resolveViewName(viewName, locale)).andReturn(viewMock2);
|
||||
expect(viewMock1.getContentType()).andReturn("application/xml");
|
||||
expect(viewMock2.getContentType()).andReturn("text/html;charset=ISO-8859-1");
|
||||
|
||||
replay(viewResolverMock1, viewResolverMock2, viewMock1, viewMock2);
|
||||
|
||||
View result = viewResolver.resolveViewName(viewName, locale);
|
||||
assertSame("Invalid view", viewMock2, result);
|
||||
|
||||
verify(viewResolverMock1, viewResolverMock2, viewMock1, viewMock2);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue