/*
* This file is part of the Spider Web Framework.
*
* The Spider Web Framework is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Spider Web Framework is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with the Spider Web Framework. If not, see <http://www.gnu.org/licenses/>.
*/
package com.medallia.spider;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.lang.reflect.Constructor;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.antlr.stringtemplate.AutoIndentWriter;
import org.antlr.stringtemplate.StringTemplate;
import org.antlr.stringtemplate.StringTemplateGroup;
import org.antlr.stringtemplate.StringTemplateWriter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import com.medallia.spider.MethodInvoker.LifecycleHandlerSet;
import com.medallia.spider.StaticResources.StaticResource;
import com.medallia.spider.StaticResources.StaticResourceLookup;
import com.medallia.spider.Task.CustomPostAction;
import com.medallia.spider.api.DynamicInputImpl;
import com.medallia.spider.api.StRenderable;
import com.medallia.spider.api.StRenderer;
import com.medallia.spider.api.StRenderable.DynamicInput;
import com.medallia.spider.api.StRenderable.PostAction;
import com.medallia.spider.api.StRenderer.InputArgHandler;
import com.medallia.spider.api.StRenderer.StRenderPostAction;
import com.medallia.spider.api.StRenderer.StToolProvider;
import com.medallia.spider.api.StRenderer.StringTemplateFactory;
import com.medallia.spider.sttools.CachedTool;
import com.medallia.spider.sttools.StTool;
import com.medallia.spider.test.RenderTaskTestCase;
import com.medallia.tiny.Clock;
import com.medallia.tiny.Empty;
import com.medallia.tiny.Encoding;
import com.medallia.tiny.Implement;
import com.medallia.tiny.ObjectProvider;
import com.medallia.tiny.Strings;
import com.medallia.tiny.string.ExplodingStringTemplateErrorListener;
import com.medallia.tiny.string.HtmlString;
import com.medallia.tiny.web.HttpHeaders;
/**
* Spider is a framework for creating web applications. Its major design goals are to:
* <ul>
*
* <li> Make it trivially easy to write good test cases
* <li> Reduce boilerplate code to a minimum
* <li> Avoid static state through dependency injection
* <li> Have strict M-V-C separation
* <li> Prefer convention over configuration
* </ul>
*
* Different techniques are used to realize each of these goals and are documented below. Here is an overview
* of the steps required to get started with spider:
* <ul>
*
* <li> Create a subclass of {@link SpiderServlet}. This class should be referenced in the web.xml file. For
* a project called "Foo" this class will typically be located in a package called "foo.web", although
* this is not a requirement.
*
* <li> Create a class that inherits {@link RenderTask}. See doc on {@link IRenderTask}. By convention
* this class should be located in a package called "st" relative to where the servlet class is, e.g.
* "foo.web.st". The task class name must end with "Task", e.g. "FooBarTask".
*
* <li> Create a .st file which holds the StringTemplate source. See doc on {@link StRenderable}. This
* file should be a resource file located in the same package as the task class and its name should
* be the same as that of the class excluding the "Task" prefix, and unlike the class name the
* first character should be lower case. E.g. if the task class name is "FooBarTask" the .st file
* should be named "fooBar.st".
* </ul>
*
* Additionally a test case should be created for each subclass of {@link RenderTask}; see doc
* on {@link spider.test.RenderTaskTestCase}. By convention the test classes should be placed in
* a package called "st.test" relative to where the servlet class is, and the name of the test class
* for each task should be the same as that of the task plus the postfix "Test". E.g. "FooBarTaskTest"
* in package "foo.web.st.test".
* <p>
*
* <b> Unit testing <br>
* ============ </b>
* <p>
*
* One of the most important feature of a framework is to facilitate testing; Spider includes
* a testing framework that makes it trivial to write comprehensive test cases. See
* {@link RenderTaskTestCase} for documentation and examples.
* <p>
*
* <b> Request parameter parsing <br>
* ========================= </b>
* <p>
*
* The HTTP protocol is text based, which means that any web application needs to parse request parameters
* into proper data types; this includes integers, enums and any custom data types defined by each
* application. This code tends to either be duplicated or at the very least need a method call for each
* request parameter to convert it into the right data type. Spider handles this via a proxy interface
* which is dependency injected: see {@link spider.api.StRenderable.Input}. Custom parsers can be
* registered by overriding @{link {@link #registerInputArgParser(InputArgHandler)}.
* <p>
*
* <b> Dependency injection <br>
* ==================== </b>
* <p>
*
* A web application often has several services and / or background tasks that are configured and instantiated
* when the servlet starts, typically in the {@link #init(ServletConfig)}} method. Since each module of
* the application typically needs to use a different set of these services a way to get references to
* the objects is needed. Often this is done by putting the references into static variables or having an
* object that has references to all the services and pass this object to all modules of the app. Both
* approaches make it difficult to determine which services a given module needs.
* <p>
*
* Spider solves this problem by dependency injecting the needed services; the dependencies are thus
* documented simply as a list of arguments. See {@link StRenderable} for more docs. The objects available
* for injection can be registered by overriding {@link #registerObjects(ObjectProvider)}.
* <p>
*
* <b> M-V-C separation <br>
* ================ </b>
* <p>
*
* M-V-C is the preferred approach for an application that presents a UI, however, it turns out that, for
* a number of reasons, it is hard to keep this separation in practice. Spider attempts to solve this
* problem by using StringTemplate as its templating language. StringTemplate was developed specifically
* to make a templating language with enough expressive power to make it useful, but no more. The author
* of StringTemplate wrote a paper going into detail on the motivation for StringTemplate:
* <p>
*
* http://www.cs.usfca.edu/~parrt/papers/mvc.templates.pdf
* <p>
*
* Spider goes a step further requiring all attributes used in the template be listed using TypeTags;
* these tags serve both a documentation for the template as well as a type safe way to set attributes.
* See {@link StRenderable} for more docs.
* <p>
*
* <b> Convention over configuration <br>
* ============================= </b>
* <p>
*
* Paradoxically having no choice can often be liberating; if there is only one way to do something
* the focus can simply be on actually getting it done.
* <p>
*
* Configuration for a web application tends to be very repetitive since most teams, if they are well
* organized, will adopt conventions to avoid having to check configuration files all the time. The
* configuration is thus copied and pasted each time a new module is added. In addition to being extra
* work this duplication also increases the maintenance burden.
* <p>
*
* Spider solves this by having no mandatory configuration (beyond the minimum required for a Java Servlet).
* A URI maps to a name of a class and the URI is valid if that class exists. Various parameters
* can be changed, however, but this is typically done by method overriding instead of external configuration
* files (which cannot be automatically refactored and are not checked at compile time).
*
*/
public abstract class SpiderServlet extends HttpServlet {
private static Log log;
private final StaticResourceLookup staticResourceLookup;
/** Used to render page.st */
private final StringTemplateGroup pageStGroup;
/** Used to render the .st files for {@link RenderTask} and {@link EmbeddedRenderTask} */
private final StringTemplateFactory stringTemplateFactory;
/** map from name of a StTool to an instance of it */
private final Map<String, StTool> stTools;
/** constructor that creates the initial state */
public SpiderServlet() {
staticResourceLookup = StaticResources.makeStaticResourceLookup(getServletClass());
stTools = buildStToolsMap();
pageStGroup = new StringTemplateGroup("PageStGroup") {
@Override public String getFileNameFromTemplateName(String name) {
return super.getFileNameFromTemplateName(findPathForTemplate(name));
}
@Override public StringTemplate getEmbeddedInstanceOf(StringTemplate enclosingInstance, String name) throws IllegalArgumentException {
final StTool t = getStTool(name);
if (t != null) return new StringTemplate() {
@Override public int write(StringTemplateWriter out) throws IOException {
String s = t.render(this).toString();
out.write(s);
return s.length();
}
};
return super.getEmbeddedInstanceOf(enclosingInstance, name);
}
};
pageStGroup.setErrorListener(ExplodingStringTemplateErrorListener.LISTENER);
StRenderer.registerWebRenderers(pageStGroup);
registerStRenderers(pageStGroup);
stringTemplateFactory = StRenderer.makeStringTemplateFactory(ExplodingStringTemplateErrorListener.LISTENER, new StToolProvider() {
@Implement public StTool getStTool(String name) {
return SpiderServlet.this.getStTool(name);
}
});
if (debugMode == null)
setDebugMode(true); // true by default if not set
}
/** Hook which can be used to call {@link StringTemplateGroup#registerRenderer(Class, Object)} */
protected void registerStRenderers(StringTemplateGroup pageStGroup) { }
/**
* @return the servlet class; usually this is {@link #getClass()}, but if that
* class is in a package named 'test' the superclass is used instead.
*/
@SuppressWarnings("unchecked")
protected Class<? extends SpiderServlet> getServletClass() {
Class<? extends SpiderServlet> c = getClass();
if (c.getPackage().getName().endsWith(".test"))
c = (Class<? extends SpiderServlet>) c.getSuperclass();
return c;
}
/**
* @return the URI which a request is redirected to if it does not contain
* a valid task name, e.g. 'foo' (assuming there is a FooTask).
*/
protected abstract String getDefaultURI();
/** Object that allows interaction with the request */
public interface RequestHandler extends DynamicInput {
/** @return the value stored for the cookie with the given name */
String getCookieValue(String name);
/** set the cookie with the given name to the given value */
void setCookieValue(String name, String value);
/** set a persistent cookie with the given name to the given value.
*
* @param expiry the number of seconds before the cookie expires; must be a positive number.
*/
void setPersistentCookieValue(String name, String value, int expiry);
/** Remove the cookie with the given name */
void removeCookieValue(String name);
/** @return the {@link HttpSession} for the request */
HttpSession getSession();
}
/** Register the objects that should be available for dependency injection. The
* {@link ObjectProvider#register(Object)} method is typically used.
*/
protected void registerObjects(ObjectProvider injector, RequestHandler request) { }
protected <X> void registerLifecycleHandlers(LifecycleHandlerSet hs, RequestHandler request) { }
/** Register any custom request parameter parsers */
protected void registerInputArgParser(InputArgHandler inputArgHandler) { }
private Boolean debugMode;
/** Set the debug mode on or off. In debug mode the .st files are re-read on each
* request and error messages and stack traces may be printed on the rendered
* page.
*
* The default is true.
*
* @param b true if debug mode should be turned on, false otherwise
*/
protected void setDebugMode(boolean b) {
debugMode = b;
// must divide by 1000 since ST expects a number in seconds (and multiplies by 1000 causing overflow otherwise)
int refreshInterval = debugMode ? 0 : Integer.MAX_VALUE / 1000;
pageStGroup.setRefreshInterval(refreshInterval);
stringTemplateFactory.setRefreshInterval(refreshInterval);
}
/** sets up the logging; this is done here instead of in the constructor to give subclasses
* a chance to configure log4j.
*/
@Override
public void init(ServletConfig cfg) throws ServletException {
log = LogFactory.getLog(getServletClass());
super.init(cfg);
}
/** Forwards to {@link #handleRequest(HttpServletRequest, HttpServletResponse)} */
@Override protected void doGet(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
handleRequest(req, res);
}
/** Forwards to {@link #handleRequest(HttpServletRequest, HttpServletResponse)} */
@Override protected void doPost(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
handleRequest(req, res);
}
/** Handle a request; exceptions are caught here and sent to {@link #handleException(HttpServletRequest, HttpServletResponse, Throwable)} */
protected void handleRequest(HttpServletRequest req, HttpServletResponse res) throws IOException {
try {
handleInternal(req, res);
} catch (Throwable t) {
handleException(req, res, t);
}
}
/** Handle an exception thrown; this should never happen during normal operation of the app and is in all
* cases the result of a programming error.
*
* In debug mode the error is printed directly to the response, otherwise a generic error messages is displayed.
*/
protected void handleException(HttpServletRequest req, HttpServletResponse res, Throwable t) throws IOException {
log.error("For URI: " + req.getRequestURI(), t);
if (debugMode) {
printError(res, t);
} else {
res.setStatus(500);
printError(res, "An error occurred in the application.");
}
}
/** print the error */
protected void printError(HttpServletResponse res, Throwable t) throws IOException {
StringWriter w = new StringWriter();
PrintWriter pw = new PrintWriter(w);
t.printStackTrace(pw);
pw.close();
printError(res, w.toString());
}
/** print the error */
protected void printError(HttpServletResponse res, String s) throws IOException {
PrintWriter w = new PrintWriter(getUtf8Writer(res));
w.println("<pre>");
w.println(s);
w.println("</pre>");
w.flush();
}
/** an {@link EmbeddedRenderTask} and the {@link PostAction} it returned */
private static class EmbeddedContent {
private final EmbeddedRenderTask embeddedTask;
private final StRenderPostAction postAction;
public EmbeddedContent(EmbeddedRenderTask embeddedTask, StRenderPostAction postAction) {
this.embeddedTask = embeddedTask;
this.postAction = postAction;
}
}
/** Parse the URI and forward the request to the appropriate task */
protected void handleInternal(HttpServletRequest req, HttpServletResponse res) throws IOException {
String uri = getUriForRequest(req);
if (uri.length() == 0) {
res.sendRedirect("/" + getDefaultURI());
return;
}
if (serveStatic(uri, res)) return;
log.info("Serving URI: " + uri + (debugMode ? " [debug mode]" : ""));
// Parse request parameters
DynamicInputImpl dynamicInput = new DynamicInputImpl(req);
registerInputArgParser(dynamicInput);
RequestHandler request = makeRequest(req, res, dynamicInput);
ITask t = findTask(uri, request);
if (t == null) {
log.info("No task found, sending to default URI");
res.sendRedirect(getDefaultURI());
return;
}
List<EmbeddedContent> embeddedContent = Empty.list();
for (EmbeddedRenderTask ert : t.dependsOn())
renderEmbedded(ert, dynamicInput, request, embeddedContent);
renderFinal(t, req, dynamicInput, request, embeddedContent, res);
}
/** @return the URI requested by the given HttpServletRequest */
protected String getUriForRequest(HttpServletRequest req) {
return req.getRequestURI().substring(req.getContextPath().length());
}
private RequestHandler makeRequest(final HttpServletRequest req, final HttpServletResponse response, final DynamicInputImpl dynamicInput) {
final Map<String, String> m = Empty.hashMap();
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
addCookie(m, c);
}
}
return new RequestHandler() {
@Implement public String getCookieValue(String name) {
return m.get(name);
}
@Implement public void setCookieValue(String name, String value) {
storeCookie(makeCookie(name, value));
}
@Implement public void setPersistentCookieValue(String name, String value, int expiry) {
if (expiry <= 0)
throw new IllegalArgumentException("expiry must be a positive number: " + expiry);
Cookie c = makeCookie(name, value);
c.setMaxAge(expiry);
storeCookie(c);
}
@Implement public void removeCookieValue(String name) {
Cookie c = makeCookie(name, null);
c.setMaxAge(0);
storeCookie(c);
}
private void storeCookie(Cookie c) {
response.addCookie(c);
addCookie(m, c);
}
private Cookie makeCookie(String name, String value) {
return new Cookie(name, value);
}
@Implement public HttpSession getSession() {
return req.getSession(true);
}
@Implement public <X> X getInput(String name, Class<X> type) {
return dynamicInput.getInput(name, type);
}
};
}
private void addCookie(final Map<String, String> m, Cookie c) {
m.put(c.getName(), c.getValue());
}
/** render the given embedded task (recursively) */
private void renderEmbedded(EmbeddedRenderTask t, DynamicInputImpl dynamicInput, RequestHandler request, List<EmbeddedContent> embeddedContent) {
for (EmbeddedRenderTask ert : t.dependsOn())
renderEmbedded(ert, dynamicInput, request, embeddedContent);
PostAction po = render(t, dynamicInput, request, null, "embedded/");
if (po instanceof StRenderPostAction)
embeddedContent.add(new EmbeddedContent(t, (StRenderPostAction) po));
else
throw new RuntimeException("EmbeddedRenderTask returned unsupported PostAction " + po);
}
private final Date boot = Clock.now();
/** serve static resources, e.g. images and css that do not have any dynamic component */
private boolean serveStatic(String uri, HttpServletResponse res) throws IOException {
StaticResource staticResource = staticResourceLookup.findStaticResource(uri);
if (staticResource != null) {
if (staticResource.exists()) {
res.setHeader("Content-Type", staticResource.getMimeType());
res.setDateHeader("Date", boot.getTime());
HttpHeaders.addCacheForeverHeaders(res);
staticResource.copyTo(res.getOutputStream());
} else {
res.sendError(404);
log.warn("Requested resource not found: " + uri);
}
return true;
} else {
return false;
}
}
private final String taskPackage = findTaskPackage(getServletClass());
/** @return the name of the package where task classes are assumed to be */
private String findTaskPackage(Class<? extends SpiderServlet> clazz) {
Class<?> p = clazz;
while (p.getSuperclass() != getServletParent())
p = p.getSuperclass();
return p.getPackage().getName() + ".st.";
}
protected Class<? extends SpiderServlet> getServletParent() {
return SpiderServlet.class;
}
/** @return an instance of the task the given URI maps to, or null if no such class exists */
private ITask findTask(String uri, RequestHandler request) {
String tn = extractTaskName(uri);
if (tn != null) {
String cn = taskPackage + tn;
Class<?> c;
try {
c = Class.forName(cn, true, Thread.currentThread().getContextClassLoader());
} catch (ClassNotFoundException e) {
throw new RuntimeException("No class " + cn);
}
if (c != null && ITask.class.isAssignableFrom(c)) {
@SuppressWarnings({"unchecked"})
Constructor<ITask>[] consArr = (Constructor<ITask>[]) c.getConstructors();
if (consArr.length != 1)
throw new RuntimeException("Class " + c + " must have exactly one constructor");
return new MethodInvoker(makeObjectProvider(request), makeLifecycleHandlerSet(request)).invoke(consArr[0]);
}
}
return null;
}
/** @return an instance of ObjectProvider with all the objects that are available for dependency injection */
private ObjectProvider makeObjectProvider(RequestHandler request) {
ObjectProvider injector = new ObjectProvider();
registerObjects(injector, request);
return injector;
}
private LifecycleHandlerSet makeLifecycleHandlerSet(RequestHandler request) {
LifecycleHandlerSet hs = MethodInvoker.getLifecycleHandlerSet();
registerLifecycleHandlers(hs, request);
return hs;
}
/** @return the task name the given URI maps to */
private String extractTaskName(String uri) {
int k = uri.lastIndexOf('/');
if (k >= 0) {
String s = Strings.capitalizeFirstCharacter(uri.substring(k+1));
return s.length() == 0 ? null : s + "Task";
}
return null;
}
private Map<String, StTool> buildStToolsMap() {
Map<String, StTool> m = Empty.hashMap();
m.put("cached", new CachedTool(staticResourceLookup));
return m;
}
/** @return the StTool for the given name */
protected StTool getStTool(String name) {
return stTools.get(name);
}
/** @return the path to the StringTemplate with the given name */
protected String findPathForTemplate(String name) {
name = "st/" + name;
String path = name + ".st";
Class<?> c = getServletClass();
while (c != null) {
if (c.getResource(path) != null)
return c.getPackage().getName().replace('.', '/') + "/" + name;
if (c == SpiderServlet.class)
break;
c = c.getSuperclass();
}
throw new RuntimeException("Cannot find template " + name);
}
/** render the given task and write the output to the response */
private void renderFinal(ITask t, HttpServletRequest req, DynamicInputImpl dynamicInput, RequestHandler request, List<EmbeddedContent> embeddedContent, HttpServletResponse res) throws IOException {
PostAction po = render(t, dynamicInput, request, embeddedContent, "pages/");
if (po instanceof CustomPostAction) {
((CustomPostAction)po).respond(req, res);
} else if (po instanceof StRenderPostAction) {
String stContent = ((StRenderPostAction)po).getStContent();
HttpHeaders.addNoCacheHeaders(res);
Writer w = getUtf8Writer(res);
try {
if (t instanceof IAjaxRenderTask) {
IOHelpers.copy(new StringReader(stContent), w);
} else if (t instanceof IRenderTask) {
IRenderTask rt = (IRenderTask) t;
StringTemplate pageSt = pageStGroup.getInstanceOf("page");
pageSt.setAttribute("pagetitle", rt.getPageTitle());
pageSt.setAttribute("body", unsafeHtmlString(stContent));
addEmbedded(embeddedContent, pageSt);
pageSt.write(new AutoIndentWriter(w));
} else {
throw new RuntimeException("Task " + t + " is of unknown type");
}
} finally {
w.close();
}
}
}
/** @return a Writer that writes UTF-8 to the given response */
protected Writer getUtf8Writer(HttpServletResponse res) throws IOException {
res.setContentType("text/html; charset=utf-8");
Writer w = new OutputStreamWriter(res.getOutputStream(), Encoding.CHARSET_UTF8);
return w;
}
/** Pattern to extract the part of the class name preceding the "Task" postfix */
private static final Pattern CLASS_NAME_PREFIX_PATTERN = Pattern.compile(".*\\.(.+)Task.*");
/** @return the PostAction returned from {@link StRenderer#actionAndRender(ObjectProvider, Map)} on the given task */
private PostAction render(ITask t, DynamicInputImpl dynamicInput, RequestHandler request, final List<EmbeddedContent> embeddedContent, final String relativeTemplatePath) {
StRenderer renderer = new StRenderer(stringTemplateFactory, t) {
@Override protected Pattern getClassNamePrefixPattern() {
return CLASS_NAME_PREFIX_PATTERN;
}
@Override protected String getPageRelativePath() {
return relativeTemplatePath;
}
@Override protected String renderFinal(StringTemplate st) {
if (embeddedContent != null)
addEmbedded(embeddedContent, st);
return super.renderFinal(st);
}
};
ObjectProvider injector = makeObjectProvider(request);
long nt = System.nanoTime();
PostAction po = renderer.actionAndRender(injector, makeLifecycleHandlerSet(request), dynamicInput);
log.info("StRender of " + t.getClass().getSimpleName() + " in " + TimeUnit.MILLISECONDS.convert(System.nanoTime() - nt, TimeUnit.NANOSECONDS) + " ms");
return po;
}
private void addEmbedded(List<EmbeddedContent> embeddedContent, StringTemplate st) {
for (EmbeddedContent ec : embeddedContent) {
// The variables that went into stContent have already been escaped
st.setAttribute(ec.embeddedTask.getStAttribute(), unsafeHtmlString(ec.postAction.getStContent()));
}
}
private HtmlString unsafeHtmlString(String html) {
@SuppressWarnings("deprecation")
HtmlString stContent = HtmlString.rawUnsafe(html);
return stContent;
}
}