1#! /usr/bin/env python
2# $Header$
3#
4# Copyright (c) 2001 Zolera Systems.  All rights reserved.
5
6from ZSI import _copyright, _seqtypes, ParsedSoap, SoapWriter, TC, ZSI_SCHEMA_URI,\
7    EvaluateException, FaultFromFaultMessage, _child_elements, _attrs, _find_arraytype,\
8    _find_type, _get_idstr, _get_postvalue_from_absoluteURI, FaultException, WSActionException,\
9    UNICODE_ENCODING
10from ZSI.auth import AUTH
11from ZSI.TC import AnyElement, AnyType, String, TypeCode, _get_global_element_declaration,\
12    _get_type_definition
13from ZSI.TCcompound import Struct
14import base64, httplib, Cookie, types, time, urlparse
15from ZSI.address import Address
16from ZSI.wstools.logging import getLogger as _GetLogger
17_b64_encode = base64.encodestring
18
19class _AuthHeader:
20    """<BasicAuth xmlns="ZSI_SCHEMA_URI">
21           <Name>%s</Name><Password>%s</Password>
22       </BasicAuth>
23    """
24    def __init__(self, name=None, password=None):
25        self.Name = name
26        self.Password = password
27_AuthHeader.typecode = Struct(_AuthHeader, ofwhat=(String((ZSI_SCHEMA_URI,'Name'), typed=False),
28        String((ZSI_SCHEMA_URI,'Password'), typed=False)), pname=(ZSI_SCHEMA_URI,'BasicAuth'),
29        typed=False)
30
31
32class _Caller:
33    '''Internal class used to give the user a callable object
34    that calls back to the Binding object to make an RPC call.
35    '''
36
37    def __init__(self, binding, name, namespace=None):
38        self.binding = binding
39        self.name = name
40        self.namespace = namespace
41
42    def __call__(self, *args):
43        nsuri = self.namespace
44        if nsuri is None:
45            return self.binding.RPC(None, self.name, args,
46                            encodingStyle="http://schemas.xmlsoap.org/soap/encoding/",
47                            replytype=TC.Any(self.name+"Response"))
48
49        return self.binding.RPC(None, (nsuri,self.name), args,
50                   encodingStyle="http://schemas.xmlsoap.org/soap/encoding/",
51                   replytype=TC.Any((nsuri,self.name+"Response")))
52
53
54class _NamedParamCaller:
55    '''Similar to _Caller, expect that there are named parameters
56    not positional.
57    '''
58
59    def __init__(self, binding, name, namespace=None):
60        self.binding = binding
61        self.name = name
62        self.namespace = namespace
63
64    def __call__(self, **params):
65        # Pull out arguments that Send() uses
66        kw = {}
67        for key in [ 'auth_header', 'nsdict', 'requesttypecode', 'soapaction' ]:
68            if params.has_key(key):
69                kw[key] = params[key]
70                del params[key]
71
72        nsuri = self.namespace
73        if nsuri is None:
74            return self.binding.RPC(None, self.name, None,
75                        encodingStyle="http://schemas.xmlsoap.org/soap/encoding/",
76                        _args=params,
77                        replytype=TC.Any(self.name+"Response", aslist=False),
78                        **kw)
79
80        return self.binding.RPC(None, (nsuri,self.name), None,
81                   encodingStyle="http://schemas.xmlsoap.org/soap/encoding/",
82                   _args=params,
83                   replytype=TC.Any((nsuri,self.name+"Response"), aslist=False),
84                   **kw)
85
86
87class _Binding:
88    '''Object that represents a binding (connection) to a SOAP server.
89    Once the binding is created, various ways of sending and
90    receiving SOAP messages are available.
91    '''
92    defaultHttpTransport = httplib.HTTPConnection
93    defaultHttpsTransport = httplib.HTTPSConnection
94    logger = _GetLogger('ZSI.client.Binding')
95
96    def __init__(self, nsdict=None, transport=None, url=None, tracefile=None,
97                 readerclass=None, writerclass=None, soapaction='',
98                 wsAddressURI=None, sig_handler=None, transdict=None, **kw):
99        '''Initialize.
100        Keyword arguments include:
101            transport -- default use HTTPConnection.
102            transdict -- dict of values to pass to transport.
103            url -- URL of resource, POST is path
104            soapaction -- value of SOAPAction header
105            auth -- (type, name, password) triplet; default is unauth
106            nsdict -- namespace entries to add
107            tracefile -- file to dump packet traces
108            cert_file, key_file -- SSL data (q.v.)
109            readerclass -- DOM reader class
110            writerclass -- DOM writer class, implements MessageInterface
111            wsAddressURI -- namespaceURI of WS-Address to use.  By default
112            it's not used.
113            sig_handler -- XML Signature handler, must sign and verify.
114            endPointReference -- optional Endpoint Reference.
115        '''
116        self.data = None
117        self.ps = None
118        self.user_headers = []
119        self.nsdict = nsdict or {}
120        self.transport = transport
121        self.transdict = transdict or {}
122        self.url = url
123        self.trace = tracefile
124        self.readerclass = readerclass
125        self.writerclass = writerclass
126        self.soapaction = soapaction
127        self.wsAddressURI = wsAddressURI
128        self.sig_handler = sig_handler
129        self.address = None
130        self.endPointReference = kw.get('endPointReference', None)
131        self.cookies = Cookie.SimpleCookie()
132        self.http_callbacks = {}
133
134        if kw.has_key('auth'):
135            self.SetAuth(*kw['auth'])
136        else:
137            self.SetAuth(AUTH.none)
138
139    def SetAuth(self, style, user=None, password=None):
140        '''Change auth style, return object to user.
141        '''
142        self.auth_style, self.auth_user, self.auth_pass = \
143            style, user, password
144        return self
145
146    def SetURL(self, url):
147        '''Set the URL we post to.
148        '''
149        self.url = url
150        return self
151
152    def ResetHeaders(self):
153        '''Empty the list of additional headers.
154        '''
155        self.user_headers = []
156        return self
157
158    def ResetCookies(self):
159        '''Empty the list of cookies.
160        '''
161        self.cookies = Cookie.SimpleCookie()
162
163    def AddHeader(self, header, value):
164        '''Add a header to send.
165        '''
166        self.user_headers.append((header, value))
167        return self
168
169    def __addcookies(self):
170        '''Add cookies from self.cookies to request in self.h
171        '''
172        for cname, morsel in self.cookies.items():
173            attrs = []
174            value = morsel.get('version', '')
175            if value != '' and value != '0':
176                attrs.append('$Version=%s' % value)
177            attrs.append('%s=%s' % (cname, morsel.coded_value))
178            value = morsel.get('path')
179            if value:
180                attrs.append('$Path=%s' % value)
181            value = morsel.get('domain')
182            if value:
183                attrs.append('$Domain=%s' % value)
184            self.h.putheader('Cookie', "; ".join(attrs))
185
186    def RPC(self, url, opname, obj, replytype=None, **kw):
187        '''Send a request, return the reply.  See Send() and Recieve()
188        docstrings for details.
189        '''
190        self.Send(url, opname, obj, **kw)
191        return self.Receive(replytype, **kw)
192
193    def Send(self, url, opname, obj, nsdict={}, soapaction=None, wsaction=None,
194             endPointReference=None, soapheaders=(), **kw):
195        '''Send a message.  If url is None, use the value from the
196        constructor (else error). obj is the object (data) to send.
197        Data may be described with a requesttypecode keyword, the default
198        is the class's typecode (if there is one), else Any.
199
200        Try to serialize as a Struct, if this is not possible serialize an Array.  If
201        data is a sequence of built-in python data types, it will be serialized as an
202        Array, unless requesttypecode is specified.
203
204        arguments:
205            url --
206            opname -- struct wrapper
207            obj -- python instance
208
209        key word arguments:
210            nsdict --
211            soapaction --
212            wsaction -- WS-Address Action, goes in SOAP Header.
213            endPointReference --  set by calling party, must be an
214                EndPointReference type instance.
215            soapheaders -- list of pyobj, typically w/typecode attribute.
216                serialized in the SOAP:Header.
217            requesttypecode --
218
219        '''
220        url = url or self.url
221        endPointReference = endPointReference or self.endPointReference
222
223        # Serialize the object.
224        d = {}
225        d.update(self.nsdict)
226        d.update(nsdict)
227
228        sw = SoapWriter(nsdict=d, header=True, outputclass=self.writerclass,
229                 encodingStyle=kw.get('encodingStyle'),)
230
231        requesttypecode = kw.get('requesttypecode')
232        if kw.has_key('_args'): #NamedParamBinding
233            tc = requesttypecode or TC.Any(pname=opname, aslist=False)
234            sw.serialize(kw['_args'], tc)
235        elif not requesttypecode:
236            tc = getattr(obj, 'typecode', None) or TC.Any(pname=opname, aslist=False)
237            try:
238                if type(obj) in _seqtypes:
239                    obj = dict(map(lambda i: (i.typecode.pname,i), obj))
240            except AttributeError:
241                # can't do anything but serialize this in a SOAP:Array
242                tc = TC.Any(pname=opname, aslist=True)
243            else:
244                tc = TC.Any(pname=opname, aslist=False)
245
246            sw.serialize(obj, tc)
247        else:
248            sw.serialize(obj, requesttypecode)
249
250        for i in soapheaders:
251           sw.serialize_header(i)
252
253        #
254        # Determine the SOAP auth element.  SOAP:Header element
255        if self.auth_style & AUTH.zsibasic:
256            sw.serialize_header(_AuthHeader(self.auth_user, self.auth_pass),
257                _AuthHeader.typecode)
258
259        #
260        # Serialize WS-Address
261        if self.wsAddressURI is not None:
262            if self.soapaction and wsaction.strip('\'"') != self.soapaction:
263                raise WSActionException, 'soapAction(%s) and WS-Action(%s) must match'\
264                    %(self.soapaction,wsaction)
265
266            self.address = Address(url, self.wsAddressURI)
267            self.address.setRequest(endPointReference, wsaction)
268            self.address.serialize(sw)
269
270        #
271        # WS-Security Signature Handler
272        if self.sig_handler is not None:
273            self.sig_handler.sign(sw)
274
275        scheme,netloc,path,nil,nil,nil = urlparse.urlparse(url)
276        transport = self.transport
277        if transport is None and url is not None:
278            if scheme == 'https':
279                transport = self.defaultHttpsTransport
280            elif scheme == 'http':
281                transport = self.defaultHttpTransport
282            else:
283                raise RuntimeError, 'must specify transport or url startswith https/http'
284
285        # Send the request.
286        if issubclass(transport, httplib.HTTPConnection) is False:
287            raise TypeError, 'transport must be a HTTPConnection'
288
289        soapdata = str(sw)
290        self.h = transport(netloc, None, **self.transdict)
291        self.h.connect()
292        self.SendSOAPData(soapdata, url, soapaction, **kw)
293
294    def SendSOAPData(self, soapdata, url, soapaction, headers={}, **kw):
295        # Tracing?
296        if self.trace:
297            print >>self.trace, "_" * 33, time.ctime(time.time()), "REQUEST:"
298            print >>self.trace, soapdata
299
300        url = url or self.url
301        request_uri = _get_postvalue_from_absoluteURI(url)
302        self.h.putrequest("POST", request_uri)
303        self.h.putheader("Content-Length", "%d" % len(soapdata))
304        self.h.putheader("Content-Type", 'text/xml; charset="%s"' %UNICODE_ENCODING)
305        self.__addcookies()
306
307        for header,value in headers.items():
308            self.h.putheader(header, value)
309
310        SOAPActionValue = '"%s"' % (soapaction or self.soapaction)
311        self.h.putheader("SOAPAction", SOAPActionValue)
312        if self.auth_style & AUTH.httpbasic:
313            val = _b64_encode(self.auth_user + ':' + self.auth_pass) \
314                        .replace("\012", "")
315            self.h.putheader('Authorization', 'Basic ' + val)
316        elif self.auth_style == AUTH.httpdigest and not headers.has_key('Authorization') \
317            and not headers.has_key('Expect'):
318            def digest_auth_cb(response):
319                self.SendSOAPDataHTTPDigestAuth(response, soapdata, url, request_uri, soapaction, **kw)
320                self.http_callbacks[401] = None
321            self.http_callbacks[401] = digest_auth_cb
322
323        for header,value in self.user_headers:
324            self.h.putheader(header, value)
325        self.h.endheaders()
326        self.h.send(soapdata)
327
328        # Clear prior receive state.
329        self.data, self.ps = None, None
330
331    def SendSOAPDataHTTPDigestAuth(self, response, soapdata, url, request_uri, soapaction, **kw):
332        '''Resend the initial request w/http digest authorization headers.
333        The SOAP server has requested authorization.  Fetch the challenge,
334        generate the authdict for building a response.
335        '''
336        if self.trace:
337            print >>self.trace, "------ Digest Auth Header"
338        url = url or self.url
339        if response.status != 401:
340            raise RuntimeError, 'Expecting HTTP 401 response.'
341        if self.auth_style != AUTH.httpdigest:
342            raise RuntimeError,\
343                'Auth style(%d) does not support requested digest authorization.' %self.auth_style
344
345        from ZSI.digest_auth import fetch_challenge,\
346            generate_response,\
347            build_authorization_arg,\
348            dict_fetch
349
350        chaldict = fetch_challenge( response.getheader('www-authenticate') )
351        if dict_fetch(chaldict,'challenge','').lower() == 'digest' and \
352            dict_fetch(chaldict,'nonce',None) and \
353            dict_fetch(chaldict,'realm',None) and \
354            dict_fetch(chaldict,'qop',None):
355            authdict = generate_response(chaldict,
356                request_uri, self.auth_user, self.auth_pass, method='POST')
357            headers = {\
358                'Authorization':build_authorization_arg(authdict),
359                'Expect':'100-continue',
360            }
361            self.SendSOAPData(soapdata, url, soapaction, headers, **kw)
362            return
363
364        raise RuntimeError,\
365            'Client expecting digest authorization challenge.'
366
367    def ReceiveRaw(self, **kw):
368        '''Read a server reply, unconverted to any format and return it.
369        '''
370        if self.data: return self.data
371        trace = self.trace
372        while 1:
373            response = self.h.getresponse()
374            self.reply_code, self.reply_msg, self.reply_headers, self.data = \
375                response.status, response.reason, response.msg, response.read()
376            if trace:
377                print >>trace, "_" * 33, time.ctime(time.time()), "RESPONSE:"
378                for i in (self.reply_code, self.reply_msg,):
379                    print >>trace, str(i)
380                print >>trace, "-------"
381                print >>trace, str(self.reply_headers)
382                print >>trace, self.data
383            saved = None
384            for d in response.msg.getallmatchingheaders('set-cookie'):
385                if d[0] in [ ' ', '\t' ]:
386                    saved += d.strip()
387                else:
388                    if saved: self.cookies.load(saved)
389                    saved = d.strip()
390            if saved: self.cookies.load(saved)
391            if response.status == 401:
392                if not callable(self.http_callbacks.get(response.status,None)):
393                    raise RuntimeError, 'HTTP Digest Authorization Failed'
394                self.http_callbacks[response.status](response)
395                continue
396            if response.status != 100: break
397
398            # The httplib doesn't understand the HTTP continuation header.
399            # Horrible internals hack to patch things up.
400            self.h._HTTPConnection__state = httplib._CS_REQ_SENT
401            self.h._HTTPConnection__response = None
402        return self.data
403
404    def IsSOAP(self):
405        if self.ps: return 1
406        self.ReceiveRaw()
407        mimetype = self.reply_headers.type
408        return mimetype == 'text/xml'
409
410    def ReceiveSOAP(self, readerclass=None, **kw):
411        '''Get back a SOAP message.
412        '''
413        if self.ps: return self.ps
414        if not self.IsSOAP():
415            raise TypeError(
416                'Response is "%s", not "text/xml"' % self.reply_headers.type)
417        if len(self.data) == 0:
418            raise TypeError('Received empty response')
419
420        self.ps = ParsedSoap(self.data,
421                        readerclass=readerclass or self.readerclass,
422                        encodingStyle=kw.get('encodingStyle'))
423
424        if self.sig_handler is not None:
425            self.sig_handler.verify(self.ps)
426
427        return self.ps
428
429    def IsAFault(self):
430        '''Get a SOAP message, see if it has a fault.
431        '''
432        self.ReceiveSOAP()
433        return self.ps.IsAFault()
434
435    def ReceiveFault(self, **kw):
436        '''Parse incoming message as a fault. Raise TypeError if no
437        fault found.
438        '''
439        self.ReceiveSOAP(**kw)
440        if not self.ps.IsAFault():
441            raise TypeError("Expected SOAP Fault not found")
442        return FaultFromFaultMessage(self.ps)
443
444    def Receive(self, replytype, **kw):
445        '''Parse message, create Python object.
446
447        KeyWord data:
448            faults   -- list of WSDL operation.fault typecodes
449            wsaction -- If using WS-Address, must specify Action value we expect to
450                receive.
451        '''
452        self.ReceiveSOAP(**kw)
453        if self.ps.IsAFault():
454            msg = FaultFromFaultMessage(self.ps)
455            raise FaultException(msg)
456
457        tc = replytype
458        if hasattr(replytype, 'typecode'):
459            tc = replytype.typecode
460
461        reply = self.ps.Parse(tc)
462        if self.address is not None:
463            self.address.checkResponse(self.ps, kw.get('wsaction'))
464        return reply
465
466    def __repr__(self):
467        return "<%s instance %s>" % (self.__class__.__name__, _get_idstr(self))
468
469
470class Binding(_Binding):
471    '''Object that represents a binding (connection) to a SOAP server.
472    Can be used in the "name overloading" style.
473
474    class attr:
475        gettypecode -- funcion that returns typecode from typesmodule,
476            can be set so can use whatever mapping you desire.
477    '''
478    gettypecode = staticmethod(lambda mod,e: getattr(mod, str(e.localName)).typecode)
479    logger = _GetLogger('ZSI.client.Binding')
480
481    def __init__(self, url, namespace=None, typesmodule=None, **kw):
482        """
483        Parameters:
484            url -- location of service
485            namespace -- optional root element namespace
486            typesmodule -- optional response only. dict(name=typecode),
487                lookup for all children of root element.
488        """
489        self.typesmodule = typesmodule
490        self.namespace = namespace
491
492        _Binding.__init__(self, url=url, **kw)
493
494    def __getattr__(self, name):
495        '''Return a callable object that will invoke the RPC method
496        named by the attribute.
497        '''
498        if name[:2] == '__' and len(name) > 5 and name[-2:] == '__':
499            if hasattr(self, name): return getattr(self, name)
500            return getattr(self.__class__, name)
501        return _Caller(self, name, self.namespace)
502
503    def __parse_child(self, node):
504        '''for rpc-style map each message part to a class in typesmodule
505        '''
506        try:
507            tc = self.gettypecode(self.typesmodule, node)
508        except:
509            self.logger.debug('didnt find typecode for "%s" in typesmodule: %s',
510                node.localName, self.typesmodule)
511            tc = TC.Any(aslist=1)
512            return tc.parse(node, self.ps)
513
514        self.logger.debug('parse child with typecode : %s', tc)
515        try:
516            return tc.parse(node, self.ps)
517        except Exception:
518            self.logger.debug('parse failed try Any : %s', tc)
519
520        tc = TC.Any(aslist=1)
521        return tc.parse(node, self.ps)
522
523    def Receive(self, replytype, **kw):
524        '''Parse message, create Python object.
525
526        KeyWord data:
527            faults   -- list of WSDL operation.fault typecodes
528            wsaction -- If using WS-Address, must specify Action value we expect to
529                receive.
530        '''
531        self.ReceiveSOAP(**kw)
532        ps = self.ps
533        tp = _find_type(ps.body_root)
534        isarray = ((type(tp) in (tuple,list) and tp[1] == 'Array') or _find_arraytype(ps.body_root))
535        if self.typesmodule is None or isarray:
536            return _Binding.Receive(self, replytype, **kw)
537
538        if ps.IsAFault():
539            msg = FaultFromFaultMessage(ps)
540            raise FaultException(msg)
541
542        tc = replytype
543        if hasattr(replytype, 'typecode'):
544            tc = replytype.typecode
545
546        #Ignore response wrapper
547        reply = {}
548        for elt in _child_elements(ps.body_root):
549            name = str(elt.localName)
550            reply[name] = self.__parse_child(elt)
551
552        if self.address is not None:
553            self.address.checkResponse(ps, kw.get('wsaction'))
554
555        return reply
556
557
558class NamedParamBinding(Binding):
559    '''Like Binding, except the argument list for invocation is
560    named parameters.
561    '''
562    logger = _GetLogger('ZSI.client.Binding')
563
564    def __getattr__(self, name):
565        '''Return a callable object that will invoke the RPC method
566        named by the attribute.
567        '''
568        if name[:2] == '__' and len(name) > 5 and name[-2:] == '__':
569            if hasattr(self, name): return getattr(self, name)
570            return getattr(self.__class__, name)
571        return _NamedParamCaller(self, name, self.namespace)
572
573
574if __name__ == '__main__': print _copyright
575