Building a Web Services Container in Python
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: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.
XML.com Copyright © 1998-2006 O'Reilly Media, Inc.