From fc68922286c42153f38a3862273d16223e86d7ea Mon Sep 17 00:00:00 2001 From: Laszlo Hornyak Date: Thu, 19 Sep 2013 11:28:23 +0200 Subject: [PATCH] Static resource compression - added compile time maven plugin to compress css and js files - Added new StaticResourceServlet to serve the requests to static files, this replaces the tomcat DefaultServlet - Tests - mapping of the static resource servlet to css and js files Signed-off-by: Laszlo Hornyak --- client/WEB-INF/web.xml | 20 +- client/pom.xml | 17 ++ .../cloud/servlet/StaticResourceServlet.java | 115 +++++++++ .../servlet/StaticResourceServletTest.java | 235 ++++++++++++++++++ 4 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 server/src/com/cloud/servlet/StaticResourceServlet.java create mode 100644 server/test/com/cloud/servlet/StaticResourceServletTest.java diff --git a/client/WEB-INF/web.xml b/client/WEB-INF/web.xml index 1af38e14535..6509a97545a 100644 --- a/client/WEB-INF/web.xml +++ b/client/WEB-INF/web.xml @@ -58,10 +58,15 @@ registerCompleteServlet com.cloud.servlet.RegisterCompleteServlet - - - apiServlet - /api/* + + + staticResources + com.cloud.servlet.StaticResourceServlet + + + + apiServlet + /api/* @@ -73,4 +78,11 @@ registerCompleteServlet /mycloud/complete + + + staticResources + *.css + *.html + *.js + diff --git a/client/pom.xml b/client/pom.xml index 5038e662db5..5c56d08a6c4 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -358,6 +358,23 @@ + + com.googlecode.todomap + maven-jettygzip-plugin + 0.0.4 + + ${project.build.directory}/generated-webapp + ${project.build.directory}/generated-webapp + + + + prepare-package + + process + + + + org.apache.maven.plugins maven-war-plugin diff --git a/server/src/com/cloud/servlet/StaticResourceServlet.java b/server/src/com/cloud/servlet/StaticResourceServlet.java new file mode 100644 index 00000000000..8e96732e4ff --- /dev/null +++ b/server/src/com/cloud/servlet/StaticResourceServlet.java @@ -0,0 +1,115 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 com.cloud.servlet; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang.StringUtils; + +/** + * Serves static resources with support for gzip compression and content + * caching. + */ +public class StaticResourceServlet extends HttpServlet { + + private static final long serialVersionUID = -8833228931973461812L; + + private File getRequestedFile(final HttpServletRequest req) { + return new File(getServletContext().getRealPath(req.getServletPath())); + } + + @Override + protected void doGet(final HttpServletRequest req, + final HttpServletResponse resp) throws ServletException, + IOException { + final File requestedFile = getRequestedFile(req); + if (!requestedFile.exists() || !requestedFile.isFile()) { + resp.setStatus(HttpServletResponse.SC_NOT_FOUND); + return; + } + final String etag = getEtag(requestedFile); + if (etag.equals(req.getHeader("If-None-Match"))) { + resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + return; + } + // have to send data, either compressed or the original + final File compressedStatic = getCompressedVersion(requestedFile); + InputStream fileContent = null; + try { + resp.setContentType(getContentType(requestedFile.getName())); + resp.setHeader("ETag", etag); + resp.setStatus(HttpServletResponse.SC_OK); + if (isClientCompressionSupported(req) && compressedStatic.exists()) { + // gzip compressed + resp.setHeader("Content-Encoding", "gzip"); + resp.setContentLength((int) compressedStatic.length()); + fileContent = new FileInputStream(compressedStatic); + } else { + // uncompressed + resp.setContentLength((int) requestedFile.length()); + fileContent = new FileInputStream(requestedFile); + } + IOUtils.copy(fileContent, resp.getOutputStream()); + } finally { + IOUtils.closeQuietly(fileContent); + } + } + + @SuppressWarnings("serial") + static final Map contentTypes = Collections + .unmodifiableMap(new HashMap() { + { + put("css", "text/css"); + put("svg", "image/svg+xml"); + put("js", "application/javascript"); + put("htm", "text/html"); + put("html", "text/html"); + put("txt", "text/plain"); + put("xml", "text/xml"); + } + }); + + static String getContentType(final String fileName) { + return contentTypes.get(StringUtils.lowerCase(StringUtils + .substringAfterLast(fileName, "."))); + } + + static File getCompressedVersion(final File requestedFile) { + return new File(requestedFile.getAbsolutePath() + ".gz"); + } + + static boolean isClientCompressionSupported(final HttpServletRequest req) { + return StringUtils.contains(req.getHeader("Accept-Encoding"), "gzip"); + } + + static String getEtag(final File resource) { + return "W/\"" + resource.length() + "-" + resource.lastModified(); + } + +} diff --git a/server/test/com/cloud/servlet/StaticResourceServletTest.java b/server/test/com/cloud/servlet/StaticResourceServletTest.java new file mode 100644 index 00000000000..ae5c3847f5b --- /dev/null +++ b/server/test/com/cloud/servlet/StaticResourceServletTest.java @@ -0,0 +1,235 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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 com.cloud.servlet; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.io.FileUtils; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Matchers; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +public class StaticResourceServletTest { + + File rootDirectory; + + @Before + public void setupFiles() throws IOException { + rootDirectory = new File("target/tmp"); + rootDirectory.mkdirs(); + final File webInf = new File(rootDirectory, "WEB-INF"); + webInf.mkdirs(); + final File dir = new File(rootDirectory, "dir"); + dir.mkdirs(); + final File indexHtml = new File(rootDirectory, "index.html"); + indexHtml.createNewFile(); + FileUtils.writeStringToFile(indexHtml, "index.html"); + final File defaultCss = new File(rootDirectory, "default.css"); + defaultCss.createNewFile(); + final File defaultCssGziped = new File(rootDirectory, "default.css.gz"); + defaultCssGziped.createNewFile(); + } + + @After + public void cleanupFiles() { + FileUtils.deleteQuietly(rootDirectory); + } + + // negative tests + + @Test + public void testNoSuchFile() throws ServletException, IOException { + final StaticResourceServlet servlet = Mockito + .mock(StaticResourceServlet.class); + Mockito.doCallRealMethod() + .when(servlet) + .doGet(Matchers.any(HttpServletRequest.class), + Matchers.any(HttpServletResponse.class)); + final ServletContext servletContext = Mockito + .mock(ServletContext.class); + Mockito.when(servletContext.getRealPath("notexisting.css")).thenReturn( + new File(rootDirectory, "notexisting.css").getAbsolutePath()); + Mockito.when(servlet.getServletContext()).thenReturn(servletContext); + + final HttpServletRequest request = Mockito + .mock(HttpServletRequest.class); + Mockito.when(request.getServletPath()).thenReturn("notexisting.css"); + final HttpServletResponse response = Mockito + .mock(HttpServletResponse.class); + servlet.doGet(request, response); + Mockito.verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); + } + + @Test + public void testDirectory() throws ServletException, IOException { + final HttpServletResponse response = doGetTest("dir"); + Mockito.verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); + } + + @Test + public void testWebInf() throws ServletException, IOException { + final HttpServletResponse response = doGetTest("WEB-INF/web.xml"); + Mockito.verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); + } + + // positive tests + + @Test + public void testNotCompressedFile() throws ServletException, IOException { + final HttpServletResponse response = doGetTest("index.html"); + Mockito.verify(response).setStatus(HttpServletResponse.SC_OK); + Mockito.verify(response).setContentType("text/html"); + Mockito.verify(response, Mockito.times(0)).setHeader( + "Content-Encoding", "gzip"); + } + + @Test + public void testCompressedFile() throws ServletException, IOException { + final HashMap headers = new HashMap(); + headers.put("Accept-Encoding", "gzip"); + final HttpServletResponse response = doGetTest("default.css", headers); + Mockito.verify(response).setStatus(HttpServletResponse.SC_OK); + Mockito.verify(response).setContentType("text/css"); + Mockito.verify(response, Mockito.times(1)).setHeader( + "Content-Encoding", "gzip"); + } + + @Test + public void testCompressedFileWithoutBrowserSupport() + throws ServletException, IOException { + final HashMap headers = new HashMap(); + headers.put("Accept-Encoding", ""); + final HttpServletResponse response = doGetTest("default.css", headers); + Mockito.verify(response).setStatus(HttpServletResponse.SC_OK); + Mockito.verify(response).setContentType("text/css"); + Mockito.verify(response, Mockito.times(0)).setHeader( + "Content-Encoding", "gzip"); + } + + @Test + public void testWithEtag() throws ServletException, IOException { + final HashMap headers = new HashMap(); + headers.put("If-None-Match", StaticResourceServlet.getEtag(new File( + rootDirectory, "default.css"))); + final HttpServletResponse response = doGetTest("default.css", headers); + Mockito.verify(response).setStatus(HttpServletResponse.SC_NOT_MODIFIED); + } + + @Test + public void testWithEtagOutdated() throws ServletException, IOException { + final HashMap headers = new HashMap(); + headers.put("If-None-Match", "NO-GOOD-ETAG"); + final HttpServletResponse response = doGetTest("default.css", headers); + Mockito.verify(response).setStatus(HttpServletResponse.SC_OK); + } + + // utility methods + + @Test + public void getEtag() { + Assert.assertNotNull(StaticResourceServlet.getEtag(new File( + rootDirectory, "index.html"))); + } + + @Test + public void getContentType() { + Assert.assertEquals("text/plain", + StaticResourceServlet.getContentType("foo.txt")); + Assert.assertEquals("text/html", + StaticResourceServlet.getContentType("index.html")); + Assert.assertEquals("text/plain", + StaticResourceServlet.getContentType("README.TXT")); + } + + @Test + public void isClientCompressionSupported() { + final HttpServletRequest request = Mockito + .mock(HttpServletRequest.class); + Mockito.when(request.getHeader("Accept-Encoding")).thenReturn( + "gzip, deflate"); + Assert.assertTrue(StaticResourceServlet + .isClientCompressionSupported(request)); + } + + @Test + public void isClientCompressionSupportedWithoutHeader() { + final HttpServletRequest request = Mockito + .mock(HttpServletRequest.class); + Mockito.when(request.getHeader("Accept-Encoding")).thenReturn(null); + Assert.assertFalse(StaticResourceServlet + .isClientCompressionSupported(request)); + } + + // test utilities + private HttpServletResponse doGetTest(final String uri) + throws ServletException, IOException { + return doGetTest(uri, Collections. emptyMap()); + } + + private HttpServletResponse doGetTest(final String uri, + final Map headers) throws ServletException, + IOException { + final StaticResourceServlet servlet = Mockito + .mock(StaticResourceServlet.class); + Mockito.doCallRealMethod() + .when(servlet) + .doGet(Matchers.any(HttpServletRequest.class), + Matchers.any(HttpServletResponse.class)); + final ServletContext servletContext = Mockito + .mock(ServletContext.class); + Mockito.when(servletContext.getRealPath(uri)).thenReturn( + new File(rootDirectory, uri).getAbsolutePath()); + Mockito.when(servlet.getServletContext()).thenReturn(servletContext); + + final HttpServletRequest request = Mockito + .mock(HttpServletRequest.class); + Mockito.when(request.getServletPath()).thenReturn(uri); + Mockito.when(request.getHeader(Matchers.anyString())).thenAnswer( + new Answer() { + + @Override + public String answer(final InvocationOnMock invocation) + throws Throwable { + return headers.get(invocation.getArguments()[0]); + } + }); + final HttpServletResponse response = Mockito + .mock(HttpServletResponse.class); + final ServletOutputStream responseBody = Mockito + .mock(ServletOutputStream.class); + Mockito.when(response.getOutputStream()).thenReturn(responseBody); + servlet.doGet(request, response); + return response; + } + +}