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.

Sunday, September 17, 2006

JAX-WS 2.0

This is a walkthrough of my first experience with the Java API for XML Web Services 2.0 (JAX-WS 2.0).

My objective was to create a web service with a single method that returns the groups to which a member belongs. JAX-WS gives you two basic ways to build a web service:

  1. Write a WSDL and use JAX-WS to generate a Java interface for you to implement.
  2. Write a set of Java classes and use annotations to control how JAX-WS maps your code to a web service.

I opted for the second approach. It was appealing to think that I could just write some code and turn it into a web service simply by adding some annotation.

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

<?xml version="1.0" ?>
  <soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" 
                    xmlns:xsd="http://www.w3.org/2001/XMLSchema" 
                    xmlns:ns1="http://xocoatl.blogspot.com/schema/1/">
    <soapenv:Body>
      <ns1:MemberGroups>
        <MemberID>2</MemberID>
      </ns1:MemberGroups>
    </soapenv:Body>
</soapenv:Envelope>

I expected my web service to send responses that look like this:

<?xml version="1.0" ?>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" 
                  xmlns:xsd="http://www.w3.org/2001/XMLSchema" 
                  xmlns:ns1="http://xocoatl.blogspot.com/schema/1/">
  <soapenv:Body>
    <ns1:MemberGroupsResponse>
      <MemberGroup>
        <ID>1</ID>
        <name>Orcs</name>
      </MemberGroup>
      <MemberGroup>
        <ID>2</ID>
        <name>Elves</name>
      </MemberGroup>
    </ns1:MemberGroupsResponse>
  </soapenv:Body>
</soapenv:Envelope>

To complicate matters slightly, I wanted to run the web service in Resin, rather than Tomcat or the Sun Java Application Server.

My development environment includes:

  1. A Windows XP laptop
  2. cygwin and Xemacs
  3. JDK 1.5
  4. ant

I do not use an IDE, so I needed to be able to code my web service in emacs and deploy it from the command line.

Getting Started

I downloaded the JAX-WS 2.0 reference implementation and expanded it.

The samples directory contains a number of good examples. I was particularly interested the fromjava-* examples, including supplychain.

The documentation does not explain how to run the fromjava samples from the command line. Here is what I figured out:

  1. Set JAXWS_HOME to the JAX-WS installation directory.
  2. Go to the samples/fromjava directory.
  3. Build and run the web service using the server-j2se task:
    $ ant server-j2se
    Buildfile: build.xml
    Trying to override old definition of task apt
    
    setup:
    
    server-j2se:
    
    clean:
       [delete] Deleting directory C:\Docs\jaxws-ri\samples\fromjava\build
    
    setup:
        [mkdir] Created dir: C:\Docs\jaxws-ri\samples\fromjava\build
        [mkdir] Created dir: C:\Docs\jaxws-ri\samples\fromjava\build\classes
        [mkdir] Created dir: C:\Docs\jaxws-ri\samples\fromjava\build\war
    
    build-server-java:
          [apt] warning: Annotation types without processors: [javax.xml.bind.annotation.XmlRootElement, javax.xml.bind.annotation.XmlAccessorType, javax.xml.bind.annotation.XmlType, javax.xml.bind.annotation.XmlElement]
          [apt] 1 warning
         [echo] Starting endpoint... To stop: ant server-j2se-stop 
    
    BUILD SUCCESSFUL
    Total time: 6 seconds
    $ 

    The service runs fine despite the annotation warning, but it exits immediately. To work around this I simply put the main thread to sleep for a few minutes after starting the server:

    public class AddWebservice {
        
        public static void main (String[] args) throws Exception {
            Endpoint endpoint = Endpoint.publish (
                "http://localhost:8080/jaxws-fromjava/addnumbers",
                new AddNumbersImpl ());
    
            // Stops the endpoint if it receives request http://localhost:9090/stop
            new EndpointStopper(9090, endpoint);
    
     Thread.sleep(60000); // Addded to keep the JVM from exiting
        }
    }
  4. Start another shell to run the client.
  5. First test the server by requesting the WSDL:
    $ wget http://localhost:8080/jaxws-fromjava/addnumbers?wsdl
  6. Now build and run the client:
    $ ant client run

    The client is built by requesting the WSDL from the server and then generating a corresponding set of JavaBean classes for working with requests and responses.

Once the sample seemed to be running, I used the tcpmon tool from the Apache Axis project to intercept and read the actual SOAP messages between server and client:

  1. Download and run the tcpmon:
    $ java -cp axis.jar org.apache.axis.utils.tcpmon 8081 localhost 8080
  2. Go to the samples/fromjava/etc directory and change the port from 8080 to 8081 in build.properties, custom-client.xml and custom-schema.xml.
  3. Make sure the server is still running and then run the client again.

Implementing the Service

The supplychain sample seemed closest to what I wanted, so I copied that project as the basis for my experiment.

I was not able to get the SOAP messages to come out the way I wanted by modifying the code and annotations from the supplychain sample.

Finally I created a new project and imported the WSDL for the Amazon Simple Queue Service, since it has messages with the desired structure.

By studying the code generated from the Amazon SQS WSDL I was ultimately able to get what I wanted with only a few lines of code:

package member.server;

import java.math.BigInteger;

import java.util.ArrayList;
import java.util.List;

import javax.jws.WebMethod;
import javax.jws.WebParam;
import javax.jws.WebParam.Mode;
import javax.jws.WebService;
import javax.xml.ws.Holder;
import javax.xml.ws.RequestWrapper;
import javax.xml.ws.ResponseWrapper;

@WebService(name="MemberService", 
     targetNamespace = "http://xocoatl.blogspot.com/schema/1/")
public class MemberService {

  private static final long INVALID_ID = 1L;

  @WebMethod(operationName = "MemberGroups", action = "MemberGroups")
  public void getMembers(@WebParam(name = "MemberID")
    BigInteger memberID,
    @WebParam(name = "MemberGroup",
       mode = Mode.OUT)
    Holder<List<MemberGroup>> memberGroups) 
    throws InvalidMemberIDException {

    if (memberID.equals(BigInteger.valueOf(INVALID_ID))) {
      throw new InvalidMemberIDException("Invalid member ID");
    }

    memberGroups.value = new ArrayList();
    
    memberGroups.value.add(new MemberGroup(1, "Steelers"));
    memberGroups.value.add(new MemberGroup(1, "49ers"));
  }
}

MemberGroup is a simple unadorned JavaBean:

package member.server;

public class MemberGroup {
  
  private String name;
  private Integer id;

  public MemberGroup() {}

  public MemberGroup(Integer id, String name) {
    setID(id);
    setName(name);
  }
    
  public String getName() { 
    return name; 
  }

  public void setName(String name) { 
    this.name = name; 
  }
  
  public Integer getID() { 
    return id; 
  }

  public void setID(Integer id) { 
    this.id = id; 
  }
    
}

Note that the service method returns void and has an OUT parameter.

Deploying to Resin

For the final step, I generated the war file containing the service implementation:

$ ant clean build-server-java create-war

I copied the resulting war file to a fresh Resin instance with an empty webapps directory, and modified the server configuration to run my service as the root application:

<host id="" root-directory=".">
  <web-app id="/" document-directory="webapps/jaxws-member"/>
</host>

After a series of NoClassDefFoundError exceptions, I determined that the following jar files must be copied to the server lib directory:

  • jaxws-api.jar
  • jaxws-rt.jar
  • jsr173-api.jar
  • jsr181-api.jar
  • jsr250-api.jar
  • resolver.jar
  • saaj-api.jar
  • saaj-impl.jar
  • jaxb-api.jar
  • jaxb-impl.jar

Once that was sorted out, the web service ran fine.