Index: test/org/apache/catalina/filters/TestExpiresFilter.java
===================================================================
--- test/org/apache/catalina/filters/TestExpiresFilter.java (revision 0)
+++ test/org/apache/catalina/filters/TestExpiresFilter.java (revision 0)
@@ -0,0 +1,438 @@
+/*
+ * 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 org.apache.catalina.filters;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Calendar;
+import java.util.List;
+import java.util.StringTokenizer;
+import java.util.TimeZone;
+import java.util.Map.Entry;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import junit.framework.Assert;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.deploy.FilterDef;
+import org.apache.catalina.deploy.FilterMap;
+import org.apache.catalina.filters.ExpiresFilter.Duration;
+import org.apache.catalina.filters.ExpiresFilter.DurationUnit;
+import org.apache.catalina.filters.ExpiresFilter.ExpiresConfiguration;
+import org.apache.catalina.filters.ExpiresFilter.StartingPoint;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.catalina.startup.TomcatBaseTest;
+
+public class TestExpiresFilter extends TomcatBaseTest {
+ public static final String TEMP_DIR = System.getProperty("java.io.tmpdir");
+
+ public void testConfiguration() throws ServletException, Exception {
+
+ Tomcat tomcat = getTomcatInstance();
+ Context root = tomcat.addContext("", TEMP_DIR);
+
+ FilterDef filterDef = new FilterDef();
+ filterDef.addInitParameter("ExpiresDefault", "access plus 1 month");
+ filterDef.addInitParameter("ExpiresByType text/html", "access plus 1 month 15 days 2 hours");
+ filterDef.addInitParameter("ExpiresByType image/gif", "modification plus 5 hours 3 minutes");
+ filterDef.addInitParameter("ExpiresByType image/jpg", "A10000");
+ filterDef.addInitParameter("ExpiresByType video/mpeg", "M20000");
+ filterDef.addInitParameter("ExpiresActive", "Off");
+ filterDef.addInitParameter("ExpiresExcludedResponseStatusCodes", "304, 503");
+
+ ExpiresFilter expiresFilter = new ExpiresFilter();
+
+ filterDef.setFilter(expiresFilter);
+ filterDef.setFilterClass(ExpiresFilter.class.getName());
+ filterDef.setFilterName(ExpiresFilter.class.getName());
+
+ root.addFilterDef(filterDef);
+
+ FilterMap filterMap = new FilterMap();
+ filterMap.setFilterName(ExpiresFilter.class.getName());
+ filterMap.addURLPattern("*");
+
+ tomcat.start();
+ try {
+ Assert.assertEquals(false, expiresFilter.isActive());
+
+ // VERIFY EXCLUDED RESPONSE STATUS CODES
+ {
+ int[] excludedResponseStatusCodes = expiresFilter.getExcludedResponseStatusCodesAsInts();
+ Assert.assertEquals(2, excludedResponseStatusCodes.length);
+ Assert.assertEquals(304, excludedResponseStatusCodes[0]);
+ Assert.assertEquals(503, excludedResponseStatusCodes[1]);
+ }
+
+ // VERIFY DEFAULT CONFIGURATION
+ {
+ ExpiresConfiguration expiresConfiguration = expiresFilter.getDefaultExpiresConfiguration();
+ Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint());
+ Assert.assertEquals(1, expiresConfiguration.getDurations().size());
+ Assert.assertEquals(DurationUnit.MONTH, expiresConfiguration.getDurations().get(0).getUnit());
+ Assert.assertEquals(1, expiresConfiguration.getDurations().get(0).getAmount());
+ }
+
+ // VERIFY TEXT/HTML
+ {
+ ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("text/html");
+ Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint());
+
+ Assert.assertEquals(3, expiresConfiguration.getDurations().size());
+
+ Duration oneMonth = expiresConfiguration.getDurations().get(0);
+ Assert.assertEquals(DurationUnit.MONTH, oneMonth.getUnit());
+ Assert.assertEquals(1, oneMonth.getAmount());
+
+ Duration fifteenDays = expiresConfiguration.getDurations().get(1);
+ Assert.assertEquals(DurationUnit.DAY, fifteenDays.getUnit());
+ Assert.assertEquals(15, fifteenDays.getAmount());
+
+ Duration twoHours = expiresConfiguration.getDurations().get(2);
+ Assert.assertEquals(DurationUnit.HOUR, twoHours.getUnit());
+ Assert.assertEquals(2, twoHours.getAmount());
+ }
+ // VERIFY IMAGE/GIF
+ {
+ ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("image/gif");
+ Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfiguration.getStartingPoint());
+
+ Assert.assertEquals(2, expiresConfiguration.getDurations().size());
+
+ Duration fiveHours = expiresConfiguration.getDurations().get(0);
+ Assert.assertEquals(DurationUnit.HOUR, fiveHours.getUnit());
+ Assert.assertEquals(5, fiveHours.getAmount());
+
+ Duration threeMinutes = expiresConfiguration.getDurations().get(1);
+ Assert.assertEquals(DurationUnit.MINUTE, threeMinutes.getUnit());
+ Assert.assertEquals(3, threeMinutes.getAmount());
+
+ }
+ // VERIFY IMAGE/JPG
+ {
+ ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("image/jpg");
+ Assert.assertEquals(StartingPoint.ACCESS_TIME, expiresConfiguration.getStartingPoint());
+
+ Assert.assertEquals(1, expiresConfiguration.getDurations().size());
+
+ Duration tenThousandSeconds = expiresConfiguration.getDurations().get(0);
+ Assert.assertEquals(DurationUnit.SECOND, tenThousandSeconds.getUnit());
+ Assert.assertEquals(10000, tenThousandSeconds.getAmount());
+
+ }
+ // VERIFY VIDEO/MPEG
+ {
+ ExpiresConfiguration expiresConfiguration = expiresFilter.getExpiresConfigurationByContentType().get("video/mpeg");
+ Assert.assertEquals(StartingPoint.LAST_MODIFICATION_TIME, expiresConfiguration.getStartingPoint());
+
+ Assert.assertEquals(1, expiresConfiguration.getDurations().size());
+
+ Duration twentyThousandSeconds = expiresConfiguration.getDurations().get(0);
+ Assert.assertEquals(DurationUnit.SECOND, twentyThousandSeconds.getUnit());
+ Assert.assertEquals(20000, twentyThousandSeconds.getAmount());
+ }
+ } finally {
+ tomcat.stop();
+ }
+ }
+
+ /**
+ * Test that a resource with empty content is also processed
+ */
+
+ public void testEmptyContent() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("text/plain");
+ // no content is written in the response
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 7 * 60;
+
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ public void testParseExpiresConfigurationCombinedDuration() {
+ ExpiresFilter expiresFilter = new ExpiresFilter();
+ ExpiresConfiguration actualConfiguration = expiresFilter.parseExpiresConfiguration("access plus 1 month 15 days 2 hours");
+
+ Assert.assertEquals(StartingPoint.ACCESS_TIME, actualConfiguration.getStartingPoint());
+
+ Assert.assertEquals(3, actualConfiguration.getDurations().size());
+
+ }
+
+ public void testParseExpiresConfigurationMonoDuration() {
+ ExpiresFilter expiresFilter = new ExpiresFilter();
+ ExpiresConfiguration actualConfiguration = expiresFilter.parseExpiresConfiguration("access plus 2 hours");
+
+ Assert.assertEquals(StartingPoint.ACCESS_TIME, actualConfiguration.getStartingPoint());
+
+ Assert.assertEquals(1, actualConfiguration.getDurations().size());
+ Assert.assertEquals(2, actualConfiguration.getDurations().get(0).getAmount());
+ Assert.assertEquals(DurationUnit.HOUR, actualConfiguration.getDurations().get(0).getUnit());
+
+ }
+
+ public void testSkipBecauseCacheControlMaxAgeIsDefined() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("text/xml; charset=utf-8");
+ response.addHeader("Cache-Control", "private, max-age=232");
+ response.getWriter().print("Hello world");
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 232;
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ public void testExcludedResponseStatusCode() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
+ response.addHeader("ETag", "W/\"1934-1269208821000\"");
+ response.addDateHeader("Date", System.currentTimeMillis());
+ }
+ };
+
+ validate(servlet, null, HttpServletResponse.SC_NOT_MODIFIED);
+ }
+
+ public void testNullContentType() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType(null);
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 1 * 60;
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ public void testSkipBecauseExpiresIsDefined() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("text/xml; charset=utf-8");
+ response.addDateHeader("Expires", System.currentTimeMillis());
+ response.getWriter().print("Hello world");
+ }
+ };
+
+ validate(servlet, null);
+ }
+
+ public void testUseContentTypeExpiresConfiguration() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("text/xml; charset=utf-8");
+ response.getWriter().print("Hello world");
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 3 * 60;
+
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ public void testUseContentTypeWithoutCharsetExpiresConfiguration() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("text/xml; charset=iso-8859-1");
+ response.getWriter().print("Hello world");
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 5 * 60;
+
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ public void testUseDefaultConfiguration1() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("image/jpeg");
+ response.getWriter().print("Hello world");
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 1 * 60;
+
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ public void testUseDefaultConfiguration2() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("image/jpeg");
+ response.addHeader("Cache-Control", "private");
+
+ response.getWriter().print("Hello world");
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 1 * 60;
+
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ public void testUseMajorTypeExpiresConfiguration() throws Exception {
+ HttpServlet servlet = new HttpServlet() {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("text/json; charset=iso-8859-1");
+ response.getWriter().print("Hello world");
+ }
+ };
+
+ int expectedMaxAgeInSeconds = 7 * 60;
+
+ validate(servlet, expectedMaxAgeInSeconds);
+ }
+
+ protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds) throws Exception {
+ validate(servlet, expectedMaxAgeInSeconds, HttpURLConnection.HTTP_OK);
+ }
+
+ protected void validate(HttpServlet servlet, Integer expectedMaxAgeInSeconds, int expectedResponseStatusCode) throws Exception {
+ // SETUP
+
+ Tomcat tomcat = getTomcatInstance();
+ Context root = tomcat.addContext("", TEMP_DIR);
+
+ FilterDef filterDef = new FilterDef();
+ filterDef.addInitParameter("ExpiresDefault", "access plus 1 minute");
+ filterDef.addInitParameter("ExpiresByType text/xml;charset=utf-8", "access plus 3 minutes");
+ filterDef.addInitParameter("ExpiresByType text/xml", "access plus 5 minutes");
+ filterDef.addInitParameter("ExpiresByType text", "access plus 7 minutes");
+ filterDef.addInitParameter("ExpiresExcludedResponseStatusCodes", "304, 503");
+
+ filterDef.setFilterClass(ExpiresFilter.class.getName());
+ filterDef.setFilterName(ExpiresFilter.class.getName());
+
+ root.addFilterDef(filterDef);
+
+ FilterMap filterMap = new FilterMap();
+ filterMap.setFilterName(ExpiresFilter.class.getName());
+ filterMap.addURLPattern("*");
+ root.addFilterMap(filterMap);
+
+ Tomcat.addServlet(root, servlet.getClass().getName(), servlet);
+ root.addServletMapping("/test", servlet.getClass().getName());
+
+ tomcat.start();
+
+ try {
+ Calendar.getInstance(TimeZone.getTimeZone("GMT"));
+ long timeBeforeInMillis = System.currentTimeMillis();
+
+ // TEST
+ HttpURLConnection httpURLConnection = (HttpURLConnection) new URL("http://localhost:" + tomcat.getConnector().getPort()
+ + "/test").openConnection();
+
+ // VALIDATE
+ Assert.assertEquals(expectedResponseStatusCode, httpURLConnection.getResponseCode());
+
+ StringBuilder msg = new StringBuilder();
+ for (Entry
+ * ExpiresFilter is a Java Servlet API port of Apache
+ * mod_expires to add ' Expires' and '
+ * Cache-Control: max-age=' headers to HTTP response according to its '
+ * Content-Type'.
+ *
+ * Following documentation is inspired by mod_expires .
+ *
+ * This filter controls the setting of the Expires HTTP header and the
+ * max-age directive of the Cache-Control HTTP header in
+ * server responses. The expiration date can set to be relative to either the
+ * time the source file was last modified, or to the time of the client access.
+ *
+ * These HTTP headers are an instruction to the client about the document's
+ * validity and persistence. If cached, the document may be fetched from the
+ * cache rather than from the source until this time has passed. After that, the
+ * cache copy is considered "expired" and invalid, and a new copy must
+ * be obtained from the source.
+ *
+ * To modify Cache-Control directives other than max-age (see
+ * RFC
+ * 2616 section 14.9), you can use other servlet filters or Apache Httpd
+ * mod_headers module.
+ *
+ * This directive enables or disables the generation of the Expires and
+ * Cache-Control headers by this ExpiresFilter. If set to
+ * Off, the headers will not be generated for any HTTP response. If set
+ * to On or true, the headers will be added to served HTTP
+ * responses according to the criteria defined by the
+ * ExpiresByType <content-type> and ExpiresDefault
+ * directives. Note that this directive does not guarantee that an
+ * Expires or Cache-Control header will be generated. If the
+ * criteria aren't met, no header will be sent, and the effect will be as
+ * though this directive wasn't even specified.
+ *
+ * This parameter is optional, default value is true.
+ *
+ * Enable filter
+ *
+ * Disable filter
+ *
+ * This directive defines the value of the Expires header and the
+ * max-age directive of the Cache-Control header generated for
+ * documents of the specified type (e.g., text/html). The second
+ * argument sets the number of seconds that will be added to a base time to
+ * construct the expiration date. The Cache-Control: max-age is
+ * calculated by subtracting the request time from the expiration date and
+ * expressing the result in seconds.
+ *
+ * The base time is either the last modification time of the file, or the time
+ * of the client's access to the document. Which should be used is
+ * specified by the <code> field; M means that the
+ * file's last modification time should be used as the base time, and
+ * A means the client's access time should be used. The duration
+ * is expressed in seconds. A2592000 stands for
+ * access plus 30 days in alternate syntax.
+ *
+ * The difference in effect is subtle. If M (modification in
+ * alternate syntax) is used, all current copies of the document in all caches
+ * will expire at the same time, which can be good for something like a weekly
+ * notice that's always found at the same URL. If A (
+ * access or now in alternate syntax) is used, the date of
+ * expiration is different for each client; this can be good for image files
+ * that don't change very often, particularly for a set of related
+ * documents that all refer to the same images (i.e., the images will be
+ * accessed repeatedly within a relatively short timespan).
+ *
+ * Example:
+ *
+ * Note that this directive only has effect if ExpiresActive On has
+ * been specified. It overrides, for the specified MIME type only, any
+ * expiration date set by the ExpiresDefault directive.
+ *
+ * You can also specify the expiration time calculation using an alternate
+ * syntax, described earlier in this document.
+ *
+ * This directive defines the http response status codes for which the
+ * ExpiresFilter will not generate expiration headers. By default, the
+ * 304 status code ("Not modified") is skipped. The
+ * value is a comma separated list of http status codes.
+ *
+ * This directive is useful to ease usage of ExpiresDefault directive.
+ * Indeed, the behavior of 304 Not modified (which does specify a
+ * Content-Type header) combined with Expires and
+ * Cache-Control:max-age= headers can be unnecessarily tricky to
+ * understand.
+ *
+ * Configuration sample :
+ *
+ * This directive sets the default algorithm for calculating the expiration time
+ * for all documents in the affected realm. It can be overridden on a
+ * type-by-type basis by the ExpiresByType directive. See the
+ * description of that directive for details about the syntax of the argument,
+ * and the "alternate syntax" description as well.
+ *
+ * The ExpiresDefault and ExpiresByType directives can also be
+ * defined in a more readable syntax of the form:
+ *
+ * where <base> is one of:
+ * Summary
+ * Filter Configuration
Basic configuration to add '
+ * Expires' and ' Cache-Control: max-age='
+ * headers to images, css and javascript
+ *
+ *
+ *
+ *
+ * <web-app ...>
+ * ...
+ * <filter>
+ * <filter-name>ExpiresFilter</filter-name>
+ * <filter-class>org.apache.catalina.filters.ExpiresFilter</filter-class>
+ * <init-param>
+ * <param-name>ExpiresByType image</param-name>
+ * <param-value>access plus 10 minutes</param-value>
+ * </init-param>
+ * <init-param>
+ * <param-name>ExpiresByType text/css</param-name>
+ * <param-value>access plus 10 minutes</param-value>
+ * </init-param>
+ * <init-param>
+ * <param-name>ExpiresByType text/javascript</param-name>
+ * <param-value>access plus 10 minutes</param-value>
+ * </init-param>
+ * </filter>
+ * ...
+ * <filter-mapping>
+ * <filter-name>ExpiresFilter</filter-name>
+ * <url-pattern>/*</url-pattern>
+ * <dispatcher>REQUEST</dispatcher>
+ * </filter-mapping>
+ * ...
+ * </web-app>
+ *
Configuration Parameters
+ * ExpiresActive
+ *
+ *
+ * <init-param>
+ * <!-- supports case insensitive 'On' or 'true' -->
+ * <param-name>ExpiresActive</param-name><param-value>On</param-value>
+ * </init-param>
+ *
+ *
+ *
+ * <init-param>
+ * <!-- supports anything different from case insensitive 'On' and 'true' -->
+ * <param-name>ExpiresActive</param-name><param-value>Off</param-value>
+ * </init-param>
+ *
+ * ExpiresByType <content-type>
+ *
+ *
+ * <init-param>
+ * <param-name>ExpiresByType text/html</param-name><param-value>access plus 1 month 15 days 2 hours</param-value>
+ * </init-param>
+ *
+ * <init-param>
+ * <!-- 2592000 seconds = 30 days -->
+ * <param-name>ExpiresByType image/gif</param-name><param-value>A2592000</param-value>
+ * </init-param>
+ *
+ * ExpiresExcludedResponseStatusCodes
+ *
+ *
+ *
+ * <init-param>
+ * <param-name>ExpiresExcludedResponseStatusCodes</param-name><param-value>302, 500, 503</param-value>
+ * </init-param>
+ *
ExpiresDefault
+ * Alternate Syntax
+ *
+ *
+ * <init-param>
+ * <param-name>ExpiresDefault</param-name><param-value><base> [plus] {<num> <type>}*</param-value>
+ * </init-param>
+ *
+ * <init-param>
+ * <param-name>ExpiresByType type/encoding</param-name><param-value><base> [plus] {<num> <type>}*</param-value>
+ * </init-param>
+ *
+ *
+ *
+ * The plus keyword is optional. <num> should be an + * integer value (acceptable to Integer.parseInt()), and + * <type> is one of: + *
+ * <init-param>
+ * <param-name>ExpiresDefault</param-name><param-value>access plus 1 month</param-value>
+ * </init-param>
+ *
+ * <init-param>
+ * <param-name>ExpiresDefault</param-name><param-value>access plus 4 weeks</param-value>
+ * </init-param>
+ *
+ * <init-param>
+ * <param-name>ExpiresDefault</param-name><param-value>access plus 30 days</param-value>
+ * </init-param>
+ *
+ * + * The expiry time can be fine-tuned by adding several ' + * <num> <type>' clauses: + *
+ * + *
+ * <init-param>
+ * <param-name>ExpiresByType text/html</param-name><param-value>access plus 1 month 15 days 2 hours</param-value>
+ * </init-param>
+ *
+ * <init-param>
+ * <param-name>ExpiresByType image/gif</param-name><param-value>modification plus 5 hours 3 minutes</param-value>
+ * </init-param>
+ *
+ * + * Note that if you use a modification date based setting, the Expires + * header will not be added to content that does not come from + * a file on disk. This is due to the fact that there is no modification time + * for such content. + *
+ *+ * A response is eligible to be enriched by ExpiresFilter if : + *
+ * Note : + *
+ * The expiration configuration if elected according to the following algorithm: + *
+ * The ExpiresFilter traps the 'on before write response + * body' event to decide whether it should generate expiration headers or + * not. + *
+ *+ * To trap the 'before write response body' event, the + * ExpiresFilter wraps the http servlet response's writer and + * outputStream to intercept calls to the methods write(), + * print(), close() and flush(). For empty response + * body (e.g. empty files), the write(), print(), + * close() and flush() methods are not called; to handle this + * case, the ExpiresFilter, at the end of its doFilter() + * method, manually triggers the onBeforeWriteResponseBody() method. + *
+ *+ * The ExpiresFilter supports the same configuration syntax as Apache + * Httpd mod_expires. + *
+ *+ * A challenge has been to choose the name of the <param-name> + * associated with ExpiresByType in the <filter> + * declaration. Indeed, Several ExpiresByType directives can be + * declared when web.xml syntax does not allow to declare several + * <init-param> with the same name. + *
+ *+ * The workaround has been to declare the content type in the + * <param-name> rather than in the <param-value>. + *
+ *+ * The ExpiresFilter has been designed for extension following the + * open/close principle. + *
+ *+ * Key methods to override for extension are : + *
+ * To troubleshoot, enable logging on the + * org.apache.catalina.filters.ExpiresFilter. + *
+ *+ * Extract of logging.properties + *
+ * + *
+ * org.apache.catalina.filters.ExpiresFilter.level = FINE
+ *
+ * + * Sample of initialization log message : + *
+ * + *
+ * Mar 26, 2010 2:01:41 PM org.apache.catalina.filters.ExpiresFilter init
+ * FINE: Filter initialized with configuration ExpiresFilter[
+ * active=true,
+ * excludedResponseStatusCode=[304],
+ * default=null,
+ * byType={
+ * image=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]],
+ * text/css=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]],
+ * text/javascript=ExpiresConfiguration[startingPoint=ACCESS_TIME, duration=[10 MINUTE]]}]
+ *
+ * + * Sample of per-request log message where ExpiresFilter adds an + * expiration date + *
+ * + *
+ * Mar 26, 2010 2:09:47 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
+ * FINE: Request "/tomcat.gif" with response status "200" content-type "image/gif", set expiration date 3/26/10 2:19 PM
+ *
+ * + * Sample of per-request log message where ExpiresFilter does not add + * an expiration date + *
+ * + *
+ * Mar 26, 2010 2:10:27 PM org.apache.catalina.filters.ExpiresFilter onBeforeWriteResponseBody
+ * FINE: Request "/docs/config/manager.html" with response status "200" content-type "text/html", no expiration configured
+ *
+ *
+ */
+public class ExpiresFilter extends FilterBase implements Filter {
+
+ /**
+ * Duration composed of an {@link #amount} and a {@link #unit}
+ */
+ protected static class Duration {
+
+ public static Duration minutes(int amount) {
+ return new Duration(amount, DurationUnit.MINUTE);
+ }
+
+ public static Duration seconds(int amount) {
+ return new Duration(amount, DurationUnit.SECOND);
+ }
+
+ final protected int amount;
+
+ final protected DurationUnit unit;
+
+ public Duration(int amount, DurationUnit unit) {
+ super();
+ this.amount = amount;
+ this.unit = unit;
+ }
+
+ public int getAmount() {
+ return amount;
+ }
+
+ public DurationUnit getUnit() {
+ return unit;
+ }
+
+ @Override
+ public String toString() {
+ return amount + " " + unit;
+ }
+ }
+
+ /**
+ * Duration unit
+ */
+ protected enum DurationUnit {
+ DAY(Calendar.DAY_OF_YEAR), HOUR(Calendar.HOUR), MINUTE(Calendar.MINUTE), MONTH(Calendar.MONTH), SECOND(Calendar.SECOND), WEEK(
+ Calendar.WEEK_OF_YEAR), YEAR(Calendar.YEAR);
+ private final int calendardField;
+
+ private DurationUnit(int calendardField) {
+ this.calendardField = calendardField;
+ }
+
+ public int getCalendardField() {
+ return calendardField;
+ }
+
+ }
+
+ /**
+ * + * Main piece of configuration of the filter. + *
+ *+ * Can be expressed like 'access plus 1 month 15 days 2 hours'. + *
+ */ + protected static class ExpiresConfiguration { + /** + * List of duration elements. + */ + private List+ * Wrapping extension of the {@link HttpServletResponse} to yrap the + * "Start Write Response Body" event. + *
+ *+ * For performance optimization : this extended response holds the + * {@link #lastModifiedHeader} and {@link #cacheControlHeader} values access + * to the slow {@link #getHeader(String)} and to spare the string + * to date to long conversion. + *
+ */ + public class XHttpServletResponse extends HttpServletResponseWrapper { + + /** + * Value of the Cache-Control/tt> http response header if it has + * been set. + */ + private String cacheControlHeader; + + /** + * Value of the Last-Modified http response header if it has + * been set. + */ + private long lastModifiedHeader; + + private boolean lastModifiedHeaderSet; + + private PrintWriter printWriter; + + private HttpServletRequest request; + + private ServletOutputStream servletOutputStream; + + /** + * Indicates whether calls to write methods (write(...), + * print(...), etc) of the response body have been called or + * not. + */ + private boolean writeResponseBodyStarted; + + public XHttpServletResponse(HttpServletRequest request, HttpServletResponse response) { + super(response); + this.request = request; + } + + @Override + public void addDateHeader(String name, long date) { + super.addDateHeader(name, date); + if (!lastModifiedHeaderSet) { + this.lastModifiedHeader = date; + this.lastModifiedHeaderSet = true; + } + } + + @Override + public void addHeader(String name, String value) { + super.addHeader(name, value); + if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name) && cacheControlHeader == null) { + cacheControlHeader = value; + } + } + + public String getCacheControlHeader() { + return cacheControlHeader; + } + + public long getLastModifiedHeader() { + return lastModifiedHeader; + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (servletOutputStream == null) { + servletOutputStream = new XServletOutputStream(super.getOutputStream(), request, this); + } + return servletOutputStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (printWriter == null) { + printWriter = new XPrintWriter(super.getWriter(), request, this); + } + return printWriter; + } + + public boolean isLastModifiedHeaderSet() { + return lastModifiedHeaderSet; + } + + public boolean isWriteResponseBodyStarted() { + return writeResponseBodyStarted; + } + + @Override + public void reset() { + super.reset(); + this.lastModifiedHeader = 0; + this.lastModifiedHeaderSet = false; + this.cacheControlHeader = null; + } + + @Override + public void setDateHeader(String name, long date) { + super.setDateHeader(name, date); + if (HEADER_LAST_MODIFIED.equalsIgnoreCase(name)) { + this.lastModifiedHeader = date; + this.lastModifiedHeaderSet = true; + } + } + + @Override + public void setHeader(String name, String value) { + super.setHeader(name, value); + if (HEADER_CACHE_CONTROL.equalsIgnoreCase(name)) { + this.cacheControlHeader = value; + } + } + + public void setWriteResponseBodyStarted(boolean writeResponseBodyStarted) { + this.writeResponseBodyStarted = writeResponseBodyStarted; + } + } + + /** + * Wrapping extension of {@link PrintWriter} to trap the + * "Start Write Response Body" event. + */ + public class XPrintWriter extends PrintWriter { + private PrintWriter out; + + private HttpServletRequest request; + + private XHttpServletResponse response; + + public XPrintWriter(PrintWriter out, HttpServletRequest request, XHttpServletResponse response) { + super(out); + this.out = out; + this.request = request; + this.response = response; + } + + public PrintWriter append(char c) { + fireBeforeWriteResponseBodyEvent(); + return out.append(c); + } + + public PrintWriter append(CharSequence csq) { + fireBeforeWriteResponseBodyEvent(); + return out.append(csq); + } + + public PrintWriter append(CharSequence csq, int start, int end) { + fireBeforeWriteResponseBodyEvent(); + return out.append(csq, start, end); + } + + public void close() { + fireBeforeWriteResponseBodyEvent(); + out.close(); + } + + private void fireBeforeWriteResponseBodyEvent() { + if (!this.response.isWriteResponseBodyStarted()) { + this.response.setWriteResponseBodyStarted(true); + onBeforeWriteResponseBody(request, response); + } + } + + public void flush() { + fireBeforeWriteResponseBodyEvent(); + out.flush(); + } + + public void print(boolean b) { + fireBeforeWriteResponseBodyEvent(); + out.print(b); + } + + public void print(char c) { + fireBeforeWriteResponseBodyEvent(); + out.print(c); + } + + public void print(char[] s) { + fireBeforeWriteResponseBodyEvent(); + out.print(s); + } + + public void print(double d) { + fireBeforeWriteResponseBodyEvent(); + out.print(d); + } + + public void print(float f) { + fireBeforeWriteResponseBodyEvent(); + out.print(f); + } + + public void print(int i) { + fireBeforeWriteResponseBodyEvent(); + out.print(i); + } + + public void print(long l) { + fireBeforeWriteResponseBodyEvent(); + out.print(l); + } + + public void print(Object obj) { + fireBeforeWriteResponseBodyEvent(); + out.print(obj); + } + + public void print(String s) { + fireBeforeWriteResponseBodyEvent(); + out.print(s); + } + + public PrintWriter printf(Locale l, String format, Object... args) { + fireBeforeWriteResponseBodyEvent(); + return out.printf(l, format, args); + } + + public PrintWriter printf(String format, Object... args) { + fireBeforeWriteResponseBodyEvent(); + return out.printf(format, args); + } + + public void println() { + fireBeforeWriteResponseBodyEvent(); + out.println(); + } + + public void println(boolean x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(char x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(char[] x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(double x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(float x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(int x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(long x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(Object x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void println(String x) { + fireBeforeWriteResponseBodyEvent(); + out.println(x); + } + + public void write(char[] buf) { + fireBeforeWriteResponseBodyEvent(); + out.write(buf); + } + + public void write(char[] buf, int off, int len) { + fireBeforeWriteResponseBodyEvent(); + out.write(buf, off, len); + } + + public void write(int c) { + fireBeforeWriteResponseBodyEvent(); + out.write(c); + } + + public void write(String s) { + fireBeforeWriteResponseBodyEvent(); + out.write(s); + } + + public void write(String s, int off, int len) { + fireBeforeWriteResponseBodyEvent(); + out.write(s, off, len); + } + + } + + /** + * Wrapping extension of {@link ServletOutputStream} to trap the + * "Start Write Response Body" event. + */ + public class XServletOutputStream extends ServletOutputStream { + + private HttpServletRequest request; + + private XHttpServletResponse response; + + private ServletOutputStream servletOutputStream; + + public XServletOutputStream(ServletOutputStream servletOutputStream, HttpServletRequest request, XHttpServletResponse response) { + super(); + this.servletOutputStream = servletOutputStream; + this.response = response; + this.request = request; + } + + public void close() throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.close(); + } + + private void fireOnBeforeWriteResponseBodyEvent() { + if (!this.response.isWriteResponseBodyStarted()) { + this.response.setWriteResponseBodyStarted(true); + onBeforeWriteResponseBody(request, response); + } + } + + public void flush() throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.flush(); + } + + public void print(boolean b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(b); + } + + public void print(char c) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(c); + } + + public void print(double d) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(d); + } + + public void print(float f) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(f); + } + + public void print(int i) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(i); + } + + public void print(long l) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(l); + } + + public void print(String s) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.print(s); + } + + public void println() throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(); + } + + public void println(boolean b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(b); + } + + public void println(char c) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(c); + } + + public void println(double d) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(d); + } + + public void println(float f) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(f); + } + + public void println(int i) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(i); + } + + public void println(long l) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(l); + } + + public void println(String s) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.println(s); + } + + public void write(byte[] b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.write(b); + } + + public void write(byte[] b, int off, int len) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.write(b, off, len); + } + + public void write(int b) throws IOException { + fireOnBeforeWriteResponseBodyEvent(); + servletOutputStream.write(b); + } + + } + + /** + * {@link Pattern} for a comma delimited string that support whitespace + * characters + */ + private static final Pattern commaSeparatedValuesPattern = Pattern.compile("\\s*,\\s*"); + + private static final String HEADER_CACHE_CONTROL = "Cache-Control"; + + private static final String HEADER_EXPIRES = "Expires"; + + private static final String HEADER_LAST_MODIFIED = "Last-Modified"; + + private static final Log log = LogFactory.getLog(ExpiresFilter.class); + + private static final String PARAMETER_EXPIRES_ACTIVE = "ExpiresActive"; + + private static final String PARAMETER_EXPIRES_BY_TYPE = "ExpiresByType"; + + private static final String PARAMETER_EXPIRES_DEFAULT = "ExpiresDefault"; + + private static final String PARAMETER_EXPIRES_EXCLUDED_RESPONSE_STATUS_CODES = "ExpiresExcludedResponseStatusCodes"; + + /** + * Convert a comma delimited list of numbers into an int[]. + * + * @param commaDelimitedInts + * can benull
+ * @return never null
array
+ */
+ protected static int[] commaDelimitedListToIntArray(String commaDelimitedInts) {
+ String[] intsAsStrings = commaDelimitedListToStringArray(commaDelimitedInts);
+ int[] ints = new int[intsAsStrings.length];
+ for (int i = 0; i < intsAsStrings.length; i++) {
+ String intAsString = intsAsStrings[i];
+ try {
+ ints[i] = Integer.parseInt(intAsString);
+ } catch (NumberFormatException e) {
+ throw new RuntimeException("Exception parsing number '" + i + "' (zero based) of comma delimited list '"
+ + commaDelimitedInts + "'");
+ }
+ }
+ return ints;
+ }
+
+ /**
+ * Convert a given comma delimited list of strings into an array of String
+ *
+ * @return array of patterns (non null
)
+ */
+ protected static String[] commaDelimitedListToStringArray(String commaDelimitedStrings) {
+ return (commaDelimitedStrings == null || commaDelimitedStrings.length() == 0) ? new String[0] : commaSeparatedValuesPattern
+ .split(commaDelimitedStrings);
+ }
+
+ /**
+ * Return true
if the given str
contains the given
+ * searchStr
.
+ */
+ protected static boolean contains(String str, String searchStr) {
+ if (str == null || searchStr == null) {
+ return false;
+ }
+ return str.indexOf(searchStr) >= 0;
+ }
+
+ /**
+ * Convert an array of ints into a comma delimited string
+ */
+ protected static String intsToCommaDelimitedString(int[] ints) {
+ if (ints == null) {
+ return "";
+ }
+
+ StringBuilder result = new StringBuilder();
+
+ for (int i = 0; i < ints.length; i++) {
+ result.append(ints[i]);
+ if (i < (ints.length - 1)) {
+ result.append(", ");
+ }
+ }
+ return result.toString();
+ }
+
+ /**
+ * Return true
if the given str
is
+ * null
or has a zero characters length.
+ */
+ protected static boolean isEmpty(String str) {
+ return str == null || str.length() == 0;
+ }
+
+ /**
+ * Return true
if the given str
has at least one
+ * character (can be a withespace).
+ */
+ protected static boolean isNotEmpty(String str) {
+ return !isEmpty(str);
+ }
+
+ /**
+ * Return true
if the given string
starts with the
+ * given prefix
ignoring case.
+ *
+ * @param string
+ * can be null
+ * @param prefix
+ * can be null
+ */
+ protected static boolean startsWithIgnoreCase(String string, String prefix) {
+ if (string == null || prefix == null) {
+ return string == null && prefix == null;
+ }
+ if (prefix.length() > string.length()) {
+ return false;
+ }
+
+ return string.regionMatches(true, 0, prefix, 0, prefix.length());
+ }
+
+ /**
+ * Return the subset of the given str
that is before the first
+ * occurence of the given separator
. Return null
+ * if the given str
or the given separator
is
+ * null. Return and empty string if the separator
is empty.
+ *
+ * @param str
+ * can be null
+ * @param separator
+ * can be null
+ * @return
+ */
+ protected static String substringBefore(String str, String separator) {
+ if (str == null || str.isEmpty() || separator == null) {
+ return null;
+ }
+
+ if (separator.isEmpty()) {
+ return "";
+ }
+
+ int separatorIndex = str.indexOf(separator);
+ if (separatorIndex == -1) {
+ return str;
+ }
+ return str.substring(0, separatorIndex);
+ }
+
+ /**
+ * @see #isActive()
+ */
+ private boolean active = true;
+
+ /**
+ * Default Expires configuration.
+ */
+ private ExpiresConfiguration defaultExpiresConfiguration;
+
+ /**
+ * list of response status code for which the {@link ExpiresFilter} will not
+ * generate expiration headers.
+ */
+ private int[] excludedResponseStatusCodes = new int[] { HttpServletResponse.SC_NOT_MODIFIED };
+
+ /**
+ * Expires configuration by content type. Visible for test.
+ */
+ private Map
+ * Returns the expiration date of the given {@link XHttpServletResponse} or
+ * null
if no expiration date has been configured for the
+ * declared content type.
+ *
+ * protected
for extension.
+ *
+ * Returns the expiration date of the given {@link ExpiresConfiguration}, + * {@link HttpServletRequest} and {@link XHttpServletResponse}. + *
+ *
+ * protected
for extension.
+ *
false
, the filter is
+ * pass-through. Default is true
.
+ */
+ public boolean isActive() {
+ return active;
+ }
+
+ /**
+ *
+ *
+ * protected
for extension.
+ *
+ * If no expiration header has been set by the servlet and an expiration has + * been defined in the {@link ExpiresFilter} configuration, sets the ' + * Expires' header and the attribute 'max-age' of the ' + * Cache-Control' header. + *
+ *+ * Must be called on the "Start Write Response Body" event. + *
+ *+ * Invocations to Logger.debug(...) are guarded by + * {@link Logger#isDebugEnabled()} because + * {@link HttpServletRequest#getRequestURI()} and + * {@link HttpServletResponse#getContentType()} costs String + * objects instantiations (as of Tomcat 7). + *
+ */ + public void onBeforeWriteResponseBody(HttpServletRequest request, XHttpServletResponse response) { + + if (!isEligibleToExpirationHeaderGeneration(request, response)) { + return; + } + + Date expirationDate = getExpirationDate(request, response); + if (expirationDate == null) { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.noExpirationConfigured", request.getRequestURI(), response.getStatus(), response + .getContentType())); + } + } else { + if (log.isDebugEnabled()) { + log.debug(sm.getString("expiresFilter.setExpirationDate", request.getRequestURI(), response.getStatus(), response + .getContentType(), expirationDate)); + } + + String maxAgeDirective = "max-age=" + ((expirationDate.getTime() - System.currentTimeMillis()) / 1000); + + String cacheControlHeader = response.getCacheControlHeader(); + String newCacheControlHeader = (cacheControlHeader == null) ? maxAgeDirective : cacheControlHeader + ", " + maxAgeDirective; + response.setHeader(HEADER_CACHE_CONTROL, newCacheControlHeader); + response.setDateHeader(HEADER_EXPIRES, expirationDate.getTime()); + } + + } + + /** + * Parse configuration lines like ' + * access plus 1 month 15 days 2 hours' or ' + * modification 1 day 2 hours 5 seconds' + * + * @param line + */ + protected ExpiresConfiguration parseExpiresConfiguration(String line) { + line = line.trim(); + + StringTokenizer tokenizer = new StringTokenizer(line, " "); + + String currentToken; + + try { + currentToken = tokenizer.nextToken(); + } catch (NoSuchElementException e) { + throw new IllegalStateException(sm.getString("expiresFilter.startingPointNotFound", line)); + } + + StartingPoint startingPoint; + if ("access".equalsIgnoreCase(currentToken) || "now".equalsIgnoreCase(currentToken)) { + startingPoint = StartingPoint.ACCESS_TIME; + } else if ("modification".equalsIgnoreCase(currentToken)) { + startingPoint = StartingPoint.LAST_MODIFICATION_TIME; + } else if (!tokenizer.hasMoreTokens() && startsWithIgnoreCase(currentToken, "a")) { + startingPoint = StartingPoint.ACCESS_TIME; + // trick : convert duration configuration from old to new style + tokenizer = new StringTokenizer(currentToken.substring(1) + " seconds", " "); + } else if (!tokenizer.hasMoreTokens() && startsWithIgnoreCase(currentToken, "m")) { + startingPoint = StartingPoint.LAST_MODIFICATION_TIME; + // trick : convert duration configuration from old to new style + tokenizer = new StringTokenizer(currentToken.substring(1) + " seconds", " "); + } else { + throw new IllegalStateException(sm.getString("expiresFilter.startingPointInvalid", currentToken, line)); + } + + try { + currentToken = tokenizer.nextToken(); + } catch (NoSuchElementException e) { + throw new IllegalStateException(sm.getString("Duration not found in directive '{}'", line)); + } + + if ("plus".equalsIgnoreCase(currentToken)) { + // skip + try { + currentToken = tokenizer.nextToken(); + } catch (NoSuchElementException e) { + throw new IllegalStateException(sm.getString("Duration not found in directive '{}'", line)); + } + } + + List+ ExpiresFilter is a Java Servlet API port of Apache + mod_expires. + This filter controls the setting of the Expires HTTP header and the + max-age directive of the Cache-Control HTTP header in + server responses. The expiration date can set to be relative to either the + time the source file was last modified, or to the time of the client access. +
+ ++ These HTTP headers are an instruction to the client about the document's + validity and persistence. If cached, the document may be fetched from the + cache rather than from the source until this time has passed. After that, the + cache copy is considered "expired" and invalid, and a new copy must + be obtained from the source. +
++ To modify Cache-Control directives other than max-age (see + RFC + 2616 section 14.9), you can use other servlet filters or Apache Httpd + mod_headers module. +
+ ++ Basic configuration to add 'Expires' and 'Cache-Control: max-age=' + headers to images, css and javascript. +
+ + + ++ The ExpiresDefault and ExpiresByType directives can also be + defined in a more readable syntax of the form: +
+ + ++ where <base> is one of: +
+ The plus keyword is optional. <num> should be an + integer value (acceptable to Integer.parseInt()), and + <type> is one of: +
+The expiry time can be fine-tuned by adding several ' +<num> <type>' clauses: +
+ + ++ Note that if you use a modification date based setting, the Expires + header will not be added to content that does not come from + a file on disk. This is due to the fact that there is no modification time + for such content. +
++ A response is eligible to be enriched by ExpiresFilter if : +
+ Note : If Cache-Control header contains other directives than + max-age, they are concatenated with the max-age directive + that is added by the ExpiresFilter. +
+ ++ The expiration configuration if elected according to the following algorithm: +
The filter class name for the Expires Filter is
+ org.apache.catalina.filters.ExpiresFilter
+ .
The Expires Filter supports the following + initialisation parameters:
+ ++ This directive enables or disables the generation of the Expires and + Cache-Control headers by this ExpiresFilter. If set to + Off, the headers will not be generated for any HTTP response. If set + to On or true, the headers will be added to served HTTP + responses according to the criteria defined by the + ExpiresByType <content-type> and ExpiresDefault + directives. Note that this directive does not guarantee that an + Expires or Cache-Control header will be generated. If the + criteria aren't met, no header will be sent, and the effect will be as + though this directive wasn't even specified. +
++ Default value is true. +
+ ++ Sample: enable filter +
+ + ++ Sample: disable filter +
+ + ++ This directive defines the http response status codes for which the + ExpiresFilter will not generate expiration headers. By default, the + 304 status code ("Not modified") is skipped. The + value is a comma separated list of http status codes. +
++ This directive is useful to ease usage of ExpiresDefault directive. + Indeed, the behavior of 304 Not modified (which does specify a + Content-Type header) combined with Expires and + Cache-Control:max-age= headers can be unnecessarily tricky to + understand. +
++ Sample : exclude response status codes 302, 500 and 503 +
+ + ++ This directive defines the value of the Expires header and the + max-age directive of the Cache-Control header generated for + documents of the specified type (e.g., text/html). The second + argument sets the number of seconds that will be added to a base time to + construct the expiration date. The Cache-Control: max-age is + calculated by subtracting the request time from the expiration date and + expressing the result in seconds. +
++ The base time is either the last modification time of the file, or the time + of the client's access to the document. Which should be used is + specified by the <code> field; M means that the + file's last modification time should be used as the base time, and + A means the client's access time should be used. The duration + is expressed in seconds. A2592000 stands for + access plus 30 days in alternate syntax. +
++ The difference in effect is subtle. If M (modification in + alternate syntax) is used, all current copies of the document in all caches + will expire at the same time, which can be good for something like a weekly + notice that's always found at the same URL. If A ( + access or now in alternate syntax) is used, the date of + expiration is different for each client; this can be good for image files + that don't change very often, particularly for a set of related + documents that all refer to the same images (i.e., the images will be + accessed repeatedly within a relatively short timespan). +
++ Note: When the content type includes a charset (e.g. + 'ExpiresByType text/xml;charset=utf-8'), Tomcat removes blank chars + between the ';' and the 'charset' keyword. Due to this, + configuration of an expiration with a charset must not include + such a space character. +
++ Sample: +
+ + ++ Note that this directive only has effect if ExpiresActive On has + been specified. It overrides, for the specified MIME type only, any + expiration date set by the ExpiresDefault directive. +
++ You can also specify the expiration time calculation using an alternate + syntax, described earlier in this document. +
++ This directive sets the default algorithm for calculating the + expiration time for all documents in the affected realm. It can be + overridden on a type-by-type basis by the ExpiresByType directive. See the + description of that directive for details about the syntax of the + argument, and the "alternate syntax" + description as well. +
++ To troubleshoot, enable logging on the + org.apache.catalina.filters.ExpiresFilter. +
++ Extract of logging.properties +
+ + ++ Sample of initialization log message : +
+ + ++ Sample of per-request log message where ExpiresFilter adds an + expiration date +
+ + ++ Sample of per-request log message where ExpiresFilter does not add + an expiration date +
+ + +