A RESTful Service as an HttpServlet

Chapter 1 has a sample RESTful service implemented as a JSP script and two backend classes: Prediction and Predictions. The JSP-based service supported only GET requests. This section revises the example to provide an HttpServlet implementation with support for the four CRUD operations:

The earlier JSP service is predictions and the servlet revision is predictions2. The structure of predictions2 differs from that of predictions in several ways. The most obvious change is that an explicit HttpServlet subclass replaces the JSP script. There are also changes in the details of the Prediction and Predictions classes, which still provide backend support. The details follow.

There are small but important changes to the Prediction class (see Example 2-1), which now includes an id property (line 1), an auto-incremented integer that the service sets when a Prediction object is constructed.

The id property is used to sort the Prediction objects, which explains why the Prediction class implements the interface Comparable used in sorting:

public class Prediction implements Serializable, Comparable<Prediction> {

Implementing the Comparable interface requires that the compareTo method be defined:

public int compareTo(Prediction other) {
    return this.id - other.id;
}

The compareTo method uses the comparison semantics of the age-old C function qsort. For illustration, suppose that this.id in the code above is 7 and other.id is 12, where this is the current object and other is another Prediction object against which the current Prediction object is being compared. The difference of 7–12 is the negative integer –5, which signals that the current Prediction precedes the other Prediction because 7 precedes 12. In general:

  • A returned negative integer signals that the current object precedes the other object.
  • A returned positive integer signals that the current object succeeds the other object.
  • A returned zero signals that the two objects are to be treated as equals with respect to sorting.

The implementation of the compareTo method means the sort is to be in ascending order. Were the return statement changed to:

return other.id - this.id;

the sort would be in descending order. The Prediction objects are sorted for ease of confirming that the CRUD operations work correctly. For example, if a Prediction object is created with the appropriate POST request, then the newly created Prediction occurs at the end of the Prediction list. In similar fashion, it is easy to confirm that the other destructive CRUD operations—PUT (update) and DELETE—work as intended by inspecting the resulting sorted list of Prediction objects.

A Prediction is still Serializable so that a list of these can be serialized into XML using the XmlEncoder utility. An added feature is that this list can be formatted in JSON if the client so requests.

The utility class Predictions has changed as well (see Example 2-2). As explained in the sidebar about thread synchronization and servlets, the Map of the earlier JSP implementation gives way to a ConcurrentMap so that the code can avoid explicit locks in the form of synchronized blocks.

Example 2-2. The backend Predictions class

package predictions2;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.Collections;
import java.beans.XMLEncoder; // simple and effective
import javax.servlet.ServletContext;

public class Predictions {
    private ConcurrentMap<Integer, Prediction> predictions;
    private ServletContext sctx;
    private AtomicInteger mapKey;

    public Predictions() {
        predictions = new ConcurrentHashMap<Integer, Prediction>();
        mapKey = new AtomicInteger();
    }
    public void setServletContext(ServletContext sctx) {
        this.sctx = sctx;
    }
    public ServletContext getServletContext() { return this.sctx; }
    public void setMap(ConcurrentMap<String, Prediction> predictions) {
        // no-op for now
    }
    public ConcurrentMap<Integer, Prediction> getMap() {
        // Has the ServletContext been set?
        if (getServletContext() == null) return null;
        // Has the data been read already?
        if (predictions.size() < 1) populate();
        return this.predictions;
    }
    public String toXML(Object obj) {
        String xml = null;
        try {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            XMLEncoder encoder = new XMLEncoder(out);
            encoder.writeObject(obj); // serialize to XML
            encoder.close();
            xml = out.toString(); // stringify
        }
        catch(Exception e) { }
        return xml;
    }
    public int addPrediction(Prediction p) {
        int id = mapKey.incrementAndGet();
        p.setId(id);
        predictions.put(id, p);
        return id;
    }
    private void populate() {
        String filename = "/WEB-INF/data/predictions.db";
        InputStream in = sctx.getResourceAsStream(filename);
        // Read the data into the array of Predictions.
        if (in != null) {
            try {
                InputStreamReader isr = new InputStreamReader(in);
                BufferedReader reader = new BufferedReader(isr);
                int i = 0;
                String record = null;
                while ((record = reader.readLine()) != null) {
                    String[] parts = record.split("!");
                    Prediction p = new Prediction();
                    p.setWho(parts[0]);
                    p.setWhat(parts[1]);
                    addPrediction(p);
                }
            }
            catch (IOException e) { }
        }
    }
}

The Predictions class now has an addPrediction method:

public int addPrediction(Prediction p) {
   int id = mapKey.incrementAndGet(); // AtomicInteger
   p.setId(id);
   predictions.put(id, p);
   return id;
}

to support POST requests. The servlet’s doPost method creates a new Prediction, sets the who and what properties with data from the POST message’s body, and then invokes addPrediction to add the newly constructed Prediction to the map whose object reference is predictions. The mapKey, a thread-safe AtomicInteger, gets incremented with each new Prediction and behaves like an auto-incremented integer in a database system; the mapKey value becomes the id of each newly constructed Prediction, thereby ensuring that each Prediction has a unique id.

The remaining Predictions code is slightly changed, if at all, from the earlier version. For example, the populate method is modified slightly to give each newly constructed Prediction an id, but the method’s main job is still to read data from the text file encapsulated in the WAR—data that contain the who and what of each Prediction.

The PredictionServlet (see Example 2-3) replaces the JSP script and differs from this script in supporting all of the CRUD operations. The servlet offers new functionality by allowing the client to request the JSON format for the response of any GET request. Further, the earlier JSP script interpreted GET to mean read all but the servlet allows the client to request one specified Prediction or all of them. The code for the PredictionServlet is long enough that it makes sense to isolate important code segments for clarification.

Example 2-3. The PredictionsServlet with full support for the CRUD operations

package predictions2;

import java.util.concurrent.ConcurrentMap;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.ws.http.HTTPException;
import java.util.Arrays;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.beans.XMLEncoder;
import org.json.JSONObject;
import org.json.XML;

public class PredictionsServlet extends HttpServlet {
    private Predictions predictions; // backend bean

    // Executed when servlet is first loaded into container.
    // Create a Predictions object and set its servletContext
    // property so that the object can do I/O.
    @Override
    public void init() {
        predictions = new Predictions();
        predictions.setServletContext(this.getServletContext());
    }
    // GET /predictions2
    // GET /predictions2?id=1
    // If the HTTP Accept header is set to application/json (or an equivalent
    // such as text/x-json), the response is JSON and XML otherwise.
    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) {
        String param = request.getParameter("id");
        Integer key = (param == null) ? null : new Integer(param.trim());
        // Check user preference for XML or JSON by inspecting
        // the HTTP headers for the Accept key.
        boolean json = false;
        String accept = request.getHeader("accept");
        if (accept != null && accept.contains("json")) json = true;
        // If no query string, assume client wants the full list.
        if (key == null) {
            ConcurrentMap<Integer, Prediction> map = predictions.getMap();
            // Sort the map's values for readability.
            Object[] list = map.values().toArray();
            Arrays.sort(list);
            String xml = predictions.toXML(list);
            sendResponse(response, xml, json);
        }
        // Otherwise, return the specified Prediction.
        else {
            Prediction pred = predictions.getMap().get(key);

            if (pred == null) { // no such Prediction
                String msg = key + " does not map to a prediction.\n";
                sendResponse(response, predictions.toXML(msg), false);
            }
            else { // requested Prediction found
                sendResponse(response, predictions.toXML(pred), json);
            }
        }
    }
    // POST /predictions2
    // HTTP body should contain two keys, one for the predictor ("who") and
    // another for the prediction ("what").
    @Override
    public void doPost(HttpServletRequest request, HttpServletResponse response) {
        String who = request.getParameter("who");
        String what = request.getParameter("what");
        // Are the data to create a new prediction present?
        if (who == null || what == null)
            throw new HTTPException(HttpServletResponse.SC_BAD_REQUEST);
        // Create a Prediction.
        Prediction p = new Prediction();
        p.setWho(who);
        p.setWhat(what);
        // Save the ID of the newly created Prediction.
        int id = predictions.addPrediction(p);
        // Generate the confirmation message.
        String msg = "Prediction " + id + " created.\n";
        sendResponse(response, predictions.toXML(msg), false);
    }
    // PUT /predictions
    // HTTP body should contain at least two keys: the prediction's id
    // and either who or what.
    @Override
    public void doPut(HttpServletRequest request, HttpServletResponse response) {
        /* A workaround is necessary for a PUT request because neither Tomcat
           nor Jetty generates a workable parameter map for this HTTP verb. */
        String key = null;
        String rest = null;
        boolean who = false;
        /* Let the hack begin. */
        try {
            BufferedReader br =
                new BufferedReader(new InputStreamReader(request.getInputStream()));
            String data = br.readLine();
            /* To simplify the hack, assume that the PUT request has exactly
               two parameters: the id and either who or what. Assume, further,
               that the id comes first. From the client side, a hash character
               # separates the id and the who/what, e.g.,
                  id=33#who=Homer Allision
            */
            String[] args = data.split("#");      // id in args[0], rest in args[1]
            String[] parts1 = args[0].split("="); // id = parts1[1]
            key = parts1[1];
            String[] parts2 = args[1].split("="); // parts2[0] is key
            if (parts2[0].contains("who")) who = true;
            rest = parts2[1];
        }
        catch(Exception e) {
            throw new HTTPException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
        // If no key, then the request is ill formed.
        if (key == null)
            throw new HTTPException(HttpServletResponse.SC_BAD_REQUEST);
        // Look up the specified prediction.
        Prediction p = predictions.getMap().get(new Integer(key.trim()));
        if (p == null) { // not found?
            String msg = key + " does not map to a Prediction.\n";
            sendResponse(response, predictions.toXML(msg), false);
        }
        else { // found
            if (rest == null) {
                throw new HTTPException(HttpServletResponse.SC_BAD_REQUEST);
            }
            // Do the editing.
            else {
                if (who) p.setWho(rest);
                else p.setWhat(rest);
                String msg = "Prediction " + key + " has been edited.\n";
                sendResponse(response, predictions.toXML(msg), false);
            }
        }
    }
    // DELETE /predictions2?id=1
    @Override
    public void doDelete(HttpServletRequest request, HttpServletResponse response) {
        String param = request.getParameter("id");
        Integer key = (param == null) ? null : new Integer(param.trim());
        // Only one Prediction can be deleted at a time.
        if (key == null)
            throw new HTTPException(HttpServletResponse.SC_BAD_REQUEST);
        try {
            predictions.getMap().remove(key);
            String msg = "Prediction " + key + " removed.\n";
            sendResponse(response, predictions.toXML(msg), false);
        }
        catch(Exception e) {
            throw new HTTPException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }
    // Method Not Allowed
    @Override
    public void doTrace(HttpServletRequest request, HttpServletResponse response) {
        throw new HTTPException(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
    }
    @Override
    public void doHead(HttpServletRequest request, HttpServletResponse response) {
        throw new HTTPException(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
    }
    @Override
    public void doOptions(HttpServletRequest request, HttpServletResponse response) {
        throw new HTTPException(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
    }
    // Send the response payload to the client.
    private void sendResponse(HttpServletResponse response,
                              String payload,
                              boolean json) {
        try {
            // Convert to JSON?
            if (json) {
                JSONObject jobt = XML.toJSONObject(payload);
                payload = jobt.toString(3); // 3 is indentation level for nice look
            }
            OutputStream out = response.getOutputStream();
            out.write(payload.getBytes());
            out.flush();
        }
        catch(Exception e) {
            throw new HTTPException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }
}

Recall that each of the do-methods in an HttpServlet take the same arguments: an HttpServletRequest; a map that contains the information encapsulated in the HTTP request; and an HttpServletResponse, which encapsulates an output stream for communicating back with the client. Here is the start of the doGet method:

public void doGet(HttpServletRequest request, HttpServletResponse response) {

The HttpServletRequest has a getParameter method that expects a string argument, a key into the request map, and returns either null if there is no such key or the key’s value as a string otherwise. The getParameter method is agnostic about whether the key/value pairs are in the body of, for example, a POST request or in the query string of, for example, a GET request. The method works the same in either case. There is also a getParameters method that returns the parameter collection as a whole.

In the case of PredictionsServlet, the doGet method needs to answer two questions about the incoming request:

The deployed WAR file predictions2.war includes a lightweight, third-party JSON library in the JAR file json.jar (it is available at the JSON in Java website). If the client prefers JSON over XML, then the response payload is converted to JSON. If anything goes awry in sending the response back to the client, the servlet throws an HTTPException, which in this case generates a response with HTTP status code 500 for Internal Server Error, a catchall for request-processing errors on the server.

The doPost and doPut operations are similar in that doPost creates an altogether new Prediction using data in the body of a POST request, whereas doPut updates an existing Prediction from data in the body of a PUT request. The main difference is that a PUT request needs to include the id of the Prediction to be updated, whereas a POST request creates a new Prediction and then sets its id to an auto-incremented integer. In implementation, however, doPost and doPut differ significantly because the servlet container’s runtime does not generate a usable parameter map, the HttpServletRequest, on a PUT request; on a POST request, the map is usable. (This is the case in both Tomcat and Jetty.) As a result, the doPut implementation extracts the data directly from an input stream.

To begin, here is the doPost implementation, without the comments:

public void doPost(HttpServletRequest request, HttpServletResponse response) {
   String who = request.getParameter("who");                           1
   String what = request.getParameter("what");                         2
   if (who == null || what == null)
      throw new HTTPException(HttpServletResponse.SC_BAD_REQUEST);
   Prediction p = new Prediction();                                    3
   p.setWho(who);                                                      4
   p.setWhat(what);                                                    5
   int id = predictions.addPrediction(p);                              6
   String msg = "Prediction " + id + " created.\n";
   sendResponse(response, predictions.toXML(msg), false);              7
}

The two calls to the getParameter method extract the required data (lines 1 and 2). A new Prediction is then constructed, its who and what properties are set, and a confirmation is generated for the client (lines 3 through 7).

In the doPut method, the getParameter method does not work correctly because neither Tomcat nor Jetty builds a usable parameter map in HttpServletRequest. The workaround is to access directly the input stream encapsulated in the request structure:

BufferedReader br =
  new BufferedReader(new InputStreamReader(request.getInputStream()));
String data = br.readLine();
...

The next step is to extract the data from this stream. The code, though not pretty, gets the job done. The point of interest is that the HttpServletRequest does provide access to the underlying input stream from which the PUT data can be extracted. Using the getParameter method is, of course, much easier.

The body of doDelete method has simple logic:

String key = request.getParameter("id");
if (key == null)
   throw new HTTPException(HttpServletResponse.SC_BAD_REQUEST);
try {
   predictions.getMap().remove(key);                         1
   String msg = "Prediction " + key + " removed.\n";
   sendResponse(response, predictions.toXML(msg), false);
}
catch(Exception e) {
   throw new HTTPException(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}

If the id for the Prediction can be extracted from the parameter map, the prediction is effectively removed from the collection by removing the lookup key from the ConcurrentMap (line 1).

The PredictionsServlet also implements three other do-methods, and all in the same way. Here, for example, is the implementation of doHead:

public void doHead(HttpServletRequest request, HttpServletResponse response) {
     throw new HTTPException(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
}

Throwing the HTTPException signals to the client that the underlying HTTP verb, in this case HEAD, is not supported. The numeric status code for Method Not Allowed is 405. The web service designer thus has an idiomatic way to reject particular HTTP verbs: throw a Method Not Allowed exception.

Example 2-4 is a list of curl calls against the service. These calls serve as a very preliminary test of the service. Two semicolons introduce comments that explain the purpose of the curl call. Recall that the Ant script can be used to deploy the predictions2 service under Tomcat:

% ant -Dwar.name=predictions2 deploy

The XML responses from the predictions2 service are formatted exactly the same as in the original version, which did not support JSON responses. Here is a sample JSON response from a GET request on the Prediction with id 31:

{"java": {"class": "java.beans.XMLDecoder", "object": {"void": [
         {"int": 31, "property": "id"},
         {"string": "Balanced clear-thinking utilisation
                     will expedite collaborative initiatives.",
          "property": "what"}, {"string": "Deven Blanda", "property": "who"}],
 "class": "predictions2.Prediction"},
 "version": "1.7.0_17"}}