1# Copyright 2016 Google Inc. All Rights Reserved. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""A library for converting service configs to discovery docs.""" 16 17import collections 18import json 19import logging 20import re 21 22import api_exceptions 23import message_parser 24from protorpc import message_types 25from protorpc import messages 26from protorpc import remote 27import resource_container 28import util 29 30 31_PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}' 32 33_MULTICLASS_MISMATCH_ERROR_TEMPLATE = ( 34 'Attempting to implement service %s, version %s, with multiple ' 35 'classes that are not compatible. See docstring for api() for ' 36 'examples how to implement a multi-class API.') 37 38_INVALID_AUTH_ISSUER = 'No auth issuer named %s defined in this Endpoints API.' 39 40_API_KEY = 'api_key' 41_API_KEY_PARAM = 'key' 42 43CUSTOM_VARIANT_MAP = { 44 messages.Variant.DOUBLE: ('number', 'double'), 45 messages.Variant.FLOAT: ('number', 'float'), 46 messages.Variant.INT64: ('string', 'int64'), 47 messages.Variant.SINT64: ('string', 'int64'), 48 messages.Variant.UINT64: ('string', 'uint64'), 49 messages.Variant.INT32: ('integer', 'int32'), 50 messages.Variant.SINT32: ('integer', 'int32'), 51 messages.Variant.UINT32: ('integer', 'uint32'), 52 messages.Variant.BOOL: ('boolean', None), 53 messages.Variant.STRING: ('string', None), 54 messages.Variant.BYTES: ('string', 'byte'), 55 messages.Variant.ENUM: ('string', None), 56} 57 58 59 60class DiscoveryGenerator(object): 61 """Generates a discovery doc from a ProtoRPC service. 62 63 Example: 64 65 class HelloRequest(messages.Message): 66 my_name = messages.StringField(1, required=True) 67 68 class HelloResponse(messages.Message): 69 hello = messages.StringField(1, required=True) 70 71 class HelloService(remote.Service): 72 73 @remote.method(HelloRequest, HelloResponse) 74 def hello(self, request): 75 return HelloResponse(hello='Hello there, %s!' % 76 request.my_name) 77 78 api_config = DiscoveryGenerator().pretty_print_config_to_json(HelloService) 79 80 The resulting api_config will be a JSON discovery document describing the API 81 implemented by HelloService. 82 """ 83 84 # Constants for categorizing a request method. 85 # __NO_BODY - Request without a request body, such as GET and DELETE methods. 86 # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body. 87 __NO_BODY = 1 # pylint: disable=invalid-name 88 __HAS_BODY = 2 # pylint: disable=invalid-name 89 90 def __init__(self): 91 self.__parser = message_parser.MessageTypeToJsonSchema() 92 93 # Maps method id to the request schema id. 94 self.__request_schema = {} 95 96 # Maps method id to the response schema id. 97 self.__response_schema = {} 98 99 def _get_resource_path(self, method_id): 100 """Return the resource path for a method or an empty array if none.""" 101 return method_id.split('.')[1:-1] 102 103 def _get_canonical_method_id(self, method_id): 104 return method_id.split('.')[-1] 105 106 def __get_request_kind(self, method_info): 107 """Categorize the type of the request. 108 109 Args: 110 method_info: _MethodInfo, method information. 111 112 Returns: 113 The kind of request. 114 """ 115 if method_info.http_method in ('GET', 'DELETE'): 116 return self.__NO_BODY 117 else: 118 return self.__HAS_BODY 119 120 def __field_to_subfields(self, field): 121 """Fully describes data represented by field, including the nested case. 122 123 In the case that the field is not a message field, we have no fields nested 124 within a message definition, so we can simply return that field. However, in 125 the nested case, we can't simply describe the data with one field or even 126 with one chain of fields. 127 128 For example, if we have a message field 129 130 m_field = messages.MessageField(RefClass, 1) 131 132 which references a class with two fields: 133 134 class RefClass(messages.Message): 135 one = messages.StringField(1) 136 two = messages.IntegerField(2) 137 138 then we would need to include both one and two to represent all the 139 data contained. 140 141 Calling __field_to_subfields(m_field) would return: 142 [ 143 [<MessageField "m_field">, <StringField "one">], 144 [<MessageField "m_field">, <StringField "two">], 145 ] 146 147 If the second field was instead a message field 148 149 class RefClass(messages.Message): 150 one = messages.StringField(1) 151 two = messages.MessageField(OtherRefClass, 2) 152 153 referencing another class with two fields 154 155 class OtherRefClass(messages.Message): 156 three = messages.BooleanField(1) 157 four = messages.FloatField(2) 158 159 then we would need to recurse one level deeper for two. 160 161 With this change, calling __field_to_subfields(m_field) would return: 162 [ 163 [<MessageField "m_field">, <StringField "one">], 164 [<MessageField "m_field">, <StringField "two">, <StringField "three">], 165 [<MessageField "m_field">, <StringField "two">, <StringField "four">], 166 ] 167 168 Args: 169 field: An instance of a subclass of messages.Field. 170 171 Returns: 172 A list of lists, where each sublist is a list of fields. 173 """ 174 # Termination condition 175 if not isinstance(field, messages.MessageField): 176 return [[field]] 177 178 result = [] 179 for subfield in sorted(field.message_type.all_fields(), 180 key=lambda f: f.number): 181 subfield_results = self.__field_to_subfields(subfield) 182 for subfields_list in subfield_results: 183 subfields_list.insert(0, field) 184 result.append(subfields_list) 185 return result 186 187 def __field_to_parameter_type_and_format(self, field): 188 """Converts the field variant type into a tuple describing the parameter. 189 190 Args: 191 field: An instance of a subclass of messages.Field. 192 193 Returns: 194 A tuple with the type and format of the field, respectively. 195 196 Raises: 197 TypeError: if the field variant is a message variant. 198 """ 199 # We use lowercase values for types (e.g. 'string' instead of 'STRING'). 200 variant = field.variant 201 if variant == messages.Variant.MESSAGE: 202 raise TypeError('A message variant cannot be used in a parameter.') 203 204 # Note that the 64-bit integers are marked as strings -- this is to 205 # accommodate JavaScript, which would otherwise demote them to 32-bit 206 # integers. 207 208 return CUSTOM_VARIANT_MAP.get(variant) or (variant.name.lower(), None) 209 210 def __get_path_parameters(self, path): 211 """Parses path paremeters from a URI path and organizes them by parameter. 212 213 Some of the parameters may correspond to message fields, and so will be 214 represented as segments corresponding to each subfield; e.g. first.second if 215 the field "second" in the message field "first" is pulled from the path. 216 217 The resulting dictionary uses the first segments as keys and each key has as 218 value the list of full parameter values with first segment equal to the key. 219 220 If the match path parameter is null, that part of the path template is 221 ignored; this occurs if '{}' is used in a template. 222 223 Args: 224 path: String; a URI path, potentially with some parameters. 225 226 Returns: 227 A dictionary with strings as keys and list of strings as values. 228 """ 229 path_parameters_by_segment = {} 230 for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path): 231 first_segment = format_var_name.split('.', 1)[0] 232 matches = path_parameters_by_segment.setdefault(first_segment, []) 233 matches.append(format_var_name) 234 235 return path_parameters_by_segment 236 237 def __validate_simple_subfield(self, parameter, field, segment_list, 238 segment_index=0): 239 """Verifies that a proposed subfield actually exists and is a simple field. 240 241 Here, simple means it is not a MessageField (nested). 242 243 Args: 244 parameter: String; the '.' delimited name of the current field being 245 considered. This is relative to some root. 246 field: An instance of a subclass of messages.Field. Corresponds to the 247 previous segment in the path (previous relative to _segment_index), 248 since this field should be a message field with the current segment 249 as a field in the message class. 250 segment_list: The full list of segments from the '.' delimited subfield 251 being validated. 252 segment_index: Integer; used to hold the position of current segment so 253 that segment_list can be passed as a reference instead of having to 254 copy using segment_list[1:] at each step. 255 256 Raises: 257 TypeError: If the final subfield (indicated by _segment_index relative 258 to the length of segment_list) is a MessageField. 259 TypeError: If at any stage the lookup at a segment fails, e.g if a.b 260 exists but a.b.c does not exist. This can happen either if a.b is not 261 a message field or if a.b.c is not a property on the message class from 262 a.b. 263 """ 264 if segment_index >= len(segment_list): 265 # In this case, the field is the final one, so should be simple type 266 if isinstance(field, messages.MessageField): 267 field_class = field.__class__.__name__ 268 raise TypeError('Can\'t use messages in path. Subfield %r was ' 269 'included but is a %s.' % (parameter, field_class)) 270 return 271 272 segment = segment_list[segment_index] 273 parameter += '.' + segment 274 try: 275 field = field.type.field_by_name(segment) 276 except (AttributeError, KeyError): 277 raise TypeError('Subfield %r from path does not exist.' % (parameter,)) 278 279 self.__validate_simple_subfield(parameter, field, segment_list, 280 segment_index=segment_index + 1) 281 282 def __validate_path_parameters(self, field, path_parameters): 283 """Verifies that all path parameters correspond to an existing subfield. 284 285 Args: 286 field: An instance of a subclass of messages.Field. Should be the root 287 level property name in each path parameter in path_parameters. For 288 example, if the field is called 'foo', then each path parameter should 289 begin with 'foo.'. 290 path_parameters: A list of Strings representing URI parameter variables. 291 292 Raises: 293 TypeError: If one of the path parameters does not start with field.name. 294 """ 295 for param in path_parameters: 296 segment_list = param.split('.') 297 if segment_list[0] != field.name: 298 raise TypeError('Subfield %r can\'t come from field %r.' 299 % (param, field.name)) 300 self.__validate_simple_subfield(field.name, field, segment_list[1:]) 301 302 def __parameter_default(self, field): 303 """Returns default value of field if it has one. 304 305 Args: 306 field: A simple field. 307 308 Returns: 309 The default value of the field, if any exists, with the exception of an 310 enum field, which will have its value cast to a string. 311 """ 312 if field.default: 313 if isinstance(field, messages.EnumField): 314 return field.default.name 315 else: 316 return field.default 317 318 def __parameter_enum(self, param): 319 """Returns enum descriptor of a parameter if it is an enum. 320 321 An enum descriptor is a list of keys. 322 323 Args: 324 param: A simple field. 325 326 Returns: 327 The enum descriptor for the field, if it's an enum descriptor, else 328 returns None. 329 """ 330 if isinstance(param, messages.EnumField): 331 return [enum_entry[0] for enum_entry in sorted( 332 param.type.to_dict().items(), key=lambda v: v[1])] 333 334 def __parameter_descriptor(self, param): 335 """Creates descriptor for a parameter. 336 337 Args: 338 param: The parameter to be described. 339 340 Returns: 341 Dictionary containing a descriptor for the parameter. 342 """ 343 descriptor = {} 344 345 param_type, param_format = self.__field_to_parameter_type_and_format(param) 346 347 # Required 348 if param.required: 349 descriptor['required'] = True 350 351 # Type 352 descriptor['type'] = param_type 353 354 # Format (optional) 355 if param_format: 356 descriptor['format'] = param_format 357 358 # Default 359 default = self.__parameter_default(param) 360 if default is not None: 361 descriptor['default'] = default 362 363 # Repeated 364 if param.repeated: 365 descriptor['repeated'] = True 366 367 # Enum 368 # Note that enumDescriptions are not currently supported using the 369 # framework's annotations, so just insert blank strings. 370 enum_descriptor = self.__parameter_enum(param) 371 if enum_descriptor is not None: 372 descriptor['enum'] = enum_descriptor 373 descriptor['enumDescriptions'] = [''] * len(enum_descriptor) 374 375 return descriptor 376 377 def __add_parameter(self, param, path_parameters, params): 378 """Adds all parameters in a field to a method parameters descriptor. 379 380 Simple fields will only have one parameter, but a message field 'x' that 381 corresponds to a message class with fields 'y' and 'z' will result in 382 parameters 'x.y' and 'x.z', for example. The mapping from field to 383 parameters is mostly handled by __field_to_subfields. 384 385 Args: 386 param: Parameter to be added to the descriptor. 387 path_parameters: A list of parameters matched from a path for this field. 388 For example for the hypothetical 'x' from above if the path was 389 '/a/{x.z}/b/{other}' then this list would contain only the element 390 'x.z' since 'other' does not match to this field. 391 params: List of parameters. Each parameter in the field. 392 """ 393 # If this is a simple field, just build the descriptor and append it. 394 # Otherwise, build a schema and assign it to this descriptor 395 descriptor = None 396 if not isinstance(param, messages.MessageField): 397 name = param.name 398 descriptor = self.__parameter_descriptor(param) 399 descriptor['location'] = 'path' if name in path_parameters else 'query' 400 401 if descriptor: 402 params[name] = descriptor 403 else: 404 for subfield_list in self.__field_to_subfields(param): 405 name = '.'.join(subfield.name for subfield in subfield_list) 406 descriptor = self.__parameter_descriptor(subfield_list[-1]) 407 if name in path_parameters: 408 descriptor['required'] = True 409 descriptor['location'] = 'path' 410 else: 411 descriptor.pop('required', None) 412 descriptor['location'] = 'query' 413 414 if descriptor: 415 params[name] = descriptor 416 417 418 def __params_descriptor_without_container(self, message_type, 419 request_kind, path): 420 """Describe parameters of a method which does not use a ResourceContainer. 421 422 Makes sure that the path parameters are included in the message definition 423 and adds any required fields and URL query parameters. 424 425 This method is to preserve backwards compatibility and will be removed in 426 a future release. 427 428 Args: 429 message_type: messages.Message class, Message with parameters to describe. 430 request_kind: The type of request being made. 431 path: string, HTTP path to method. 432 433 Returns: 434 A list of dicts: Descriptors of the parameters 435 """ 436 params = {} 437 438 path_parameter_dict = self.__get_path_parameters(path) 439 for field in sorted(message_type.all_fields(), key=lambda f: f.number): 440 matched_path_parameters = path_parameter_dict.get(field.name, []) 441 self.__validate_path_parameters(field, matched_path_parameters) 442 if matched_path_parameters or request_kind == self.__NO_BODY: 443 self.__add_parameter(field, matched_path_parameters, params) 444 445 return params 446 447 def __params_descriptor(self, message_type, request_kind, path, method_id, 448 request_params_class): 449 """Describe the parameters of a method. 450 451 If the message_type is not a ResourceContainer, will fall back to 452 __params_descriptor_without_container (which will eventually be deprecated). 453 454 If the message type is a ResourceContainer, then all path/query parameters 455 will come from the ResourceContainer. This method will also make sure all 456 path parameters are covered by the message fields. 457 458 Args: 459 message_type: messages.Message or ResourceContainer class, Message with 460 parameters to describe. 461 request_kind: The type of request being made. 462 path: string, HTTP path to method. 463 method_id: string, Unique method identifier (e.g. 'myapi.items.method') 464 request_params_class: messages.Message, the original params message when 465 using a ResourceContainer. Otherwise, this should be null. 466 467 Returns: 468 A tuple (dict, list of string): Descriptor of the parameters, Order of the 469 parameters. 470 """ 471 path_parameter_dict = self.__get_path_parameters(path) 472 473 if request_params_class is None: 474 if path_parameter_dict: 475 logging.warning('Method %s specifies path parameters but you are not ' 476 'using a ResourceContainer. This will fail in future ' 477 'releases; please switch to using ResourceContainer as ' 478 'soon as possible.', method_id) 479 return self.__params_descriptor_without_container( 480 message_type, request_kind, path) 481 482 # From here, we can assume message_type is from a ResourceContainer. 483 message_type = request_params_class 484 485 params = {} 486 487 # Make sure all path parameters are covered. 488 for field_name, matched_path_parameters in path_parameter_dict.iteritems(): 489 field = message_type.field_by_name(field_name) 490 self.__validate_path_parameters(field, matched_path_parameters) 491 492 # Add all fields, sort by field.number since we have parameterOrder. 493 for field in sorted(message_type.all_fields(), key=lambda f: f.number): 494 matched_path_parameters = path_parameter_dict.get(field.name, []) 495 self.__add_parameter(field, matched_path_parameters, params) 496 497 return params 498 499 def __params_order_descriptor(self, message_type, path): 500 """Describe the order of path parameters. 501 502 Args: 503 message_type: messages.Message class, Message with parameters to describe. 504 path: string, HTTP path to method. 505 506 Returns: 507 Descriptor list for the parameter order. 508 """ 509 descriptor = [] 510 path_parameter_dict = self.__get_path_parameters(path) 511 512 for field in sorted(message_type.all_fields(), key=lambda f: f.number): 513 matched_path_parameters = path_parameter_dict.get(field.name, []) 514 if not isinstance(field, messages.MessageField): 515 name = field.name 516 if name in matched_path_parameters: 517 descriptor.append(name) 518 else: 519 for subfield_list in self.__field_to_subfields(field): 520 name = '.'.join(subfield.name for subfield in subfield_list) 521 if name in matched_path_parameters: 522 descriptor.append(name) 523 524 return descriptor 525 526 def __schemas_descriptor(self): 527 """Describes the schemas section of the discovery document. 528 529 Returns: 530 Dictionary describing the schemas of the document. 531 """ 532 # Filter out any keys that aren't 'properties', 'type', or 'id' 533 result = {} 534 for schema_key, schema_value in self.__parser.schemas().iteritems(): 535 field_keys = schema_value.keys() 536 key_result = {} 537 538 # Some special processing for the properties value 539 if 'properties' in field_keys: 540 key_result['properties'] = schema_value['properties'].copy() 541 # Add in enumDescriptions for any enum properties and strip out 542 # the required tag for consistency with Java framework 543 for prop_key, prop_value in schema_value['properties'].iteritems(): 544 if 'enum' in prop_value: 545 num_enums = len(prop_value['enum']) 546 key_result['properties'][prop_key]['enumDescriptions'] = ( 547 [''] * num_enums) 548 key_result['properties'][prop_key].pop('required', None) 549 550 for key in ('type', 'id', 'description'): 551 if key in field_keys: 552 key_result[key] = schema_value[key] 553 554 if key_result: 555 result[schema_key] = key_result 556 557 # Add 'type': 'object' to all object properties 558 for schema_value in result.itervalues(): 559 for field_value in schema_value.itervalues(): 560 if isinstance(field_value, dict): 561 if '$ref' in field_value: 562 field_value['type'] = 'object' 563 564 return result 565 566 def __request_message_descriptor(self, request_kind, message_type, method_id, 567 request_body_class): 568 """Describes the parameters and body of the request. 569 570 Args: 571 request_kind: The type of request being made. 572 message_type: messages.Message or ResourceContainer class. The message to 573 describe. 574 method_id: string, Unique method identifier (e.g. 'myapi.items.method') 575 request_body_class: messages.Message of the original body when using 576 a ResourceContainer. Otherwise, this should be null. 577 578 Returns: 579 Dictionary describing the request. 580 581 Raises: 582 ValueError: if the method path and request required fields do not match 583 """ 584 if request_body_class: 585 message_type = request_body_class 586 587 if (request_kind != self.__NO_BODY and 588 message_type != message_types.VoidMessage()): 589 self.__request_schema[method_id] = self.__parser.add_message( 590 message_type.__class__) 591 return { 592 '$ref': self.__request_schema[method_id], 593 'parameterName': 'resource', 594 } 595 596 def __response_message_descriptor(self, message_type, method_id): 597 """Describes the response. 598 599 Args: 600 message_type: messages.Message class, The message to describe. 601 method_id: string, Unique method identifier (e.g. 'myapi.items.method') 602 603 Returns: 604 Dictionary describing the response. 605 """ 606 if message_type != message_types.VoidMessage(): 607 self.__parser.add_message(message_type.__class__) 608 self.__response_schema[method_id] = self.__parser.ref_for_message_type( 609 message_type.__class__) 610 return {'$ref': self.__response_schema[method_id]} 611 else: 612 return None 613 614 def __method_descriptor(self, service, method_info, 615 protorpc_method_info): 616 """Describes a method. 617 618 Args: 619 service: endpoints.Service, Implementation of the API as a service. 620 method_info: _MethodInfo, Configuration for the method. 621 protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC 622 description of the method. 623 624 Returns: 625 Dictionary describing the method. 626 """ 627 descriptor = {} 628 629 request_message_type = (resource_container.ResourceContainer. 630 get_request_message(protorpc_method_info.remote)) 631 request_kind = self.__get_request_kind(method_info) 632 remote_method = protorpc_method_info.remote 633 634 method_id = method_info.method_id(service.api_info) 635 636 path = method_info.get_path(service.api_info) 637 638 description = protorpc_method_info.remote.method.__doc__ 639 640 descriptor['id'] = method_id 641 descriptor['path'] = path 642 descriptor['httpMethod'] = method_info.http_method 643 644 if description: 645 descriptor['description'] = description 646 647 descriptor['scopes'] = [ 648 'https://www.googleapis.com/auth/userinfo.email' 649 ] 650 651 parameters = self.__params_descriptor( 652 request_message_type, request_kind, path, method_id, 653 method_info.request_params_class) 654 if parameters: 655 descriptor['parameters'] = parameters 656 657 if method_info.request_params_class: 658 parameter_order = self.__params_order_descriptor( 659 method_info.request_params_class, path) 660 else: 661 parameter_order = self.__params_order_descriptor( 662 request_message_type, path) 663 if parameter_order: 664 descriptor['parameterOrder'] = parameter_order 665 666 request_descriptor = self.__request_message_descriptor( 667 request_kind, request_message_type, method_id, 668 method_info.request_body_class) 669 if request_descriptor is not None: 670 descriptor['request'] = request_descriptor 671 672 response_descriptor = self.__response_message_descriptor( 673 remote_method.response_type(), method_info.method_id(service.api_info)) 674 if response_descriptor is not None: 675 descriptor['response'] = response_descriptor 676 677 return descriptor 678 679 def __resource_descriptor(self, resource_path, methods): 680 """Describes a resource. 681 682 Args: 683 resource_path: string, the path of the resource (e.g., 'entries.items') 684 methods: list of tuples of type 685 (endpoints.Service, protorpc.remote._RemoteMethodInfo), the methods 686 that serve this resource. 687 688 Returns: 689 Dictionary describing the resource. 690 """ 691 descriptor = {} 692 method_map = {} 693 sub_resource_index = collections.defaultdict(list) 694 sub_resource_map = {} 695 696 resource_path_tokens = resource_path.split('.') 697 for service, protorpc_meth_info in methods: 698 method_info = getattr(protorpc_meth_info, 'method_info', None) 699 path = method_info.get_path(service.api_info) 700 method_id = method_info.method_id(service.api_info) 701 canonical_method_id = self._get_canonical_method_id(method_id) 702 703 current_resource_path = self._get_resource_path(method_id) 704 705 # Sanity-check that this method belongs to the resource path 706 if (current_resource_path[:len(resource_path_tokens)] != 707 resource_path_tokens): 708 raise api_exceptions.ToolError( 709 'Internal consistency error in resource path {0}'.format( 710 current_resource_path)) 711 712 # Remove the portion of the current method's resource path that's already 713 # part of the resource path at this level. 714 effective_resource_path = current_resource_path[ 715 len(resource_path_tokens):] 716 717 # If this method is part of a sub-resource, note it and skip it for now 718 if effective_resource_path: 719 sub_resource_name = effective_resource_path[0] 720 new_resource_path = '.'.join([resource_path, sub_resource_name]) 721 sub_resource_index[new_resource_path].append( 722 (service, protorpc_meth_info)) 723 else: 724 method_map[canonical_method_id] = self.__method_descriptor( 725 service, method_info, protorpc_meth_info) 726 727 # Process any sub-resources 728 for sub_resource, sub_resource_methods in sub_resource_index.items(): 729 sub_resource_name = sub_resource.split('.')[-1] 730 sub_resource_map[sub_resource_name] = self.__resource_descriptor( 731 sub_resource, sub_resource_methods) 732 733 if method_map: 734 descriptor['methods'] = method_map 735 736 if sub_resource_map: 737 descriptor['resources'] = sub_resource_map 738 739 return descriptor 740 741 def __standard_parameters_descriptor(self): 742 return { 743 'alt': { 744 'type': 'string', 745 'description': 'Data format for the response.', 746 'default': 'json', 747 'enum': ['json'], 748 'enumDescriptions': [ 749 'Responses with Content-Type of application/json' 750 ], 751 'location': 'query', 752 }, 753 'fields': { 754 'type': 'string', 755 'description': 'Selector specifying which fields to include in a ' 756 'partial response.', 757 'location': 'query', 758 }, 759 'key': { 760 'type': 'string', 761 'description': 'API key. Your API key identifies your project and ' 762 'provides you with API access, quota, and reports. ' 763 'Required unless you provide an OAuth 2.0 token.', 764 'location': 'query', 765 }, 766 'oauth_token': { 767 'type': 'string', 768 'description': 'OAuth 2.0 token for the current user.', 769 'location': 'query', 770 }, 771 'prettyPrint': { 772 'type': 'boolean', 773 'description': 'Returns response with indentations and line ' 774 'breaks.', 775 'default': 'true', 776 'location': 'query', 777 }, 778 'quotaUser': { 779 'type': 'string', 780 'description': 'Available to use for quota purposes for ' 781 'server-side applications. Can be any arbitrary ' 782 'string assigned to a user, but should not exceed ' 783 '40 characters. Overrides userIp if both are ' 784 'provided.', 785 'location': 'query', 786 }, 787 'userIp': { 788 'type': 'string', 789 'description': 'IP address of the site where the request ' 790 'originates. Use this if you want to enforce ' 791 'per-user limits.', 792 'location': 'query', 793 }, 794 } 795 796 def __standard_auth_descriptor(self): 797 return { 798 'oauth2': { 799 'scopes': { 800 'https://www.googleapis.com/auth/userinfo.email': { 801 'description': 'View your email address' 802 } 803 } 804 } 805 } 806 807 def __get_merged_api_info(self, services): 808 """Builds a description of an API. 809 810 Args: 811 services: List of protorpc.remote.Service instances implementing an 812 api/version. 813 814 Returns: 815 The _ApiInfo object to use for the API that the given services implement. 816 817 Raises: 818 ApiConfigurationError: If there's something wrong with the API 819 configuration, such as a multiclass API decorated with different API 820 descriptors (see the docstring for api()). 821 """ 822 merged_api_info = services[0].api_info 823 824 # Verify that, if there are multiple classes here, they're allowed to 825 # implement the same API. 826 for service in services[1:]: 827 if not merged_api_info.is_same_api(service.api_info): 828 raise api_exceptions.ApiConfigurationError( 829 _MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name, 830 service.api_info.version)) 831 832 return merged_api_info 833 834 def __discovery_doc_descriptor(self, services, hostname=None): 835 """Builds a discovery doc for an API. 836 837 Args: 838 services: List of protorpc.remote.Service instances implementing an 839 api/version. 840 hostname: string, Hostname of the API, to override the value set on the 841 current service. Defaults to None. 842 843 Returns: 844 A dictionary that can be deserialized into JSON in discovery doc format. 845 846 Raises: 847 ApiConfigurationError: If there's something wrong with the API 848 configuration, such as a multiclass API decorated with different API 849 descriptors (see the docstring for api()), or a repeated method 850 signature. 851 """ 852 merged_api_info = self.__get_merged_api_info(services) 853 descriptor = self.get_descriptor_defaults(merged_api_info, 854 hostname=hostname) 855 856 description = merged_api_info.description 857 if not description and len(services) == 1: 858 description = services[0].__doc__ 859 if description: 860 descriptor['description'] = description 861 862 descriptor['parameters'] = self.__standard_parameters_descriptor() 863 descriptor['auth'] = self.__standard_auth_descriptor() 864 865 method_map = {} 866 method_collision_tracker = {} 867 rest_collision_tracker = {} 868 869 resource_index = collections.defaultdict(list) 870 resource_map = {} 871 872 # For the first pass, only process top-level methods (that is, those methods 873 # that are unattached to a resource). 874 for service in services: 875 remote_methods = service.all_remote_methods() 876 877 for protorpc_meth_name, protorpc_meth_info in remote_methods.iteritems(): 878 method_info = getattr(protorpc_meth_info, 'method_info', None) 879 # Skip methods that are not decorated with @method 880 if method_info is None: 881 continue 882 path = method_info.get_path(service.api_info) 883 method_id = method_info.method_id(service.api_info) 884 canonical_method_id = self._get_canonical_method_id(method_id) 885 resource_path = self._get_resource_path(method_id) 886 887 # Make sure the same method name isn't repeated. 888 if method_id in method_collision_tracker: 889 raise api_exceptions.ApiConfigurationError( 890 'Method %s used multiple times, in classes %s and %s' % 891 (method_id, method_collision_tracker[method_id], 892 service.__name__)) 893 else: 894 method_collision_tracker[method_id] = service.__name__ 895 896 # Make sure the same HTTP method & path aren't repeated. 897 rest_identifier = (method_info.http_method, path) 898 if rest_identifier in rest_collision_tracker: 899 raise api_exceptions.ApiConfigurationError( 900 '%s path "%s" used multiple times, in classes %s and %s' % 901 (method_info.http_method, path, 902 rest_collision_tracker[rest_identifier], 903 service.__name__)) 904 else: 905 rest_collision_tracker[rest_identifier] = service.__name__ 906 907 # If this method is part of a resource, note it and skip it for now 908 if resource_path: 909 resource_index[resource_path[0]].append((service, protorpc_meth_info)) 910 else: 911 method_map[canonical_method_id] = self.__method_descriptor( 912 service, method_info, protorpc_meth_info) 913 914 # Do another pass for methods attached to resources 915 for resource, resource_methods in resource_index.items(): 916 resource_map[resource] = self.__resource_descriptor(resource, 917 resource_methods) 918 919 if method_map: 920 descriptor['methods'] = method_map 921 922 if resource_map: 923 descriptor['resources'] = resource_map 924 925 # Add schemas, if any 926 schemas = self.__schemas_descriptor() 927 if schemas: 928 descriptor['schemas'] = schemas 929 930 return descriptor 931 932 def get_descriptor_defaults(self, api_info, hostname=None): 933 """Gets a default configuration for a service. 934 935 Args: 936 api_info: _ApiInfo object for this service. 937 hostname: string, Hostname of the API, to override the value set on the 938 current service. Defaults to None. 939 940 Returns: 941 A dictionary with the default configuration. 942 """ 943 hostname = (hostname or util.get_app_hostname() or 944 api_info.hostname) 945 protocol = 'http' if ((hostname and hostname.startswith('localhost')) or 946 util.is_running_on_devserver()) else 'https' 947 full_base_path = '{0}{1}/{2}/'.format(api_info.base_path, 948 api_info.name, 949 api_info.version) 950 base_url = '{0}://{1}{2}'.format(protocol, hostname, full_base_path) 951 root_url = '{0}://{1}{2}'.format(protocol, hostname, api_info.base_path) 952 defaults = { 953 'kind': 'discovery#restDescription', 954 'discoveryVersion': 'v1', 955 'id': '{0}:{1}'.format(api_info.name, api_info.version), 956 'name': api_info.name, 957 'version': api_info.version, 958 'icons': { 959 'x16': 'http://www.google.com/images/icons/product/search-16.gif', 960 'x32': 'http://www.google.com/images/icons/product/search-32.gif' 961 }, 962 'protocol': 'rest', 963 'servicePath': '{0}/{1}/'.format(api_info.name, api_info.version), 964 'batchPath': 'batch', 965 'basePath': full_base_path, 966 'rootUrl': root_url, 967 'baseUrl': base_url, 968 } 969 970 return defaults 971 972 def get_discovery_doc(self, services, hostname=None): 973 """JSON dict description of a protorpc.remote.Service in discovery format. 974 975 Args: 976 services: Either a single protorpc.remote.Service or a list of them 977 that implements an api/version. 978 hostname: string, Hostname of the API, to override the value set on the 979 current service. Defaults to None. 980 981 Returns: 982 dict, The discovery document as a JSON dict. 983 """ 984 985 if not isinstance(services, (tuple, list)): 986 services = [services] 987 988 # The type of a class that inherits from remote.Service is actually 989 # remote._ServiceClass, thanks to metaclass strangeness. 990 # pylint: disable=protected-access 991 util.check_list_type(services, remote._ServiceClass, 'services', 992 allow_none=False) 993 994 return self.__discovery_doc_descriptor(services, hostname=hostname) 995 996 def pretty_print_config_to_json(self, services, hostname=None): 997 """JSON string description of a protorpc.remote.Service in a discovery doc. 998 999 Args: 1000 services: Either a single protorpc.remote.Service or a list of them 1001 that implements an api/version. 1002 hostname: string, Hostname of the API, to override the value set on the 1003 current service. Defaults to None. 1004 1005 Returns: 1006 string, The discovery doc descriptor document as a JSON string. 1007 """ 1008 descriptor = self.get_discovery_doc(services, hostname) 1009 return json.dumps(descriptor, sort_keys=True, indent=2, 1010 separators=(',', ': ')) 1011