Thursday, September 21, 2006

A REST interface for a SOAP Web Service

In my last post I created a simple web service using JAX-WS 2.0.

The next thing I wanted to do was to make the same service accessible via a REST interface in addition to SOAP.

I expected my web service to receive REST requests that look like this:

GET /member/MemberGroups?MemberID=2

I expected the responses to these requests to look like this:

<ns1:MemberGroupsResponse>
  <MemberGroup>
    <ID>1</ID>
    <name>Orcs</name>
  </MemberGroup>
  <MemberGroup>
    <ID>2</ID>
    <name>Elves</name>
  </MemberGroup>
</ns1:MemberGroupsResponse>

I was hoping to do this simply by adding another annotation to my web service method. I looked at the REST sample provided with the JAX-WS 2.0 reference implementation, but it seemed cumbersome. I wanted a mechanism that would provide me with an automatic REST interface to each of my web service methods without having to do any additional work.

It occurred to me that a simple servlet filter sitting in front of the SOAP servlet might do the trick, as long as I was willing to stick to a few structural conventions:

  1. The URL of each REST method has the form SOAPaddress/SOAPoperation.
  2. It must be possible to map query parameters for each RESTful GET method into a valid SOAP request consisting of a flat list of child elements within the operation element.
  3. It must be possible to transform bare XML content submitted to a RESTful POST method into a valid SOAP request by wrapping the content in a soapenv:Body element.
  4. The return value for each REST method must be the contents of the soapenv:Body element in the SOAP response.
  5. All service methods must be capable of handling the simple messages received from REST requests, without any additional header, body or envelope information.

Here is the filter that I wrote:

package member.server;

import java.io.*;

import javax.servlet.*;
import javax.servlet.http.*;

/**
 * Adapts REST requests to SOAP
 **/
public class RestFilter implements Filter {

  private static final String FORM_TYPE = "application/x-www-form-urlencoded";

  private FilterConfig filterConfig = null;
  private String prolog = null;

  public void init(FilterConfig filterConfig) throws ServletException {

    this.filterConfig = filterConfig;

    String ns = filterConfig.getInitParameter("namespace");
    if (ns == null) {
      throw new IllegalStateException("RestFilter requires a " +
          "namespace init parameter");
    }

    prolog = "<?xml version=\"1.0\"?><soapenv:Envelope " +
      "xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" " +
      "xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" " +
      "xmlns:ns1=\"" + ns + "\">";
  }

  public void destroy() {
    filterConfig = null;
  }

  public void doFilter(ServletRequest request, ServletResponse response, 
         FilterChain chain) 
    throws IOException, ServletException {

    if (filterConfig == null) { return; }

    HttpServletRequest httpRequest = (HttpServletRequest) request;
    HttpServletResponse httpResponse = (HttpServletResponse) response;

    String contentType = request.getContentType();
    String method = httpRequest.getMethod();
    boolean isMeta = httpRequest.getParameter("wsdl") != null || 
      httpRequest.getParameter("xsd") != null;
    boolean isForm = 
      ("POST".equals(method) && FORM_TYPE.equals(contentType));

    boolean isRest = ((! isMeta && "GET".equals(method)) || isForm);

    if (! isRest) {

      // not a REST call, so proceed directly to the SOAP servlet
      chain.doFilter(request, response);

    } else {

      // transform the REST request into a SOAP request
      RestRequestWrapper restRequest = 
 new RestRequestWrapper(httpRequest, prolog);

      // extract the body from the SOAP response and return it raw
      RestResponseWrapper restResponse = 
 new RestResponseWrapper(httpResponse, restRequest.getOperation());

      ServletContext sc = filterConfig.getServletContext();
      RequestDispatcher rd = 
 sc.getRequestDispatcher(restRequest.getPortPath());
      rd.forward(restRequest, restResponse);

      restResponse.commit();
    }
  }
}

The filter check for REST requests based on the content type and HTTP method. If the request is a GET or it is a form submission, then the filter routes the request through the REST interface. The only exceptions are for WSDL or XML Schema documents, which pass directly to the SOAP servlet just like SOAP messages.

The request wrapper transforms the request into a SOAP message that can be passed to the SOAP servlet:

package member.server;

import java.io.*;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.Hashtable;
import java.util.Map;
import java.util.Vector;

import javax.servlet.*;
import javax.servlet.http.*;

/**
 * Transforms REST requests into SOAP requests.
 *
 * TO DO: handle POST requests
 **/
public class RestRequestWrapper extends HttpServletRequestWrapper {

  private static final Vector EMPTY_VECTOR = new Vector();

  private String portPath;
  private String operation;
  private String message;
  private Hashtable<String, Vector> headers;

  public RestRequestWrapper(HttpServletRequest request, String prolog) {

    super(request);

    String requestURI = request.getRequestURI();

    int slash = requestURI.lastIndexOf('/');
    if (slash == -1 || slash == 0 || slash == requestURI.length() - 1) { 
      return; 
    }
    
    portPath = requestURI.substring(0, slash);
    operation = requestURI.substring(slash + 1);

    StringBuilder builder = new StringBuilder(2000);
    builder.append(prolog);
    builder.append("<soapenv:Body>");
    builder.append("<ns1:").append(operation).append(">");

    Iterator i = request.getParameterMap().entrySet().iterator();    

    while (i.hasNext()) {

      Map.Entry entry = (Map.Entry) i.next();

      String name = (String) entry.getKey();
      String[] values = (String[]) entry.getValue();

      for (int j = 0; j < values.length; j++) {
        builder.append("<").append(name).append(">");
        builder.append(values[j]);
        builder.append("</").append(name).append(">");
      }
    }

    builder.append("</ns1:").append(operation).append(">");
    builder.append("</soapenv:Body>");
    builder.append("</soapenv:Envelope>");

    message = builder.toString();

    buildHeaders(request);
  }

  String getPortPath() {
    return portPath;
  }

  String getOperation() {
    return operation;
  }
  
  public String getMethod() {
    return "POST";
  }

  private void buildHeaders(HttpServletRequest request) {

    headers = new Hashtable<String, Vector>();

    for (Enumeration e = request.getHeaderNames(); e.hasMoreElements();) {

      String name = (String) e.nextElement();
      Vector v = new Vector();
      Enumeration f = request.getHeaders(name);

      while (f.hasMoreElements()) {
        v.add(f.nextElement());
      }

      headers.put(name.toLowerCase(), v);
    }

    setHeader("content-type", getContentType());
    setHeader("content-length", Integer.toString(getContentLength()));
    setHeader("soapaction", '"' + operation + '"');
  }

  private void setHeader(String name, String value) {

    Vector v = new Vector();
    v.add(value);
    headers.put(name, v);
  }

  public Enumeration getHeaderNames() {
    return headers.keys();
  }

  public Enumeration getHeaders(String name) {

    Vector v = headers.get(name.toLowerCase());
    return (v == null) ? EMPTY_VECTOR.elements() : v.elements();
  }

  public String getHeader(String name) {

    Enumeration e = getHeaders(name);
    return e.hasMoreElements() ? (String) e.nextElement() : null;
  }

  public int getIntHeader(String name) throws NumberFormatException {

    String value = getHeader(name);
    return (value == null) ? -1 : Integer.parseInt(value);
  }

  public String getContentType() {
    return "text/xml; charset=utf-8";
  }

  public String getCharacterEncoding() {
    return "UTF-8";
  }

  public int getContentLength() {
    return message.length();
  }

  public ServletInputStream getInputStream() throws IOException {

    try {
      return new ByteArrayServletInputStream(message.getBytes("UTF-8"));
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e);
    }
  }
}

Besides streaming the SOAP message to the servlet, the request wrapper must also ensure that servlet receives the appropriate headers.

The response wrapper simply accumulates the response generated by the SOAP servlet, and returns only the body to the client:

package member.server;

import java.io.*;

import javax.servlet.*;
import javax.servlet.http.*;

/**
 * Extracts the body of SOAP response for returning as bare XML.
 *
 * TO DO: buffer response stream to handle larger responses
 **/
public class RestResponseWrapper extends HttpServletResponseWrapper {

  private static final String START_TAG = "<soapenv:Body>";
  private static final String END_TAG = "</soapenv:Body>"; 

  private StringWriter buffer = new StringWriter();
  private PrintWriter charOut = new PrintWriter(buffer);

  private ByteArrayServletOutputStream byteOut = 
    new ByteArrayServletOutputStream();

  private boolean isCommitted = false;

  public RestResponseWrapper(HttpServletResponse response, String operation) {
    super(response);
  }

  public PrintWriter getWriter() throws IOException {
    return charOut;
  }

  public ServletOutputStream getOutputStream() throws IOException {
    return byteOut;
  }

  public void flushBuffer() {
    // Prevent flushing by servlet
  }

  public boolean isCommited() {
    return isCommitted;
  }

  void commit() throws IOException {

    String message = byteOut.toString("UTF-8");
    int bodyStart = message.indexOf(START_TAG);
    int bodyEnd = message.indexOf(END_TAG);

    if (bodyStart != -1 && bodyEnd != -1) {
      message = message.substring(bodyStart + START_TAG.length(), bodyEnd);
    }

    setCharacterEncoding("UTF-8");
    setContentLength(message.length());
    setContentType("text/xml; charset=UTF-8");

    getResponse().getOutputStream().print(message);
    getResponse().flushBuffer();

    isCommitted = true;
  }
}

To test my filter, I added it to the web.xml for the web service I developed in my last post:

<web-app version="2.4" xmlns="http://java.sun.com/xml/ns/j2ee">
  <listener>
    <listener-class>com.sun.xml.ws.transport.http.servlet.WSServletContextListener</listener-class>
  </listener>
   <filter>
     <filter-name>RestFilter</filter-name>
     <filter-class>member.server.RestFilter</filter-class>
     <init-param>
      <param-name>namespace</param-name>
      <param-value>http://xocoatl.blogspot.com/schema/1/</param-value>
    </init-param>
   </filter>
   <filter-mapping>
    <filter-name>RestFilter</filter-name>
    <url-pattern>/*</url-pattern>
   </filter-mapping>  
   <servlet>
    <servlet-name>member</servlet-name>
    <servlet-class>com.sun.xml.ws.transport.http.servlet.WSServlet</servlet-class>
    <load-on-startup>1</load-on-startup>
  </servlet>
  <servlet-mapping>
    <servlet-name>member</servlet-name>
    <url-pattern>/member</url-pattern>
  </servlet-mapping>
  <session-config>
    <session-timeout>60</session-timeout>
  </session-config>
</web-app>

I compiled the filter along with the simple web service, deployed it to Resin, and was then able to talk to my service using either REST or SOAP.