1# Copyright 2008-2018 Canonical Ltd.  All rights reserved.
2
3# This file is part of wadllib.
4#
5# wadllib is free software: you can redistribute it and/or modify it under the
6# terms of the GNU Lesser General Public License as published by the Free
7# Software Foundation, version 3 of the License.
8#
9# wadllib is distributed in the hope that it will be useful, but WITHOUT ANY
10# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
12# details.
13#
14# You should have received a copy of the GNU Lesser General Public License
15# along with wadllib. If not, see <http://www.gnu.org/licenses/>.
16
17"""Navigate the resources exposed by a web service.
18
19The wadllib library helps a web client navigate the resources
20exposed by a web service. The service defines its resources in a
21single WADL file. wadllib parses this file and gives access to the
22resources defined inside. The client code can see the capabilities of
23a given resource and make the corresponding HTTP requests.
24
25If a request returns a representation of the resource, the client can
26bind the string representation to the wadllib Resource object.
27"""
28
29__metaclass__ = type
30
31__all__ = [
32    'Application',
33    'Link',
34    'Method',
35    'NoBoundRepresentationError',
36    'Parameter',
37    'RepresentationDefinition',
38    'ResponseDefinition',
39    'Resource',
40    'ResourceType',
41    'WADLError',
42    ]
43
44import datetime
45from email.utils import quote
46import io
47import json
48import random
49import re
50import sys
51import time
52try:
53    from urllib.parse import urlencode
54except ImportError:
55    from urllib import urlencode
56try:
57    import xml.etree.cElementTree as ET
58except ImportError:
59    import xml.etree.ElementTree as ET
60
61from lazr.uri import URI, merge
62
63from wadllib import (
64    _make_unicode,
65    _string_types,
66    )
67from wadllib.iso_strptime import iso_strptime
68
69NS_MAP = "xmlns:map"
70XML_SCHEMA_NS_URI = 'http://www.w3.org/2001/XMLSchema'
71
72def wadl_tag(tag_name):
73    """Scope a tag name with the WADL namespace."""
74    return '{http://research.sun.com/wadl/2006/10}' + tag_name
75
76
77def wadl_xpath(tag_name):
78    """Turn a tag name into an XPath path."""
79    return './' + wadl_tag(tag_name)
80
81
82def _merge_dicts(*dicts):
83    """Merge any number of dictionaries, some of which may be None."""
84    final = {}
85    for dict in dicts:
86        if dict is not None:
87            final.update(dict)
88    return final
89
90
91class WADLError(Exception):
92    """An exception having to do with the state of the WADL application."""
93    pass
94
95
96class NoBoundRepresentationError(WADLError):
97    """An unbound resource was used where wadllib expected a bound resource.
98
99    To obtain the value of a resource's parameter, you first must bind
100    the resource to a representation. Otherwise the resource has no
101    idea what the value is and doesn't even know if you've given it a
102    parameter name that makes sense.
103    """
104
105
106class UnsupportedMediaTypeError(WADLError):
107    """A media type was given that's not supported in this context.
108
109    A resource can only be bound to media types it has representations
110    of.
111    """
112
113
114class WADLBase(object):
115    """A base class for objects that contain WADL-derived information."""
116
117
118class HasParametersMixin:
119    """A mixin class for objects that have associated Parameter objects."""
120
121    def params(self, styles, resource=None):
122        """Find subsidiary parameters that have the given styles."""
123        if resource is None:
124            resource = self.resource
125        if resource is None:
126            raise ValueError("Could not find any particular resource")
127        if self.tag is None:
128            return []
129        param_tags = self.tag.findall(wadl_xpath('param'))
130        if param_tags is None:
131            return []
132        return [Parameter(resource, param_tag)
133                for param_tag in param_tags
134                if param_tag.attrib.get('style') in styles]
135
136    def validate_param_values(self, params, param_values,
137                              enforce_completeness=True, **kw_param_values):
138        """Make sure the given valueset is valid.
139
140        A valueset might be invalid because it contradicts a fixed
141        value or (if enforce_completeness is True) because it lacks a
142        required value.
143
144        :param params: A list of Parameter objects.
145        :param param_values: A dictionary of parameter values. May include
146           paramters whose names are not valid Python identifiers.
147        :param enforce_completeness: If True, this method will raise
148           an exception when the given value set lacks a value for a
149           required parameter.
150        :param kw_param_values: A dictionary of parameter values.
151        :return: A dictionary of validated parameter values.
152        """
153        param_values = _merge_dicts(param_values, kw_param_values)
154        validated_values = {}
155        for param in params:
156            name = param.name
157            if param.fixed_value is not None:
158                if (name in param_values
159                    and param_values[name] != param.fixed_value):
160                    raise ValueError(("Value '%s' for parameter '%s' "
161                                      "conflicts with fixed value '%s'")
162                                     % (param_values[name], name,
163                                        param.fixed_value))
164                param_values[name] = param.fixed_value
165            options = [option.value for option in param.options]
166            if (len(options) > 0 and name in param_values
167                and param_values[name] not in options):
168                raise ValueError(("Invalid value '%s' for parameter '%s': "
169                                  'valid values are: "%s"') % (
170                        param_values[name], name, '", "'.join(options)))
171            if (enforce_completeness and param.is_required
172                and not name in param_values):
173                raise ValueError("No value for required parameter '%s'"
174                                 % name)
175            if name in param_values:
176                validated_values[name] = param_values[name]
177                del param_values[name]
178        if len(param_values) > 0:
179            raise ValueError("Unrecognized parameter(s): '%s'"
180                             % "', '".join(param_values.keys()))
181        return validated_values
182
183
184class WADLResolvableDefinition(WADLBase):
185    """A base class for objects whose definitions may be references."""
186
187    def __init__(self, application):
188        """Initialize with a WADL application.
189
190        :param application: A WADLDefinition. Relative links are
191            assumed to be relative to this object's URL.
192        """
193        self._definition = None
194        self.application = application
195
196    def resolve_definition(self):
197        """Return the definition of this object, wherever it is.
198
199        Resource is a good example. A WADL <resource> tag
200        may contain a large number of nested tags describing a
201        resource, or it may just contain a 'type' attribute that
202        references a <resource_type> which contains those same
203        tags. Resource.resolve_definition() will return the original
204        Resource object in the first case, and a
205        ResourceType object in the second case.
206        """
207        if self._definition is not None:
208            return self._definition
209        object_url = self._get_definition_url()
210        if object_url is None:
211            # The object contains its own definition.
212            # XXX leonardr 2008-05-28:
213            # This code path is not tested in Launchpad.
214            self._definition = self
215            return self
216        # The object makes reference to some other object. Resolve
217        # its URL and return it.
218        xml_id = self.application.lookup_xml_id(object_url)
219        definition = self._definition_factory(xml_id)
220        if definition is None:
221            # XXX leonardr 2008-06-
222            # This code path is not tested in Launchpad.
223            # It requires an invalid WADL file that makes
224            # a reference to a nonexistent tag within the
225            # same WADL file.
226            raise KeyError('No such XML ID: "%s"' % object_url)
227        self._definition = definition
228        return definition
229
230    def _definition_factory(self, id):
231        """Transform an XML ID into a wadllib wrapper object.
232
233        Which kind of object it is depends on the subclass.
234        """
235        raise NotImplementedError()
236
237    def _get_definition_url(self):
238        """Find the URL that identifies an external reference.
239
240        How to do this depends on the subclass.
241        """
242        raise NotImplementedError()
243
244
245class Resource(WADLResolvableDefinition):
246    """A resource, possibly bound to a representation."""
247
248    def __init__(self, application, url, resource_type,
249                 representation=None, media_type=None,
250                 representation_needs_processing=True,
251                 representation_definition=None):
252        """
253        :param application: A WADLApplication.
254        :param url: The URL to this resource.
255        :param resource_type: An ElementTree <resource> or <resource_type> tag.
256        :param representation: A string representation.
257        :param media_type: The media type of the representation.
258        :param representation_needs_processing: Set to False if the
259            'representation' parameter should be used as
260            is. Otherwise, it will be transformed from a string into
261            an appropriate Python data structure, depending on its
262            media type.
263        :param representation_definition: A RepresentationDefinition
264            object describing the structure of this
265            representation. Used in cases when the representation
266            isn't the result of sending a standard GET to the
267            resource.
268        """
269        super(Resource, self).__init__(application)
270        self._url = url
271        if isinstance(resource_type, _string_types):
272            # We were passed the URL to a resource type. Look up the
273            # type object itself
274            self.tag = self.application.get_resource_type(resource_type).tag
275        else:
276            # We were passed an XML tag that describes a resource or
277            # resource type.
278            self.tag = resource_type
279
280        self.representation = None
281        if representation is not None:
282            if media_type == 'application/json':
283                if representation_needs_processing:
284                    self.representation = json.loads(
285                        _make_unicode(representation))
286                else:
287                    self.representation = representation
288            else:
289                raise UnsupportedMediaTypeError(
290                    "This resource doesn't define a representation for "
291                    "media type %s" % media_type)
292        self.media_type = media_type
293        if representation is not None:
294            if representation_definition is not None:
295                self.representation_definition = representation_definition
296            else:
297                self.representation_definition = (
298                    self.get_representation_definition(self.media_type))
299
300    @property
301    def url(self):
302        """Return the URL to this resource."""
303        return self._url
304
305    @property
306    def type_url(self):
307        """Return the URL to the type definition for this resource, if any."""
308        if self.tag is None:
309            return None
310        url = self.tag.attrib.get('type')
311        if url is not None:
312            # This resource is defined in the WADL file.
313            return url
314        type_id = self.tag.attrib.get('id')
315        if type_id is not None:
316            # This resource was obtained by following a link.
317            base = URI(self.application.markup_url).ensureSlash()
318            return str(base) + '#' + type_id
319
320        # This resource does not have any associated resource type.
321        return None
322
323    @property
324    def id(self):
325        """Return the ID of this resource."""
326        return self.tag.attrib['id']
327
328    def bind(self, representation, media_type='application/json',
329             representation_needs_processing=True,
330             representation_definition=None):
331        """Bind the resource to a representation of that resource.
332
333        :param representation: A string representation
334        :param media_type: The media type of the representation.
335        :param representation_needs_processing: Set to False if the
336            'representation' parameter should be used as
337            is.
338        :param representation_definition: A RepresentationDefinition
339            object describing the structure of this
340            representation. Used in cases when the representation
341            isn't the result of sending a standard GET to the
342            resource.
343        :return: A Resource bound to a particular representation.
344        """
345        return Resource(self.application, self.url, self.tag,
346                        representation, media_type,
347                        representation_needs_processing,
348                        representation_definition)
349
350    def get_representation_definition(self, media_type):
351        """Get a description of one of this resource's representations."""
352        default_get_response = self.get_method('GET').response
353        for representation in default_get_response:
354            representation_tag = representation.resolve_definition().tag
355            if representation_tag.attrib.get('mediaType') == media_type:
356                return representation
357        raise UnsupportedMediaTypeError("No definition for representation "
358                                        "with media type %s." % media_type)
359
360    def get_method(self, http_method=None, media_type=None, query_params=None,
361                   representation_params=None):
362        """Look up one of this resource's methods by HTTP method.
363
364        :param http_method: The HTTP method used to invoke the desired
365                            method. Case-insensitive and optional.
366
367        :param media_type: The media type of the representation
368                           accepted by the method. Optional.
369
370        :param query_params: The names and values of any fixed query
371                             parameters used to distinguish between
372                             two methods that use the same HTTP
373                             method. Optional.
374
375        :param representation_params: The names and values of any
376                             fixed representation parameters used to
377                             distinguish between two methods that use
378                             the same HTTP method and have the same
379                             media type. Optional.
380
381        :return: A MethodDefinition, or None if there's no definition
382                  that fits the given constraints.
383        """
384        for method_tag in self._method_tag_iter():
385            name = method_tag.attrib.get('name', '').lower()
386            if http_method is None or name == http_method.lower():
387                method = Method(self, method_tag)
388                if method.is_described_by(media_type, query_params,
389                                          representation_params):
390                    return method
391        return None
392
393    def parameters(self, media_type=None):
394        """A list of this resource's parameters.
395
396        :param media_type: Media type of the representation definition
397            whose parameters are being named. Must be present unless
398            this resource is bound to a representation.
399
400        :raise NoBoundRepresentationError: If this resource is not
401            bound to a representation and media_type was not provided.
402        """
403        return self._find_representation_definition(
404            media_type).params(self)
405
406    def parameter_names(self, media_type=None):
407        """A list naming this resource's parameters.
408
409        :param media_type: Media type of the representation definition
410            whose parameters are being named. Must be present unless
411            this resource is bound to a representation.
412
413        :raise NoBoundRepresentationError: If this resource is not
414            bound to a representation and media_type was not provided.
415        """
416        return self._find_representation_definition(
417            media_type).parameter_names(self)
418
419    @property
420    def method_iter(self):
421        """An iterator over the methods defined on this resource."""
422        for method_tag in self._method_tag_iter():
423            yield Method(self, method_tag)
424
425    def get_parameter(self, param_name, media_type=None):
426        """Find a parameter within a representation definition.
427
428        :param param_name: Name of the parameter to find.
429
430        :param media_type: Media type of the representation definition
431            whose parameters are being named. Must be present unless
432            this resource is bound to a representation.
433
434        :raise NoBoundRepresentationError: If this resource is not
435            bound to a representation and media_type was not provided.
436        """
437        definition = self._find_representation_definition(media_type)
438        representation_tag = definition.tag
439        for param_tag in representation_tag.findall(wadl_xpath('param')):
440            if param_tag.attrib.get('name') == param_name:
441                return Parameter(self, param_tag)
442        return None
443
444    def get_parameter_value(self, parameter):
445        """Find the value of a parameter, given the Parameter object.
446
447        :raise ValueError: If the parameter value can't be converted into
448        its defined type.
449        """
450
451        if self.representation is None:
452            raise NoBoundRepresentationError(
453                "Resource is not bound to any representation.")
454        if self.media_type == 'application/json':
455            # XXX leonardr 2008-05-28 A real JSONPath implementation
456            # should go here. It should execute tag.attrib['path']
457            # against the JSON representation.
458            #
459            # Right now the implementation assumes the JSON
460            # representation is a hash and treats tag.attrib['name'] as a
461            # key into the hash.
462            if parameter.style != 'plain':
463                raise NotImplementedError(
464                    "Don't know how to find value for a parameter of "
465                    "type %s." % parameter.style)
466            value = self.representation[parameter.name]
467            if value is not None:
468                namespace_url, data_type = self._dereference_namespace(
469                    parameter.tag, parameter.type)
470                if (namespace_url == XML_SCHEMA_NS_URI
471                    and data_type in ['dateTime', 'date']):
472                    try:
473                        # Parse it as an ISO 8601 date and time.
474                        value = iso_strptime(value)
475                    except ValueError:
476                        # Parse it as an ISO 8601 date.
477                        try:
478                            value = datetime.datetime(
479                                *(time.strptime(value, "%Y-%m-%d")[0:6]))
480                        except ValueError:
481                            # Raise an unadorned ValueError so the client
482                            # can treat the value as a string if they
483                            # want.
484                            raise ValueError(value)
485            return value
486
487        raise NotImplementedError("Path traversal not implemented for "
488                                  "a representation of media type %s."
489                                  % self.media_type)
490
491
492    def _dereference_namespace(self, tag, value):
493        """Splits a value into namespace URI and value.
494
495        :param tag: A tag to use as context when mapping namespace
496        names to URIs.
497        """
498        if value is not None and ':' in value:
499            namespace, value = value.split(':', 1)
500        else:
501            namespace = ''
502        ns_map = tag.get(NS_MAP)
503        namespace_url = ns_map.get(namespace, None)
504        return namespace_url, value
505
506    def _definition_factory(self, id):
507        """Given an ID, find a ResourceType for that ID."""
508        return self.application.resource_types.get(id)
509
510    def _get_definition_url(self):
511        """Return the URL that shows where a resource is 'really' defined.
512
513        If a resource's capabilities are defined by reference, the
514        <resource> tag's 'type' attribute will contain the URL to the
515        <resource_type> that defines them.
516        """
517        return self.tag.attrib.get('type')
518
519    def _find_representation_definition(self, media_type=None):
520        """Get the most appropriate representation definition.
521
522        If media_type is provided, the most appropriate definition is
523        the definition of the representation of that media type.
524
525        If this resource is bound to a representation, the most
526        appropriate definition is the definition of that
527        representation. Otherwise, the most appropriate definition is
528        the definition of the representation served in response to a
529        standard GET.
530
531        :param media_type: Media type of the definition to find. Must
532            be present unless the resource is bound to a
533            representation.
534
535        :raise NoBoundRepresentationError: If this resource is not
536            bound to a representation and media_type was not provided.
537
538        :return: A RepresentationDefinition
539        """
540        if self.representation is not None:
541            # We know that when this object was created, a
542            # representation definition was either looked up, or
543            # directly passed in along with the representation.
544            definition = self.representation_definition.resolve_definition()
545        elif media_type is not None:
546            definition = self.get_representation_definition(media_type)
547        else:
548            raise NoBoundRepresentationError(
549                "Resource is not bound to any representation, and no media "
550                "media type was specified.")
551        return definition.resolve_definition()
552
553
554    def _method_tag_iter(self):
555        """Iterate over this resource's <method> tags."""
556        definition = self.resolve_definition().tag
557        for method_tag in definition.findall(wadl_xpath('method')):
558            yield method_tag
559
560
561class Method(WADLBase):
562    """A wrapper around an XML <method> tag.
563    """
564    def __init__(self, resource, method_tag):
565        """Initialize with a <method> tag.
566
567        :param method_tag: An ElementTree <method> tag.
568        """
569        self.resource = resource
570        self.application = self.resource.application
571        self.tag = method_tag
572
573    @property
574    def request(self):
575        """Return the definition of a request that invokes the WADL method."""
576        return RequestDefinition(self, self.tag.find(wadl_xpath('request')))
577
578    @property
579    def response(self):
580        """Return the definition of the response to the WADL method."""
581        return ResponseDefinition(self.resource,
582                                  self.tag.find(wadl_xpath('response')))
583
584    @property
585    def id(self):
586        """The XML ID of the WADL method definition."""
587        return self.tag.attrib.get('id')
588
589    @property
590    def name(self):
591        """The name of the WADL method definition.
592
593        This is also the name of the HTTP method (GET, POST, etc.)
594        that should be used to invoke the WADL method.
595        """
596        return self.tag.attrib.get('name').lower()
597
598    def build_request_url(self, param_values=None, **kw_param_values):
599        """Return the request URL to use to invoke this method."""
600        return self.request.build_url(param_values, **kw_param_values)
601
602    def build_representation(self, media_type=None,
603                             param_values=None, **kw_param_values):
604        """Build a representation to be sent when invoking this method.
605
606        :return: A 2-tuple of (media_type, representation).
607        """
608        return self.request.representation(
609            media_type, param_values, **kw_param_values)
610
611    def is_described_by(self, media_type=None, query_values=None,
612                        representation_values=None):
613        """Returns true if this method fits the given constraints.
614
615        :param media_type: The method must accept this media type as a
616                           representation.
617
618        :param query_values: These key-value pairs must be acceptable
619                           as values for this method's query
620                           parameters. This need not be a complete set
621                           of parameters acceptable to the method.
622
623        :param representation_values: These key-value pairs must be
624                           acceptable as values for this method's
625                           representation parameters. Again, this need
626                           not be a complete set of parameters
627                           acceptable to the method.
628        """
629        representation = None
630        if media_type is not None:
631            representation = self.request.get_representation_definition(
632                media_type)
633            if representation is None:
634                return False
635
636        if query_values is not None and len(query_values) > 0:
637            request = self.request
638            if request is None:
639                # This method takes no special request
640                # parameters, so it can't match.
641                return False
642            try:
643                request.validate_param_values(
644                    request.query_params, query_values, False)
645            except ValueError:
646                return False
647
648        # At this point we know the media type and query values match.
649        if (representation_values is None
650            or len(representation_values) == 0):
651            return True
652
653        if representation is not None:
654            return representation.is_described_by(
655                representation_values)
656        for representation in self.request.representations:
657            try:
658                representation.validate_param_values(
659                    representation.params(self.resource),
660                    representation_values, False)
661                return True
662            except ValueError:
663                pass
664        return False
665
666
667class RequestDefinition(WADLBase, HasParametersMixin):
668    """A wrapper around the description of the request invoking a method."""
669    def __init__(self, method, request_tag):
670        """Initialize with a <request> tag.
671
672        :param resource: The resource to which this request can be sent.
673        :param request_tag: An ElementTree <request> tag.
674        """
675        self.method = method
676        self.resource = self.method.resource
677        self.application = self.resource.application
678        self.tag = request_tag
679
680    @property
681    def query_params(self):
682        """Return the query parameters for this method."""
683        return self.params(['query'])
684
685    @property
686    def representations(self):
687        for definition in self.tag.findall(wadl_xpath('representation')):
688            yield RepresentationDefinition(
689                self.application, self.resource, definition)
690
691    def get_representation_definition(self, media_type=None):
692        """Return the appropriate representation definition."""
693        for representation in self.representations:
694            if media_type is None or representation.media_type == media_type:
695                return representation
696        return None
697
698    def representation(self, media_type=None, param_values=None,
699                       **kw_param_values):
700        """Build a representation to be sent along with this request.
701
702        :return: A 2-tuple of (media_type, representation).
703        """
704        definition = self.get_representation_definition(media_type)
705        if definition is None:
706            raise TypeError("Cannot build representation of media type %s"
707                            % media_type)
708        return definition.bind(param_values, **kw_param_values)
709
710    def build_url(self, param_values=None, **kw_param_values):
711        """Return the request URL to use to invoke this method."""
712        validated_values = self.validate_param_values(
713            self.query_params, param_values, **kw_param_values)
714        url = self.resource.url
715        if len(validated_values) > 0:
716            if '?' in url:
717                append = '&'
718            else:
719                append = '?'
720            url += append + urlencode(sorted(validated_values.items()))
721        return url
722
723
724class ResponseDefinition(HasParametersMixin):
725    """A wrapper around the description of a response to a method."""
726
727    # XXX leonardr 2008-05-29 it would be nice to have
728    # ResponseDefinitions for POST operations and nonstandard GET
729    # operations say what representations and/or status codes you get
730    # back. Getting this to work with Launchpad requires work on the
731    # Launchpad side.
732    def __init__(self, resource, response_tag, headers=None):
733        """Initialize with a <response> tag.
734
735        :param response_tag: An ElementTree <response> tag.
736        """
737        self.application = resource.application
738        self.resource = resource
739        self.tag = response_tag
740        self.headers = headers
741
742    def __iter__(self):
743        """Get an iterator over the representation definitions.
744
745        These are the representations returned in response to an
746        invocation of this method.
747        """
748        path = wadl_xpath('representation')
749        for representation_tag in self.tag.findall(path):
750            yield RepresentationDefinition(
751                self.resource.application, self.resource, representation_tag)
752
753    def bind(self, headers):
754        """Bind the response to a set of HTTP headers.
755
756        A WADL response can have associated header parameters, but no
757        other kind.
758        """
759        return ResponseDefinition(self.resource, self.tag, headers)
760
761    def get_parameter(self, param_name):
762        """Find a header parameter within the response."""
763        for param_tag in self.tag.findall(wadl_xpath('param')):
764            if (param_tag.attrib.get('name') == param_name
765                and param_tag.attrib.get('style') == 'header'):
766                return Parameter(self, param_tag)
767        return None
768
769    def get_parameter_value(self, parameter):
770        """Find the value of a parameter, given the Parameter object."""
771        if self.headers is None:
772            raise NoBoundRepresentationError(
773                "Response object is not bound to any headers.")
774        if parameter.style != 'header':
775            raise NotImplementedError(
776                "Don't know how to find value for a parameter of "
777                "type %s." % parameter.style)
778        return self.headers.get(parameter.name)
779
780    def get_representation_definition(self, media_type):
781        """Get one of the possible representations of the response."""
782        if self.tag is None:
783            return None
784        for representation in self:
785            if representation.media_type == media_type:
786                return representation
787        return None
788
789
790class RepresentationDefinition(WADLResolvableDefinition, HasParametersMixin):
791    """A definition of the structure of a representation."""
792
793    def __init__(self, application, resource, representation_tag):
794        super(RepresentationDefinition, self).__init__(application)
795        self.resource = resource
796        self.tag = representation_tag
797
798    def params(self, resource):
799        return super(RepresentationDefinition, self).params(
800            ['query', 'plain'], resource)
801
802    def parameter_names(self, resource):
803        """Return the names of all parameters."""
804        return [param.name for param in self.params(resource)]
805
806    @property
807    def media_type(self):
808        """The media type of the representation described here."""
809        return self.resolve_definition().tag.attrib['mediaType']
810
811    def _make_boundary(self, all_parts):
812        """Make a random boundary that does not appear in `all_parts`."""
813        _width = len(repr(sys.maxsize - 1))
814        _fmt = '%%0%dd' % _width
815        token = random.randrange(sys.maxsize)
816        boundary = ('=' * 15) + (_fmt % token) + '=='
817        if all_parts is None:
818            return boundary
819        b = boundary
820        counter = 0
821        while True:
822            pattern = ('^--' + re.escape(b) + '(--)?$').encode('ascii')
823            if not re.search(pattern, all_parts, flags=re.MULTILINE):
824                break
825            b = boundary + '.' + str(counter)
826            counter += 1
827        return b
828
829    def _write_headers(self, buf, headers):
830        """Write MIME headers to a file object."""
831        for key, value in headers:
832            buf.write(key.encode('UTF-8'))
833            buf.write(b': ')
834            buf.write(value.encode('UTF-8'))
835            buf.write(b'\r\n')
836        buf.write(b'\r\n')
837
838    def _write_boundary(self, buf, boundary, closing=False):
839        """Write a multipart boundary to a file object."""
840        buf.write(b'--')
841        buf.write(boundary.encode('UTF-8'))
842        if closing:
843            buf.write(b'--')
844        buf.write(b'\r\n')
845
846    def _generate_multipart_form(self, parts):
847        """Generate a multipart/form-data message.
848
849        This is very loosely based on the email module in the Python standard
850        library.  However, that module doesn't really support directly embedding
851        binary data in a form: various versions of Python have mangled line
852        separators in different ways, and none of them get it quite right.
853        Since we only need a tiny subset of MIME here, it's easier to implement
854        it ourselves.
855
856        :return: a tuple of two elements: the Content-Type of the message, and
857            the entire encoded message as a byte string.
858        """
859        # Generate the subparts first so that we can calculate a safe boundary.
860        encoded_parts = []
861        for is_binary, name, value in parts:
862            buf = io.BytesIO()
863            if is_binary:
864                ctype = 'application/octet-stream'
865                # RFC 7578 says that the filename parameter isn't mandatory
866                # in our case, but without it cgi.FieldStorage tries to
867                # decode as text on Python 3.
868                cdisp = 'form-data; name="%s"; filename="%s"' % (
869                    quote(name), quote(name))
870            else:
871                ctype = 'text/plain; charset="utf-8"'
872                cdisp = 'form-data; name="%s"' % quote(name)
873            self._write_headers(buf, [
874                ('MIME-Version', '1.0'),
875                ('Content-Type', ctype),
876                ('Content-Disposition', cdisp),
877                ])
878            if is_binary:
879                if not isinstance(value, bytes):
880                    raise TypeError('bytes payload expected: %s' % type(value))
881                buf.write(value)
882            else:
883                if not isinstance(value, _string_types):
884                    raise TypeError(
885                        'string payload expected: %s' % type(value))
886                lines = re.split(r'\r\n|\r|\n', value)
887                for line in lines[:-1]:
888                    buf.write(line.encode('UTF-8'))
889                    buf.write(b'\r\n')
890                buf.write(lines[-1].encode('UTF-8'))
891            encoded_parts.append(buf.getvalue())
892
893        # Create a suitable boundary.
894        boundary = self._make_boundary(b'\r\n'.join(encoded_parts))
895
896        # Now we can write the multipart headers, followed by all the parts.
897        buf = io.BytesIO()
898        ctype = 'multipart/form-data; boundary="%s"' % quote(boundary)
899        self._write_headers(buf, [
900            ('MIME-Version', '1.0'),
901            ('Content-Type', ctype),
902            ])
903        for encoded_part in encoded_parts:
904            self._write_boundary(buf, boundary)
905            buf.write(encoded_part)
906            buf.write(b'\r\n')
907        self._write_boundary(buf, boundary, closing=True)
908
909        return ctype, buf.getvalue()
910
911    def bind(self, param_values, **kw_param_values):
912        """Bind the definition to parameter values, creating a document.
913
914        :return: A 2-tuple (media_type, document).
915        """
916        definition = self.resolve_definition()
917        params = definition.params(self.resource)
918        validated_values = self.validate_param_values(
919            params, param_values, **kw_param_values)
920        media_type = self.media_type
921        if media_type == 'application/x-www-form-urlencoded':
922            doc = urlencode(sorted(validated_values.items()))
923        elif media_type == 'multipart/form-data':
924            parts = []
925            missing = object()
926            for param in params:
927                value = validated_values.get(param.name, missing)
928                if value is not missing:
929                    parts.append((param.type == 'binary', param.name, value))
930            media_type, doc = self._generate_multipart_form(parts)
931        elif media_type == 'application/json':
932            doc = json.dumps(validated_values)
933        else:
934            raise ValueError("Unsupported media type: '%s'" % media_type)
935        return media_type, doc
936
937    def _definition_factory(self, id):
938        """Turn a representation ID into a RepresentationDefinition."""
939        return self.application.representation_definitions.get(id)
940
941    def _get_definition_url(self):
942        """Find the URL containing the representation's 'real' definition.
943
944        If a representation's structure is defined by reference, the
945        <representation> tag's 'href' attribute will contain the URL
946        to the <representation> that defines the structure.
947        """
948        return self.tag.attrib.get('href')
949
950
951class Parameter(WADLBase):
952    """One of the parameters of a representation definition."""
953
954    def __init__(self, value_container, tag):
955        """Initialize with respect to a value container.
956
957        :param value_container: Usually the resource whose representation
958            has this parameter. If the resource is bound to a representation,
959            you'll be able to find the value of this parameter in the
960            representation. This may also be a server response whose headers
961            define a value for this parameter.
962        :tag: The ElementTree <param> tag for this parameter.
963        """
964        self.application = value_container.application
965        self.value_container = value_container
966        self.tag = tag
967
968    @property
969    def name(self):
970        """The name of this parameter."""
971        return self.tag.attrib.get('name')
972
973    @property
974    def style(self):
975        """The style of this parameter."""
976        return self.tag.attrib.get('style')
977
978    @property
979    def type(self):
980        """The XSD type of this parameter."""
981        return self.tag.attrib.get('type')
982
983    @property
984    def fixed_value(self):
985        """The value to which this parameter is fixed, if any.
986
987        A fixed parameter must be present in invocations of a WADL
988        method, and it must have a particular value. This is commonly
989        used to designate one parameter as containing the name of the
990        server-side operation to be invoked.
991        """
992        return self.tag.attrib.get('fixed')
993
994    @property
995    def is_required(self):
996        """Whether or not a value for this parameter is required."""
997        return (self.tag.attrib.get('required', 'false').lower()
998                in ['1', 'true'])
999
1000    def get_value(self):
1001        """The value of this parameter in the bound representation/headers.
1002
1003        :raise NoBoundRepresentationError: If this parameter's value
1004               container is not bound to a representation or a set of
1005               headers.
1006        """
1007        return self.value_container.get_parameter_value(self)
1008
1009    @property
1010    def options(self):
1011        """Return the set of acceptable values for this parameter."""
1012        return [Option(self, option_tag)
1013                for option_tag  in self.tag.findall(wadl_xpath('option'))]
1014
1015    @property
1016    def link(self):
1017        """Get the link to another resource.
1018
1019        The link may be examined and, if its type is of a known WADL
1020        description, it may be followed.
1021
1022        :return: A Link object, or None.
1023        """
1024        link_tag = self.tag.find(wadl_xpath('link'))
1025        if link_tag is None:
1026            return None
1027        return Link(self, link_tag)
1028
1029    @property
1030    def linked_resource(self):
1031        """Follow a link from this parameter to a new resource.
1032
1033        This only works for parameters whose WADL definition includes a
1034        <link> tag that points to a known WADL description.
1035
1036        :return: A Resource object for the resource at the other end
1037        of the link.
1038        """
1039        link = self.link
1040        if link is None:
1041            raise ValueError("This parameter isn't a link to anything.")
1042        return link.follow
1043
1044class Option(WADLBase):
1045    """One of a set of possible values for a parameter."""
1046
1047    def __init__(self, parameter, option_tag):
1048        """Initialize the option.
1049
1050        :param parameter: A Parameter.
1051        :param link_tag: An ElementTree <option> tag.
1052        """
1053        self.parameter = parameter
1054        self.tag = option_tag
1055
1056    @property
1057    def value(self):
1058        return self.tag.attrib.get('value')
1059
1060
1061class Link(WADLResolvableDefinition):
1062    """A link from one resource to another.
1063
1064    Calling resolve_definition() on a Link will give you a Resource for the
1065    type of resource linked to. An alias for this is 'follow'.
1066    """
1067
1068    def __init__(self, parameter, link_tag):
1069        """Initialize the link.
1070
1071        :param parameter: A Parameter.
1072        :param link_tag: An ElementTree <link> tag.
1073        """
1074        super(Link, self).__init__(parameter.application)
1075        self.parameter = parameter
1076        self.tag = link_tag
1077
1078    @property
1079    def follow(self):
1080        """Follow the link to another Resource."""
1081        if not self.can_follow:
1082            raise WADLError("Cannot follow a link when the target has no "
1083                            "WADL description. Try using a general HTTP "
1084                            "client instead.")
1085        return self.resolve_definition()
1086
1087    @property
1088    def can_follow(self):
1089        """Can this link be followed within wadllib?
1090
1091        wadllib can follow a link if it points to a resource that has
1092        a WADL definition.
1093        """
1094        try:
1095            definition_url = self._get_definition_url()
1096        except WADLError:
1097            return False
1098        return True
1099
1100    def _definition_factory(self, id):
1101        """Turn a resource type ID into a ResourceType."""
1102        return Resource(
1103            self.application, self.parameter.get_value(),
1104            self.application.resource_types.get(id).tag)
1105
1106    def _get_definition_url(self):
1107        """Find the URL containing the definition ."""
1108        type = self.tag.attrib.get('resource_type')
1109        if type is None:
1110            raise WADLError("Parameter is a link, but not to a resource "
1111                            "with a known WADL description.")
1112        return type
1113
1114
1115class ResourceType(WADLBase):
1116    """A wrapper around an XML <resource_type> tag."""
1117
1118    def __init__(self, resource_type_tag):
1119        """Initialize with a <resource_type> tag.
1120
1121        :param resource_type_tag: An ElementTree <resource_type> tag.
1122        """
1123        self.tag = resource_type_tag
1124
1125
1126class Application(WADLBase):
1127    """A WADL document made programmatically accessible."""
1128
1129    def __init__(self, markup_url, markup):
1130        """Parse WADL and find the most important parts of the document.
1131
1132        :param markup_url: The URL from which this document was obtained.
1133        :param markup: The WADL markup itself, or an open filehandle to it.
1134        """
1135        self.markup_url = markup_url
1136        if hasattr(markup, 'read'):
1137            self.doc = self._from_stream(markup)
1138        else:
1139            self.doc = self._from_string(markup)
1140        self.resources = self.doc.find(wadl_xpath('resources'))
1141        self.resource_base = self.resources.attrib.get('base')
1142        self.representation_definitions = {}
1143        self.resource_types = {}
1144        for representation in self.doc.findall(wadl_xpath('representation')):
1145            id = representation.attrib.get('id')
1146            if id is not None:
1147                definition = RepresentationDefinition(
1148                    self, None, representation)
1149                self.representation_definitions[id] = definition
1150        for resource_type in self.doc.findall(wadl_xpath('resource_type')):
1151            id = resource_type.attrib['id']
1152            self.resource_types[id] = ResourceType(resource_type)
1153
1154    def _from_stream(self, stream):
1155        """Turns markup into a document.
1156
1157        Just a wrapper around ElementTree which keeps track of namespaces.
1158        """
1159        events = "start", "start-ns", "end-ns"
1160        root = None
1161        ns_map = []
1162
1163        for event, elem in ET.iterparse(stream, events):
1164            if event == "start-ns":
1165                ns_map.append(elem)
1166            elif event == "end-ns":
1167                ns_map.pop()
1168            elif event == "start":
1169                if root is None:
1170                    root = elem
1171                elem.set(NS_MAP, dict(ns_map))
1172        return ET.ElementTree(root)
1173
1174    def _from_string(self, markup):
1175        """Turns markup into a document."""
1176        if not isinstance(markup, bytes):
1177            markup = markup.encode("UTF-8")
1178        return self._from_stream(io.BytesIO(markup))
1179
1180    def get_resource_type(self, resource_type_url):
1181        """Retrieve a resource type by the URL of its description."""
1182        xml_id = self.lookup_xml_id(resource_type_url)
1183        resource_type = self.resource_types.get(xml_id)
1184        if resource_type is None:
1185            raise KeyError('No such XML ID: "%s"' % resource_type_url)
1186        return resource_type
1187
1188    def lookup_xml_id(self, url):
1189        """A helper method for locating a part of a WADL document.
1190
1191        :param url: The URL (with anchor) of the desired part of the
1192        WADL document.
1193        :return: The XML ID corresponding to the anchor.
1194        """
1195        markup_uri = URI(self.markup_url).ensureNoSlash()
1196        markup_uri.fragment = None
1197
1198        if url.startswith('http'):
1199            # It's an absolute URI.
1200            this_uri = URI(url).ensureNoSlash()
1201        else:
1202            # It's a relative URI.
1203            this_uri = markup_uri.resolve(url)
1204        possible_xml_id = this_uri.fragment
1205        this_uri.fragment = None
1206
1207        if this_uri == markup_uri:
1208            # The URL pointed elsewhere within the same WADL document.
1209            # Return its fragment.
1210            return possible_xml_id
1211
1212        # XXX leonardr 2008-05-28:
1213        # This needs to be implemented eventually for Launchpad so
1214        # that a script using this client can navigate from a WADL
1215        # representation of a non-root resource to its definition at
1216        # the server root.
1217        raise NotImplementedError("Can't look up definition in another "
1218                                  "url (%s)" % url)
1219
1220    def get_resource_by_path(self, path):
1221        """Locate one of the resources described by this document.
1222
1223        :param path: The path to the resource.
1224        """
1225        # XXX leonardr 2008-05-27 This method only finds top-level
1226        # resources. That's all we need for Launchpad because we don't
1227        # define nested resources yet.
1228        matching = [resource for resource in self.resources
1229                    if resource.attrib['path'] == path]
1230        if len(matching) < 1:
1231            return None
1232        if len(matching) > 1:
1233            raise WADLError("More than one resource defined with path %s"
1234                            % path)
1235        return Resource(
1236            self, merge(self.resource_base, path, True), matching[0])
1237