/**
* 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();
}
}
}