blob: 72c05334e4819283802f979fe08b8593a79af738 [file] [log] [blame]
# 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=(',', ': '))