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=(',', ': '))