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]