OLD | NEW |
(Empty) | |
| 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 |
| 17 import collections |
| 18 import json |
| 19 import logging |
| 20 import re |
| 21 |
| 22 import api_exceptions |
| 23 import message_parser |
| 24 from protorpc import message_types |
| 25 from protorpc import messages |
| 26 from protorpc import remote |
| 27 import resource_container |
| 28 import 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 |
| 43 CUSTOM_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 |
| 60 class 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=(',', ': ')) |
OLD | NEW |