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