diff --git a/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/config/MvcNamespaceHandler.java b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/config/MvcNamespaceHandler.java index 58848b8fd7..b85da7bf2a 100644 --- a/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/config/MvcNamespaceHandler.java +++ b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/config/MvcNamespaceHandler.java @@ -30,6 +30,7 @@ public class MvcNamespaceHandler extends NamespaceHandlerSupport { public void init() { registerBeanDefinitionParser("annotation-driven", new AnnotationDrivenBeanDefinitionParser()); registerBeanDefinitionParser("interceptors", new InterceptorsBeanDefinitionParser()); + registerBeanDefinitionParser("resources", new ResourcesBeanDefinitionParser()); registerBeanDefinitionParser("view-controller", new ViewControllerBeanDefinitionParser()); } diff --git a/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/config/ResourcesBeanDefinitionParser.java b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/config/ResourcesBeanDefinitionParser.java new file mode 100644 index 0000000000..70e0fa8190 --- /dev/null +++ b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/config/ResourcesBeanDefinitionParser.java @@ -0,0 +1,88 @@ +package org.springframework.web.servlet.config; + +import java.util.Map; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.parsing.BeanComponentDefinition; +import org.springframework.beans.factory.support.ManagedMap; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.beans.factory.xml.BeanDefinitionParser; +import org.springframework.beans.factory.xml.ParserContext; +import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping; +import org.springframework.web.servlet.mvc.HttpRequestHandlerAdapter; +import org.springframework.web.servlet.resources.ResourceHttpRequestHandler; +import org.w3c.dom.Element; + +/** + * {@link org.springframework.beans.factory.xml.BeanDefinitionParser} that parses a + * {@code resources} element to register a {@link ResourceHttpRequestHandler}. + * Will also register a {@link SimpleUrlHandlerMapping} for mapping resource requests, if necessary. + * Will also register a {@link HttpRequestHandlerAdapter} if necessary. + * + * @author Keith Donald + * @since 3.0.4 + */ +public class ResourcesBeanDefinitionParser implements BeanDefinitionParser { + + private static final String HANDLER_ADAPTER_BEAN_NAME = "org.springframework.web.servlet.mvc.HttpRequestHandlerAdapter"; + + private static final String HANDLER_MAPPING_BEAN_NAME = "org.springframework.web.servlet.config.resourcesHandlerMapping"; + + public BeanDefinition parse(Element element, ParserContext parserContext) { + Object source = parserContext.extractSource(element); + + registerHandlerAdapterIfNecessary(parserContext, source); + BeanDefinition handlerMappingDef = registerHandlerMappingIfNecessary(parserContext, source); + + String resourceDirectory = "/resources/"; + RootBeanDefinition resourceHandlerDef = new RootBeanDefinition(ResourceHttpRequestHandler.class); + resourceHandlerDef.setSource(source); + resourceHandlerDef.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + resourceHandlerDef.getConstructorArgumentValues().addIndexedArgumentValue(0, resourceDirectory); + + Map urlMap = getUrlMap(handlerMappingDef); + String resourceRequestPath = "/resources/**"; + urlMap.put(resourceRequestPath, resourceHandlerDef); + + return null; + } + + private void registerHandlerAdapterIfNecessary(ParserContext parserContext, Object source) { + if (!parserContext.getRegistry().containsBeanDefinition(HANDLER_ADAPTER_BEAN_NAME)) { + RootBeanDefinition handlerAdapterDef = new RootBeanDefinition(HttpRequestHandlerAdapter.class); + handlerAdapterDef.setSource(source); + handlerAdapterDef.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + parserContext.getRegistry().registerBeanDefinition(HANDLER_ADAPTER_BEAN_NAME, handlerAdapterDef); + parserContext.registerComponent(new BeanComponentDefinition(handlerAdapterDef, HANDLER_ADAPTER_BEAN_NAME)); + } + } + + private BeanDefinition registerHandlerMappingIfNecessary(ParserContext parserContext, Object source) { + if (!parserContext.getRegistry().containsBeanDefinition(HANDLER_MAPPING_BEAN_NAME)) { + RootBeanDefinition handlerMappingDef = new RootBeanDefinition(SimpleUrlHandlerMapping.class); + handlerMappingDef.setSource(source); + handlerMappingDef.getPropertyValues().add("order", "2"); + handlerMappingDef.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + parserContext.getRegistry().registerBeanDefinition(HANDLER_MAPPING_BEAN_NAME, handlerMappingDef); + parserContext.registerComponent(new BeanComponentDefinition(handlerMappingDef, HANDLER_MAPPING_BEAN_NAME)); + return handlerMappingDef; + } + else { + return parserContext.getRegistry().getBeanDefinition(HANDLER_MAPPING_BEAN_NAME); + } + } + + @SuppressWarnings("unchecked") + private Map getUrlMap(BeanDefinition handlerMappingDef) { + Map urlMap; + if (handlerMappingDef.getPropertyValues().contains("urlMap")) { + urlMap = (Map) handlerMappingDef.getPropertyValues().getPropertyValue("urlMap").getValue(); + } + else { + urlMap = new ManagedMap(); + handlerMappingDef.getPropertyValues().add("urlMap", urlMap); + } + return urlMap; + } + +} diff --git a/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/resources/ResourceHttpRequestHandler.java b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/resources/ResourceHttpRequestHandler.java new file mode 100644 index 0000000000..f966b41b0d --- /dev/null +++ b/org.springframework.web.servlet/src/main/java/org/springframework/web/servlet/resources/ResourceHttpRequestHandler.java @@ -0,0 +1,263 @@ +package org.springframework.web.servlet.resources; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import javax.activation.FileTypeMap; +import javax.activation.MimetypesFileTypeMap; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.HttpRequestHandler; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.mvc.multiaction.NoSuchRequestHandlingMethodException; +import org.springframework.web.servlet.view.ContentNegotiatingViewResolver; + +public class ResourceHttpRequestHandler implements HttpRequestHandler { + + private static final Log logger = LogFactory.getLog(ResourceHttpRequestHandler.class); + + private Resource resourceDirectory; + + private int maxAge = 31556926; + + private FileMediaTypeMap fileMediaTypeMap = new DefaultFileMediaTypeMap(); + + public ResourceHttpRequestHandler(Resource resourceDirectory) { + Assert.notNull(resourceDirectory, "The resource directory may not be null"); + this.resourceDirectory = resourceDirectory; + } + + public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + if (!"GET".equals(request.getMethod())) { + throw new HttpRequestMethodNotSupportedException(request.getMethod(), + new String[] {"GET"}, "ResourceHttpRequestHandler only supports GET requests"); + } + List resources = getResources(request); + if (resources.size() == 0) { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + boolean notModified = checkNotModified(resources, request, response); + if (notModified) { + return; + } + prepareResponse(resources, response); + writeResponse(resources, response); + } + + private List getResources(HttpServletRequest request) throws ServletException, IOException { + String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); + if (path == null) { + throw new IllegalStateException("Required request attribute '" + HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE + "' is not set"); + } + String[] resourceElements = path.split(","); + if (resourceElements.length == 1 && resourceElements[0].length() == 0) { + throw new NoSuchRequestHandlingMethodException(request); + } + List resources = new ArrayList(); + String[] dirAndFilename = splitDirectoryAndFilename(resourceElements[0]); + String dir = dirAndFilename[0]; + String filename = dirAndFilename[1]; + Resource parent = dir != null ? this.resourceDirectory.createRelative(dir) : this.resourceDirectory; + addResource(parent, filename, resources); + if (resourceElements.length > 1) { + for (int i = 1; i < resourceElements.length; i++) { + addResource(parent, resourceElements[i], resources); + } + } + return resources; + } + + private boolean checkNotModified(List resources,HttpServletRequest request, HttpServletResponse response) throws IOException { + long lastModifiedTimestamp = -1; + long ifModifiedSince = request.getDateHeader("If-Modified-Since"); + for (Resource resource : resources) { + long resourceLastModified = resource.lastModified(); + if (resourceLastModified > lastModifiedTimestamp) { + lastModifiedTimestamp = resourceLastModified; + } + } + boolean notModified = ifModifiedSince >= (lastModifiedTimestamp / 1000 * 1000); + if (notModified) { + response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + } else { + response.setDateHeader("Last-Modified", lastModifiedTimestamp); + } + return notModified; + } + + private void prepareResponse(List resources, HttpServletResponse response) { + MediaType mediaType = null; + int contentLength = 0; + for (Resource resource : resources) { + try { + File file = resource.getFile(); + if (mediaType == null) { + mediaType = fileMediaTypeMap.getMediaType(file.getName()); + } + contentLength += file.length(); + } catch (IOException e) { + + } + } + if (mediaType != null) { + response.setContentType(mediaType.toString()); + } + response.setContentLength(contentLength); + if (this.maxAge > 0) { + // HTTP 1.0 header + response.setDateHeader("Expires", System.currentTimeMillis() + this.maxAge * 1000L); + // HTTP 1.1 header + response.setHeader("Cache-Control", "max-age=" + this.maxAge); + } + } + + private void writeResponse(List resources, HttpServletResponse response) throws IOException { + for (Resource resource : resources) { + InputStream in = null; + try { + in = resource.getInputStream(); + int bytesRead = -1; + byte[] buffer = new byte[4096]; + while ((bytesRead = in.read(buffer)) != -1) { + response.getOutputStream().write(buffer, 0, bytesRead); + } + } catch (IOException e) { + + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException e) { + + } + } + } + } + } + + private String[] splitDirectoryAndFilename(String firstResourceElement) { + int index = firstResourceElement.lastIndexOf("/"); + String dir; + if (index == -1) { + dir = null; + } else { + dir = firstResourceElement.substring(0, index + 1); + } + String filename = firstResourceElement.substring(index + 1, firstResourceElement.length()); + return new String[] { dir, filename }; + } + + private void addResource(Resource parent, String name, List resources) throws IOException { + if (name.length() > 0) { + Resource resource = parent.createRelative(name); + if (isAllowed(resource)) { + resources.add(resource); + } + } + } + + private boolean isAllowed(Resource resource) throws IOException { + return resource.exists() && resource.getFile().isFile(); + } + + // TODO promote to top-level and make reusable + // TODO check ServletContext.getMimeType(String) first + + public interface FileMediaTypeMap { + MediaType getMediaType(String fileName); + } + + public static class DefaultFileMediaTypeMap implements FileMediaTypeMap { + + private static final boolean jafPresent = + ClassUtils.isPresent("javax.activation.FileTypeMap", ContentNegotiatingViewResolver.class.getClassLoader()); + + private boolean useJaf = true; + + private ConcurrentMap mediaTypes = new ConcurrentHashMap(); + + public MediaType getMediaType(String filename) { + String extension = StringUtils.getFilenameExtension(filename); + if (!StringUtils.hasText(extension)) { + return null; + } + extension = extension.toLowerCase(Locale.ENGLISH); + MediaType mediaType = this.mediaTypes.get(extension); + if (mediaType == null && useJaf && jafPresent) { + mediaType = ActivationMediaTypeFactory.getMediaType(filename); + if (mediaType != null) { + this.mediaTypes.putIfAbsent(extension, mediaType); + } + } + return mediaType; + } + + /** + * 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()) { + if (logger.isTraceEnabled()) { + logger.trace("Loading Java Activation Framework FileTypeMap from " + mappingLocation); + } + 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 + } + } + } + } + if (logger.isTraceEnabled()) { + logger.trace("Loading default Java Activation Framework FileTypeMap"); + } + 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/main/resources/org/springframework/web/servlet/config/spring-mvc-3.0.xsd b/org.springframework.web.servlet/src/main/resources/org/springframework/web/servlet/config/spring-mvc-3.0.xsd index 79f9e0a3e7..6f3df5e0d9 100644 --- a/org.springframework.web.servlet/src/main/resources/org/springframework/web/servlet/config/spring-mvc-3.0.xsd +++ b/org.springframework.web.servlet/src/main/resources/org/springframework/web/servlet/config/spring-mvc-3.0.xsd @@ -49,6 +49,16 @@ + + + + + + System.currentTimeMillis() + (31556926 * 1000) - 10000); + assertEquals("max-age=31556926", response.getHeader("Cache-Control")); + assertTrue(response.containsHeader("Last-Modified")); + assertEquals(response.getHeader("Last-Modified"), new ClassPathResource("test/foo.css", getClass()).getFile().lastModified()); + assertEquals("h1 { color:red; }", response.getContentAsString()); + } + + @Test + public void getResourceBundle() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/foo.css,bar.css"); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + assertEquals("text/css", response.getContentType()); + assertEquals(36, response.getContentLength()); + assertTrue(((Long)response.getHeader("Expires")) > System.currentTimeMillis() + (31556926 * 1000) - 10000); + assertEquals("max-age=31556926", response.getHeader("Cache-Control")); + assertTrue(response.containsHeader("Last-Modified")); + assertEquals(response.getHeader("Last-Modified"), new ClassPathResource("test/bar.css", getClass()).getFile().lastModified()); + assertEquals("h1 { color:red; }h2 { color:white; }", response.getContentAsString()); + } + + @Test + public void getResourceBundleDifferentTypes() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/foo.css,/js/bar.js"); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + assertEquals("text/css", response.getContentType()); + assertEquals("h1 { color:red; }function foo() { console.log(\"hello bar\"); }", response.getContentAsString()); + } + + @Test + public void getResourceFromSubDirectory() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/js/foo.js"); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + System.out.println(response.getContentAsString()); + assertEquals("text/javascript", response.getContentType()); + assertEquals("function foo() { console.log(\"hello world\"); }", response.getContentAsString()); + } + + @Test + public void getResourceBundleDifferentTypesIncludingDirectory() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/foo.css,/js,/js/foo.js"); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + assertEquals("text/css", response.getContentType()); + assertEquals("h1 { color:red; }function foo() { console.log(\"hello world\"); }", response.getContentAsString()); + } + + @Test + public void notModified() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/foo.css"); + request.addHeader("If-Modified-Since", new ClassPathResource("test/foo.css", getClass()).getFile().lastModified()); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + assertEquals(HttpServletResponse.SC_NOT_MODIFIED, response.getStatus()); + } + + @Test + public void modified() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/foo.css"); + request.addHeader("If-Modified-Since", new ClassPathResource("test/foo.css", getClass()).getFile().lastModified() - 1); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + assertEquals(HttpServletResponse.SC_OK, response.getStatus()); + assertEquals("h1 { color:red; }", response.getContentAsString()); + } + + @Test + public void directory() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/js/"); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + assertEquals(404, response.getStatus()); + } + + @Test(expected=NoSuchRequestHandlingMethodException.class) + public void missingResourcePath() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, ""); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + } + + @Test(expected=IllegalStateException.class) + public void noPathWithinHandlerMappingAttribute() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + } + + @Test(expected=HttpRequestMethodNotSupportedException.class) + public void unsupportedHttpMethod() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/foo.css"); + request.setMethod("POST"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + } + + @Test + public void resourceNotFound() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "/not-there.css"); + request.setMethod("GET"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handleRequest(request, response); + assertEquals(404, response.getStatus()); + } + +} diff --git a/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/bar.css b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/bar.css new file mode 100644 index 0000000000..d1fdea69ce --- /dev/null +++ b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/bar.css @@ -0,0 +1 @@ +h2 { color:white; } \ No newline at end of file diff --git a/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/foo.css b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/foo.css new file mode 100644 index 0000000000..e2f0b1c742 --- /dev/null +++ b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/foo.css @@ -0,0 +1 @@ +h1 { color:red; } \ No newline at end of file diff --git a/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/js/bar.js b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/js/bar.js new file mode 100644 index 0000000000..8c3dd5bf82 --- /dev/null +++ b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/js/bar.js @@ -0,0 +1 @@ +function foo() { console.log("hello bar"); } \ No newline at end of file diff --git a/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/js/foo.js b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/js/foo.js new file mode 100644 index 0000000000..0a694588c5 --- /dev/null +++ b/org.springframework.web.servlet/src/test/resources/org/springframework/web/servlet/resources/test/js/foo.js @@ -0,0 +1 @@ +function foo() { console.log("hello world"); } \ No newline at end of file