/** * Copyright 2014 55 Minutes (http://www.55minutes.com) * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package fiftyfive.wicket.resource; import java.nio.charset.Charset; import java.util.List; import java.util.Locale; import javax.servlet.http.Cookie; import org.apache.wicket.request.IRequestCycle; import org.apache.wicket.request.IRequestHandler; import org.apache.wicket.request.IRequestParameters; import org.apache.wicket.request.Url; import org.apache.wicket.request.cycle.RequestCycle; import org.apache.wicket.request.handler.resource.ResourceRequestHandler; import org.apache.wicket.request.http.WebRequest; import org.apache.wicket.request.http.WebResponse; import org.apache.wicket.request.mapper.parameter.PageParameters; import org.apache.wicket.request.resource.ResourceReference; import org.apache.wicket.util.time.Time; /** * Handles a request by delegating to {@link ResourceRequestHandler} for each of a list of * {@link ResourceReference} objects. In other words, merge the streams of several resources * into a single response. * <p> * Delegating most of the work to each individual resource makes this handler's implementation * quite simple, but it results in a few important limitations: * <ol> * <li><b>All resources must be the same content type.</b> For example, if you mix CSS and JS * resources in a single response, the browser will obviously be very confused. A more subtle * pitfall is if some resources are compressed and others are not: this would result in a * single stream of clear text mixed with gzipped data, which again would confuse the browser. * </li> * <li><b>The Content-Length header is not sent.</b> Although it is technically possible to loop * through the resources twice -- once to sum up the total content length, and then again to * respond with the actual content -- for simplicity and performance this implementation does * not calculate the merged content length. Instead, the header is omitted from the response. * </li> * <li><b>A failure of one resource to respond will corrupt the merged response.</b> Headers are * committed and bytes are written as soon as the first resource is processed. If a later * resource in the list of resources to merge fails to respond for whatever reason, this will * result in an incomplete merged response.</li> * </ol> * * @since 3.0 */ public class MergedResourceRequestHandler implements IRequestHandler { private List<ResourceReference> resources; private PageParameters pageParameters; private Time lastModified; public MergedResourceRequestHandler(List<ResourceReference> resources, PageParameters params, Time lastModified) { this.resources = resources; this.pageParameters = params; this.lastModified = lastModified; } public void respond(IRequestCycle requestCycle) { WebRequest origRequest = (WebRequest) requestCycle.getRequest(); // Explicitly set the last modified header of the response based on the last modified // time of the aggregate. Do this on the original response because our wrapped response // ignores the last modified headers contributed by each individual resource. WebResponse origResponse = (WebResponse) requestCycle.getResponse(); if(this.lastModified != null) { origResponse.setLastModifiedTime(this.lastModified); } try { // Make a special response object that merges the contributions of each resource, // but maintains a single set of headers. MergedResponse merged = new MergedResponse(origResponse); requestCycle.setResponse(merged); // Make a special request object that tweaks the If-Modified-Since header to ensure // we don't end up in a situation where some resources respond 200 and others 304. // Yes, calling RequestCycle#setRequest() is frowned upon so this is a bit of a hack. ((RequestCycle)requestCycle).setRequest(new MergedRequest(origRequest)); for(ResourceReference ref : this.resources) { ResourceRequestHandler handler = new ResourceRequestHandler( ref.getResource(), this.pageParameters); handler.respond(requestCycle); // If first resource sent 304 Not Modified that means all will. // We can therefore skip the rest. if(304 == merged.status) { break; } } } finally { // Restore the original request once we're done. We don't need to restore the // original response because Wicket takes care of that automatically. ((RequestCycle)requestCycle).setRequest(origRequest); } } public void detach(IRequestCycle requestCycle) { this.resources = null; this.pageParameters = null; this.lastModified = null; } /** * A WebResponse wrapper that allows data to accumulate, but only accepts the * headers of the first resource. Headers contributed by subsequent resources are * ignored. The content-length header is always ignored, because we don't know it ahead * of time. */ private class MergedResponse extends WebResponse { private int status = 200; private WebResponse wrapped; private boolean headersOpen = true; MergedResponse(WebResponse original) { this.wrapped = original; } @Override public void close() { // ignore } @Override public void reset() { this.headersOpen = true; this.wrapped.reset(); } @Override public void write(CharSequence sequence) { this.headersOpen = false; this.wrapped.write(sequence); } @Override public void write(byte[] array) { this.headersOpen = false; this.wrapped.write(array); } @Override public String encodeURL(CharSequence url) { return this.wrapped.encodeURL(url); } @Override public Object getContainerResponse() { return this.wrapped.getContainerResponse(); } @Override public void addCookie(final Cookie cookie) { if(this.headersOpen) this.wrapped.addCookie(cookie); } @Override public void clearCookie(final Cookie cookie) { if(this.headersOpen) this.wrapped.clearCookie(cookie); } @Override public void setHeader(String name, String value) { if(this.headersOpen) this.wrapped.setHeader(name, value); } @Override public void addHeader(String name, String value) { if(this.headersOpen) this.wrapped.addHeader(name, value); } @Override public void setDateHeader(String name, Time date) { if(this.headersOpen && name != null && !name.equalsIgnoreCase("Last-Modified")) { this.wrapped.setDateHeader(name, date); } } @Override public void setContentLength(final long length) { // ignore } @Override public void setContentType(final String mimeType) { if(this.headersOpen) this.wrapped.setContentType(mimeType); } @Override public void setAttachmentHeader(final String filename) { // ignore } @Override public void setInlineHeader(final String filename) { // ignore } @Override public void setStatus(int sc) { if(this.headersOpen) { this.status = sc; this.wrapped.setStatus(sc); } } @Override public void sendError(int sc, String msg) { this.wrapped.sendError(sc, msg); } @Override public void sendRedirect(String url) { this.wrapped.sendRedirect(url); } @Override public boolean isRedirect() { return this.wrapped.isRedirect(); } @Override public String encodeRedirectURL(CharSequence url) { return this.wrapped.encodeRedirectURL(url); } @Override public void flush() { this.wrapped.flush(); } } /** * A WebRequest wrapper than allows all method calls to pass through to the wrapped request, * except for getDateHeader(). We need to take special action for the If-Modified-Since header * to fool the individual resources into behaving as a single resource with a single * modification date. */ private class MergedRequest extends WebRequest { private final WebRequest wrapped; MergedRequest(WebRequest original) { this.wrapped = original; } @Override public Url getUrl() { return this.wrapped.getUrl(); } @Override public IRequestParameters getPostParameters() { return this.wrapped.getPostParameters(); } @Override public List<Cookie> getCookies() { return this.wrapped.getCookies(); } @Override public Time getDateHeader(final String name) { Time headerTime = this.wrapped.getDateHeader(name); if(headerTime != null && name != null && name.equalsIgnoreCase("If-Modified-Since")) { // Truncate milliseconds since the modified since header has only second precision long modified = lastModified.getMilliseconds() / 1000 * 1000; if(headerTime.getMilliseconds() < modified) { // Our merged data in aggregate is newer than what the browser has cached. // Therefore remove the If-Modified-Since header from the request to // force all resources to respond with data. headerTime = null; } else { // Our merged data in aggregate has not changed. Set the If-Modified-Since // header to an extremely high value to force all resources to respond 304. headerTime = Time.millis(Long.MAX_VALUE); } } return headerTime; } @Override public Locale getLocale() { return this.wrapped.getLocale(); } @Override public String getHeader(final String name) { return this.wrapped.getHeader(name); } @Override public List<String> getHeaders(final String name) { return this.wrapped.getHeaders(name); } @Override public Charset getCharset() { return this.wrapped.getCharset(); } @Override public Url getClientUrl() { return this.wrapped.getClientUrl(); } @Override public Object getContainerRequest() { return this.wrapped.getContainerRequest(); } } }