package org.gomba;
import java.io.IOException;
import java.sql.SQLException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.sql.DataSource;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Result;
import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.sax.SAXSource;
import javax.xml.transform.stream.StreamResult;
import org.gomba.utils.servlet.ServletLogger;
import org.gomba.utils.token.IdGenerator;
import org.gomba.utils.xml.ContentHandlerUtils;
import org.gomba.utils.xml.ObjectInputSource;
import org.gomba.utils.xml.ObjectXMLReader;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
/**
* This servlet implements transactions for RESTful web services. This servlet
* is used to start, commit or rollback a transaction. This servlet will be
* typically mapped to <code>/transactions/*</code>.
*
* HTTP methods are mapped to transaction operations in this way:
* <dl>
* <dt>POST</dt>
* <dd>Start a new transaction. Posting an empty resource will create a new
* transaction. In the future we may add an XML document type to specify
* transaction options.</dd>
* <dt>GET</dt>
* <dd>Get information about a transaction.</dd>
* <dt>PUT</dt>
* <dd>Commit a transaction. Putting an empty resource is allowed. In the
* future we may support PUTting the (updated) XML data returned by GET.</dd>
* <dt>DELETE</dt>
* <dd>Rollback a transaction and destroy it.</dd>
* </dl>
*
* The XML returned by this servlet in response to GET request looks like this:
*
* <pre>
* <transaction>
* <uri>http://domain.com/transactions/238476245jhg34589345k</uri>
* <creationTime>2004-12-25T00:00:00</creationTime>
* <lastAccessedTime>2004-12-25T00:10:00</lastAccessedTime>
* <maxInactiveInterval>600</maxInactiveInterval>
* </transaction>
* </pre>
*
* <p>
* TODO: provide a DTD.
* </p>
*
* Init-params:
* <dl>
* <dt>transaction-id</dt>
* <dd>An expression that evaluates to the transaction id. May contain ${}
* parameters. This will typically be: ${path.0} (Required)</dd>
* <dt>transaction-uri</dt>
* <dd>The transaction URI. This is not a full-blown expression, ${} cannot be
* used here. Only ${transaction.id} will be replaced with actual transaction
* id. A typical setting is: http://domain.org/transactions/${transaction.id}.
* In a sense, this the reverse of the transaction-id expression: transaction-id
* is used to extract an id from a URI, while transaction-URI is used to
* generate a URI from an id. (Required)</dd>
* <dt>transaction-timeout</dt>
* <dd>The transaction timeout interval for all transactions created by this
* servlet. The specified timeout must be expressed in a whole number of
* seconds. Since version 0.8 this value may be exceeded, it depends on when the
* HarvesterThread processes the transaction. The default value is 30.
* (Optional)</dd>
* </dt>
* <dt>jvm-route</dt>
* <dd>Identifier which must be used in load balancing scenarios to enable
* session affinity. The identifier, which must be unique across all servers
* which participate in the cluster, will be appended to the generated
* transaction identifier, therefore allowing the front end proxy to always
* forward requests that belong to a particular transaction to the same
* instance. Value can be an expression evaluated at servlet initialization
* time, so don't use request-related expressions. The "systemProperty"
* parameter domain is particurarly useful for this setting.</dd>
* </dl>
*
* @author Flavio Tordini
* @version $Id: TransactionServlet.java,v 1.8 2005/12/07 11:19:01 flaviotordini
* Exp $
* @see http://www.seairth.com/web/resttp.html,
* http://www.xml.com/lpt/a/2004/08/11/rest.html,
* http://lists.xml.org/archives/xml-dev/200402/msg00267.html
* http://groups.yahoo.com/group/rest-discuss/message/4141
*/
public class TransactionServlet extends HttpServlet {
/**
* A regular expression used to build the transaction URI.
*/
private final static Pattern TRANSACTION_ID_PATTERN = Pattern
.compile("\\$\\{transaction.id\\}");
/**
* Name of the context attribute that holds the transactions Map. Mapping is
* transaction URI to Trasaction.
*/
protected final static String CONTEXT_ATTRIBUTE_NAME_TRANSACTIONS = "org_gomba_transactions";
/**
* Name of the context attribute that holds the servlet instance.
*/
protected final static String CONTEXT_ATTRIBUTE_NAME_SERVLET = "org_gomba_transactionServlet";
private final static String INIT_PARAM_TRANSACTION_URI = "transaction-uri";
private final static String INIT_PARAM_TRANSACTION_ID = "transaction-id";
private final static String INIT_PARAM_TRANSACTION_TIMEOUT = "transaction-timeout";
private final static String INIT_PARAM_JVMROUTE = "jvm-route";
private final static int DEFAULT_TRANSACTION_TIMEOUT = 30;
/**
* <code>true</code> if debug logging is turned on.
*/
private boolean debugMode;
/**
* A logger to be passed around to enable servlet logging in non-servlet
* classes.
*/
private ServletLogger logger;
/**
* The data source to query.
*/
private DataSource dataSource;
/**
* Expression that evaluates to the transaction id. Will tipically be the
* first extra path element. The id of a transaction is private affair of
* this servlet. Clients refer to a transaction using its URI.
*/
private Expression idExpression;
/**
* Pseudo-expression that evaluates to the transaction URI. This is not a
* full-blown expression, only the <code>TRANSACTION_ID_PATTERN</code> is
* replaced with actual transaction id.
*/
private String uriExpression;
/**
* Transaction timeout in seconds
*/
private int transactionTimeout = DEFAULT_TRANSACTION_TIMEOUT;
/**
* Map containing current active transactions. Mapping is transaction URI to
* Transaction.
*/
private Map transactions;
/**
* This will help us generate secure random transaction ids
*/
private final IdGenerator idGenerator = new IdGenerator();
/**
* <code>true</code> when the destroy() method has been called by the
* servlet container.
*/
private boolean destroyed;
/**
* Dummy object acting as a semaphore for the harvester thread.
*/
private final Object semaphore = new Object();
/**
* Server identifier
*/
private String jvmRoute;
/**
* @see javax.servlet.Servlet#init(javax.servlet.ServletConfig)
*/
public void init(ServletConfig config) throws ServletException {
super.init(config);
// debug mode
this.debugMode = AbstractServlet.getDebugMode(config);
// build our strange logger
this.logger = new ServletLogger(this, this.debugMode);
// get the JNDI data source
this.dataSource = AbstractServlet.getDataSource(config);
// transaction URI pseudo-expression
this.uriExpression = config
.getInitParameter(INIT_PARAM_TRANSACTION_URI);
if (this.uriExpression == null) {
throw new ServletException("Missing init-param: "
+ INIT_PARAM_TRANSACTION_URI);
}
// transaction id expression
String idExpressionStr = config
.getInitParameter(INIT_PARAM_TRANSACTION_ID);
if (idExpressionStr == null) {
throw new ServletException("Missing init-param: "
+ INIT_PARAM_TRANSACTION_ID);
}
try {
this.idExpression = new Expression(idExpressionStr);
} catch (Exception e) {
throw new ServletException("Error parsing "
+ INIT_PARAM_TRANSACTION_ID + " expression.", e);
}
// transaction timeout
String transactionTimeoutStr = config
.getInitParameter(INIT_PARAM_TRANSACTION_TIMEOUT);
if (transactionTimeoutStr != null) {
this.transactionTimeout = Integer.parseInt(transactionTimeoutStr);
}
// server identifier
String jvmRouteStr = config.getInitParameter(INIT_PARAM_JVMROUTE);
if (jvmRouteStr != null) {
Expression jvmRouteExpression;
try {
jvmRouteExpression = new Expression(jvmRouteStr);
} catch (Exception e) {
throw new ServletException("Error parsing "
+ INIT_PARAM_JVMROUTE + " expression.", e);
}
// domains using the request will throw a npe
ParameterResolver parameterResolver = new ParameterResolver(null);
try {
this.jvmRoute = jvmRouteExpression.replaceParameters(parameterResolver).toString();
} catch (Exception e) {
throw new ServletException("Error evaluating "
+ INIT_PARAM_JVMROUTE + " expression.", e);
}
}
// put this servlet instance in application scope
// FIXME servlets shold not be put in scopes
if (getServletContext().getAttribute(CONTEXT_ATTRIBUTE_NAME_SERVLET) != null) {
throw new ServletException("A " + this.getClass().getName()
+ " is already configured for the current context.");
}
getServletContext().setAttribute(CONTEXT_ATTRIBUTE_NAME_SERVLET, this);
// init the transactions map
// since we're in a servlet env where multiple thread will modify the
// map, we need a synchronized impl
this.transactions = Collections.synchronizedMap(new HashMap());
getServletContext().setAttribute(CONTEXT_ATTRIBUTE_NAME_TRANSACTIONS,
this.transactions);
// start background thread that removes expired transactions
TransactionHarvester harvester = new TransactionHarvester();
harvester.setDaemon(true);
harvester.setPriority(Thread.MIN_PRIORITY);
harvester.start();
}
/**
* Create a new transaction and add it the map.
*/
protected Transaction createTransaction()
throws ServletException {
final Transaction transaction;
// build transaction URI
// since this block is not synchronized
// there is a slight chance to generate an existing id
String transactionURI;
do {
// generate id
String transactionId = this.idGenerator.generateId();
if (this.jvmRoute != null) {
transactionId += '.' + this.jvmRoute;
}
transactionURI = getTransactionURI(transactionId);
} while (this.transactions.containsKey(transactionURI));
// create transaction
transaction = new Transaction(this.logger, this.dataSource,
transactionURI, this.transactionTimeout);
// add transaction to our map
this.transactions.put(transaction.getUri(), transaction);
this.logger.debug("Created transaction: " + transaction.getUri());
return transaction;
}
/**
* Create a transaction.
*
* @see javax.servlet.http.HttpServlet#doPost(javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse)
*/
protected void doPost(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
// TODO do we need XML from the request body ???
// TODO if we need it, it should be customizable with XSLT
// create the transaction
Transaction transaction = createTransaction();
// 201!
response.setStatus(HttpServletResponse.SC_CREATED);
// set Location header
response.setHeader("Location", transaction.getUri());
// TODO output XML with XLink
// TODO and make it customizable via XSLT
}
/**
* Get the transaction id by evaluating the id expression.
*
* @return Never returns null
*/
private String getTransactionId(ParameterResolver parameterResolver)
throws ServletException {
Object obj;
try {
obj = this.idExpression.replaceParameters(parameterResolver);
} catch (Exception e) {
throw new ServletException(
"Error evaluating transaction id expression.", e);
}
if (obj instanceof java.lang.String) {
return (String) obj;
}
throw new ServletException(
"Transaction id expression does not evaluate to String: "
+ obj.getClass().getName());
}
/**
* Get the transaction URI by evaluating the URI pseudo expression.
*/
private String getTransactionURI(String transactionId) {
Matcher m = TRANSACTION_ID_PATTERN.matcher(this.uriExpression);
return m.replaceFirst(transactionId);
}
/**
* Get the Transaction object for the specified request.
*
* @return May return null if the specified transaction does not exist.
*/
private Transaction getTransaction(HttpServletRequest request)
throws ServletException {
// create the parameter resolver that will help us throughout this
// request
final ParameterResolver parameterResolver = new ParameterResolver(
request);
// get transaction id
String transactionId = getTransactionId(parameterResolver);
// get transaction URI
String transactionURI = getTransactionURI(transactionId);
// get transaction
Transaction transaction = (Transaction) this.transactions
.get(transactionURI);
if (transaction == null) {
log("Invalid or expired transaction: " + transactionURI);
}
return transaction;
}
/**
* Get the Transaction object for the specified request.
*
* @return May return null if the specified transaction does not exist.
*/
private Transaction getAndRemoveTransaction(HttpServletRequest request)
throws ServletException {
// create the parameter resolver that will help us throughout this
// request
final ParameterResolver parameterResolver = new ParameterResolver(
request);
// get transaction id
String transactionId = getTransactionId(parameterResolver);
// get transaction URI
String transactionURI = getTransactionURI(transactionId);
// get transaction
Transaction transaction = (Transaction) this.transactions
.remove(transactionURI);
if (transaction == null) {
log("Invalid, expired or completed transaction: " + transactionURI);
}
return transaction;
}
/**
* Obtain information about a transaction.
*
* @see javax.servlet.http.HttpServlet#doGet(javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse)
*/
protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
// get transaction
Transaction transaction = getTransaction(request);
if (transaction == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// output xml
try {
serializeXML(transaction, response);
} catch (Exception e) {
throw new ServletException("Error serializing transaction to XML.",
e);
}
}
/**
* Remove a transaction from the map.
*/
private void removeTransaction(Transaction transaction)
throws ServletException {
Object previousValue = this.transactions.remove(transaction.getUri());
if (previousValue == null) {
log("Transaction already removed: " + transaction.getUri());
}
}
/**
* Serialize an object to XML using SAX and TrAX APIs in a smart way.
* (dagnele sucks :)
*
* @param object
* The object to serialize
* @param saxReader
* The SAX "parser"
* @param response
* The HTTP response
* @see <a
* href="http://java.sun.com/j2se/1.4.2/docs/api/javax/xml/transform/package-summary.html">TrAX
* API </a>
*/
private void serializeXML(Transaction object, HttpServletResponse response)
throws TransformerException, IOException {
ObjectXMLReader saxReader = new TransactionXMLReader();
// Let the HTTP client know the output content type
response.setContentType("text/xml");
// Create TrAX Transformer
Transformer t = TransformerFactory.newInstance().newTransformer();
// Set trasformation output properties
t.setOutputProperty(OutputKeys.ENCODING, response
.getCharacterEncoding());
// Create the trasformation source using our custom ObjectInputSource
InputSource inputSource = new ObjectInputSource(object);
Source source = new SAXSource(saxReader, inputSource);
// Create the trasformation result
// Result result = new StreamResult(response.getWriter());
Result result = new StreamResult(response.getOutputStream());
// Go!
t.transform(source, result);
}
/**
* Commit a transaction.
*
* @see javax.servlet.http.HttpServlet#doPut(javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse)
*/
protected void doPut(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
// get transaction
Transaction transaction = getAndRemoveTransaction(request);
if (transaction == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// commit transaction
this.logger.debug("Committing transaction: " + transaction.getUri());
try {
transaction.commit();
} catch (SQLException e) {
throw new ServletException("Error committing transaction: "
+ transaction.getUri(), e);
}
// output xml
try {
serializeXML(transaction, response);
} catch (Exception e) {
throw new ServletException("Error serializing transaction to XML.",
e);
}
}
/**
* Rollback a transaction.
*
* @see javax.servlet.http.HttpServlet#doDelete(javax.servlet.http.HttpServletRequest,
* javax.servlet.http.HttpServletResponse)
*/
protected void doDelete(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
// get transaction
Transaction transaction = getAndRemoveTransaction(request);
if (transaction == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
// rollback transaction
this.logger.debug("Rolling back transaction: " + transaction.getUri());
try {
transaction.rollback();
} catch (SQLException e) {
throw new ServletException("Error rolling back transaction: "
+ transaction.getUri(), e);
}
}
/**
* @see javax.servlet.Servlet#destroy()
*/
public void destroy() {
// check for multiple calls
if (this.destroyed) {
log("Servlet already destroyed. This should not happen.");
return;
}
// mark this servlet as destroyed so the harvester thread knows it
// has to exit.
this.destroyed = true;
// notify the harvester thread
synchronized (this.semaphore) {
this.semaphore.notifyAll();
}
}
/**
* This SAX XMLReader generates an XML document from a Transaction.
*/
final class TransactionXMLReader extends ObjectXMLReader {
private static final String PATTERN_TIMESTAMP = "yyyy-MM-dd'T'HH:mm:ss";
private final static String ROOT_ELEMENT = "transaction";
private final static String ELEMENT_URI = "uri";
private final static String ELEMENT_CREATIONTIME = "creationTime";
private final static String ELEMENT_LASTACCESSED = "lastAccessedTime";
private final static String ELEMENT_MAXINACTIVEINTERVAL = "maxInactiveInterval";
/**
* @see org.gomba.utils.xml.ObjectXMLReader#parse(org.gomba.utils.xml.ObjectInputSource)
*/
public void parse(ObjectInputSource input) throws IOException,
SAXException {
// Note that SimpleDateFormat objects cannot be used
// concurrently by multiple threads
SimpleDateFormat timestampFormatter = new SimpleDateFormat(
PATTERN_TIMESTAMP);
Transaction transaction = (Transaction) input.getObject();
this.handler.startDocument();
this.handler.startElement(ContentHandlerUtils.DUMMY_NSU,
ROOT_ELEMENT, ROOT_ELEMENT, ContentHandlerUtils.DUMMY_ATTS);
ContentHandlerUtils.tag(this.handler, ELEMENT_URI, transaction
.getUri());
ContentHandlerUtils.tag(this.handler, ELEMENT_CREATIONTIME,
timestampFormatter.format(transaction.getCreationTime()));
ContentHandlerUtils.tag(this.handler, ELEMENT_LASTACCESSED,
timestampFormatter.format(new Date(transaction
.getLastAccessedTime())));
ContentHandlerUtils.tag(this.handler, ELEMENT_MAXINACTIVEINTERVAL,
Integer.toString(transaction.getMaxInactiveInterval()));
this.handler.endElement(ContentHandlerUtils.DUMMY_NSU,
ROOT_ELEMENT, ROOT_ELEMENT);
this.handler.endDocument();
}
}
private class TransactionHarvester extends Thread {
/**
* Private constructor
*/
private TransactionHarvester() {
// Give this thread a useful name
super("TransactionHarvester");
}
public void run() {
log(this + " started.");
do {
try {
// create a temporary list of transactions
// to avoid synchronization problems
final List transactionsList = new ArrayList(transactions.keySet());
// scan for expired transactions
for (Iterator i = transactionsList.iterator(); i.hasNext();) {
final String transactionUri = (String) i.next();
final Transaction transaction = (Transaction) transactions.get(transactionUri);
if (transaction == null) {
// transaction has been committed or rolled back in the meantime...
continue;
}
if (transaction.isExpired()) {
log("Found expired transaction: "
+ transaction.getUri());
try {
removeTransaction(transaction);
} catch (Exception e) {
log("Error removing expired transaction: "
+ transaction.getUri(), e);
} finally {
try {
transaction.rollback();
} catch (Exception e) {
log(
"Error rolling back expired transaction: "
+ transaction.getUri(), e);
}
}
}
}
// sleep interval or until notified
synchronized (TransactionServlet.this.semaphore) {
try {
TransactionServlet.this.semaphore
.wait(transactionTimeout * 1000);
} catch (InterruptedException e) {
log(
"Thread interrupted: "
+ Thread.currentThread(), e);
}
}
} catch (Throwable t) {
// this prevents the thread from dying for an uncaught
// exception
log("Error checking for expired transactions.", t);
}
} while (!TransactionServlet.this.destroyed);
log(this + " stopped.");
}
}
}