Building a Web Services Container in Python
January 20, 2004
In the present run of columns, I'm using the web services framework provided by Python and the ZSI SOAP implementation to implement the XKMS registration service. Last month's column ended with a link to a skeleton server, but there was neither space nor time to explain it. This time we'll look at that server in some detail so that we can get an understanding of what features are provided by generic container servers (Apache Axis, J2EE servers, and the like).
More from Rich Salz |
Python provides a simple HTTP server framework in the BaseHTTPServer
class.
Create an instance of the class with TCP/IP binding information, and the name of your
handler class. When a request comes in, the server will create an instance of your
class,
populate it with items from (and a reference to) the containing server object, and
then call
the appropriate method in the handler. You can subclass the server; a common reason
to do
this is to add additional debugging or other state information which the server can
make
available to the handler.
One of the nice features about the M2Crypto
package is that it preserves this
development model, with the SSLServer
class replacing the standard Python
server class. The following fragment lists the import statements we need, and shows
how we
subclass the server class:
import os, sys from BaseHTTPServer import BaseHTTPRequestHandler from ZSI import * from M2Crypto import SSL from M2Crypto.SSL.SSLServer import SSLServer class HTTPS_Server(SSLServer): '''Subclass SSLServer; we add an optional "tracefile" parameter which will record all messages sent and received.''' def __init__(self, ME, handler, ssl_ctx, tracefile=None): SSLServer.__init__(self, ME, handler, ssl_ctx) self.tracefile = tracefile
Creating a server is easy. We first need to create an SSL context, which has the security
(private key) and policy information (which protocol) that OpenSSL needs. In this
case we
want to use SSL 3 with the keypair we created last month. We don't care if the client
uses
an SSL certificate, so we're not going to do any verification or make sure that we
know the
client's CA. These choices translate into the following code to create a context,
where
home
specifies the directory where the keypair files are found:
def init_ssl_context(home, debug=None): ctx = SSL.Context('sslv3') ctx.load_cert(certfile=home + '/openssl/ssl/cert.pem', keyfile=home + '/openssl/ssl/plainkey.pem') ctx.set_verify(SSL.verify_none, 1) ctx.set_allow_unknown_ca(1) ctx.set_session_id_ctx('xkms_srv') if debug: ctx.set_info_callback() return ctx
Later on we'll define the XKMSRequestHandler
class, but if we assume that the
name is already in scope, then it is very simple to start our server. The
os.environ.get()
call is like the Unix shell's
${XKMSHOME-/opt/xkms}
syntax. As with Perl, the environment is put into a
dictionary, and the default value is used if the key XKMSHOME
isn't found. The
('', 443)
construct creates a Python tuple (immutable list) that the
HTTP class interprets as "port 443 on all network interfaces". As is often typical
of
Python, the explanation is more complicated than the code itself:
sslctx = init_ssl_context(os.environ.get('XKMSHOME', '/opt/xkms')) s = HTTPS_Server(('', 443), XKMSRequestHandler, sslctx) s.serve_forever()
It doesn't do anything yet, but we've just implemented an HTTP/SSL server. In order
to do
something, we need to define the class that will handle requests. Our class is a subtype
of
a class provided by Python. The first thing we do is to define an identification string.
The
variable server_version
will be output by the Python classes in the HTTP Server
header. In keeping with common practice, we prefix our own information to the front:
class XKMSRequestHandler(BaseHTTPRequestHandler): server_version = 'ZSI/1.4-CVS XKMS/1.0 ' \ + BaseHTTPRequestHandler.server_version
This will result in HTTP output like this:
Server: ZSI/1.4 XKMS/1.0 BaseHTTP/0.2 Python/2.2.2
Our messages will be fairly small -- never more than a few tens of kilobytes -- so it's practical to buffer our responses in memory and use a Content-Length header. This method will send text identified as XML to the client:
def send_xml(self, text, code=200): self.send_response(code) self.send_header('Content-type', 'text/xml; charset="utf-8"') self.send_header('Content-Length', str(len(text))) self.end_headers() self.wfile.write(text) self.trace(text, 'SENT') self.wfile.flush()
ZSI has a Fault
object to encapsulate SOAP faults. One method serializes the
object into XML, as the following convenience methods shows. Note that we're using
HTTP
status code 500, which implies SOAP 1.1; if we were doing SOAP 1.2, we'd use HTTP
status
code 200.
def send_fault(self, f): self.send_xml(f.AsSOAP(), 500)
The trace
method is an internal debugging utility. It shows that the request
handler has a reference back to the server, self.server
; and it also shows how
we check to see if the server was created with tracing enabled:
def trace(self, text, what): '''Log a debug/trace message.''' F = self.server.tracefile if not F: return tstr = time.ctime(time.time()) print >>F, '=' * 60, '\n', '%s %s %s %s:' % (what, self.client_address, self.path, tstr) print >>F, text print >>F, '=' * 60, '\n' F.flush()
To implement an HTTP method like GET or POST, we have to implement a method like
do_GET
or do_POST
. Since all SOAP messages will come in as HTTP
POST's, we can have a trivial implementation for GET that redirects the client to
a more
useful resource. A common convention for web services is to provide the relevant WSDL
file
in response to a particular GET. We'll do that later, but for now we'll use the "moved
temporarily" HTTP status:
def do_GET(self): self.send_response(301) self.send_header('Content-type', 'text/html'); self.send_header('Location', 'http://webservices.xml.com'); self.end_headers() self.trace('', 'GET')
Now let's look at the implementation of HTTP POST. We get the Content-Length header
and
then read the message body. If something goes wrong, ZSI will raise a
ParseException
, which it can then convert into a SOAP fault. Python
exceptions can also be converted, and if we provide the backtrace information
(sys.exc_info
), then ZSI will add that to the fault detail.
def do_POST(self): try: cl = int(self.headers['content-length']) IN = self.rfile.read(cl) self.trace(IN, 'RECEIVED') ps = ParsedSoap(IN) except ParseException, e: self.send_fault(FaultFromZSIException(e)) return except Exception, e: self.send_fault(FaultFromException(e, 0, sys.exc_info()[2])) return if not check_xkms_method(ps): return if not check_xkms_headers(ps): return process_xkms(ps)
For example, posting garbage to the server will result in a reply like the following (SOAP envelope and namespace declarations omitted):
<SOAP-ENV:Fault> <faultcode>SOAP-ENV:Client</faultcode> <faultstring>Unparseable message</faultstring> <detail> <ZSI:ParseFaultDetail> <ZSI:string>Can't parse document (xml.parsers.expat.ExpatError): no element found: line 3, column 14</ZSI:string> <ZSI:trace>/ZSI:trace> </ZSI:ParseFaultDetail> </detail> </SOAP-ENV:Fault>
At this point we have a fairly robust, SSL-enabled web services container. We could make it more generic by dispatching based on the URL or the namespace or element name of the data in the SOAP Body. Since Python can dynamically import new modules, we could have a configuration that maintains the dispatch mapping and load new code on the fly as needed. ZSI includes a "server dispatch" model that shows how to do some of that.
For our purposes, we'll just inline the XKMS implementation. Our first step is to see if the message is one we can implement.
def check_xkms_method(self, ps): root = ps.body_root if root.namespaceURI != 'http://www.w3.org/2002/03/xkms#': self.send_fault( Fault(Fault.Client, 'Unknown namespace "%s"' % root.namespaceURI)) return None if root.localName not in ['Register','Revoke']: self.send_fault( Fault(Fault.Client, 'Unknown operation "%s"' % root.localName)) return None return 1
The next step is to look at the SOAP headers. We don't implement any headers, so make
sure
that none of the headers sent by the client are marked mustUnderstand
. If we
find any, we return a fault and stop processing.
def check_xkms_headers(self, ps): for (uri, localname) in ps.WhatMustIUnderstand(): self.send_fault(FaultFromNotUnderstood(uri, lname, 'XKMS')) return None return 1
Next month we'll start to tear into the XML request message and do some real application-level work. Until then, you can download the server source and use it to start implementing your own web services.