Merge branch 'main' into avm99963-monorail

Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266

GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
diff --git a/third_party/endpoints/discovery_generator.py b/third_party/endpoints/discovery_generator.py
new file mode 100644
index 0000000..72c0533
--- /dev/null
+++ b/third_party/endpoints/discovery_generator.py
@@ -0,0 +1,1057 @@
+# 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 discovery docs."""
+
+from __future__ import absolute_import
+
+import collections
+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 are not 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'
+
+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),
+}
+
+
+
+class DiscoveryGenerator(object):
+  """Generates a discovery doc 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 = DiscoveryGenerator().pretty_print_config_to_json(HelloService)
+
+  The resulting api_config will be a JSON discovery 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, request=None):
+    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 = {}
+
+    # The ApiRequest that called this generator
+    self.__request = request
+
+  def _get_resource_path(self, method_id):
+    """Return the resource path for a method or an empty array if none."""
+    return method_id.split('.')[1:-1]
+
+  def _get_canonical_method_id(self, method_id):
+    return method_id.split('.')[-1]
+
+  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, cycle=tuple()):
+    """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]]
+
+    if field.message_type.__name__ in cycle:
+      # We have a recursive cycle of messages. Call it quits.
+      return []
+
+    result = []
+    for subfield in sorted(field.message_type.all_fields(),
+                           key=lambda f: f.number):
+      cycle = cycle + (field.message_type.__name__, )
+      subfield_results = self.__field_to_subfields(subfield, cycle=cycle)
+      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 cannot 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.
+
+    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
+      elif isinstance(field, messages.BooleanField):
+        # The Python standard representation of a boolean value causes problems
+        # when generating client code.
+        return 'true' if field.default else 'false'
+      else:
+        return str(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 __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 = {}
+
+    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
+    # Note that enumDescriptions are not currently supported using the
+    # framework's annotations, so just insert blank strings.
+    enum_descriptor = self.__parameter_enum(param)
+    if enum_descriptor is not None:
+      descriptor['enum'] = enum_descriptor
+      descriptor['enumDescriptions'] = [''] * len(enum_descriptor)
+
+    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
+    descriptor = None
+    if not isinstance(param, messages.MessageField):
+      name = param.name
+      descriptor = self.__parameter_descriptor(param)
+      descriptor['location'] = 'path' if name in path_parameters else 'query'
+
+      if descriptor:
+        params[name] = descriptor
+    else:
+      for subfield_list in self.__field_to_subfields(param):
+        name = '.'.join(subfield.name for subfield in subfield_list)
+        descriptor = self.__parameter_descriptor(subfield_list[-1])
+        if name in path_parameters:
+          descriptor['required'] = True
+          descriptor['location'] = 'path'
+        else:
+          descriptor.pop('required', None)
+          descriptor['location'] = 'query'
+
+        if descriptor:
+          params[name] = descriptor
+
+
+  def __params_descriptor_without_container(self, message_type,
+                                            request_kind, 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.
+      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)
+
+    return params
+
+  def __params_descriptor(self, message_type, request_kind, path, method_id,
+                          request_params_class):
+    """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')
+      request_params_class: messages.Message, the original params message when
+        using a ResourceContainer. Otherwise, this should be null.
+
+    Returns:
+      A tuple (dict, list of string): Descriptor of the parameters, Order of the
+        parameters.
+    """
+    path_parameter_dict = self.__get_path_parameters(path)
+
+    if request_params_class is None:
+      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, path)
+
+    # From here, we can assume message_type is from a ResourceContainer.
+    message_type = request_params_class
+
+    params = {}
+
+    # Make sure all path parameters are covered.
+    for field_name, matched_path_parameters in path_parameter_dict.items():
+      field = 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(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 __params_order_descriptor(self, message_type, path, is_params_class=False):
+    """Describe the order of path parameters.
+
+    Args:
+      message_type: messages.Message class, Message with parameters to describe.
+      path: string, HTTP path to method.
+      is_params_class: boolean, Whether the message represents URL parameters.
+
+    Returns:
+      Descriptor list for the parameter order.
+    """
+    path_params = []
+    query_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, [])
+      if not isinstance(field, messages.MessageField):
+        name = field.name
+        if name in matched_path_parameters:
+          path_params.append(name)
+        elif is_params_class and field.required:
+          query_params.append(name)
+      else:
+        for subfield_list in self.__field_to_subfields(field):
+          name = '.'.join(subfield.name for subfield in subfield_list)
+          if name in matched_path_parameters:
+            path_params.append(name)
+          elif is_params_class and field.required:
+            query_params.append(name)
+
+    return path_params + sorted(query_params)
+
+  def __schemas_descriptor(self):
+    """Describes the schemas section of the discovery document.
+
+    Returns:
+      Dictionary describing the schemas of the document.
+    """
+    # Filter out any keys that aren't 'properties', 'type', or 'id'
+    result = {}
+    for schema_key, schema_value in self.__parser.schemas().items():
+      field_keys = schema_value.keys()
+      key_result = {}
+
+      # Some special processing for the properties value
+      if 'properties' in field_keys:
+        key_result['properties'] = schema_value['properties'].copy()
+        # Add in enumDescriptions for any enum properties and strip out
+        # the required tag for consistency with Java framework
+        for prop_key, prop_value in schema_value['properties'].items():
+          if 'enum' in prop_value:
+            num_enums = len(prop_value['enum'])
+            key_result['properties'][prop_key]['enumDescriptions'] = (
+                [''] * num_enums)
+          elif 'default' in prop_value:
+            # stringify default values
+            if prop_value.get('type') == 'boolean':
+              prop_value['default'] = 'true' if prop_value['default'] else 'false'
+            else:
+              prop_value['default'] = str(prop_value['default'])
+          key_result['properties'][prop_key].pop('required', None)
+
+      for key in ('type', 'id', 'description'):
+        if key in field_keys:
+          key_result[key] = schema_value[key]
+
+      if key_result:
+        result[schema_key] = key_result
+
+    # Add 'type': 'object' to all object properties
+    for schema_value in result.values():
+      for field_value in schema_value.values():
+        if isinstance(field_value, dict):
+          if '$ref' in field_value:
+            field_value['type'] = 'object'
+
+    return result
+
+  def __request_message_descriptor(self, request_kind, message_type, method_id,
+                                   request_body_class):
+    """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')
+      request_body_class: messages.Message of the original body when using
+          a ResourceContainer. Otherwise, this should be null.
+
+    Returns:
+      Dictionary describing the request.
+
+    Raises:
+      ValueError: if the method path and request required fields do not match
+    """
+    if request_body_class:
+      message_type = request_body_class
+
+    if (request_kind != self.__NO_BODY and
+        message_type != message_types.VoidMessage()):
+      self.__request_schema[method_id] = self.__parser.add_message(
+          message_type.__class__)
+      return {
+          '$ref': self.__request_schema[method_id],
+          'parameterName': 'resource',
+      }
+
+  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.
+    """
+    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__)
+      return {'$ref': self.__response_schema[method_id]}
+    else:
+      return None
+
+  def __method_descriptor(self, service, method_info,
+                          protorpc_method_info):
+    """Describes a method.
+
+    Args:
+      service: endpoints.Service, Implementation of the API as a service.
+      method_info: _MethodInfo, Configuration for the method.
+      protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC
+        description of the method.
+
+    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
+
+    method_id = method_info.method_id(service.api_info)
+
+    path = method_info.get_path(service.api_info)
+
+    description = protorpc_method_info.remote.method.__doc__
+
+    descriptor['id'] = method_id
+    descriptor['path'] = path
+    descriptor['httpMethod'] = method_info.http_method
+
+    if description:
+      descriptor['description'] = description
+
+    descriptor['scopes'] = [
+        'https://www.googleapis.com/auth/userinfo.email'
+    ]
+
+    parameters = self.__params_descriptor(
+        request_message_type, request_kind, path, method_id,
+        method_info.request_params_class)
+    if parameters:
+      descriptor['parameters'] = parameters
+
+    if method_info.request_params_class:
+      parameter_order = self.__params_order_descriptor(
+        method_info.request_params_class, path, is_params_class=True)
+    else:
+      parameter_order = self.__params_order_descriptor(
+        request_message_type, path, is_params_class=False)
+    if parameter_order:
+      descriptor['parameterOrder'] = parameter_order
+
+    request_descriptor = self.__request_message_descriptor(
+        request_kind, request_message_type, method_id,
+        method_info.request_body_class)
+    if request_descriptor is not None:
+      descriptor['request'] = request_descriptor
+
+    response_descriptor = self.__response_message_descriptor(
+        remote_method.response_type(), method_info.method_id(service.api_info))
+    if response_descriptor is not None:
+      descriptor['response'] = response_descriptor
+
+    return descriptor
+
+  def __resource_descriptor(self, resource_path, methods):
+    """Describes a resource.
+
+    Args:
+      resource_path: string, the path of the resource (e.g., 'entries.items')
+      methods: list of tuples of type
+        (endpoints.Service, protorpc.remote._RemoteMethodInfo), the methods
+        that serve this resource.
+
+    Returns:
+      Dictionary describing the resource.
+    """
+    descriptor = {}
+    method_map = {}
+    sub_resource_index = collections.defaultdict(list)
+    sub_resource_map = {}
+
+    resource_path_tokens = resource_path.split('.')
+    for service, protorpc_meth_info in methods:
+      method_info = getattr(protorpc_meth_info, 'method_info', None)
+      path = method_info.get_path(service.api_info)
+      method_id = method_info.method_id(service.api_info)
+      canonical_method_id = self._get_canonical_method_id(method_id)
+
+      current_resource_path = self._get_resource_path(method_id)
+
+      # Sanity-check that this method belongs to the resource path
+      if (current_resource_path[:len(resource_path_tokens)] !=
+          resource_path_tokens):
+        raise api_exceptions.ToolError(
+            'Internal consistency error in resource path {0}'.format(
+                current_resource_path))
+
+      # Remove the portion of the current method's resource path that's already
+      # part of the resource path at this level.
+      effective_resource_path = current_resource_path[
+          len(resource_path_tokens):]
+
+      # If this method is part of a sub-resource, note it and skip it for now
+      if effective_resource_path:
+        sub_resource_name = effective_resource_path[0]
+        new_resource_path = '.'.join([resource_path, sub_resource_name])
+        sub_resource_index[new_resource_path].append(
+            (service, protorpc_meth_info))
+      else:
+        method_map[canonical_method_id] = self.__method_descriptor(
+            service, method_info, protorpc_meth_info)
+
+    # Process any sub-resources
+    for sub_resource, sub_resource_methods in sub_resource_index.items():
+      sub_resource_name = sub_resource.split('.')[-1]
+      sub_resource_map[sub_resource_name] = self.__resource_descriptor(
+          sub_resource, sub_resource_methods)
+
+    if method_map:
+      descriptor['methods'] = method_map
+
+    if sub_resource_map:
+      descriptor['resources'] = sub_resource_map
+
+    return descriptor
+
+  def __standard_parameters_descriptor(self):
+    return {
+        'alt': {
+            'type': 'string',
+            'description': 'Data format for the response.',
+            'default': 'json',
+            'enum': ['json'],
+            'enumDescriptions': [
+                'Responses with Content-Type of application/json'
+            ],
+            'location': 'query',
+        },
+        'fields': {
+          'type': 'string',
+          'description': 'Selector specifying which fields to include in a '
+                         'partial response.',
+          'location': 'query',
+        },
+        'key': {
+            'type': 'string',
+            'description': 'API key. Your API key identifies your project and '
+                           'provides you with API access, quota, and reports. '
+                           'Required unless you provide an OAuth 2.0 token.',
+            'location': 'query',
+        },
+        'oauth_token': {
+            'type': 'string',
+            'description': 'OAuth 2.0 token for the current user.',
+            'location': 'query',
+        },
+        'prettyPrint': {
+            'type': 'boolean',
+            'description': 'Returns response with indentations and line '
+                           'breaks.',
+            'default': 'true',
+            'location': 'query',
+        },
+        'quotaUser': {
+            'type': 'string',
+            'description': 'Available to use for quota purposes for '
+                           'server-side applications. Can be any arbitrary '
+                           'string assigned to a user, but should not exceed '
+                           '40 characters. Overrides userIp if both are '
+                           'provided.',
+            'location': 'query',
+        },
+        'userIp': {
+            'type': 'string',
+            'description': 'IP address of the site where the request '
+                           'originates. Use this if you want to enforce '
+                           'per-user limits.',
+            'location': 'query',
+        },
+    }
+
+  def __standard_auth_descriptor(self, services):
+    scopes = {}
+    for service in services:
+      for scope in service.api_info.scope_objs:
+        scopes[scope.scope] = {'description': scope.description}
+    return {
+        'oauth2': {
+            'scopes': scopes
+        }
+    }
+
+  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.
+    """
+    base_paths = sorted(set(s.api_info.base_path for s in services))
+    if len(base_paths) != 1:
+      raise api_exceptions.ApiConfigurationError(
+          'Multiple base_paths found: {!r}'.format(base_paths))
+    names_versions = sorted(set(
+        (s.api_info.name, s.api_info.api_version) for s in services))
+    if len(names_versions) != 1:
+      raise api_exceptions.ApiConfigurationError(
+          'Multiple apis/versions found: {!r}'.format(names_versions))
+    return services[0].api_info
+
+  def __discovery_doc_descriptor(self, services, hostname=None):
+    """Builds a discovery doc for 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 in discovery doc 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)
+
+    description = merged_api_info.description
+    if not description and len(services) == 1:
+      description = services[0].__doc__
+    if description:
+      descriptor['description'] = description
+
+    descriptor['parameters'] = self.__standard_parameters_descriptor()
+    descriptor['auth'] = self.__standard_auth_descriptor(services)
+
+    # Add namespace information, if provided
+    if merged_api_info.namespace:
+      descriptor['ownerDomain'] = merged_api_info.namespace.owner_domain
+      descriptor['ownerName'] = merged_api_info.namespace.owner_name
+      descriptor['packagePath'] = merged_api_info.namespace.package_path or ''
+    else:
+      if merged_api_info.owner_domain is not None:
+        descriptor['ownerDomain'] = merged_api_info.owner_domain
+      if merged_api_info.owner_name is not None:
+        descriptor['ownerName'] = merged_api_info.owner_name
+      if merged_api_info.package_path is not None:
+        descriptor['packagePath'] = merged_api_info.package_path
+
+    method_map = {}
+    method_collision_tracker = {}
+    rest_collision_tracker = {}
+
+    resource_index = collections.defaultdict(list)
+    resource_map = {}
+
+    # For the first pass, only process top-level methods (that is, those methods
+    # that are unattached to a resource).
+    for service in services:
+      remote_methods = service.all_remote_methods()
+
+      for protorpc_meth_name, protorpc_meth_info in remote_methods.items():
+        method_info = getattr(protorpc_meth_info, 'method_info', None)
+        # Skip methods that are not decorated with @method
+        if method_info is None:
+          continue
+        path = method_info.get_path(service.api_info)
+        method_id = method_info.method_id(service.api_info)
+        canonical_method_id = self._get_canonical_method_id(method_id)
+        resource_path = self._get_resource_path(method_id)
+
+        # 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, path)
+        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, path,
+               rest_collision_tracker[rest_identifier],
+               service.__name__))
+        else:
+          rest_collision_tracker[rest_identifier] = service.__name__
+
+        # If this method is part of a resource, note it and skip it for now
+        if resource_path:
+          resource_index[resource_path[0]].append((service, protorpc_meth_info))
+        else:
+          method_map[canonical_method_id] = self.__method_descriptor(
+              service, method_info, protorpc_meth_info)
+
+    # Do another pass for methods attached to resources
+    for resource, resource_methods in resource_index.items():
+      resource_map[resource] = self.__resource_descriptor(resource,
+          resource_methods)
+
+    if method_map:
+      descriptor['methods'] = method_map
+
+    if resource_map:
+      descriptor['resources'] = resource_map
+
+    # Add schemas, if any
+    schemas = self.__schemas_descriptor()
+    if schemas:
+      descriptor['schemas'] = schemas
+
+    return descriptor
+
+  def get_descriptor_defaults(self, api_info, hostname=None):
+    """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.
+    """
+    if self.__request:
+      hostname = self.__request.reconstruct_hostname()
+      protocol = self.__request.url_scheme
+    else:
+      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'
+    full_base_path = '{0}{1}/{2}/'.format(api_info.base_path,
+                                          api_info.name,
+                                          api_info.path_version)
+    base_url = '{0}://{1}{2}'.format(protocol, hostname, full_base_path)
+    root_url = '{0}://{1}{2}'.format(protocol, hostname, api_info.base_path)
+    defaults = {
+        'kind': 'discovery#restDescription',
+        'discoveryVersion': 'v1',
+        'id': '{0}:{1}'.format(api_info.name, api_info.path_version),
+        'name': api_info.name,
+        'version': api_info.api_version,
+        'icons': {
+            'x16': 'https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png',
+            'x32': 'https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png'
+        },
+        'protocol': 'rest',
+        'servicePath': '{0}/{1}/'.format(api_info.name, api_info.path_version),
+        'batchPath': 'batch',
+        'basePath': full_base_path,
+        'rootUrl': root_url,
+        'baseUrl': base_url,
+        'description': 'This is an API',
+    }
+    if api_info.description:
+        defaults['description'] = api_info.description
+    if api_info.title:
+        defaults['title'] = api_info.title
+    if api_info.documentation:
+        defaults['documentationLink'] = api_info.documentation
+    if api_info.canonical_name:
+        defaults['canonicalName'] = api_info.canonical_name
+
+    return defaults
+
+  def get_discovery_doc(self, services, hostname=None):
+    """JSON dict description of a protorpc.remote.Service in discovery 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 discovery 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.__discovery_doc_descriptor(services, hostname=hostname)
+
+  def pretty_print_config_to_json(self, services, hostname=None):
+    """JSON string description of a protorpc.remote.Service in a discovery doc.
+
+    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 discovery doc descriptor document as a JSON string.
+    """
+    descriptor = self.get_discovery_doc(services, hostname)
+    return json.dumps(descriptor, sort_keys=True, indent=2,
+                      separators=(',', ': '))