diff --git a/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/ContentNegotiatingViewResolver.java b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/ContentNegotiatingViewResolver.java new file mode 100644 index 0000000000..abb8bdb7d7 --- /dev/null +++ b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/view/ContentNegotiatingViewResolver.java @@ -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. + * + *

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

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

    + *
  1. If the requested path has a file extension and if the {@link #setFavorPathExtension(boolean)} property is + * true, the {@link #setMediaTypes(Map) mediaTypes} property is inspected for a matching media type.
  2. + *
  3. If there is no match and if the Java Activation Framework (JAF) is present on the class path, + * {@link FileTypeMap#getContentType(String)} is used.
  4. + *
  5. If the previous steps did not result in a media type, the request {@code Accept} header is used.
  6. + *
+ * 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. + * + *

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 mediaTypes = new HashMap(); + + private List 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. + * + *

For instance, when this flag is true (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. + * + *

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 mediaTypes) { + Assert.notNull(mediaTypes, "'mediaTypes' must not be null"); + for (Map.Entry 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. + * + *

If this property is not set, view resolvers will be detected automatically. + */ + public void setViewResolvers(List viewResolvers) { + this.viewResolvers = viewResolvers; + } + + @Override + protected void initServletContext(ServletContext servletContext) { + if (viewResolvers == null) { + Map matchingBeans = BeanFactoryUtils + .beansOfTypeIncludingAncestors(getApplicationContext(), ViewResolver.class, true, false); + this.viewResolvers = new ArrayList(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}. + * + *

The default implementation invokes {@link #getMediaTypeFromFilename(String)} if {@linkplain + * #setFavorPathExtension(boolean) favorPathExtension} property is true. If the property is + * false, or when a media type cannot be determined from the request path, this method will inspect the + * {@code Accept} header of the request. + * + *

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 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 mediaTypes = new ArrayList(); + mediaTypes.add(mediaType); + return mediaTypes; + } + } + String acceptHeader = request.getHeader(ACCEPT_HEADER); + if (StringUtils.hasText(acceptHeader)) { + List 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. + * + *

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

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 requestedMediaTypes = getMediaTypes(servletAttrs.getRequest()); + Collections.sort(requestedMediaTypes); + + SortedMap views = new TreeMap(); + 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; + } + } + +} diff --git a/org.springframework.web.servlet/src/test/java/org/springframework/web/servlet/view/ContentNegotiatingViewResolverTests.java b/org.springframework.web.servlet/src/test/java/org/springframework/web/servlet/view/ContentNegotiatingViewResolverTests.java new file mode 100644 index 0000000000..2d381812ab --- /dev/null +++ b/org.springframework.web.servlet/src/test/java/org/springframework/web/servlet/view/ContentNegotiatingViewResolverTests.java @@ -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 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 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 viewResolverMocks = new ArrayList(); + 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 viewResolverMocks = new ArrayList(); + 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); + } + +}