Merge branch 'main' into avm99963-monorail
Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266
GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
diff --git a/third_party/endpoints/openapi_generator.py b/third_party/endpoints/openapi_generator.py
new file mode 100644
index 0000000..058bf8d
--- /dev/null
+++ b/third_party/endpoints/openapi_generator.py
@@ -0,0 +1,1073 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A library for converting service configs to OpenAPI (Swagger) specs."""
+from __future__ import absolute_import
+
+import hashlib
+import json
+import logging
+import re
+
+from . import api_exceptions
+from . import message_parser
+from . import message_types
+from . import messages
+from . import remote
+from . import resource_container
+from . import util
+
+_logger = logging.getLogger(__name__)
+
+_PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}'
+
+_MULTICLASS_MISMATCH_ERROR_TEMPLATE = (
+ 'Attempting to implement service %s, version %s, with multiple '
+ 'classes that aren\'t compatible. See docstring for api() for '
+ 'examples how to implement a multi-class API.')
+
+_INVALID_AUTH_ISSUER = 'No auth issuer named %s defined in this Endpoints API.'
+
+_API_KEY = 'api_key'
+_API_KEY_PARAM = 'key'
+_DEFAULT_SECURITY_DEFINITION = 'google_id_token'
+
+
+_VALID_API_NAME = re.compile('^[a-z][a-z0-9]{0,39}$')
+
+
+def _validate_api_name(name):
+ valid = (_VALID_API_NAME.match(name) is not None)
+ if not valid:
+ raise api_exceptions.InvalidApiNameException(
+ 'The API name must match the regular expression {}'.format(
+ _VALID_API_NAME.pattern[1:-1]))
+ return name
+
+
+class OpenApiGenerator(object):
+ """Generates an OpenAPI spec from a ProtoRPC service.
+
+ Example:
+
+ class HelloRequest(messages.Message):
+ my_name = messages.StringField(1, required=True)
+
+ class HelloResponse(messages.Message):
+ hello = messages.StringField(1, required=True)
+
+ class HelloService(remote.Service):
+
+ @remote.method(HelloRequest, HelloResponse)
+ def hello(self, request):
+ return HelloResponse(hello='Hello there, %s!' %
+ request.my_name)
+
+ api_config = OpenApiGenerator().pretty_print_config_to_json(HelloService)
+
+ The resulting api_config will be a JSON OpenAPI document describing the API
+ implemented by HelloService.
+ """
+
+ # Constants for categorizing a request method.
+ # __NO_BODY - Request without a request body, such as GET and DELETE methods.
+ # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body.
+ __NO_BODY = 1 # pylint: disable=invalid-name
+ __HAS_BODY = 2 # pylint: disable=invalid-name
+
+ def __init__(self):
+ self.__parser = message_parser.MessageTypeToJsonSchema()
+
+ # Maps method id to the request schema id.
+ self.__request_schema = {}
+
+ # Maps method id to the response schema id.
+ self.__response_schema = {}
+
+ def _add_def_paths(self, prop_dict):
+ """Recursive method to add relative paths for any $ref objects.
+
+ Args:
+ prop_dict: The property dict to alter.
+
+ Side Effects:
+ Alters prop_dict in-place.
+ """
+ for prop_key, prop_value in prop_dict.items():
+ if prop_key == '$ref' and not 'prop_value'.startswith('#'):
+ prop_dict[prop_key] = '#/definitions/' + prop_dict[prop_key]
+ elif isinstance(prop_value, dict):
+ self._add_def_paths(prop_value)
+
+ def _construct_operation_id(self, service_name, protorpc_method_name):
+ """Return an operation id for a service method.
+
+ Args:
+ service_name: The name of the service.
+ protorpc_method_name: The ProtoRPC method name.
+
+ Returns:
+ A string representing the operation id.
+ """
+
+ # camelCase the ProtoRPC method name
+ method_name_camel = util.snake_case_to_headless_camel_case(
+ protorpc_method_name)
+
+ return '{0}_{1}'.format(service_name, method_name_camel)
+
+ def __get_request_kind(self, method_info):
+ """Categorize the type of the request.
+
+ Args:
+ method_info: _MethodInfo, method information.
+
+ Returns:
+ The kind of request.
+ """
+ if method_info.http_method in ('GET', 'DELETE'):
+ return self.__NO_BODY
+ else:
+ return self.__HAS_BODY
+
+ def __field_to_subfields(self, field):
+ """Fully describes data represented by field, including the nested case.
+
+ In the case that the field is not a message field, we have no fields nested
+ within a message definition, so we can simply return that field. However, in
+ the nested case, we can't simply describe the data with one field or even
+ with one chain of fields.
+
+ For example, if we have a message field
+
+ m_field = messages.MessageField(RefClass, 1)
+
+ which references a class with two fields:
+
+ class RefClass(messages.Message):
+ one = messages.StringField(1)
+ two = messages.IntegerField(2)
+
+ then we would need to include both one and two to represent all the
+ data contained.
+
+ Calling __field_to_subfields(m_field) would return:
+ [
+ [<MessageField "m_field">, <StringField "one">],
+ [<MessageField "m_field">, <StringField "two">],
+ ]
+
+ If the second field was instead a message field
+
+ class RefClass(messages.Message):
+ one = messages.StringField(1)
+ two = messages.MessageField(OtherRefClass, 2)
+
+ referencing another class with two fields
+
+ class OtherRefClass(messages.Message):
+ three = messages.BooleanField(1)
+ four = messages.FloatField(2)
+
+ then we would need to recurse one level deeper for two.
+
+ With this change, calling __field_to_subfields(m_field) would return:
+ [
+ [<MessageField "m_field">, <StringField "one">],
+ [<MessageField "m_field">, <StringField "two">, <StringField "three">],
+ [<MessageField "m_field">, <StringField "two">, <StringField "four">],
+ ]
+
+ Args:
+ field: An instance of a subclass of messages.Field.
+
+ Returns:
+ A list of lists, where each sublist is a list of fields.
+ """
+ # Termination condition
+ if not isinstance(field, messages.MessageField):
+ return [[field]]
+
+ result = []
+ for subfield in sorted(field.message_type.all_fields(),
+ key=lambda f: f.number):
+ subfield_results = self.__field_to_subfields(subfield)
+ for subfields_list in subfield_results:
+ subfields_list.insert(0, field)
+ result.append(subfields_list)
+ return result
+
+ def __field_to_parameter_type_and_format(self, field):
+ """Converts the field variant type into a tuple describing the parameter.
+
+ Args:
+ field: An instance of a subclass of messages.Field.
+
+ Returns:
+ A tuple with the type and format of the field, respectively.
+
+ Raises:
+ TypeError: if the field variant is a message variant.
+ """
+ # We use lowercase values for types (e.g. 'string' instead of 'STRING').
+ variant = field.variant
+ if variant == messages.Variant.MESSAGE:
+ raise TypeError('A message variant can\'t be used in a parameter.')
+
+ # Note that the 64-bit integers are marked as strings -- this is to
+ # accommodate JavaScript, which would otherwise demote them to 32-bit
+ # integers.
+
+ custom_variant_map = {
+ messages.Variant.DOUBLE: ('number', 'double'),
+ messages.Variant.FLOAT: ('number', 'float'),
+ messages.Variant.INT64: ('string', 'int64'),
+ messages.Variant.SINT64: ('string', 'int64'),
+ messages.Variant.UINT64: ('string', 'uint64'),
+ messages.Variant.INT32: ('integer', 'int32'),
+ messages.Variant.SINT32: ('integer', 'int32'),
+ messages.Variant.UINT32: ('integer', 'uint32'),
+ messages.Variant.BOOL: ('boolean', None),
+ messages.Variant.STRING: ('string', None),
+ messages.Variant.BYTES: ('string', 'byte'),
+ messages.Variant.ENUM: ('string', None),
+ }
+ return custom_variant_map.get(variant) or (variant.name.lower(), None)
+
+ def __get_path_parameters(self, path):
+ """Parses path paremeters from a URI path and organizes them by parameter.
+
+ Some of the parameters may correspond to message fields, and so will be
+ represented as segments corresponding to each subfield; e.g. first.second if
+ the field "second" in the message field "first" is pulled from the path.
+
+ The resulting dictionary uses the first segments as keys and each key has as
+ value the list of full parameter values with first segment equal to the key.
+
+ If the match path parameter is null, that part of the path template is
+ ignored; this occurs if '{}' is used in a template.
+
+ Args:
+ path: String; a URI path, potentially with some parameters.
+
+ Returns:
+ A dictionary with strings as keys and list of strings as values.
+ """
+ path_parameters_by_segment = {}
+ for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path):
+ first_segment = format_var_name.split('.', 1)[0]
+ matches = path_parameters_by_segment.setdefault(first_segment, [])
+ matches.append(format_var_name)
+
+ return path_parameters_by_segment
+
+ def __validate_simple_subfield(self, parameter, field, segment_list,
+ segment_index=0):
+ """Verifies that a proposed subfield actually exists and is a simple field.
+
+ Here, simple means it is not a MessageField (nested).
+
+ Args:
+ parameter: String; the '.' delimited name of the current field being
+ considered. This is relative to some root.
+ field: An instance of a subclass of messages.Field. Corresponds to the
+ previous segment in the path (previous relative to _segment_index),
+ since this field should be a message field with the current segment
+ as a field in the message class.
+ segment_list: The full list of segments from the '.' delimited subfield
+ being validated.
+ segment_index: Integer; used to hold the position of current segment so
+ that segment_list can be passed as a reference instead of having to
+ copy using segment_list[1:] at each step.
+
+ Raises:
+ TypeError: If the final subfield (indicated by _segment_index relative
+ to the length of segment_list) is a MessageField.
+ TypeError: If at any stage the lookup at a segment fails, e.g if a.b
+ exists but a.b.c does not exist. This can happen either if a.b is not
+ a message field or if a.b.c is not a property on the message class from
+ a.b.
+ """
+ if segment_index >= len(segment_list):
+ # In this case, the field is the final one, so should be simple type
+ if isinstance(field, messages.MessageField):
+ field_class = field.__class__.__name__
+ raise TypeError('Can\'t use messages in path. Subfield %r was '
+ 'included but is a %s.' % (parameter, field_class))
+ return
+
+ segment = segment_list[segment_index]
+ parameter += '.' + segment
+ try:
+ field = field.type.field_by_name(segment)
+ except (AttributeError, KeyError):
+ raise TypeError('Subfield %r from path does not exist.' % (parameter,))
+
+ self.__validate_simple_subfield(parameter, field, segment_list,
+ segment_index=segment_index + 1)
+
+ def __validate_path_parameters(self, field, path_parameters):
+ """Verifies that all path parameters correspond to an existing subfield.
+
+ Args:
+ field: An instance of a subclass of messages.Field. Should be the root
+ level property name in each path parameter in path_parameters. For
+ example, if the field is called 'foo', then each path parameter should
+ begin with 'foo.'.
+ path_parameters: A list of Strings representing URI parameter variables.
+
+ Raises:
+ TypeError: If one of the path parameters does not start with field.name.
+ """
+ for param in path_parameters:
+ segment_list = param.split('.')
+ if segment_list[0] != field.name:
+ raise TypeError('Subfield %r can\'t come from field %r.'
+ % (param, field.name))
+ self.__validate_simple_subfield(field.name, field, segment_list[1:])
+
+ def __parameter_default(self, field):
+ """Returns default value of field if it has one.
+
+ Args:
+ field: A simple field.
+
+ Returns:
+ The default value of the field, if any exists, with the exception of an
+ enum field, which will have its value cast to a string.
+ """
+ if field.default:
+ if isinstance(field, messages.EnumField):
+ return field.default.name
+ else:
+ return field.default
+
+ def __parameter_enum(self, param):
+ """Returns enum descriptor of a parameter if it is an enum.
+
+ An enum descriptor is a list of keys.
+
+ Args:
+ param: A simple field.
+
+ Returns:
+ The enum descriptor for the field, if it's an enum descriptor, else
+ returns None.
+ """
+ if isinstance(param, messages.EnumField):
+ return [enum_entry[0] for enum_entry in sorted(
+ param.type.to_dict().items(), key=lambda v: v[1])]
+
+ def __body_parameter_descriptor(self, method_id):
+ return {
+ 'name': 'body',
+ 'in': 'body',
+ 'required': True,
+ 'schema': {
+ '$ref': '#/definitions/{0}'.format(
+ self.__request_schema[method_id])
+ }
+ }
+
+ def __non_body_parameter_descriptor(self, param):
+ """Creates descriptor for a parameter.
+
+ Args:
+ param: The parameter to be described.
+
+ Returns:
+ Dictionary containing a descriptor for the parameter.
+ """
+ descriptor = {}
+
+ descriptor['name'] = param.name
+
+ param_type, param_format = self.__field_to_parameter_type_and_format(param)
+
+ # Required
+ if param.required:
+ descriptor['required'] = True
+
+ # Type
+ descriptor['type'] = param_type
+
+ # Format (optional)
+ if param_format:
+ descriptor['format'] = param_format
+
+ # Default
+ default = self.__parameter_default(param)
+ if default is not None:
+ descriptor['default'] = default
+
+ # Repeated
+ if param.repeated:
+ descriptor['repeated'] = True
+
+ # Enum
+ enum_descriptor = self.__parameter_enum(param)
+ if enum_descriptor is not None:
+ descriptor['enum'] = enum_descriptor
+
+ return descriptor
+
+ def __path_parameter_descriptor(self, param):
+ descriptor = self.__non_body_parameter_descriptor(param)
+ descriptor['required'] = True
+ descriptor['in'] = 'path'
+
+ return descriptor
+
+ def __query_parameter_descriptor(self, param):
+ descriptor = self.__non_body_parameter_descriptor(param)
+ descriptor['in'] = 'query'
+
+ # If this is a repeated field, convert it to the collectionFormat: multi
+ # style.
+ if param.repeated:
+ descriptor['collectionFormat'] = 'multi'
+ descriptor['items'] = {
+ 'type': descriptor['type']
+ }
+ descriptor['type'] = 'array'
+ descriptor.pop('repeated', None)
+
+ return descriptor
+
+ def __add_parameter(self, param, path_parameters, params):
+ """Adds all parameters in a field to a method parameters descriptor.
+
+ Simple fields will only have one parameter, but a message field 'x' that
+ corresponds to a message class with fields 'y' and 'z' will result in
+ parameters 'x.y' and 'x.z', for example. The mapping from field to
+ parameters is mostly handled by __field_to_subfields.
+
+ Args:
+ param: Parameter to be added to the descriptor.
+ path_parameters: A list of parameters matched from a path for this field.
+ For example for the hypothetical 'x' from above if the path was
+ '/a/{x.z}/b/{other}' then this list would contain only the element
+ 'x.z' since 'other' does not match to this field.
+ params: List of parameters. Each parameter in the field.
+ """
+ # If this is a simple field, just build the descriptor and append it.
+ # Otherwise, build a schema and assign it to this descriptor
+ if not isinstance(param, messages.MessageField):
+ if param.name in path_parameters:
+ descriptor = self.__path_parameter_descriptor(param)
+ else:
+ descriptor = self.__query_parameter_descriptor(param)
+
+ params.append(descriptor)
+ else:
+ # If a subfield of a MessageField is found in the path, build a descriptor
+ # for the path parameter.
+ for subfield_list in self.__field_to_subfields(param):
+ qualified_name = '.'.join(subfield.name for subfield in subfield_list)
+ if qualified_name in path_parameters:
+ descriptor = self.__path_parameter_descriptor(subfield_list[-1])
+ descriptor['required'] = True
+
+ params.append(descriptor)
+
+ def __params_descriptor_without_container(self, message_type,
+ request_kind, method_id, path):
+ """Describe parameters of a method which does not use a ResourceContainer.
+
+ Makes sure that the path parameters are included in the message definition
+ and adds any required fields and URL query parameters.
+
+ This method is to preserve backwards compatibility and will be removed in
+ a future release.
+
+ Args:
+ message_type: messages.Message class, Message with parameters to describe.
+ request_kind: The type of request being made.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+ path: string, HTTP path to method.
+
+ Returns:
+ A list of dicts: Descriptors of the parameters
+ """
+ params = []
+
+ path_parameter_dict = self.__get_path_parameters(path)
+ for field in sorted(message_type.all_fields(), key=lambda f: f.number):
+ matched_path_parameters = path_parameter_dict.get(field.name, [])
+ self.__validate_path_parameters(field, matched_path_parameters)
+
+ if matched_path_parameters or request_kind == self.__NO_BODY:
+ self.__add_parameter(field, matched_path_parameters, params)
+
+ # If the request has a body, add the body parameter
+ if (message_type != message_types.VoidMessage() and
+ request_kind == self.__HAS_BODY):
+ params.append(self.__body_parameter_descriptor(method_id))
+
+ return params
+
+ def __params_descriptor(self, message_type, request_kind, path, method_id):
+ """Describe the parameters of a method.
+
+ If the message_type is not a ResourceContainer, will fall back to
+ __params_descriptor_without_container (which will eventually be deprecated).
+
+ If the message type is a ResourceContainer, then all path/query parameters
+ will come from the ResourceContainer. This method will also make sure all
+ path parameters are covered by the message fields.
+
+ Args:
+ message_type: messages.Message or ResourceContainer class, Message with
+ parameters to describe.
+ request_kind: The type of request being made.
+ path: string, HTTP path to method.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+
+ Returns:
+ A tuple (dict, list of string): Descriptor of the parameters, Order of the
+ parameters.
+ """
+ path_parameter_dict = self.__get_path_parameters(path)
+
+ if not isinstance(message_type, resource_container.ResourceContainer):
+ if path_parameter_dict:
+ _logger.warning('Method %s specifies path parameters but you are not '
+ 'using a ResourceContainer; instead, you are using %r. '
+ 'This will fail in future releases; please switch to '
+ 'using ResourceContainer as soon as possible.',
+ method_id, type(message_type))
+ return self.__params_descriptor_without_container(
+ message_type, request_kind, method_id, path)
+
+ # From here, we can assume message_type is a ResourceContainer.
+ params = []
+
+ # Process body parameter, if any
+ if message_type.body_message_class != message_types.VoidMessage:
+ params.append(self.__body_parameter_descriptor(method_id))
+
+ # Process path/querystring parameters
+ params_message_type = message_type.parameters_message_class()
+
+ # Make sure all path parameters are covered.
+ for field_name, matched_path_parameters in path_parameter_dict.items():
+ field = params_message_type.field_by_name(field_name)
+ self.__validate_path_parameters(field, matched_path_parameters)
+
+ # Add all fields, sort by field.number since we have parameterOrder.
+ for field in sorted(params_message_type.all_fields(),
+ key=lambda f: f.number):
+ matched_path_parameters = path_parameter_dict.get(field.name, [])
+ self.__add_parameter(field, matched_path_parameters, params)
+
+ return params
+
+ def __request_message_descriptor(self, request_kind, message_type, method_id,
+ path):
+ """Describes the parameters and body of the request.
+
+ Args:
+ request_kind: The type of request being made.
+ message_type: messages.Message or ResourceContainer class. The message to
+ describe.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+ path: string, HTTP path to method.
+
+ Returns:
+ Dictionary describing the request.
+
+ Raises:
+ ValueError: if the method path and request required fields do not match
+ """
+ if isinstance(message_type, resource_container.ResourceContainer):
+ base_message_type = message_type.body_message_class()
+ if (request_kind == self.__NO_BODY and
+ base_message_type != message_types.VoidMessage()):
+ msg = ('Method %s specifies a body message in its ResourceContainer, but '
+ 'is a HTTP method type that cannot accept a body.') % method_id
+ raise api_exceptions.ApiConfigurationError(msg)
+ else:
+ base_message_type = message_type
+
+ if (request_kind != self.__NO_BODY and
+ base_message_type != message_types.VoidMessage()):
+ self.__request_schema[method_id] = self.__parser.add_message(
+ base_message_type.__class__)
+
+ params = self.__params_descriptor(message_type, request_kind, path,
+ method_id)
+
+ return params
+
+ def __definitions_descriptor(self):
+ """Describes the definitions section of the OpenAPI spec.
+
+ Returns:
+ Dictionary describing the definitions of the spec.
+ """
+ # Filter out any keys that aren't 'properties' or 'type'
+ result = {}
+ for def_key, def_value in self.__parser.schemas().items():
+ if 'properties' in def_value or 'type' in def_value:
+ key_result = {}
+ required_keys = set()
+ if 'type' in def_value:
+ key_result['type'] = def_value['type']
+ if 'properties' in def_value:
+ for prop_key, prop_value in def_value['properties'].items():
+ if isinstance(prop_value, dict) and 'required' in prop_value:
+ required_keys.add(prop_key)
+ del prop_value['required']
+ key_result['properties'] = def_value['properties']
+ # Add in the required fields, if any
+ if required_keys:
+ key_result['required'] = sorted(required_keys)
+ result[def_key] = key_result
+
+ # Add 'type': 'object' to all object properties
+ # Also, recursively add relative path to all $ref values
+ for def_value in result.values():
+ for prop_value in def_value.values():
+ if isinstance(prop_value, dict):
+ if '$ref' in prop_value:
+ prop_value['type'] = 'object'
+ self._add_def_paths(prop_value)
+
+ return result
+
+ def __response_message_descriptor(self, message_type, method_id):
+ """Describes the response.
+
+ Args:
+ message_type: messages.Message class, The message to describe.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+
+ Returns:
+ Dictionary describing the response.
+ """
+
+ # Skeleton response descriptor, common to all response objects
+ descriptor = {'200': {'description': 'A successful response'}}
+
+ if message_type != message_types.VoidMessage():
+ self.__parser.add_message(message_type.__class__)
+ self.__response_schema[method_id] = self.__parser.ref_for_message_type(
+ message_type.__class__)
+ descriptor['200']['schema'] = {'$ref': '#/definitions/{0}'.format(
+ self.__response_schema[method_id])}
+
+ return dict(descriptor)
+
+ def __x_google_quota_descriptor(self, metric_costs):
+ """Describes the metric costs for a call.
+
+ Args:
+ metric_costs: Dict of metric definitions to the integer cost value against
+ that metric.
+
+ Returns:
+ A dict descriptor describing the Quota limits for the endpoint.
+ """
+ return {
+ 'metricCosts': {
+ metric: cost for (metric, cost) in metric_costs.items()
+ }
+ } if metric_costs else None
+
+ def __x_google_quota_definitions_descriptor(self, limit_definitions):
+ """Describes the quota limit definitions for an API.
+
+ Args:
+ limit_definitions: List of endpoints.LimitDefinition tuples
+
+ Returns:
+ A dict descriptor of the API's quota limit definitions.
+ """
+ if not limit_definitions:
+ return None
+
+ definitions_list = [{
+ 'name': ld.metric_name,
+ 'metric': ld.metric_name,
+ 'unit': '1/min/{project}',
+ 'values': {'STANDARD': ld.default_limit},
+ 'displayName': ld.display_name,
+ } for ld in limit_definitions]
+
+ metrics = [{
+ 'name': ld.metric_name,
+ 'valueType': 'INT64',
+ 'metricKind': 'GAUGE',
+ } for ld in limit_definitions]
+
+ return {
+ 'quota': {'limits': definitions_list},
+ 'metrics': metrics,
+ }
+
+ def __method_descriptor(self, service, method_info, operation_id,
+ protorpc_method_info, security_definitions):
+ """Describes a method.
+
+ Args:
+ service: endpoints.Service, Implementation of the API as a service.
+ method_info: _MethodInfo, Configuration for the method.
+ operation_id: string, Operation ID of the method
+ protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC
+ description of the method.
+ security_definitions: list of dicts, security definitions for the API.
+
+ Returns:
+ Dictionary describing the method.
+ """
+ descriptor = {}
+
+ request_message_type = (resource_container.ResourceContainer.
+ get_request_message(protorpc_method_info.remote))
+ request_kind = self.__get_request_kind(method_info)
+ remote_method = protorpc_method_info.remote
+
+ path = method_info.get_path(service.api_info)
+
+ descriptor['parameters'] = self.__request_message_descriptor(
+ request_kind, request_message_type,
+ method_info.method_id(service.api_info),
+ path)
+ descriptor['responses'] = self.__response_message_descriptor(
+ remote_method.response_type(), method_info.method_id(service.api_info))
+ descriptor['operationId'] = operation_id
+
+ # Insert the auth audiences, if any
+ api_key_required = method_info.is_api_key_required(service.api_info)
+ if method_info.audiences is not None:
+ descriptor['security'] = self.__security_descriptor(
+ method_info.audiences, security_definitions,
+ api_key_required=api_key_required)
+ elif service.api_info.audiences is not None or api_key_required:
+ descriptor['security'] = self.__security_descriptor(
+ service.api_info.audiences, security_definitions,
+ api_key_required=api_key_required)
+
+ # Insert the metric costs, if any
+ if method_info.metric_costs:
+ descriptor['x-google-quota'] = self.__x_google_quota_descriptor(
+ method_info.metric_costs)
+
+ return descriptor
+
+ def __security_descriptor(self, audiences, security_definitions,
+ api_key_required=False):
+ if not audiences:
+ if not api_key_required:
+ # no security
+ return []
+ # api key only
+ return [{_API_KEY: []}]
+
+ if isinstance(audiences, (tuple, list)):
+ # security_definitions includes not just the base issuers, but also the
+ # hash-appended versions, so we need to filter them out
+ security_issuers = set()
+ for definition_key in security_definitions.keys():
+ if definition_key == _API_KEY:
+ # API key definitions don't count for these purposes
+ continue
+ if '-' in definition_key:
+ split_key = definition_key.rsplit('-', 1)[0]
+ if split_key in security_definitions:
+ continue
+ security_issuers.add(definition_key)
+
+ if security_issuers != {_DEFAULT_SECURITY_DEFINITION}:
+ raise api_exceptions.ApiConfigurationError(
+ 'audiences must be a dict when third-party issuers '
+ '(auth0, firebase, etc) are in use.'
+ )
+ audiences = {_DEFAULT_SECURITY_DEFINITION: audiences}
+
+ results = []
+ for issuer, issuer_audiences in audiences.items():
+ result_dict = {}
+ if issuer not in security_definitions:
+ raise TypeError('Missing issuer {}'.format(issuer))
+ audience_string = ','.join(sorted(issuer_audiences))
+ audience_hash = hashfunc(audience_string)
+ full_definition_key = '-'.join([issuer, audience_hash])
+ result_dict[full_definition_key] = []
+ if api_key_required:
+ result_dict[_API_KEY] = []
+ if full_definition_key not in security_definitions:
+ new_definition = dict(security_definitions[issuer])
+ new_definition['x-google-audiences'] = audience_string
+ security_definitions[full_definition_key] = new_definition
+ results.append(result_dict)
+
+ return results
+
+ def __security_definitions_descriptor(self, issuers):
+ """Create a descriptor for the security definitions.
+
+ Args:
+ issuers: dict, mapping issuer names to Issuer tuples
+
+ Returns:
+ The dict representing the security definitions descriptor.
+ """
+ if not issuers:
+ result = {
+ _DEFAULT_SECURITY_DEFINITION: {
+ 'authorizationUrl': '',
+ 'flow': 'implicit',
+ 'type': 'oauth2',
+ 'x-google-issuer': 'https://accounts.google.com',
+ 'x-google-jwks_uri': 'https://www.googleapis.com/oauth2/v3/certs',
+ }
+ }
+ return result
+
+ result = {}
+
+ for issuer_key, issuer_value in issuers.items():
+ result[issuer_key] = {
+ 'authorizationUrl': '',
+ 'flow': 'implicit',
+ 'type': 'oauth2',
+ 'x-google-issuer': issuer_value.issuer,
+ }
+
+ # If jwks_uri is omitted, the auth library will use OpenID discovery
+ # to find it. Otherwise, include it in the descriptor explicitly.
+ if issuer_value.jwks_uri:
+ result[issuer_key]['x-google-jwks_uri'] = issuer_value.jwks_uri
+
+ return result
+
+ def __get_merged_api_info(self, services):
+ """Builds a description of an API.
+
+ Args:
+ services: List of protorpc.remote.Service instances implementing an
+ api/version.
+
+ Returns:
+ The _ApiInfo object to use for the API that the given services implement.
+
+ Raises:
+ ApiConfigurationError: If there's something wrong with the API
+ configuration, such as a multiclass API decorated with different API
+ descriptors (see the docstring for api()).
+ """
+ merged_api_info = services[0].api_info
+
+ # Verify that, if there are multiple classes here, they're allowed to
+ # implement the same API.
+ for service in services[1:]:
+ if not merged_api_info.is_same_api(service.api_info):
+ raise api_exceptions.ApiConfigurationError(
+ _MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name,
+ service.api_info.api_version))
+
+ return merged_api_info
+
+ def __api_openapi_descriptor(self, services, hostname=None, x_google_api_name=False):
+ """Builds an OpenAPI description of an API.
+
+ Args:
+ services: List of protorpc.remote.Service instances implementing an
+ api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ A dictionary that can be deserialized into JSON and stored as an API
+ description document in OpenAPI format.
+
+ Raises:
+ ApiConfigurationError: If there's something wrong with the API
+ configuration, such as a multiclass API decorated with different API
+ descriptors (see the docstring for api()), or a repeated method
+ signature.
+ """
+ merged_api_info = self.__get_merged_api_info(services)
+ descriptor = self.get_descriptor_defaults(merged_api_info,
+ hostname=hostname,
+ x_google_api_name=x_google_api_name)
+
+ description = merged_api_info.description
+ if not description and len(services) == 1:
+ description = services[0].__doc__
+ if description:
+ descriptor['info']['description'] = description
+
+ security_definitions = self.__security_definitions_descriptor(
+ merged_api_info.issuers)
+
+ method_map = {}
+ method_collision_tracker = {}
+ rest_collision_tracker = {}
+
+ for service in services:
+ remote_methods = service.all_remote_methods()
+
+ for protorpc_meth_name in sorted(remote_methods.keys()):
+ protorpc_meth_info = remote_methods[protorpc_meth_name]
+ method_info = getattr(protorpc_meth_info, 'method_info', None)
+ # Skip methods that are not decorated with @method
+ if method_info is None:
+ continue
+ method_id = method_info.method_id(service.api_info)
+ is_api_key_required = method_info.is_api_key_required(service.api_info)
+ path = '/{0}/{1}/{2}'.format(merged_api_info.name,
+ merged_api_info.path_version,
+ method_info.get_path(service.api_info))
+ verb = method_info.http_method.lower()
+
+ if path not in method_map:
+ method_map[path] = {}
+
+ # If an API key is required and the security definitions don't already
+ # have the apiKey issuer, add the appropriate notation now
+ if is_api_key_required and _API_KEY not in security_definitions:
+ security_definitions[_API_KEY] = {
+ 'type': 'apiKey',
+ 'name': _API_KEY_PARAM,
+ 'in': 'query'
+ }
+
+ # Derive an OperationId from the method name data
+ operation_id = self._construct_operation_id(
+ service.__name__, protorpc_meth_name)
+
+ method_map[path][verb] = self.__method_descriptor(
+ service, method_info, operation_id, protorpc_meth_info,
+ security_definitions)
+
+ # Make sure the same method name isn't repeated.
+ if method_id in method_collision_tracker:
+ raise api_exceptions.ApiConfigurationError(
+ 'Method %s used multiple times, in classes %s and %s' %
+ (method_id, method_collision_tracker[method_id],
+ service.__name__))
+ else:
+ method_collision_tracker[method_id] = service.__name__
+
+ # Make sure the same HTTP method & path aren't repeated.
+ rest_identifier = (method_info.http_method,
+ method_info.get_path(service.api_info))
+ if rest_identifier in rest_collision_tracker:
+ raise api_exceptions.ApiConfigurationError(
+ '%s path "%s" used multiple times, in classes %s and %s' %
+ (method_info.http_method, method_info.get_path(service.api_info),
+ rest_collision_tracker[rest_identifier],
+ service.__name__))
+ else:
+ rest_collision_tracker[rest_identifier] = service.__name__
+
+ if method_map:
+ descriptor['paths'] = method_map
+
+ # Add request and/or response definitions, if any
+ definitions = self.__definitions_descriptor()
+ if definitions:
+ descriptor['definitions'] = definitions
+
+ descriptor['securityDefinitions'] = security_definitions
+
+ # Add quota limit metric definitions, if any
+ limit_definitions = self.__x_google_quota_definitions_descriptor(
+ merged_api_info.limit_definitions)
+ if limit_definitions:
+ descriptor['x-google-management'] = limit_definitions
+
+ return descriptor
+
+ def get_descriptor_defaults(self, api_info, hostname=None, x_google_api_name=False):
+ """Gets a default configuration for a service.
+
+ Args:
+ api_info: _ApiInfo object for this service.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ A dictionary with the default configuration.
+ """
+ hostname = (hostname or util.get_app_hostname() or
+ api_info.hostname)
+ protocol = 'http' if ((hostname and hostname.startswith('localhost')) or
+ util.is_running_on_devserver()) else 'https'
+ base_path = api_info.base_path
+ if base_path != '/':
+ base_path = base_path.rstrip('/')
+ defaults = {
+ 'swagger': '2.0',
+ 'info': {
+ 'version': api_info.api_version,
+ 'title': api_info.name
+ },
+ 'host': hostname,
+ 'consumes': ['application/json'],
+ 'produces': ['application/json'],
+ 'schemes': [protocol],
+ 'basePath': base_path,
+ }
+
+ if x_google_api_name:
+ defaults['x-google-api-name'] = _validate_api_name(api_info.name)
+
+ return defaults
+
+ def get_openapi_dict(self, services, hostname=None, x_google_api_name=False):
+ """JSON dict description of a protorpc.remote.Service in OpenAPI format.
+
+ Args:
+ services: Either a single protorpc.remote.Service or a list of them
+ that implements an api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ dict, The OpenAPI descriptor document as a JSON dict.
+ """
+
+ if not isinstance(services, (tuple, list)):
+ services = [services]
+
+ # The type of a class that inherits from remote.Service is actually
+ # remote._ServiceClass, thanks to metaclass strangeness.
+ # pylint: disable=protected-access
+ util.check_list_type(services, remote._ServiceClass, 'services',
+ allow_none=False)
+
+ return self.__api_openapi_descriptor(services, hostname=hostname, x_google_api_name=x_google_api_name)
+
+ def pretty_print_config_to_json(self, services, hostname=None, x_google_api_name=False):
+ """JSON string description of a protorpc.remote.Service in OpenAPI format.
+
+ Args:
+ services: Either a single protorpc.remote.Service or a list of them
+ that implements an api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ string, The OpenAPI descriptor document as a JSON string.
+ """
+ descriptor = self.get_openapi_dict(services, hostname, x_google_api_name=x_google_api_name)
+ return json.dumps(descriptor, sort_keys=True, indent=2,
+ separators=(',', ': '))
+
+
+def hashfunc(string):
+ return hashlib.md5(string).hexdigest()[:8]