CompressFilter.java

package com.ziesemer.utils.web.compressFilter;

import java.io.IOException;
import java.util.Collections;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.Deflater;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.GZIPOutputStream;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletOutputStream;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * <p>{@link Filter} to compress outgoing content, using either a {@link GZIPOutputStream} or {@link DeflaterOutputStream}.</p>
 *
 * <p>Follows the standards described in
 * 	<a href="http://www.w3.org/Protocols/rfc2616/rfc2616.html">RFC 2616</a>
 * 	as applicable, particularly sections 3 and 14.</p>
 *
 * <p>This filter supports Content-Encoding (14.11).  While Transfer-Encoding (14.41)
 * 	seems more appropriate, it is not currently widely used or accepted.
 * 	Transfer-Encoding would allow for properly supporting ranges (14.35) that
 * 	refer to the entity content rather than the encoded content.</p>
 *
 * <p>Is is recommended to include <code>&lt;dispatcher&gt;REQUEST&lt;/dispatcher&gt;</code>
 * 	into this filter's <code>&lt;filter-mapping/&gt;</code>(s) in <code>web.xml</code> to
 * 	prevent output from being compressed multiple times (e.g. from <code>&lt;jsp:include/&gt;</code>),
 * 	as defined in the <a href="http://jcp.org/aboutJava/communityprocess/final/jsr154/index.html"
 * 	>Java Servlet 2.4 Specification</a> (see SRV.6.2.5).</p>
 *
 * <p>Accepted {@link javax.servlet.ServletConfig} parameters:</p>
 * <ul>
 * 	<li>Accept-Encoding: Configuration to use when multiple encodings are accepted by the client.
 * 		The format of this configuration is the same as expected from the client, as described in
 * 		<a href="http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3">RFC 2616 section 14.3</a>.
 * 		Defaults to {@link #DEFAULT_ACCEPT_ENCODINGS}.</li>
 * </ul>
 *
 * @author Mark A. Ziesemer
 * <a href="http://www.ziesemer.com.">&lt;www.ziesemer.com&gt;</a>
 */
public class CompressFilter implements Filter{
	
	public static final String DEFAULT_ACCEPT_ENCODINGS = "gzip;q=0.75,deflate;q=0.5";
	
	public static final String ACCEPT_ENCODING = "Accept-Encoding";
	public static final String IDENTITY = "identity";
	
	protected static final Logger LOGGER = LoggerFactory.getLogger(CompressFilter.class);
	// http://www.and.org/texts/server-http
	protected static final Pattern ENCODINGS_PATTERN = Pattern.compile(
		"\\s*(\\w+)(?:\\s*;\\s*q\\s*=\\s*([01](?:\\s*.\\s*\\d+)?)\\s*)?");
	protected static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
	
	protected Map<String, Short> configuredEncodings;
	protected Comparator<String> matchingComparator;
	protected Comparator<Short> reverseShortComparator;
	
	// Cache for performance
	protected String filterName;
	
	public void init(final FilterConfig config) throws ServletException{
		filterName = config.getFilterName();
		String ae = config.getInitParameter(ACCEPT_ENCODING);
		if(ae == null){
			ae = DEFAULT_ACCEPT_ENCODINGS;
		}
		configuredEncodings = findAcceptEncodings(ae).names;
		LOGGER.info("({}) Configured encodings: {}", filterName, configuredEncodings);
		matchingComparator = new MatchedShortComparator(configuredEncodings);
		reverseShortComparator = new ReverseShortComparator();
	}

	public void doFilter(final ServletRequest request, final ServletResponse response, final FilterChain chain)
			throws IOException, ServletException{
		if(request instanceof HttpServletRequest
				&& response instanceof HttpServletResponse){
			final HttpServletRequest req = (HttpServletRequest)request;
			final HttpServletResponse res = (HttpServletResponse)response;
			
			// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.44
			res.addHeader("Vary", ACCEPT_ENCODING);
			
			final AcceptEncodingsResults aer = findAcceptEncodings(req);
			LOGGER.debug("({}) Received Accept-Encodings: {}", filterName, aer.names);
			final String enc = calculateEncoding(aer);
			LOGGER.debug("({}) Calculated encoding: {}", filterName, enc);
			if(enc == null){
				res.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, ACCEPT_ENCODING);
				return;
			}
			
			final CompressResponseWrapper crw;
			if("gzip".equals(enc)){
				crw = new CompressResponseWrapper(res, enc){
					@Override
					public ServletOutputStream getEncodingOutputStream() throws IOException{
						return new CompressOutputStream(new GZIPOutputStream(res.getOutputStream()));
					}
				};
			}else if("deflate".equals(enc)){
				crw = new CompressResponseWrapper(res, enc){
					@Override
					public ServletOutputStream getEncodingOutputStream() throws IOException{
						final Deflater def = new Deflater(Deflater.DEFAULT_COMPRESSION, true);
						return new CompressOutputStream(new DeflaterOutputStream(res.getOutputStream(), def));
					}
				};
			}else{
				crw = null;
			}
			
			// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.11
			if(crw != null){
				// It seems that if no content-length is specified, the servlet container will/should
				// automatically set a transfer-coding of "chunked" and send the output appropriately,
				// or send a content-length if the compressed content size is less than the server's
				// response buffer, so not handling it here...
				// http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6
				
				chain.doFilter(req, crw);
				crw.close();
				return;
			}
		}
		chain.doFilter(request, response);
	}
	
	protected boolean isValid(final Short s){
		return (s != null) && (s > 0);
	}
	
	protected boolean isValidDef(final Short s){
		return (s == null) || (s > 0);
	}
	
	protected boolean isValidIdentity(final AcceptEncodingsResults aer){
		return isValidDef(aer.names.get(IDENTITY))
			&& isValidDef(configuredEncodings.get(IDENTITY));
	}
	
	protected String calculateEncoding(final AcceptEncodingsResults aer){
		for(final Map.Entry<Short, List<String>> e : aer.sortedValues.entrySet()){
			final List<String> list = e.getValue();
			Collections.sort(list, matchingComparator);
			if(!list.isEmpty()){
				final String n = list.get(0);
				if(isValid(aer.names.get(n)) && isValid(configuredEncodings.get(n))){
					return n;
				}
				if(IDENTITY.equals(n) && isValidIdentity(aer)){
					return n;
				}
			}
		}
		
		// Try to fall-back to identity.
		if(isValidIdentity(aer)){
			return IDENTITY;
		}
		return null;
	}
	
	protected AcceptEncodingsResults findAcceptEncodings(final HttpServletRequest req){
		// http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
		final Enumeration<?> e = req.getHeaders(ACCEPT_ENCODING);
		final StringBuilder sb = new StringBuilder();
		while(e.hasMoreElements()){
			sb.append(e.nextElement());
			sb.append(',');
		}
		return findAcceptEncodings(sb.toString());
	}
	
	protected AcceptEncodingsResults findAcceptEncodings(final String encodes){
		final AcceptEncodingsResults aer = new AcceptEncodingsResults(reverseShortComparator);
		final Matcher m = ENCODINGS_PATTERN.matcher("");
		for(final String s : encodes.split(",")){
			m.reset(s);
			if(m.matches()){
				final String name = m.group(1);
				// RFC2616 doesn't specify a "default" value; using the median.
				short qValue = 500;
				String qValueStr = m.group(2);
				if(qValueStr != null){
					qValueStr = WHITESPACE_PATTERN.matcher(qValueStr).replaceAll("");
					qValue = (short)(Float.parseFloat(qValueStr) * 1000);
				}
				
				aer.names.put(name, qValue);
				if(aer.sortedValues != null){
					List<String> values = aer.sortedValues.get(qValue);
					if(values == null){
						values = new LinkedList<String>();
						aer.sortedValues.put(qValue, values);
					}
					values.add(name);
				}
			}
		}
		return aer;
	}

	public void destroy(){
		// Nothing to do.
	}
}