Adrià Vilanova MartÃnez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 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 OpenAPI (Swagger) specs.""" |
| 16 | from __future__ import absolute_import |
| 17 | |
| 18 | import hashlib |
| 19 | import json |
| 20 | import logging |
| 21 | import re |
| 22 | |
| 23 | from . import api_exceptions |
| 24 | from . import message_parser |
| 25 | from . import message_types |
| 26 | from . import messages |
| 27 | from . import remote |
| 28 | from . import resource_container |
| 29 | from . import util |
| 30 | |
| 31 | _logger = logging.getLogger(__name__) |
| 32 | |
| 33 | _PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}' |
| 34 | |
| 35 | _MULTICLASS_MISMATCH_ERROR_TEMPLATE = ( |
| 36 | 'Attempting to implement service %s, version %s, with multiple ' |
| 37 | 'classes that aren\'t compatible. See docstring for api() for ' |
| 38 | 'examples how to implement a multi-class API.') |
| 39 | |
| 40 | _INVALID_AUTH_ISSUER = 'No auth issuer named %s defined in this Endpoints API.' |
| 41 | |
| 42 | _API_KEY = 'api_key' |
| 43 | _API_KEY_PARAM = 'key' |
| 44 | _DEFAULT_SECURITY_DEFINITION = 'google_id_token' |
| 45 | |
| 46 | |
| 47 | _VALID_API_NAME = re.compile('^[a-z][a-z0-9]{0,39}$') |
| 48 | |
| 49 | |
| 50 | def _validate_api_name(name): |
| 51 | valid = (_VALID_API_NAME.match(name) is not None) |
| 52 | if not valid: |
| 53 | raise api_exceptions.InvalidApiNameException( |
| 54 | 'The API name must match the regular expression {}'.format( |
| 55 | _VALID_API_NAME.pattern[1:-1])) |
| 56 | return name |
| 57 | |
| 58 | |
| 59 | class OpenApiGenerator(object): |
| 60 | """Generates an OpenAPI spec from a ProtoRPC service. |
| 61 | |
| 62 | Example: |
| 63 | |
| 64 | class HelloRequest(messages.Message): |
| 65 | my_name = messages.StringField(1, required=True) |
| 66 | |
| 67 | class HelloResponse(messages.Message): |
| 68 | hello = messages.StringField(1, required=True) |
| 69 | |
| 70 | class HelloService(remote.Service): |
| 71 | |
| 72 | @remote.method(HelloRequest, HelloResponse) |
| 73 | def hello(self, request): |
| 74 | return HelloResponse(hello='Hello there, %s!' % |
| 75 | request.my_name) |
| 76 | |
| 77 | api_config = OpenApiGenerator().pretty_print_config_to_json(HelloService) |
| 78 | |
| 79 | The resulting api_config will be a JSON OpenAPI document describing the API |
| 80 | implemented by HelloService. |
| 81 | """ |
| 82 | |
| 83 | # Constants for categorizing a request method. |
| 84 | # __NO_BODY - Request without a request body, such as GET and DELETE methods. |
| 85 | # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body. |
| 86 | __NO_BODY = 1 # pylint: disable=invalid-name |
| 87 | __HAS_BODY = 2 # pylint: disable=invalid-name |
| 88 | |
| 89 | def __init__(self): |
| 90 | self.__parser = message_parser.MessageTypeToJsonSchema() |
| 91 | |
| 92 | # Maps method id to the request schema id. |
| 93 | self.__request_schema = {} |
| 94 | |
| 95 | # Maps method id to the response schema id. |
| 96 | self.__response_schema = {} |
| 97 | |
| 98 | def _add_def_paths(self, prop_dict): |
| 99 | """Recursive method to add relative paths for any $ref objects. |
| 100 | |
| 101 | Args: |
| 102 | prop_dict: The property dict to alter. |
| 103 | |
| 104 | Side Effects: |
| 105 | Alters prop_dict in-place. |
| 106 | """ |
| 107 | for prop_key, prop_value in prop_dict.items(): |
| 108 | if prop_key == '$ref' and not 'prop_value'.startswith('#'): |
| 109 | prop_dict[prop_key] = '#/definitions/' + prop_dict[prop_key] |
| 110 | elif isinstance(prop_value, dict): |
| 111 | self._add_def_paths(prop_value) |
| 112 | |
| 113 | def _construct_operation_id(self, service_name, protorpc_method_name): |
| 114 | """Return an operation id for a service method. |
| 115 | |
| 116 | Args: |
| 117 | service_name: The name of the service. |
| 118 | protorpc_method_name: The ProtoRPC method name. |
| 119 | |
| 120 | Returns: |
| 121 | A string representing the operation id. |
| 122 | """ |
| 123 | |
| 124 | # camelCase the ProtoRPC method name |
| 125 | method_name_camel = util.snake_case_to_headless_camel_case( |
| 126 | protorpc_method_name) |
| 127 | |
| 128 | return '{0}_{1}'.format(service_name, method_name_camel) |
| 129 | |
| 130 | def __get_request_kind(self, method_info): |
| 131 | """Categorize the type of the request. |
| 132 | |
| 133 | Args: |
| 134 | method_info: _MethodInfo, method information. |
| 135 | |
| 136 | Returns: |
| 137 | The kind of request. |
| 138 | """ |
| 139 | if method_info.http_method in ('GET', 'DELETE'): |
| 140 | return self.__NO_BODY |
| 141 | else: |
| 142 | return self.__HAS_BODY |
| 143 | |
| 144 | def __field_to_subfields(self, field): |
| 145 | """Fully describes data represented by field, including the nested case. |
| 146 | |
| 147 | In the case that the field is not a message field, we have no fields nested |
| 148 | within a message definition, so we can simply return that field. However, in |
| 149 | the nested case, we can't simply describe the data with one field or even |
| 150 | with one chain of fields. |
| 151 | |
| 152 | For example, if we have a message field |
| 153 | |
| 154 | m_field = messages.MessageField(RefClass, 1) |
| 155 | |
| 156 | which references a class with two fields: |
| 157 | |
| 158 | class RefClass(messages.Message): |
| 159 | one = messages.StringField(1) |
| 160 | two = messages.IntegerField(2) |
| 161 | |
| 162 | then we would need to include both one and two to represent all the |
| 163 | data contained. |
| 164 | |
| 165 | Calling __field_to_subfields(m_field) would return: |
| 166 | [ |
| 167 | [<MessageField "m_field">, <StringField "one">], |
| 168 | [<MessageField "m_field">, <StringField "two">], |
| 169 | ] |
| 170 | |
| 171 | If the second field was instead a message field |
| 172 | |
| 173 | class RefClass(messages.Message): |
| 174 | one = messages.StringField(1) |
| 175 | two = messages.MessageField(OtherRefClass, 2) |
| 176 | |
| 177 | referencing another class with two fields |
| 178 | |
| 179 | class OtherRefClass(messages.Message): |
| 180 | three = messages.BooleanField(1) |
| 181 | four = messages.FloatField(2) |
| 182 | |
| 183 | then we would need to recurse one level deeper for two. |
| 184 | |
| 185 | With this change, calling __field_to_subfields(m_field) would return: |
| 186 | [ |
| 187 | [<MessageField "m_field">, <StringField "one">], |
| 188 | [<MessageField "m_field">, <StringField "two">, <StringField "three">], |
| 189 | [<MessageField "m_field">, <StringField "two">, <StringField "four">], |
| 190 | ] |
| 191 | |
| 192 | Args: |
| 193 | field: An instance of a subclass of messages.Field. |
| 194 | |
| 195 | Returns: |
| 196 | A list of lists, where each sublist is a list of fields. |
| 197 | """ |
| 198 | # Termination condition |
| 199 | if not isinstance(field, messages.MessageField): |
| 200 | return [[field]] |
| 201 | |
| 202 | result = [] |
| 203 | for subfield in sorted(field.message_type.all_fields(), |
| 204 | key=lambda f: f.number): |
| 205 | subfield_results = self.__field_to_subfields(subfield) |
| 206 | for subfields_list in subfield_results: |
| 207 | subfields_list.insert(0, field) |
| 208 | result.append(subfields_list) |
| 209 | return result |
| 210 | |
| 211 | def __field_to_parameter_type_and_format(self, field): |
| 212 | """Converts the field variant type into a tuple describing the parameter. |
| 213 | |
| 214 | Args: |
| 215 | field: An instance of a subclass of messages.Field. |
| 216 | |
| 217 | Returns: |
| 218 | A tuple with the type and format of the field, respectively. |
| 219 | |
| 220 | Raises: |
| 221 | TypeError: if the field variant is a message variant. |
| 222 | """ |
| 223 | # We use lowercase values for types (e.g. 'string' instead of 'STRING'). |
| 224 | variant = field.variant |
| 225 | if variant == messages.Variant.MESSAGE: |
| 226 | raise TypeError('A message variant can\'t be used in a parameter.') |
| 227 | |
| 228 | # Note that the 64-bit integers are marked as strings -- this is to |
| 229 | # accommodate JavaScript, which would otherwise demote them to 32-bit |
| 230 | # integers. |
| 231 | |
| 232 | custom_variant_map = { |
| 233 | messages.Variant.DOUBLE: ('number', 'double'), |
| 234 | messages.Variant.FLOAT: ('number', 'float'), |
| 235 | messages.Variant.INT64: ('string', 'int64'), |
| 236 | messages.Variant.SINT64: ('string', 'int64'), |
| 237 | messages.Variant.UINT64: ('string', 'uint64'), |
| 238 | messages.Variant.INT32: ('integer', 'int32'), |
| 239 | messages.Variant.SINT32: ('integer', 'int32'), |
| 240 | messages.Variant.UINT32: ('integer', 'uint32'), |
| 241 | messages.Variant.BOOL: ('boolean', None), |
| 242 | messages.Variant.STRING: ('string', None), |
| 243 | messages.Variant.BYTES: ('string', 'byte'), |
| 244 | messages.Variant.ENUM: ('string', None), |
| 245 | } |
| 246 | return custom_variant_map.get(variant) or (variant.name.lower(), None) |
| 247 | |
| 248 | def __get_path_parameters(self, path): |
| 249 | """Parses path paremeters from a URI path and organizes them by parameter. |
| 250 | |
| 251 | Some of the parameters may correspond to message fields, and so will be |
| 252 | represented as segments corresponding to each subfield; e.g. first.second if |
| 253 | the field "second" in the message field "first" is pulled from the path. |
| 254 | |
| 255 | The resulting dictionary uses the first segments as keys and each key has as |
| 256 | value the list of full parameter values with first segment equal to the key. |
| 257 | |
| 258 | If the match path parameter is null, that part of the path template is |
| 259 | ignored; this occurs if '{}' is used in a template. |
| 260 | |
| 261 | Args: |
| 262 | path: String; a URI path, potentially with some parameters. |
| 263 | |
| 264 | Returns: |
| 265 | A dictionary with strings as keys and list of strings as values. |
| 266 | """ |
| 267 | path_parameters_by_segment = {} |
| 268 | for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path): |
| 269 | first_segment = format_var_name.split('.', 1)[0] |
| 270 | matches = path_parameters_by_segment.setdefault(first_segment, []) |
| 271 | matches.append(format_var_name) |
| 272 | |
| 273 | return path_parameters_by_segment |
| 274 | |
| 275 | def __validate_simple_subfield(self, parameter, field, segment_list, |
| 276 | segment_index=0): |
| 277 | """Verifies that a proposed subfield actually exists and is a simple field. |
| 278 | |
| 279 | Here, simple means it is not a MessageField (nested). |
| 280 | |
| 281 | Args: |
| 282 | parameter: String; the '.' delimited name of the current field being |
| 283 | considered. This is relative to some root. |
| 284 | field: An instance of a subclass of messages.Field. Corresponds to the |
| 285 | previous segment in the path (previous relative to _segment_index), |
| 286 | since this field should be a message field with the current segment |
| 287 | as a field in the message class. |
| 288 | segment_list: The full list of segments from the '.' delimited subfield |
| 289 | being validated. |
| 290 | segment_index: Integer; used to hold the position of current segment so |
| 291 | that segment_list can be passed as a reference instead of having to |
| 292 | copy using segment_list[1:] at each step. |
| 293 | |
| 294 | Raises: |
| 295 | TypeError: If the final subfield (indicated by _segment_index relative |
| 296 | to the length of segment_list) is a MessageField. |
| 297 | TypeError: If at any stage the lookup at a segment fails, e.g if a.b |
| 298 | exists but a.b.c does not exist. This can happen either if a.b is not |
| 299 | a message field or if a.b.c is not a property on the message class from |
| 300 | a.b. |
| 301 | """ |
| 302 | if segment_index >= len(segment_list): |
| 303 | # In this case, the field is the final one, so should be simple type |
| 304 | if isinstance(field, messages.MessageField): |
| 305 | field_class = field.__class__.__name__ |
| 306 | raise TypeError('Can\'t use messages in path. Subfield %r was ' |
| 307 | 'included but is a %s.' % (parameter, field_class)) |
| 308 | return |
| 309 | |
| 310 | segment = segment_list[segment_index] |
| 311 | parameter += '.' + segment |
| 312 | try: |
| 313 | field = field.type.field_by_name(segment) |
| 314 | except (AttributeError, KeyError): |
| 315 | raise TypeError('Subfield %r from path does not exist.' % (parameter,)) |
| 316 | |
| 317 | self.__validate_simple_subfield(parameter, field, segment_list, |
| 318 | segment_index=segment_index + 1) |
| 319 | |
| 320 | def __validate_path_parameters(self, field, path_parameters): |
| 321 | """Verifies that all path parameters correspond to an existing subfield. |
| 322 | |
| 323 | Args: |
| 324 | field: An instance of a subclass of messages.Field. Should be the root |
| 325 | level property name in each path parameter in path_parameters. For |
| 326 | example, if the field is called 'foo', then each path parameter should |
| 327 | begin with 'foo.'. |
| 328 | path_parameters: A list of Strings representing URI parameter variables. |
| 329 | |
| 330 | Raises: |
| 331 | TypeError: If one of the path parameters does not start with field.name. |
| 332 | """ |
| 333 | for param in path_parameters: |
| 334 | segment_list = param.split('.') |
| 335 | if segment_list[0] != field.name: |
| 336 | raise TypeError('Subfield %r can\'t come from field %r.' |
| 337 | % (param, field.name)) |
| 338 | self.__validate_simple_subfield(field.name, field, segment_list[1:]) |
| 339 | |
| 340 | def __parameter_default(self, field): |
| 341 | """Returns default value of field if it has one. |
| 342 | |
| 343 | Args: |
| 344 | field: A simple field. |
| 345 | |
| 346 | Returns: |
| 347 | The default value of the field, if any exists, with the exception of an |
| 348 | enum field, which will have its value cast to a string. |
| 349 | """ |
| 350 | if field.default: |
| 351 | if isinstance(field, messages.EnumField): |
| 352 | return field.default.name |
| 353 | else: |
| 354 | return field.default |
| 355 | |
| 356 | def __parameter_enum(self, param): |
| 357 | """Returns enum descriptor of a parameter if it is an enum. |
| 358 | |
| 359 | An enum descriptor is a list of keys. |
| 360 | |
| 361 | Args: |
| 362 | param: A simple field. |
| 363 | |
| 364 | Returns: |
| 365 | The enum descriptor for the field, if it's an enum descriptor, else |
| 366 | returns None. |
| 367 | """ |
| 368 | if isinstance(param, messages.EnumField): |
| 369 | return [enum_entry[0] for enum_entry in sorted( |
| 370 | param.type.to_dict().items(), key=lambda v: v[1])] |
| 371 | |
| 372 | def __body_parameter_descriptor(self, method_id): |
| 373 | return { |
| 374 | 'name': 'body', |
| 375 | 'in': 'body', |
| 376 | 'required': True, |
| 377 | 'schema': { |
| 378 | '$ref': '#/definitions/{0}'.format( |
| 379 | self.__request_schema[method_id]) |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | def __non_body_parameter_descriptor(self, param): |
| 384 | """Creates descriptor for a parameter. |
| 385 | |
| 386 | Args: |
| 387 | param: The parameter to be described. |
| 388 | |
| 389 | Returns: |
| 390 | Dictionary containing a descriptor for the parameter. |
| 391 | """ |
| 392 | descriptor = {} |
| 393 | |
| 394 | descriptor['name'] = param.name |
| 395 | |
| 396 | param_type, param_format = self.__field_to_parameter_type_and_format(param) |
| 397 | |
| 398 | # Required |
| 399 | if param.required: |
| 400 | descriptor['required'] = True |
| 401 | |
| 402 | # Type |
| 403 | descriptor['type'] = param_type |
| 404 | |
| 405 | # Format (optional) |
| 406 | if param_format: |
| 407 | descriptor['format'] = param_format |
| 408 | |
| 409 | # Default |
| 410 | default = self.__parameter_default(param) |
| 411 | if default is not None: |
| 412 | descriptor['default'] = default |
| 413 | |
| 414 | # Repeated |
| 415 | if param.repeated: |
| 416 | descriptor['repeated'] = True |
| 417 | |
| 418 | # Enum |
| 419 | enum_descriptor = self.__parameter_enum(param) |
| 420 | if enum_descriptor is not None: |
| 421 | descriptor['enum'] = enum_descriptor |
| 422 | |
| 423 | return descriptor |
| 424 | |
| 425 | def __path_parameter_descriptor(self, param): |
| 426 | descriptor = self.__non_body_parameter_descriptor(param) |
| 427 | descriptor['required'] = True |
| 428 | descriptor['in'] = 'path' |
| 429 | |
| 430 | return descriptor |
| 431 | |
| 432 | def __query_parameter_descriptor(self, param): |
| 433 | descriptor = self.__non_body_parameter_descriptor(param) |
| 434 | descriptor['in'] = 'query' |
| 435 | |
| 436 | # If this is a repeated field, convert it to the collectionFormat: multi |
| 437 | # style. |
| 438 | if param.repeated: |
| 439 | descriptor['collectionFormat'] = 'multi' |
| 440 | descriptor['items'] = { |
| 441 | 'type': descriptor['type'] |
| 442 | } |
| 443 | descriptor['type'] = 'array' |
| 444 | descriptor.pop('repeated', None) |
| 445 | |
| 446 | return descriptor |
| 447 | |
| 448 | def __add_parameter(self, param, path_parameters, params): |
| 449 | """Adds all parameters in a field to a method parameters descriptor. |
| 450 | |
| 451 | Simple fields will only have one parameter, but a message field 'x' that |
| 452 | corresponds to a message class with fields 'y' and 'z' will result in |
| 453 | parameters 'x.y' and 'x.z', for example. The mapping from field to |
| 454 | parameters is mostly handled by __field_to_subfields. |
| 455 | |
| 456 | Args: |
| 457 | param: Parameter to be added to the descriptor. |
| 458 | path_parameters: A list of parameters matched from a path for this field. |
| 459 | For example for the hypothetical 'x' from above if the path was |
| 460 | '/a/{x.z}/b/{other}' then this list would contain only the element |
| 461 | 'x.z' since 'other' does not match to this field. |
| 462 | params: List of parameters. Each parameter in the field. |
| 463 | """ |
| 464 | # If this is a simple field, just build the descriptor and append it. |
| 465 | # Otherwise, build a schema and assign it to this descriptor |
| 466 | if not isinstance(param, messages.MessageField): |
| 467 | if param.name in path_parameters: |
| 468 | descriptor = self.__path_parameter_descriptor(param) |
| 469 | else: |
| 470 | descriptor = self.__query_parameter_descriptor(param) |
| 471 | |
| 472 | params.append(descriptor) |
| 473 | else: |
| 474 | # If a subfield of a MessageField is found in the path, build a descriptor |
| 475 | # for the path parameter. |
| 476 | for subfield_list in self.__field_to_subfields(param): |
| 477 | qualified_name = '.'.join(subfield.name for subfield in subfield_list) |
| 478 | if qualified_name in path_parameters: |
| 479 | descriptor = self.__path_parameter_descriptor(subfield_list[-1]) |
| 480 | descriptor['required'] = True |
| 481 | |
| 482 | params.append(descriptor) |
| 483 | |
| 484 | def __params_descriptor_without_container(self, message_type, |
| 485 | request_kind, method_id, path): |
| 486 | """Describe parameters of a method which does not use a ResourceContainer. |
| 487 | |
| 488 | Makes sure that the path parameters are included in the message definition |
| 489 | and adds any required fields and URL query parameters. |
| 490 | |
| 491 | This method is to preserve backwards compatibility and will be removed in |
| 492 | a future release. |
| 493 | |
| 494 | Args: |
| 495 | message_type: messages.Message class, Message with parameters to describe. |
| 496 | request_kind: The type of request being made. |
| 497 | method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| 498 | path: string, HTTP path to method. |
| 499 | |
| 500 | Returns: |
| 501 | A list of dicts: Descriptors of the parameters |
| 502 | """ |
| 503 | params = [] |
| 504 | |
| 505 | path_parameter_dict = self.__get_path_parameters(path) |
| 506 | for field in sorted(message_type.all_fields(), key=lambda f: f.number): |
| 507 | matched_path_parameters = path_parameter_dict.get(field.name, []) |
| 508 | self.__validate_path_parameters(field, matched_path_parameters) |
| 509 | |
| 510 | if matched_path_parameters or request_kind == self.__NO_BODY: |
| 511 | self.__add_parameter(field, matched_path_parameters, params) |
| 512 | |
| 513 | # If the request has a body, add the body parameter |
| 514 | if (message_type != message_types.VoidMessage() and |
| 515 | request_kind == self.__HAS_BODY): |
| 516 | params.append(self.__body_parameter_descriptor(method_id)) |
| 517 | |
| 518 | return params |
| 519 | |
| 520 | def __params_descriptor(self, message_type, request_kind, path, method_id): |
| 521 | """Describe the parameters of a method. |
| 522 | |
| 523 | If the message_type is not a ResourceContainer, will fall back to |
| 524 | __params_descriptor_without_container (which will eventually be deprecated). |
| 525 | |
| 526 | If the message type is a ResourceContainer, then all path/query parameters |
| 527 | will come from the ResourceContainer. This method will also make sure all |
| 528 | path parameters are covered by the message fields. |
| 529 | |
| 530 | Args: |
| 531 | message_type: messages.Message or ResourceContainer class, Message with |
| 532 | parameters to describe. |
| 533 | request_kind: The type of request being made. |
| 534 | path: string, HTTP path to method. |
| 535 | method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| 536 | |
| 537 | Returns: |
| 538 | A tuple (dict, list of string): Descriptor of the parameters, Order of the |
| 539 | parameters. |
| 540 | """ |
| 541 | path_parameter_dict = self.__get_path_parameters(path) |
| 542 | |
| 543 | if not isinstance(message_type, resource_container.ResourceContainer): |
| 544 | if path_parameter_dict: |
| 545 | _logger.warning('Method %s specifies path parameters but you are not ' |
| 546 | 'using a ResourceContainer; instead, you are using %r. ' |
| 547 | 'This will fail in future releases; please switch to ' |
| 548 | 'using ResourceContainer as soon as possible.', |
| 549 | method_id, type(message_type)) |
| 550 | return self.__params_descriptor_without_container( |
| 551 | message_type, request_kind, method_id, path) |
| 552 | |
| 553 | # From here, we can assume message_type is a ResourceContainer. |
| 554 | params = [] |
| 555 | |
| 556 | # Process body parameter, if any |
| 557 | if message_type.body_message_class != message_types.VoidMessage: |
| 558 | params.append(self.__body_parameter_descriptor(method_id)) |
| 559 | |
| 560 | # Process path/querystring parameters |
| 561 | params_message_type = message_type.parameters_message_class() |
| 562 | |
| 563 | # Make sure all path parameters are covered. |
| 564 | for field_name, matched_path_parameters in path_parameter_dict.items(): |
| 565 | field = params_message_type.field_by_name(field_name) |
| 566 | self.__validate_path_parameters(field, matched_path_parameters) |
| 567 | |
| 568 | # Add all fields, sort by field.number since we have parameterOrder. |
| 569 | for field in sorted(params_message_type.all_fields(), |
| 570 | key=lambda f: f.number): |
| 571 | matched_path_parameters = path_parameter_dict.get(field.name, []) |
| 572 | self.__add_parameter(field, matched_path_parameters, params) |
| 573 | |
| 574 | return params |
| 575 | |
| 576 | def __request_message_descriptor(self, request_kind, message_type, method_id, |
| 577 | path): |
| 578 | """Describes the parameters and body of the request. |
| 579 | |
| 580 | Args: |
| 581 | request_kind: The type of request being made. |
| 582 | message_type: messages.Message or ResourceContainer class. The message to |
| 583 | describe. |
| 584 | method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| 585 | path: string, HTTP path to method. |
| 586 | |
| 587 | Returns: |
| 588 | Dictionary describing the request. |
| 589 | |
| 590 | Raises: |
| 591 | ValueError: if the method path and request required fields do not match |
| 592 | """ |
| 593 | if isinstance(message_type, resource_container.ResourceContainer): |
| 594 | base_message_type = message_type.body_message_class() |
| 595 | if (request_kind == self.__NO_BODY and |
| 596 | base_message_type != message_types.VoidMessage()): |
| 597 | msg = ('Method %s specifies a body message in its ResourceContainer, but ' |
| 598 | 'is a HTTP method type that cannot accept a body.') % method_id |
| 599 | raise api_exceptions.ApiConfigurationError(msg) |
| 600 | else: |
| 601 | base_message_type = message_type |
| 602 | |
| 603 | if (request_kind != self.__NO_BODY and |
| 604 | base_message_type != message_types.VoidMessage()): |
| 605 | self.__request_schema[method_id] = self.__parser.add_message( |
| 606 | base_message_type.__class__) |
| 607 | |
| 608 | params = self.__params_descriptor(message_type, request_kind, path, |
| 609 | method_id) |
| 610 | |
| 611 | return params |
| 612 | |
| 613 | def __definitions_descriptor(self): |
| 614 | """Describes the definitions section of the OpenAPI spec. |
| 615 | |
| 616 | Returns: |
| 617 | Dictionary describing the definitions of the spec. |
| 618 | """ |
| 619 | # Filter out any keys that aren't 'properties' or 'type' |
| 620 | result = {} |
| 621 | for def_key, def_value in self.__parser.schemas().items(): |
| 622 | if 'properties' in def_value or 'type' in def_value: |
| 623 | key_result = {} |
| 624 | required_keys = set() |
| 625 | if 'type' in def_value: |
| 626 | key_result['type'] = def_value['type'] |
| 627 | if 'properties' in def_value: |
| 628 | for prop_key, prop_value in def_value['properties'].items(): |
| 629 | if isinstance(prop_value, dict) and 'required' in prop_value: |
| 630 | required_keys.add(prop_key) |
| 631 | del prop_value['required'] |
| 632 | key_result['properties'] = def_value['properties'] |
| 633 | # Add in the required fields, if any |
| 634 | if required_keys: |
| 635 | key_result['required'] = sorted(required_keys) |
| 636 | result[def_key] = key_result |
| 637 | |
| 638 | # Add 'type': 'object' to all object properties |
| 639 | # Also, recursively add relative path to all $ref values |
| 640 | for def_value in result.values(): |
| 641 | for prop_value in def_value.values(): |
| 642 | if isinstance(prop_value, dict): |
| 643 | if '$ref' in prop_value: |
| 644 | prop_value['type'] = 'object' |
| 645 | self._add_def_paths(prop_value) |
| 646 | |
| 647 | return result |
| 648 | |
| 649 | def __response_message_descriptor(self, message_type, method_id): |
| 650 | """Describes the response. |
| 651 | |
| 652 | Args: |
| 653 | message_type: messages.Message class, The message to describe. |
| 654 | method_id: string, Unique method identifier (e.g. 'myapi.items.method') |
| 655 | |
| 656 | Returns: |
| 657 | Dictionary describing the response. |
| 658 | """ |
| 659 | |
| 660 | # Skeleton response descriptor, common to all response objects |
| 661 | descriptor = {'200': {'description': 'A successful response'}} |
| 662 | |
| 663 | if message_type != message_types.VoidMessage(): |
| 664 | self.__parser.add_message(message_type.__class__) |
| 665 | self.__response_schema[method_id] = self.__parser.ref_for_message_type( |
| 666 | message_type.__class__) |
| 667 | descriptor['200']['schema'] = {'$ref': '#/definitions/{0}'.format( |
| 668 | self.__response_schema[method_id])} |
| 669 | |
| 670 | return dict(descriptor) |
| 671 | |
| 672 | def __x_google_quota_descriptor(self, metric_costs): |
| 673 | """Describes the metric costs for a call. |
| 674 | |
| 675 | Args: |
| 676 | metric_costs: Dict of metric definitions to the integer cost value against |
| 677 | that metric. |
| 678 | |
| 679 | Returns: |
| 680 | A dict descriptor describing the Quota limits for the endpoint. |
| 681 | """ |
| 682 | return { |
| 683 | 'metricCosts': { |
| 684 | metric: cost for (metric, cost) in metric_costs.items() |
| 685 | } |
| 686 | } if metric_costs else None |
| 687 | |
| 688 | def __x_google_quota_definitions_descriptor(self, limit_definitions): |
| 689 | """Describes the quota limit definitions for an API. |
| 690 | |
| 691 | Args: |
| 692 | limit_definitions: List of endpoints.LimitDefinition tuples |
| 693 | |
| 694 | Returns: |
| 695 | A dict descriptor of the API's quota limit definitions. |
| 696 | """ |
| 697 | if not limit_definitions: |
| 698 | return None |
| 699 | |
| 700 | definitions_list = [{ |
| 701 | 'name': ld.metric_name, |
| 702 | 'metric': ld.metric_name, |
| 703 | 'unit': '1/min/{project}', |
| 704 | 'values': {'STANDARD': ld.default_limit}, |
| 705 | 'displayName': ld.display_name, |
| 706 | } for ld in limit_definitions] |
| 707 | |
| 708 | metrics = [{ |
| 709 | 'name': ld.metric_name, |
| 710 | 'valueType': 'INT64', |
| 711 | 'metricKind': 'GAUGE', |
| 712 | } for ld in limit_definitions] |
| 713 | |
| 714 | return { |
| 715 | 'quota': {'limits': definitions_list}, |
| 716 | 'metrics': metrics, |
| 717 | } |
| 718 | |
| 719 | def __method_descriptor(self, service, method_info, operation_id, |
| 720 | protorpc_method_info, security_definitions): |
| 721 | """Describes a method. |
| 722 | |
| 723 | Args: |
| 724 | service: endpoints.Service, Implementation of the API as a service. |
| 725 | method_info: _MethodInfo, Configuration for the method. |
| 726 | operation_id: string, Operation ID of the method |
| 727 | protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC |
| 728 | description of the method. |
| 729 | security_definitions: list of dicts, security definitions for the API. |
| 730 | |
| 731 | Returns: |
| 732 | Dictionary describing the method. |
| 733 | """ |
| 734 | descriptor = {} |
| 735 | |
| 736 | request_message_type = (resource_container.ResourceContainer. |
| 737 | get_request_message(protorpc_method_info.remote)) |
| 738 | request_kind = self.__get_request_kind(method_info) |
| 739 | remote_method = protorpc_method_info.remote |
| 740 | |
| 741 | path = method_info.get_path(service.api_info) |
| 742 | |
| 743 | descriptor['parameters'] = self.__request_message_descriptor( |
| 744 | request_kind, request_message_type, |
| 745 | method_info.method_id(service.api_info), |
| 746 | path) |
| 747 | descriptor['responses'] = self.__response_message_descriptor( |
| 748 | remote_method.response_type(), method_info.method_id(service.api_info)) |
| 749 | descriptor['operationId'] = operation_id |
| 750 | |
| 751 | # Insert the auth audiences, if any |
| 752 | api_key_required = method_info.is_api_key_required(service.api_info) |
| 753 | if method_info.audiences is not None: |
| 754 | descriptor['security'] = self.__security_descriptor( |
| 755 | method_info.audiences, security_definitions, |
| 756 | api_key_required=api_key_required) |
| 757 | elif service.api_info.audiences is not None or api_key_required: |
| 758 | descriptor['security'] = self.__security_descriptor( |
| 759 | service.api_info.audiences, security_definitions, |
| 760 | api_key_required=api_key_required) |
| 761 | |
| 762 | # Insert the metric costs, if any |
| 763 | if method_info.metric_costs: |
| 764 | descriptor['x-google-quota'] = self.__x_google_quota_descriptor( |
| 765 | method_info.metric_costs) |
| 766 | |
| 767 | return descriptor |
| 768 | |
| 769 | def __security_descriptor(self, audiences, security_definitions, |
| 770 | api_key_required=False): |
| 771 | if not audiences: |
| 772 | if not api_key_required: |
| 773 | # no security |
| 774 | return [] |
| 775 | # api key only |
| 776 | return [{_API_KEY: []}] |
| 777 | |
| 778 | if isinstance(audiences, (tuple, list)): |
| 779 | # security_definitions includes not just the base issuers, but also the |
| 780 | # hash-appended versions, so we need to filter them out |
| 781 | security_issuers = set() |
| 782 | for definition_key in security_definitions.keys(): |
| 783 | if definition_key == _API_KEY: |
| 784 | # API key definitions don't count for these purposes |
| 785 | continue |
| 786 | if '-' in definition_key: |
| 787 | split_key = definition_key.rsplit('-', 1)[0] |
| 788 | if split_key in security_definitions: |
| 789 | continue |
| 790 | security_issuers.add(definition_key) |
| 791 | |
| 792 | if security_issuers != {_DEFAULT_SECURITY_DEFINITION}: |
| 793 | raise api_exceptions.ApiConfigurationError( |
| 794 | 'audiences must be a dict when third-party issuers ' |
| 795 | '(auth0, firebase, etc) are in use.' |
| 796 | ) |
| 797 | audiences = {_DEFAULT_SECURITY_DEFINITION: audiences} |
| 798 | |
| 799 | results = [] |
| 800 | for issuer, issuer_audiences in audiences.items(): |
| 801 | result_dict = {} |
| 802 | if issuer not in security_definitions: |
| 803 | raise TypeError('Missing issuer {}'.format(issuer)) |
| 804 | audience_string = ','.join(sorted(issuer_audiences)) |
| 805 | audience_hash = hashfunc(audience_string) |
| 806 | full_definition_key = '-'.join([issuer, audience_hash]) |
| 807 | result_dict[full_definition_key] = [] |
| 808 | if api_key_required: |
| 809 | result_dict[_API_KEY] = [] |
| 810 | if full_definition_key not in security_definitions: |
| 811 | new_definition = dict(security_definitions[issuer]) |
| 812 | new_definition['x-google-audiences'] = audience_string |
| 813 | security_definitions[full_definition_key] = new_definition |
| 814 | results.append(result_dict) |
| 815 | |
| 816 | return results |
| 817 | |
| 818 | def __security_definitions_descriptor(self, issuers): |
| 819 | """Create a descriptor for the security definitions. |
| 820 | |
| 821 | Args: |
| 822 | issuers: dict, mapping issuer names to Issuer tuples |
| 823 | |
| 824 | Returns: |
| 825 | The dict representing the security definitions descriptor. |
| 826 | """ |
| 827 | if not issuers: |
| 828 | result = { |
| 829 | _DEFAULT_SECURITY_DEFINITION: { |
| 830 | 'authorizationUrl': '', |
| 831 | 'flow': 'implicit', |
| 832 | 'type': 'oauth2', |
| 833 | 'x-google-issuer': 'https://accounts.google.com', |
| 834 | 'x-google-jwks_uri': 'https://www.googleapis.com/oauth2/v3/certs', |
| 835 | } |
| 836 | } |
| 837 | return result |
| 838 | |
| 839 | result = {} |
| 840 | |
| 841 | for issuer_key, issuer_value in issuers.items(): |
| 842 | result[issuer_key] = { |
| 843 | 'authorizationUrl': '', |
| 844 | 'flow': 'implicit', |
| 845 | 'type': 'oauth2', |
| 846 | 'x-google-issuer': issuer_value.issuer, |
| 847 | } |
| 848 | |
| 849 | # If jwks_uri is omitted, the auth library will use OpenID discovery |
| 850 | # to find it. Otherwise, include it in the descriptor explicitly. |
| 851 | if issuer_value.jwks_uri: |
| 852 | result[issuer_key]['x-google-jwks_uri'] = issuer_value.jwks_uri |
| 853 | |
| 854 | return result |
| 855 | |
| 856 | def __get_merged_api_info(self, services): |
| 857 | """Builds a description of an API. |
| 858 | |
| 859 | Args: |
| 860 | services: List of protorpc.remote.Service instances implementing an |
| 861 | api/version. |
| 862 | |
| 863 | Returns: |
| 864 | The _ApiInfo object to use for the API that the given services implement. |
| 865 | |
| 866 | Raises: |
| 867 | ApiConfigurationError: If there's something wrong with the API |
| 868 | configuration, such as a multiclass API decorated with different API |
| 869 | descriptors (see the docstring for api()). |
| 870 | """ |
| 871 | merged_api_info = services[0].api_info |
| 872 | |
| 873 | # Verify that, if there are multiple classes here, they're allowed to |
| 874 | # implement the same API. |
| 875 | for service in services[1:]: |
| 876 | if not merged_api_info.is_same_api(service.api_info): |
| 877 | raise api_exceptions.ApiConfigurationError( |
| 878 | _MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name, |
| 879 | service.api_info.api_version)) |
| 880 | |
| 881 | return merged_api_info |
| 882 | |
| 883 | def __api_openapi_descriptor(self, services, hostname=None, x_google_api_name=False): |
| 884 | """Builds an OpenAPI description of an API. |
| 885 | |
| 886 | Args: |
| 887 | services: List of protorpc.remote.Service instances implementing an |
| 888 | api/version. |
| 889 | hostname: string, Hostname of the API, to override the value set on the |
| 890 | current service. Defaults to None. |
| 891 | |
| 892 | Returns: |
| 893 | A dictionary that can be deserialized into JSON and stored as an API |
| 894 | description document in OpenAPI format. |
| 895 | |
| 896 | Raises: |
| 897 | ApiConfigurationError: If there's something wrong with the API |
| 898 | configuration, such as a multiclass API decorated with different API |
| 899 | descriptors (see the docstring for api()), or a repeated method |
| 900 | signature. |
| 901 | """ |
| 902 | merged_api_info = self.__get_merged_api_info(services) |
| 903 | descriptor = self.get_descriptor_defaults(merged_api_info, |
| 904 | hostname=hostname, |
| 905 | x_google_api_name=x_google_api_name) |
| 906 | |
| 907 | description = merged_api_info.description |
| 908 | if not description and len(services) == 1: |
| 909 | description = services[0].__doc__ |
| 910 | if description: |
| 911 | descriptor['info']['description'] = description |
| 912 | |
| 913 | security_definitions = self.__security_definitions_descriptor( |
| 914 | merged_api_info.issuers) |
| 915 | |
| 916 | method_map = {} |
| 917 | method_collision_tracker = {} |
| 918 | rest_collision_tracker = {} |
| 919 | |
| 920 | for service in services: |
| 921 | remote_methods = service.all_remote_methods() |
| 922 | |
| 923 | for protorpc_meth_name in sorted(remote_methods.keys()): |
| 924 | protorpc_meth_info = remote_methods[protorpc_meth_name] |
| 925 | method_info = getattr(protorpc_meth_info, 'method_info', None) |
| 926 | # Skip methods that are not decorated with @method |
| 927 | if method_info is None: |
| 928 | continue |
| 929 | method_id = method_info.method_id(service.api_info) |
| 930 | is_api_key_required = method_info.is_api_key_required(service.api_info) |
| 931 | path = '/{0}/{1}/{2}'.format(merged_api_info.name, |
| 932 | merged_api_info.path_version, |
| 933 | method_info.get_path(service.api_info)) |
| 934 | verb = method_info.http_method.lower() |
| 935 | |
| 936 | if path not in method_map: |
| 937 | method_map[path] = {} |
| 938 | |
| 939 | # If an API key is required and the security definitions don't already |
| 940 | # have the apiKey issuer, add the appropriate notation now |
| 941 | if is_api_key_required and _API_KEY not in security_definitions: |
| 942 | security_definitions[_API_KEY] = { |
| 943 | 'type': 'apiKey', |
| 944 | 'name': _API_KEY_PARAM, |
| 945 | 'in': 'query' |
| 946 | } |
| 947 | |
| 948 | # Derive an OperationId from the method name data |
| 949 | operation_id = self._construct_operation_id( |
| 950 | service.__name__, protorpc_meth_name) |
| 951 | |
| 952 | method_map[path][verb] = self.__method_descriptor( |
| 953 | service, method_info, operation_id, protorpc_meth_info, |
| 954 | security_definitions) |
| 955 | |
| 956 | # Make sure the same method name isn't repeated. |
| 957 | if method_id in method_collision_tracker: |
| 958 | raise api_exceptions.ApiConfigurationError( |
| 959 | 'Method %s used multiple times, in classes %s and %s' % |
| 960 | (method_id, method_collision_tracker[method_id], |
| 961 | service.__name__)) |
| 962 | else: |
| 963 | method_collision_tracker[method_id] = service.__name__ |
| 964 | |
| 965 | # Make sure the same HTTP method & path aren't repeated. |
| 966 | rest_identifier = (method_info.http_method, |
| 967 | method_info.get_path(service.api_info)) |
| 968 | if rest_identifier in rest_collision_tracker: |
| 969 | raise api_exceptions.ApiConfigurationError( |
| 970 | '%s path "%s" used multiple times, in classes %s and %s' % |
| 971 | (method_info.http_method, method_info.get_path(service.api_info), |
| 972 | rest_collision_tracker[rest_identifier], |
| 973 | service.__name__)) |
| 974 | else: |
| 975 | rest_collision_tracker[rest_identifier] = service.__name__ |
| 976 | |
| 977 | if method_map: |
| 978 | descriptor['paths'] = method_map |
| 979 | |
| 980 | # Add request and/or response definitions, if any |
| 981 | definitions = self.__definitions_descriptor() |
| 982 | if definitions: |
| 983 | descriptor['definitions'] = definitions |
| 984 | |
| 985 | descriptor['securityDefinitions'] = security_definitions |
| 986 | |
| 987 | # Add quota limit metric definitions, if any |
| 988 | limit_definitions = self.__x_google_quota_definitions_descriptor( |
| 989 | merged_api_info.limit_definitions) |
| 990 | if limit_definitions: |
| 991 | descriptor['x-google-management'] = limit_definitions |
| 992 | |
| 993 | return descriptor |
| 994 | |
| 995 | def get_descriptor_defaults(self, api_info, hostname=None, x_google_api_name=False): |
| 996 | """Gets a default configuration for a service. |
| 997 | |
| 998 | Args: |
| 999 | api_info: _ApiInfo object for this service. |
| 1000 | hostname: string, Hostname of the API, to override the value set on the |
| 1001 | current service. Defaults to None. |
| 1002 | |
| 1003 | Returns: |
| 1004 | A dictionary with the default configuration. |
| 1005 | """ |
| 1006 | hostname = (hostname or util.get_app_hostname() or |
| 1007 | api_info.hostname) |
| 1008 | protocol = 'http' if ((hostname and hostname.startswith('localhost')) or |
| 1009 | util.is_running_on_devserver()) else 'https' |
| 1010 | base_path = api_info.base_path |
| 1011 | if base_path != '/': |
| 1012 | base_path = base_path.rstrip('/') |
| 1013 | defaults = { |
| 1014 | 'swagger': '2.0', |
| 1015 | 'info': { |
| 1016 | 'version': api_info.api_version, |
| 1017 | 'title': api_info.name |
| 1018 | }, |
| 1019 | 'host': hostname, |
| 1020 | 'consumes': ['application/json'], |
| 1021 | 'produces': ['application/json'], |
| 1022 | 'schemes': [protocol], |
| 1023 | 'basePath': base_path, |
| 1024 | } |
| 1025 | |
| 1026 | if x_google_api_name: |
| 1027 | defaults['x-google-api-name'] = _validate_api_name(api_info.name) |
| 1028 | |
| 1029 | return defaults |
| 1030 | |
| 1031 | def get_openapi_dict(self, services, hostname=None, x_google_api_name=False): |
| 1032 | """JSON dict description of a protorpc.remote.Service in OpenAPI format. |
| 1033 | |
| 1034 | Args: |
| 1035 | services: Either a single protorpc.remote.Service or a list of them |
| 1036 | that implements an api/version. |
| 1037 | hostname: string, Hostname of the API, to override the value set on the |
| 1038 | current service. Defaults to None. |
| 1039 | |
| 1040 | Returns: |
| 1041 | dict, The OpenAPI descriptor document as a JSON dict. |
| 1042 | """ |
| 1043 | |
| 1044 | if not isinstance(services, (tuple, list)): |
| 1045 | services = [services] |
| 1046 | |
| 1047 | # The type of a class that inherits from remote.Service is actually |
| 1048 | # remote._ServiceClass, thanks to metaclass strangeness. |
| 1049 | # pylint: disable=protected-access |
| 1050 | util.check_list_type(services, remote._ServiceClass, 'services', |
| 1051 | allow_none=False) |
| 1052 | |
| 1053 | return self.__api_openapi_descriptor(services, hostname=hostname, x_google_api_name=x_google_api_name) |
| 1054 | |
| 1055 | def pretty_print_config_to_json(self, services, hostname=None, x_google_api_name=False): |
| 1056 | """JSON string description of a protorpc.remote.Service in OpenAPI format. |
| 1057 | |
| 1058 | Args: |
| 1059 | services: Either a single protorpc.remote.Service or a list of them |
| 1060 | that implements an api/version. |
| 1061 | hostname: string, Hostname of the API, to override the value set on the |
| 1062 | current service. Defaults to None. |
| 1063 | |
| 1064 | Returns: |
| 1065 | string, The OpenAPI descriptor document as a JSON string. |
| 1066 | """ |
| 1067 | descriptor = self.get_openapi_dict(services, hostname, x_google_api_name=x_google_api_name) |
| 1068 | return json.dumps(descriptor, sort_keys=True, indent=2, |
| 1069 | separators=(',', ': ')) |
| 1070 | |
| 1071 | |
| 1072 | def hashfunc(string): |
| 1073 | return hashlib.md5(string).hexdigest()[:8] |