blob: e24cd57298cd2318a45147a8680b5c9d26334770 [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.
"""Library for generating an API configuration document for a ProtoRPC backend.
The protorpc.remote.Service is inspected and a JSON document describing
the API is returned.
class MyResponse(messages.Message):
bool_value = messages.BooleanField(1)
int32_value = messages.IntegerField(2)
class MyService(remote.Service):
@remote.method(message_types.VoidMessage, MyResponse)
def entries_get(self, request):
pass
api = ApiConfigGenerator().pretty_print_config_to_json(MyService)
"""
# pylint: disable=g-bad-name
# pylint: disable=g-statement-before-imports,g-import-not-at-top
from __future__ import absolute_import
import json
import logging
import re
import six
from google.appengine.api import app_identity
import attr
from protorpc import util
from . import api_exceptions
from . import constants
from . import message_parser
from . import message_types
from . import messages
from . import remote
from . import resource_container
from . import types as endpoints_types
# originally in this module
from .types import Issuer, LimitDefinition, Namespace
from . import users_id_token
from . import util as endpoints_util
_logger = logging.getLogger(__name__)
package = 'google.appengine.endpoints'
__all__ = [
'ApiAuth',
'ApiConfigGenerator',
'ApiFrontEndLimitRule',
'ApiFrontEndLimits',
'EMAIL_SCOPE',
'Issuer',
'LimitDefinition',
'Namespace',
'api',
'method',
'AUTH_LEVEL',
'package',
]
EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
_EMAIL_SCOPE_DESCRIPTION = 'View your email address'
_EMAIL_SCOPE_OBJ = endpoints_types.OAuth2Scope(
scope=EMAIL_SCOPE, description=_EMAIL_SCOPE_DESCRIPTION)
_PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}'
_MULTICLASS_MISMATCH_ERROR_TEMPLATE = (
'Attempting to implement service %s, version %s, with multiple '
'classes that aren\'t compatible. See docstring for api() for '
'examples how to implement a multi-class API.')
_INVALID_NAMESPACE_ERROR_TEMPLATE = (
'Invalid namespace configuration. If a namespace is set, make sure to set '
'%s. package_path is optional.')
_VALID_PART_RE = re.compile('^{[^{}]+}$')
_VALID_LAST_PART_RE = re.compile('^{[^{}]+}(:)?(?(1)[^{}]+)$')
def _Enum(docstring, *names):
"""Utility to generate enum classes used by annotations.
Args:
docstring: Docstring for the generated enum class.
*names: Enum names.
Returns:
A class that contains enum names as attributes.
"""
enums = dict(zip(names, range(len(names))))
reverse = dict((value, key) for key, value in enums.items())
enums['reverse_mapping'] = reverse
enums['__doc__'] = docstring
return type('Enum', (object,), enums)
_AUTH_LEVEL_DOCSTRING = """
Define the enums used by the auth_level annotation to specify frontend
authentication requirement.
Frontend authentication is handled by a Google API server prior to the
request reaching backends. An early return before hitting the backend can
happen if the request does not fulfil the requirement specified by the
auth_level.
Valid values of auth_level and their meanings are:
AUTH_LEVEL.REQUIRED: Valid authentication credentials are required. Backend
will be called only if authentication credentials are present and valid.
AUTH_LEVEL.OPTIONAL: Authentication is optional. If authentication credentials
are supplied they must be valid. Backend will be called if the request
contains valid authentication credentials or no authentication credentials.
AUTH_LEVEL.OPTIONAL_CONTINUE: Authentication is optional and will be attempted
if authentication credentials are supplied. Invalid authentication
credentials will be removed but the request can always reach backend.
AUTH_LEVEL.NONE: Frontend authentication will be skipped. If authentication is
desired, it will need to be performed by the backend.
"""
AUTH_LEVEL = _Enum(_AUTH_LEVEL_DOCSTRING, 'REQUIRED', 'OPTIONAL',
'OPTIONAL_CONTINUE', 'NONE')
_AUTH_LEVEL_WARNING = ("Due to a design error, auth_level has never actually been functional. "
"It will likely be removed and replaced by a functioning alternative "
"in a future version of the framework. Please stop using auth_level now.")
def _GetFieldAttributes(field):
"""Decomposes field into the needed arguments to pass to the constructor.
This can be used to create copies of the field or to compare if two fields
are "equal" (since __eq__ is not implemented on messages.Field).
Args:
field: A ProtoRPC message field (potentially to be copied).
Raises:
TypeError: If the field is not an instance of messages.Field.
Returns:
A pair of relevant arguments to be passed to the constructor for the field
type. The first element is a list of positional arguments for the
constructor and the second is a dictionary of keyword arguments.
"""
if not isinstance(field, messages.Field):
raise TypeError('Field %r to be copied not a ProtoRPC field.' % (field,))
positional_args = []
kwargs = {
'required': field.required,
'repeated': field.repeated,
'variant': field.variant,
'default': field._Field__default, # pylint: disable=protected-access
}
if isinstance(field, messages.MessageField):
# Message fields can't have a default
kwargs.pop('default')
if not isinstance(field, message_types.DateTimeField):
positional_args.insert(0, field.message_type)
elif isinstance(field, messages.EnumField):
positional_args.insert(0, field.type)
return positional_args, kwargs
def _CheckType(value, check_type, name, allow_none=True):
"""Check that the type of an object is acceptable.
Args:
value: The object whose type is to be checked.
check_type: The type that the object must be an instance of.
name: Name of the object, to be placed in any error messages.
allow_none: True if value can be None, false if not.
Raises:
TypeError: If value is not an acceptable type.
"""
if value is None and allow_none:
return
if not isinstance(value, check_type):
raise TypeError('%s type doesn\'t match %s.' % (name, check_type))
def _CheckEnum(value, check_type, name):
if value is None:
return
if value not in check_type.reverse_mapping:
raise TypeError('%s is not a valid value for %s' % (value, name))
def _CheckNamespace(namespace):
_CheckType(namespace, Namespace, 'namespace')
if namespace:
if not namespace.owner_domain:
raise api_exceptions.InvalidNamespaceException(
_INVALID_NAMESPACE_ERROR_TEMPLATE % 'owner_domain')
if not namespace.owner_name:
raise api_exceptions.InvalidNamespaceException(
_INVALID_NAMESPACE_ERROR_TEMPLATE % 'owner_name')
_CheckType(namespace.owner_domain, six.string_types, 'namespace.owner_domain')
_CheckType(namespace.owner_name, six.string_types, 'namespace.owner_name')
_CheckType(namespace.package_path, six.string_types, 'namespace.package_path')
def _CheckAudiences(audiences):
# Audiences can either be a list of audiences using the google_id_token
# or a dict mapping auth issuer name to the list of audiences.
if audiences is None or isinstance(audiences, dict):
return
else:
endpoints_util.check_list_type(audiences, six.string_types, 'audiences')
def _CheckLimitDefinitions(limit_definitions):
_CheckType(limit_definitions, list, 'limit_definitions')
if limit_definitions:
for ld in limit_definitions:
if not ld.metric_name:
raise api_exceptions.InvalidLimitDefinitionException(
"Metric name must be set in all limit definitions.")
if not ld.display_name:
raise api_exceptions.InvalidLimitDefinitionException(
"Display name must be set in all limit definitions.")
_CheckType(ld.metric_name, six.string_types, 'limit_definition.metric_name')
_CheckType(ld.display_name, six.string_types, 'limit_definition.display_name')
_CheckType(ld.default_limit, int, 'limit_definition.default_limit')
# pylint: disable=g-bad-name
class _ApiInfo(object):
"""Configurable attributes of an API.
A structured data object used to store API information associated with each
remote.Service-derived class that implements an API. This stores properties
that could be different for each class (such as the path or
collection/resource name), as well as properties common to all classes in
the API (such as API name and version).
"""
@util.positional(2)
def __init__(self, common_info, resource_name=None, path=None, audiences=None,
scopes=None, allowed_client_ids=None, auth_level=None,
api_key_required=None):
"""Constructor for _ApiInfo.
Args:
common_info: _ApiDecorator.__ApiCommonInfo, Information that's common for
all classes that implement an API.
resource_name: string, The collection that the annotated class will
implement in the API. (Default: None)
path: string, Base request path for all methods in this API.
(Default: None)
audiences: list of strings, Acceptable audiences for authentication.
(Default: None)
scopes: list of strings, Acceptable scopes for authentication.
(Default: None)
allowed_client_ids: list of strings, Acceptable client IDs for auth.
(Default: None)
auth_level: enum from AUTH_LEVEL, Frontend authentication level.
(Default: None)
api_key_required: bool, whether a key is required to call this API.
"""
_CheckType(resource_name, six.string_types, 'resource_name')
_CheckType(path, six.string_types, 'path')
endpoints_util.check_list_type(audiences, six.string_types, 'audiences')
endpoints_util.check_list_type(scopes, six.string_types, 'scopes')
endpoints_util.check_list_type(allowed_client_ids, six.string_types,
'allowed_client_ids')
_CheckEnum(auth_level, AUTH_LEVEL, 'auth_level')
_CheckType(api_key_required, bool, 'api_key_required')
self.__common_info = common_info
self.__resource_name = resource_name
self.__path = path
self.__audiences = audiences
self.__scopes = endpoints_types.OAuth2Scope.convert_list(scopes)
self.__allowed_client_ids = allowed_client_ids
self.__auth_level = auth_level
self.__api_key_required = api_key_required
def is_same_api(self, other):
"""Check if this implements the same API as another _ApiInfo instance."""
if not isinstance(other, _ApiInfo):
return False
# pylint: disable=protected-access
return self.__common_info is other.__common_info
@property
def name(self):
"""Name of the API."""
return self.__common_info.name
@property
def api_version(self):
"""Version of the API."""
return self.__common_info.api_version
@property
def path_version(self):
"""Version of the API for putting in the path."""
return self.__common_info.path_version
@property
def description(self):
"""Description of the API."""
return self.__common_info.description
@property
def hostname(self):
"""Hostname for the API."""
return self.__common_info.hostname
@property
def audiences(self):
"""List of audiences accepted for the API, overriding the defaults."""
if self.__audiences is not None:
return self.__audiences
return self.__common_info.audiences
@property
def scope_objs(self):
"""List of scopes (as OAuth2Scopes) accepted for the API, overriding the defaults."""
if self.__scopes is not None:
return self.__scopes
return self.__common_info.scope_objs
@property
def scopes(self):
"""List of scopes (as strings) accepted for the API, overriding the defaults."""
if self.scope_objs is not None:
return [_s.scope for _s in self.scope_objs]
@property
def allowed_client_ids(self):
"""List of client IDs accepted for the API, overriding the defaults."""
if self.__allowed_client_ids is not None:
return self.__allowed_client_ids
return self.__common_info.allowed_client_ids
@property
def issuers(self):
"""Dict mapping auth issuer names to auth issuers for the API."""
return self.__common_info.issuers
@property
def namespace(self):
"""Namespace for the API."""
return self.__common_info.namespace
@property
def auth_level(self):
"""Enum from AUTH_LEVEL specifying the frontend authentication level."""
if self.__auth_level is not None:
return self.__auth_level
return self.__common_info.auth_level
@property
def api_key_required(self):
"""bool specifying whether a key is required to call into this API."""
if self.__api_key_required is not None:
return self.__api_key_required
return self.__common_info.api_key_required
@property
def canonical_name(self):
"""Canonical name for the API."""
return self.__common_info.canonical_name
@property
def auth(self):
"""Authentication configuration information for this API."""
return self.__common_info.auth
@property
def owner_domain(self):
"""Domain of the owner of this API."""
return self.__common_info.owner_domain
@property
def owner_name(self):
"""Name of the owner of this API."""
return self.__common_info.owner_name
@property
def package_path(self):
"""Package this API belongs to, '/' delimited. Used by client libs."""
return self.__common_info.package_path
@property
def frontend_limits(self):
"""Optional query limits for unregistered developers."""
return self.__common_info.frontend_limits
@property
def title(self):
"""Human readable name of this API."""
return self.__common_info.title
@property
def documentation(self):
"""Link to the documentation for this version of the API."""
return self.__common_info.documentation
@property
def resource_name(self):
"""Resource name for the class this decorates."""
return self.__resource_name
@property
def path(self):
"""Base path prepended to any method paths in the class this decorates."""
return self.__path
@property
def base_path(self):
"""Base path for the entire API prepended before the path property."""
return self.__common_info.base_path
@property
def limit_definitions(self):
"""Rate limiting metric definitions for this API."""
return self.__common_info.limit_definitions
@property
def use_request_uri(self):
"""Match request paths based on the REQUEST_URI instead of PATH_INFO."""
return self.__common_info.use_request_uri
class _ApiDecorator(object):
"""Decorator for single- or multi-class APIs.
An instance of this class can be used directly as a decorator for a
single-class API. Or call the api_class() method to decorate a multi-class
API.
"""
@util.positional(3)
def __init__(self, name, version, description=None, hostname=None,
audiences=None, scopes=None, allowed_client_ids=None,
canonical_name=None, auth=None, owner_domain=None,
owner_name=None, package_path=None, frontend_limits=None,
title=None, documentation=None, auth_level=None, issuers=None,
namespace=None, api_key_required=None, base_path=None,
limit_definitions=None, use_request_uri=None):
"""Constructor for _ApiDecorator.
Args:
name: string, Name of the API.
version: string, Version of the API.
description: string, Short description of the API (Default: None)
hostname: string, Hostname of the API (Default: app engine default host)
audiences: list of strings, Acceptable audiences for authentication.
scopes: list of strings, Acceptable scopes for authentication.
allowed_client_ids: list of strings, Acceptable client IDs for auth.
canonical_name: string, the canonical name for the API, a more human
readable version of the name.
auth: ApiAuth instance, the authentication configuration information
for this API.
owner_domain: string, the domain of the person or company that owns
this API. Along with owner_name, this provides hints to properly
name client libraries for this API.
owner_name: string, the name of the owner of this API. Along with
owner_domain, this provides hints to properly name client libraries
for this API.
package_path: string, the "package" this API belongs to. This '/'
delimited value specifies logical groupings of APIs. This is used by
client libraries of this API.
frontend_limits: ApiFrontEndLimits, optional query limits for unregistered
developers.
title: string, the human readable title of your API. It is exposed in the
discovery service.
documentation: string, a URL where users can find documentation about this
version of the API. This will be surfaced in the API Explorer and GPE
plugin to allow users to learn about your service.
auth_level: enum from AUTH_LEVEL, Frontend authentication level.
issuers: dict, mapping auth issuer names to endpoints.Issuer objects.
namespace: endpoints.Namespace, the namespace for the API.
api_key_required: bool, whether a key is required to call this API.
base_path: string, the base path for all endpoints in this API.
limit_definitions: list of LimitDefinition tuples used in this API.
use_request_uri: if true, match requests against REQUEST_URI instead of PATH_INFO
"""
self.__common_info = self.__ApiCommonInfo(
name, version, description=description, hostname=hostname,
audiences=audiences, scopes=scopes,
allowed_client_ids=allowed_client_ids,
canonical_name=canonical_name, auth=auth, owner_domain=owner_domain,
owner_name=owner_name, package_path=package_path,
frontend_limits=frontend_limits, title=title,
documentation=documentation, auth_level=auth_level, issuers=issuers,
namespace=namespace, api_key_required=api_key_required,
base_path=base_path, limit_definitions=limit_definitions,
use_request_uri=use_request_uri)
self.__classes = []
class __ApiCommonInfo(object):
"""API information that's common among all classes that implement an API.
When a remote.Service-derived class implements part of an API, there is
some common information that remains constant across all such classes
that implement the same API. This includes things like name, version,
hostname, and so on. __ApiComminInfo stores that common information, and
a single __ApiCommonInfo instance is shared among all classes that
implement the same API, guaranteeing that they share the same common
information.
Some of these values can be overridden (such as audiences and scopes),
while some can't and remain the same for all classes that implement
the API (such as name and version).
"""
@util.positional(3)
def __init__(self, name, version, description=None, hostname=None,
audiences=None, scopes=None, allowed_client_ids=None,
canonical_name=None, auth=None, owner_domain=None,
owner_name=None, package_path=None, frontend_limits=None,
title=None, documentation=None, auth_level=None, issuers=None,
namespace=None, api_key_required=None, base_path=None,
limit_definitions=None, use_request_uri=None):
"""Constructor for _ApiCommonInfo.
Args:
name: string, Name of the API.
version: string, Version of the API.
description: string, Short description of the API (Default: None)
hostname: string, Hostname of the API (Default: app engine default host)
audiences: list of strings, Acceptable audiences for authentication.
scopes: list of strings, Acceptable scopes for authentication.
allowed_client_ids: list of strings, Acceptable client IDs for auth.
canonical_name: string, the canonical name for the API, a more human
readable version of the name.
auth: ApiAuth instance, the authentication configuration information
for this API.
owner_domain: string, the domain of the person or company that owns
this API. Along with owner_name, this provides hints to properly
name client libraries for this API.
owner_name: string, the name of the owner of this API. Along with
owner_domain, this provides hints to properly name client libraries
for this API.
package_path: string, the "package" this API belongs to. This '/'
delimited value specifies logical groupings of APIs. This is used by
client libraries of this API.
frontend_limits: ApiFrontEndLimits, optional query limits for
unregistered developers.
title: string, the human readable title of your API. It is exposed in
the discovery service.
documentation: string, a URL where users can find documentation about
this version of the API. This will be surfaced in the API Explorer and
GPE plugin to allow users to learn about your service.
auth_level: enum from AUTH_LEVEL, Frontend authentication level.
issuers: dict, mapping auth issuer names to endpoints.Issuer objects.
namespace: endpoints.Namespace, the namespace for the API.
api_key_required: bool, whether a key is required to call into this API.
base_path: string, the base path for all endpoints in this API.
limit_definitions: list of LimitDefinition tuples used in this API.
use_request_uri: if true, match requests against REQUEST_URI instead of PATH_INFO
"""
_CheckType(name, six.string_types, 'name', allow_none=False)
_CheckType(version, six.string_types, 'version', allow_none=False)
_CheckType(description, six.string_types, 'description')
_CheckType(hostname, six.string_types, 'hostname')
endpoints_util.check_list_type(scopes, (six.string_types, endpoints_types.OAuth2Scope), 'scopes')
endpoints_util.check_list_type(allowed_client_ids, six.string_types,
'allowed_client_ids')
_CheckType(canonical_name, six.string_types, 'canonical_name')
_CheckType(auth, ApiAuth, 'auth')
_CheckType(owner_domain, six.string_types, 'owner_domain')
_CheckType(owner_name, six.string_types, 'owner_name')
_CheckType(package_path, six.string_types, 'package_path')
_CheckType(frontend_limits, ApiFrontEndLimits, 'frontend_limits')
_CheckType(title, six.string_types, 'title')
_CheckType(documentation, six.string_types, 'documentation')
_CheckEnum(auth_level, AUTH_LEVEL, 'auth_level')
_CheckType(api_key_required, bool, 'api_key_required')
_CheckType(base_path, six.string_types, 'base_path')
_CheckType(issuers, dict, 'issuers')
if issuers:
for issuer_name, issuer_value in issuers.items():
_CheckType(issuer_name, six.string_types, 'issuer %s' % issuer_name)
_CheckType(issuer_value, Issuer, 'issuer value for %s' % issuer_name)
_CheckNamespace(namespace)
_CheckAudiences(audiences)
_CheckLimitDefinitions(limit_definitions)
_CheckType(use_request_uri, bool, 'use_request_uri')
if hostname is None:
hostname = app_identity.get_default_version_hostname()
if scopes is None:
scopes = [_EMAIL_SCOPE_OBJ]
else:
scopes = endpoints_types.OAuth2Scope.convert_list(scopes)
if allowed_client_ids is None:
allowed_client_ids = [constants.API_EXPLORER_CLIENT_ID]
if auth_level is None:
auth_level = AUTH_LEVEL.NONE
if api_key_required is None:
api_key_required = False
if base_path is None:
base_path = '/_ah/api/'
if use_request_uri is None:
use_request_uri = False
self.__name = name
self.__api_version = version
self.__path_version = version
self.__description = description
self.__hostname = hostname
self.__audiences = audiences
self.__scopes = scopes
self.__allowed_client_ids = allowed_client_ids
self.__canonical_name = canonical_name
self.__auth = auth
self.__owner_domain = owner_domain
self.__owner_name = owner_name
self.__package_path = package_path
self.__frontend_limits = frontend_limits
self.__title = title
self.__documentation = documentation
self.__auth_level = auth_level
self.__issuers = issuers
self.__namespace = namespace
self.__api_key_required = api_key_required
self.__base_path = base_path
self.__limit_definitions = limit_definitions
self.__use_request_uri = use_request_uri
@property
def name(self):
"""Name of the API."""
return self.__name
@property
def api_version(self):
"""Version of the API."""
return self.__api_version
@property
def path_version(self):
"""Version of the API for putting in the path."""
return self.__path_version
@property
def description(self):
"""Description of the API."""
return self.__description
@property
def hostname(self):
"""Hostname for the API."""
return self.__hostname
@property
def audiences(self):
"""List of audiences accepted by default for the API."""
return self.__audiences
@property
def scope_objs(self):
"""List of scopes (as OAuth2Scopes) accepted by default for the API."""
return self.__scopes
@property
def scopes(self):
"""List of scopes (as strings) accepted by default for the API."""
if self.scope_objs is not None:
return [_s.scope for _s in self.scope_objs]
@property
def allowed_client_ids(self):
"""List of client IDs accepted by default for the API."""
return self.__allowed_client_ids
@property
def issuers(self):
"""List of auth issuers for the API."""
return self.__issuers
@property
def namespace(self):
"""Namespace of the API."""
return self.__namespace
@property
def auth_level(self):
"""Enum from AUTH_LEVEL specifying default frontend auth level."""
return self.__auth_level
@property
def canonical_name(self):
"""Canonical name for the API."""
return self.__canonical_name
@property
def auth(self):
"""Authentication configuration for this API."""
return self.__auth
@property
def api_key_required(self):
"""Whether a key is required to call into this API."""
return self.__api_key_required
@property
def owner_domain(self):
"""Domain of the owner of this API."""
return self.__owner_domain
@property
def owner_name(self):
"""Name of the owner of this API."""
return self.__owner_name
@property
def package_path(self):
"""Package this API belongs to, '/' delimited. Used by client libs."""
return self.__package_path
@property
def frontend_limits(self):
"""Optional query limits for unregistered developers."""
return self.__frontend_limits
@property
def title(self):
"""Human readable name of this API."""
return self.__title
@property
def documentation(self):
"""Link to the documentation for this version of the API."""
return self.__documentation
@property
def base_path(self):
"""The base path for all endpoints in this API."""
return self.__base_path
@property
def limit_definitions(self):
"""Rate limiting metric definitions for this API."""
return self.__limit_definitions
@property
def use_request_uri(self):
"""Match request paths based on the REQUEST_URI instead of PATH_INFO."""
return self.__use_request_uri
def __call__(self, service_class):
"""Decorator for ProtoRPC class that configures Google's API server.
Args:
service_class: remote.Service class, ProtoRPC service class being wrapped.
Returns:
Same class with API attributes assigned in api_info.
"""
return self.api_class()(service_class)
def api_class(self, resource_name=None, path=None, audiences=None,
scopes=None, allowed_client_ids=None, auth_level=None,
api_key_required=None):
"""Get a decorator for a class that implements an API.
This can be used for single-class or multi-class implementations. It's
used implicitly in simple single-class APIs that only use @api directly.
Args:
resource_name: string, Resource name for the class this decorates.
(Default: None)
path: string, Base path prepended to any method paths in the class this
decorates. (Default: None)
audiences: list of strings, Acceptable audiences for authentication.
(Default: None)
scopes: list of strings, Acceptable scopes for authentication.
(Default: None)
allowed_client_ids: list of strings, Acceptable client IDs for auth.
(Default: None)
auth_level: enum from AUTH_LEVEL, Frontend authentication level.
(Default: None)
api_key_required: bool, Whether a key is required to call into this API.
(Default: None)
Returns:
A decorator function to decorate a class that implements an API.
"""
if auth_level is not None:
_logger.warn(_AUTH_LEVEL_WARNING)
def apiserving_api_decorator(api_class):
"""Decorator for ProtoRPC class that configures Google's API server.
Args:
api_class: remote.Service class, ProtoRPC service class being wrapped.
Returns:
Same class with API attributes assigned in api_info.
"""
self.__classes.append(api_class)
api_class.api_info = _ApiInfo(
self.__common_info, resource_name=resource_name,
path=path, audiences=audiences, scopes=scopes,
allowed_client_ids=allowed_client_ids, auth_level=auth_level,
api_key_required=api_key_required)
return api_class
return apiserving_api_decorator
def get_api_classes(self):
"""Get the list of remote.Service classes that implement this API."""
return self.__classes
class ApiAuth(object):
"""Optional authorization configuration information for an API."""
def __init__(self, allow_cookie_auth=None, blocked_regions=None):
"""Constructor for ApiAuth, authentication information for an API.
Args:
allow_cookie_auth: boolean, whether cooking auth is allowed. By
default, API methods do not allow cookie authentication, and
require the use of OAuth2 or ID tokens. Setting this field to
True will allow cookies to be used to access the API, with
potentially dangerous results. Please be very cautious in enabling
this setting, and make sure to require appropriate XSRF tokens to
protect your API.
blocked_regions: list of Strings, a list of 2-letter ISO region codes
to block.
"""
_CheckType(allow_cookie_auth, bool, 'allow_cookie_auth')
endpoints_util.check_list_type(blocked_regions, six.string_types,
'blocked_regions')
self.__allow_cookie_auth = allow_cookie_auth
self.__blocked_regions = blocked_regions
@property
def allow_cookie_auth(self):
"""Whether cookie authentication is allowed for this API."""
return self.__allow_cookie_auth
@property
def blocked_regions(self):
"""List of 2-letter ISO region codes to block."""
return self.__blocked_regions
class ApiFrontEndLimitRule(object):
"""Custom rule to limit unregistered traffic."""
def __init__(self, match=None, qps=None, user_qps=None, daily=None,
analytics_id=None):
"""Constructor for ApiFrontEndLimitRule.
Args:
match: string, the matching rule that defines this traffic segment.
qps: int, the aggregate QPS for this segment.
user_qps: int, the per-end-user QPS for this segment.
daily: int, the aggregate daily maximum for this segment.
analytics_id: string, the project ID under which traffic for this segment
will be logged.
"""
_CheckType(match, six.string_types, 'match')
_CheckType(qps, int, 'qps')
_CheckType(user_qps, int, 'user_qps')
_CheckType(daily, int, 'daily')
_CheckType(analytics_id, six.string_types, 'analytics_id')
self.__match = match
self.__qps = qps
self.__user_qps = user_qps
self.__daily = daily
self.__analytics_id = analytics_id
@property
def match(self):
"""The matching rule that defines this traffic segment."""
return self.__match
@property
def qps(self):
"""The aggregate QPS for this segment."""
return self.__qps
@property
def user_qps(self):
"""The per-end-user QPS for this segment."""
return self.__user_qps
@property
def daily(self):
"""The aggregate daily maximum for this segment."""
return self.__daily
@property
def analytics_id(self):
"""Project ID under which traffic for this segment will be logged."""
return self.__analytics_id
class ApiFrontEndLimits(object):
"""Optional front end limit information for an API."""
def __init__(self, unregistered_user_qps=None, unregistered_qps=None,
unregistered_daily=None, rules=None):
"""Constructor for ApiFrontEndLimits, front end limit info for an API.
Args:
unregistered_user_qps: int, the per-end-user QPS. Users are identified
by their IP address. A value of 0 will block unregistered requests.
unregistered_qps: int, an aggregate QPS upper-bound for all unregistered
traffic. A value of 0 currently means unlimited, though it might change
in the future. To block unregistered requests, use unregistered_user_qps
or unregistered_daily instead.
unregistered_daily: int, an aggregate daily upper-bound for all
unregistered traffic. A value of 0 will block unregistered requests.
rules: A list or tuple of ApiFrontEndLimitRule instances: custom rules
used to apply limits to unregistered traffic.
"""
_CheckType(unregistered_user_qps, int, 'unregistered_user_qps')
_CheckType(unregistered_qps, int, 'unregistered_qps')
_CheckType(unregistered_daily, int, 'unregistered_daily')
endpoints_util.check_list_type(rules, ApiFrontEndLimitRule, 'rules')
self.__unregistered_user_qps = unregistered_user_qps
self.__unregistered_qps = unregistered_qps
self.__unregistered_daily = unregistered_daily
self.__rules = rules
@property
def unregistered_user_qps(self):
"""Per-end-user QPS limit."""
return self.__unregistered_user_qps
@property
def unregistered_qps(self):
"""Aggregate QPS upper-bound for all unregistered traffic."""
return self.__unregistered_qps
@property
def unregistered_daily(self):
"""Aggregate daily upper-bound for all unregistered traffic."""
return self.__unregistered_daily
@property
def rules(self):
"""Custom rules used to apply limits to unregistered traffic."""
return self.__rules
@util.positional(2)
def api(name, version, description=None, hostname=None, audiences=None,
scopes=None, allowed_client_ids=None, canonical_name=None,
auth=None, owner_domain=None, owner_name=None, package_path=None,
frontend_limits=None, title=None, documentation=None, auth_level=None,
issuers=None, namespace=None, api_key_required=None, base_path=None,
limit_definitions=None, use_request_uri=None):
"""Decorate a ProtoRPC Service class for use by the framework above.
This decorator can be used to specify an API name, version, description, and
hostname for your API.
Sample usage (python 2.7):
@endpoints.api(name='guestbook', version='v0.2',
description='Guestbook API')
class PostService(remote.Service):
...
Sample usage (python 2.5):
class PostService(remote.Service):
...
endpoints.api(name='guestbook', version='v0.2',
description='Guestbook API')(PostService)
Sample usage if multiple classes implement one API:
api_root = endpoints.api(name='library', version='v1.0')
@api_root.api_class(resource_name='shelves')
class Shelves(remote.Service):
...
@api_root.api_class(resource_name='books', path='books')
class Books(remote.Service):
...
Args:
name: string, Name of the API.
version: string, Version of the API.
description: string, Short description of the API (Default: None)
hostname: string, Hostname of the API (Default: app engine default host)
audiences: list of strings, Acceptable audiences for authentication.
scopes: list of strings, Acceptable scopes for authentication.
allowed_client_ids: list of strings, Acceptable client IDs for auth.
canonical_name: string, the canonical name for the API, a more human
readable version of the name.
auth: ApiAuth instance, the authentication configuration information
for this API.
owner_domain: string, the domain of the person or company that owns
this API. Along with owner_name, this provides hints to properly
name client libraries for this API.
owner_name: string, the name of the owner of this API. Along with
owner_domain, this provides hints to properly name client libraries
for this API.
package_path: string, the "package" this API belongs to. This '/'
delimited value specifies logical groupings of APIs. This is used by
client libraries of this API.
frontend_limits: ApiFrontEndLimits, optional query limits for unregistered
developers.
title: string, the human readable title of your API. It is exposed in the
discovery service.
documentation: string, a URL where users can find documentation about this
version of the API. This will be surfaced in the API Explorer and GPE
plugin to allow users to learn about your service.
auth_level: enum from AUTH_LEVEL, frontend authentication level.
issuers: dict, mapping auth issuer names to endpoints.Issuer objects.
namespace: endpoints.Namespace, the namespace for the API.
api_key_required: bool, whether a key is required to call into this API.
base_path: string, the base path for all endpoints in this API.
limit_definitions: list of endpoints.LimitDefinition objects, quota metric
definitions for this API.
use_request_uri: if true, match requests against REQUEST_URI instead of PATH_INFO
Returns:
Class decorated with api_info attribute, an instance of ApiInfo.
"""
if auth_level is not None:
_logger.warn(_AUTH_LEVEL_WARNING)
return _ApiDecorator(name, version, description=description,
hostname=hostname, audiences=audiences, scopes=scopes,
allowed_client_ids=allowed_client_ids,
canonical_name=canonical_name, auth=auth,
owner_domain=owner_domain, owner_name=owner_name,
package_path=package_path,
frontend_limits=frontend_limits, title=title,
documentation=documentation, auth_level=auth_level,
issuers=issuers, namespace=namespace,
api_key_required=api_key_required, base_path=base_path,
limit_definitions=limit_definitions,
use_request_uri=use_request_uri)
class _MethodInfo(object):
"""Configurable attributes of an API method.
Consolidates settings from @method decorator and/or any settings that were
calculating from the ProtoRPC method name, so they only need to be calculated
once.
"""
@util.positional(1)
def __init__(self, name=None, path=None, http_method=None,
scopes=None, audiences=None, allowed_client_ids=None,
auth_level=None, api_key_required=None, request_body_class=None,
request_params_class=None, metric_costs=None, use_request_uri=None):
"""Constructor.
Args:
name: string, Name of the method, prepended with <apiname>. to make it
unique.
path: string, Path portion of the URL to the method, for RESTful methods.
http_method: string, HTTP method supported by the method.
scopes: list of string, OAuth2 token must contain one of these scopes.
audiences: list of string, IdToken must contain one of these audiences.
allowed_client_ids: list of string, Client IDs allowed to call the method.
auth_level: enum from AUTH_LEVEL, Frontend auth level for the method.
api_key_required: bool, whether a key is required to call the method.
request_body_class: The type for the request body when using a
ResourceContainer. Otherwise, null.
request_params_class: The type for the request parameters when using a
ResourceContainer. Otherwise, null.
metric_costs: dict with keys matching an API limit metric and values
representing the cost for each successful call against that metric.
use_request_uri: if true, match requests against REQUEST_URI instead of PATH_INFO
"""
self.__name = name
self.__path = path
self.__http_method = http_method
self.__scopes = endpoints_types.OAuth2Scope.convert_list(scopes)
self.__audiences = audiences
self.__allowed_client_ids = allowed_client_ids
self.__auth_level = auth_level
self.__api_key_required = api_key_required
self.__request_body_class = request_body_class
self.__request_params_class = request_params_class
self.__metric_costs = metric_costs
self.__use_request_uri = use_request_uri
def __safe_name(self, method_name):
"""Restrict method name to a-zA-Z0-9_, first char lowercase."""
# Endpoints backend restricts what chars are allowed in a method name.
safe_name = re.sub(r'[^\.a-zA-Z0-9_]', '', method_name)
# Strip any number of leading underscores.
safe_name = safe_name.lstrip('_')
# Ensure the first character is lowercase.
# Slice from 0:1 rather than indexing [0] in case safe_name is length 0.
return safe_name[0:1].lower() + safe_name[1:]
@property
def name(self):
"""Method name as specified in decorator or derived."""
return self.__name
def get_path(self, api_info):
"""Get the path portion of the URL to the method (for RESTful methods).
Request path can be specified in the method, and it could have a base
path prepended to it.
Args:
api_info: API information for this API, possibly including a base path.
This is the api_info property on the class that's been annotated for
this API.
Returns:
This method's request path (not including the http://.../{base_path}
prefix).
Raises:
ApiConfigurationError: If the path isn't properly formatted.
"""
path = self.__path or ''
if path and path[0] == '/':
# Absolute path, ignoring any prefixes. Just strip off the leading /.
path = path[1:]
else:
# Relative path.
if api_info.path:
path = '%s%s%s' % (api_info.path, '/' if path else '', path)
# Verify that the path seems valid.
parts = path.split('/')
for n, part in enumerate(parts):
r = _VALID_PART_RE if n < len(parts) - 1 else _VALID_LAST_PART_RE
if part and '{' in part and '}' in part:
if not r.match(part):
raise api_exceptions.ApiConfigurationError(
'Invalid path segment: %s (part of %s)' % (part, path))
return path
@property
def http_method(self):
"""HTTP method supported by the method (e.g. GET, POST)."""
return self.__http_method
@property
def scope_objs(self):
"""List of scopes (as OAuth2Scopes) accepted for the API method."""
return self.__scopes
@property
def scopes(self):
"""List of scopes (as strings) accepted for the API method."""
if self.scope_objs is not None:
return [_s.scope for _s in self.scope_objs]
@property
def audiences(self):
"""List of audiences for the API method."""
return self.__audiences
@property
def allowed_client_ids(self):
"""List of allowed client IDs for the API method."""
return self.__allowed_client_ids
@property
def auth_level(self):
"""Enum from AUTH_LEVEL specifying default frontend auth level."""
return self.__auth_level
@property
def api_key_required(self):
"""bool whether a key is required to call the API method."""
return self.__api_key_required
@property
def metric_costs(self):
"""Dict mapping API limit metric names to costs against that metric."""
return self.__metric_costs
@property
def request_body_class(self):
"""Type of request body when using a ResourceContainer."""
return self.__request_body_class
@property
def request_params_class(self):
"""Type of request parameter message when using a ResourceContainer."""
return self.__request_params_class
def is_api_key_required(self, api_info):
if self.api_key_required is not None:
return self.api_key_required
else:
return api_info.api_key_required
def use_request_uri(self, api_info):
if self.__use_request_uri is not None:
return self.__use_request_uri
else:
return api_info.use_request_uri
def method_id(self, api_info):
"""Computed method name."""
# This is done here for now because at __init__ time, the method is known
# but not the api, and thus not the api name. Later, in
# ApiConfigGenerator.__method_descriptor, the api name is known.
if api_info.resource_name:
resource_part = '.%s' % self.__safe_name(api_info.resource_name)
else:
resource_part = ''
return '%s%s.%s' % (self.__safe_name(api_info.name), resource_part,
self.__safe_name(self.name))
@util.positional(2)
def method(request_message=message_types.VoidMessage,
response_message=message_types.VoidMessage,
name=None,
path=None,
http_method='POST',
scopes=None,
audiences=None,
allowed_client_ids=None,
auth_level=None,
api_key_required=None,
metric_costs=None,
use_request_uri=None):
"""Decorate a ProtoRPC Method for use by the framework above.
This decorator can be used to specify a method name, path, http method,
scopes, audiences, client ids and auth_level.
Sample usage:
@api_config.method(RequestMessage, ResponseMessage,
name='insert', http_method='PUT')
def greeting_insert(request):
...
return response
Args:
request_message: Message type of expected request.
response_message: Message type of expected response.
name: string, Name of the method, prepended with <apiname>. to make it
unique. (Default: python method name)
path: string, Path portion of the URL to the method, for RESTful methods.
http_method: string, HTTP method supported by the method. (Default: POST)
scopes: list of string, OAuth2 token must contain one of these scopes.
audiences: list of string, IdToken must contain one of these audiences.
allowed_client_ids: list of string, Client IDs allowed to call the method.
If None and auth_level is REQUIRED, no calls will be allowed.
auth_level: enum from AUTH_LEVEL, Frontend auth level for the method.
api_key_required: bool, whether a key is required to call the method
metric_costs: dict with keys matching an API limit metric and values
representing the cost for each successful call against that metric.
use_request_uri: if true, match requests against REQUEST_URI instead of PATH_INFO
Returns:
'apiserving_method_wrapper' function.
Raises:
TypeError: if the request_type or response_type parameters are not
proper subclasses of messages.Message.
"""
if auth_level is not None:
_logger.warn(_AUTH_LEVEL_WARNING)
# Default HTTP method if one is not specified.
DEFAULT_HTTP_METHOD = 'POST'
def apiserving_method_decorator(api_method):
"""Decorator for ProtoRPC method that configures Google's API server.
Args:
api_method: Original method being wrapped.
Returns:
Function responsible for actual invocation.
Assigns the following attributes to invocation function:
remote: Instance of RemoteInfo, contains remote method information.
remote.request_type: Expected request type for remote method.
remote.response_type: Response type returned from remote method.
method_info: Instance of _MethodInfo, api method configuration.
It is also assigned attributes corresponding to the aforementioned kwargs.
Raises:
TypeError: if the request_type or response_type parameters are not
proper subclasses of messages.Message.
KeyError: if the request_message is a ResourceContainer and the newly
created remote method has been reference by the container before. This
should never occur because a remote method is created once.
"""
request_body_class = None
request_params_class = None
if isinstance(request_message, resource_container.ResourceContainer):
remote_decorator = remote.method(request_message.combined_message_class,
response_message)
request_body_class = request_message.body_message_class()
request_params_class = request_message.parameters_message_class()
else:
remote_decorator = remote.method(request_message, response_message)
remote_method = remote_decorator(api_method)
def invoke_remote(service_instance, request):
# If the server didn't specify any auth information, build it now.
# pylint: disable=protected-access
users_id_token._maybe_set_current_user_vars(
invoke_remote, api_info=getattr(service_instance, 'api_info', None),
request=request)
# pylint: enable=protected-access
return remote_method(service_instance, request)
invoke_remote.remote = remote_method.remote
if isinstance(request_message, resource_container.ResourceContainer):
resource_container.ResourceContainer.add_to_cache(
invoke_remote.remote, request_message)
invoke_remote.method_info = _MethodInfo(
name=name or api_method.__name__, path=path or api_method.__name__,
http_method=http_method or DEFAULT_HTTP_METHOD,
scopes=scopes, audiences=audiences,
allowed_client_ids=allowed_client_ids, auth_level=auth_level,
api_key_required=api_key_required, metric_costs=metric_costs,
use_request_uri=use_request_uri,
request_body_class=request_body_class,
request_params_class=request_params_class)
invoke_remote.__name__ = invoke_remote.method_info.name
return invoke_remote
endpoints_util.check_list_type(scopes, (six.string_types, endpoints_types.OAuth2Scope), 'scopes')
endpoints_util.check_list_type(allowed_client_ids, six.string_types,
'allowed_client_ids')
_CheckEnum(auth_level, AUTH_LEVEL, 'auth_level')
_CheckAudiences(audiences)
_CheckType(metric_costs, dict, 'metric_costs')
return apiserving_method_decorator
class ApiConfigGenerator(object):
"""Generates an API configuration 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 = ApiConfigGenerator().pretty_print_config_to_json(HelloService)
The resulting api_config will be a JSON 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
__HAS_BODY = 2
def __init__(self):
self.__parser = message_parser.MessageTypeToJsonSchema()
# Maps method id to the request schema id.
self.__request_schema = {}
# Maps method id to the response schema id.
self.__response_schema = {}
# Maps from ProtoRPC name to method id.
self.__id_from_name = {}
def __get_request_kind(self, method_info):
"""Categorize the type of the request.
Args:
method_info: _MethodInfo, method information.
Returns:
The kind of request.
"""
if method_info.http_method in ('GET', 'DELETE'):
return self.__NO_BODY
else:
return self.__HAS_BODY
def __field_to_subfields(self, field):
"""Fully describes data represented by field, including the nested case.
In the case that the field is not a message field, we have no fields nested
within a message definition, so we can simply return that field. However, in
the nested case, we can't simply describe the data with one field or even
with one chain of fields.
For example, if we have a message field
m_field = messages.MessageField(RefClass, 1)
which references a class with two fields:
class RefClass(messages.Message):
one = messages.StringField(1)
two = messages.IntegerField(2)
then we would need to include both one and two to represent all the
data contained.
Calling __field_to_subfields(m_field) would return:
[
[<MessageField "m_field">, <StringField "one">],
[<MessageField "m_field">, <StringField "two">],
]
If the second field was instead a message field
class RefClass(messages.Message):
one = messages.StringField(1)
two = messages.MessageField(OtherRefClass, 2)
referencing another class with two fields
class OtherRefClass(messages.Message):
three = messages.BooleanField(1)
four = messages.FloatField(2)
then we would need to recurse one level deeper for two.
With this change, calling __field_to_subfields(m_field) would return:
[
[<MessageField "m_field">, <StringField "one">],
[<MessageField "m_field">, <StringField "two">, <StringField "three">],
[<MessageField "m_field">, <StringField "two">, <StringField "four">],
]
Args:
field: An instance of a subclass of messages.Field.
Returns:
A list of lists, where each sublist is a list of fields.
"""
# Termination condition
if not isinstance(field, messages.MessageField):
return [[field]]
result = []
for subfield in sorted(field.message_type.all_fields(),
key=lambda f: f.number):
subfield_results = self.__field_to_subfields(subfield)
for subfields_list in subfield_results:
subfields_list.insert(0, field)
result.append(subfields_list)
return result
# TODO(dhermes): Support all the parameter types
# Currently missing DATE and ETAG
def __field_to_parameter_type(self, field):
"""Converts the field variant type into a string describing the parameter.
Args:
field: An instance of a subclass of messages.Field.
Returns:
A string corresponding to the variant enum of the field, with a few
exceptions. In the case of signed ints, the 's' is dropped; for the BOOL
variant, 'boolean' is used; and for the ENUM variant, 'string' is used.
Raises:
TypeError: if the field variant is a message variant.
"""
# We use lowercase values for types (e.g. 'string' instead of 'STRING').
variant = field.variant
if variant == messages.Variant.MESSAGE:
raise TypeError('A message variant can\'t be used in a parameter.')
custom_variant_map = {
messages.Variant.SINT32: 'int32',
messages.Variant.SINT64: 'int64',
messages.Variant.BOOL: 'boolean',
messages.Variant.ENUM: 'string',
}
return custom_variant_map.get(variant) or variant.name.lower()
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, final_subfield):
"""Returns default value of final subfield if it has one.
If this subfield comes from a field list returned from __field_to_subfields,
none of the fields in the subfield list can have a default except the final
one since they all must be message fields.
Args:
final_subfield: A simple field from the end of a subfield list.
Returns:
The default value of the subfield, if any exists, with the exception of an
enum field, which will have its value cast to a string.
"""
if final_subfield.default:
if isinstance(final_subfield, messages.EnumField):
return final_subfield.default.name
else:
return final_subfield.default
def __parameter_enum(self, final_subfield):
"""Returns enum descriptor of final subfield if it is an enum.
An enum descriptor is a dictionary with keys as the names from the enum and
each value is a dictionary with a single key "backendValue" and value equal
to the same enum name used to stored it in the descriptor.
The key "description" can also be used next to "backendValue", but protorpc
Enum classes have no way of supporting a description for each value.
Args:
final_subfield: A simple field from the end of a subfield list.
Returns:
The enum descriptor for the field, if it's an enum descriptor, else
returns None.
"""
if isinstance(final_subfield, messages.EnumField):
enum_descriptor = {}
for enum_value in final_subfield.type.to_dict().keys():
enum_descriptor[enum_value] = {'backendValue': enum_value}
return enum_descriptor
def __parameter_descriptor(self, subfield_list):
"""Creates descriptor for a parameter using the subfields that define it.
Each parameter is defined by a list of fields, with all but the last being
a message field and the final being a simple (non-message) field.
Many of the fields in the descriptor are determined solely by the simple
field at the end, though some (such as repeated and required) take the whole
chain of fields into consideration.
Args:
subfield_list: List of fields describing the parameter.
Returns:
Dictionary containing a descriptor for the parameter described by the list
of fields.
"""
descriptor = {}
final_subfield = subfield_list[-1]
# Required
if all(subfield.required for subfield in subfield_list):
descriptor['required'] = True
# Type
descriptor['type'] = self.__field_to_parameter_type(final_subfield)
# Default
default = self.__parameter_default(final_subfield)
if default is not None:
descriptor['default'] = default
# Repeated
if any(subfield.repeated for subfield in subfield_list):
descriptor['repeated'] = True
# Enum
enum_descriptor = self.__parameter_enum(final_subfield)
if enum_descriptor is not None:
descriptor['enum'] = enum_descriptor
return descriptor
def __add_parameters_from_field(self, field, path_parameters,
params, param_order):
"""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:
field: Field from which parameters will be added to the method 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: Dictionary with parameter names as keys and parameter descriptors
as values. This will be updated for each parameter in the field.
param_order: List of required parameter names to give them an order in the
descriptor. All required parameters in the field will be added to this
list.
"""
for subfield_list in self.__field_to_subfields(field):
descriptor = self.__parameter_descriptor(subfield_list)
qualified_name = '.'.join(subfield.name for subfield in subfield_list)
in_path = qualified_name in path_parameters
if descriptor.get('required', in_path):
descriptor['required'] = True
param_order.append(qualified_name)
params[qualified_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 tuple (dict, list of string): Descriptor of the parameters, Order of the
parameters.
"""
params = {}
param_order = []
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_parameters_from_field(field, matched_path_parameters,
params, param_order)
return params, param_order
# TODO(user): request_kind is only used by
# __params_descriptor_without_container so can be removed
# once that method is fully deprecated.
def __params_descriptor(self, message_type, request_kind, path, method_id):
"""Describe the parameters of a method.
If the message_type is not a ResourceContainer, will fall back to
__params_descriptor_without_container (which will eventually be deprecated).
If the message type is a ResourceContainer, then all path/query parameters
will come from the ResourceContainer This method will also make sure all
path parameters are covered by the message fields.
Args:
message_type: messages.Message or ResourceContainer class, Message with
parameters to describe.
request_kind: The type of request being made.
path: string, HTTP path to method.
method_id: string, Unique method identifier (e.g. 'myapi.items.method')
Returns:
A tuple (dict, list of string): Descriptor of the parameters, Order of the
parameters.
"""
path_parameter_dict = self.__get_path_parameters(path)
if not isinstance(message_type, resource_container.ResourceContainer):
if path_parameter_dict:
_logger.warning('Method %s specifies path parameters but you are not '
'using a ResourceContainer; instead, you are using %r. '
'This will fail in future releases; please switch to '
'using ResourceContainer as soon as possible.',
method_id, type(message_type))
return self.__params_descriptor_without_container(
message_type, request_kind, path)
# From here, we can assume message_type is a ResourceContainer
message_type = message_type.parameters_message_class()
params = {}
param_order = []
# 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_parameters_from_field(field, matched_path_parameters,
params, param_order)
return params, param_order
def __request_message_descriptor(self, request_kind, message_type, method_id,
path):
"""Describes the parameters and body of the request.
Args:
request_kind: The type of request being made.
message_type: messages.Message or ResourceContainer class. The message to
describe.
method_id: string, Unique method identifier (e.g. 'myapi.items.method')
path: string, HTTP path to method.
Returns:
Dictionary describing the request.
Raises:
ValueError: if the method path and request required fields do not match
"""
descriptor = {}
params, param_order = self.__params_descriptor(message_type, request_kind,
path, method_id)
if isinstance(message_type, resource_container.ResourceContainer):
message_type = message_type.body_message_class()
if (request_kind == self.__NO_BODY or
message_type == message_types.VoidMessage()):
descriptor['body'] = 'empty'
else:
descriptor['body'] = 'autoTemplate(backendRequest)'
descriptor['bodyName'] = 'resource'
self.__request_schema[method_id] = self.__parser.add_message(
message_type.__class__)
if params:
descriptor['parameters'] = params
if param_order:
descriptor['parameterOrder'] = param_order
return descriptor
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.
"""
descriptor = {}
self.__parser.add_message(message_type.__class__)
if message_type == message_types.VoidMessage():
descriptor['body'] = 'empty'
else:
descriptor['body'] = 'autoTemplate(backendResponse)'
descriptor['bodyName'] = 'resource'
self.__response_schema[method_id] = self.__parser.ref_for_message_type(
message_type.__class__)
return descriptor
def __method_descriptor(self, service, method_info,
rosy_method, protorpc_method_info):
"""Describes a method.
Args:
service: endpoints.Service, Implementation of the API as a service.
method_info: _MethodInfo, Configuration for the method.
rosy_method: string, ProtoRPC method name prefixed with the
name of the service.
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
descriptor['path'] = method_info.get_path(service.api_info)
descriptor['httpMethod'] = method_info.http_method
descriptor['rosyMethod'] = rosy_method
descriptor['request'] = self.__request_message_descriptor(
request_kind, request_message_type,
method_info.method_id(service.api_info),
descriptor['path'])
descriptor['response'] = self.__response_message_descriptor(
remote_method.response_type(), method_info.method_id(service.api_info))
# Audiences, scopes, allowed_client_ids and auth_level could be set at
# either the method level or the API level. Allow an empty list at the
# method level to override the setting at the API level.
scopes = (method_info.scopes
if method_info.scopes is not None
else service.api_info.scopes)
if scopes:
descriptor['scopes'] = scopes
audiences = (method_info.audiences
if method_info.audiences is not None
else service.api_info.audiences)
if audiences:
descriptor['audiences'] = audiences
allowed_client_ids = (method_info.allowed_client_ids
if method_info.allowed_client_ids is not None
else service.api_info.allowed_client_ids)
if allowed_client_ids:
descriptor['clientIds'] = allowed_client_ids
if remote_method.method.__doc__:
descriptor['description'] = remote_method.method.__doc__
auth_level = (method_info.auth_level
if method_info.auth_level is not None
else service.api_info.auth_level)
if auth_level is not None:
descriptor['authLevel'] = AUTH_LEVEL.reverse_mapping[auth_level]
descriptor['useRequestUri'] = method_info.use_request_uri(service.api_info)
return descriptor
def __schema_descriptor(self, services):
"""Descriptor for the all the JSON Schema used.
Args:
services: List of protorpc.remote.Service instances implementing an
api/version.
Returns:
Dictionary containing all the JSON Schema used in the service.
"""
methods_desc = {}
for service in services:
protorpc_methods = service.all_remote_methods()
for protorpc_method_name in protorpc_methods.keys():
rosy_method = '%s.%s' % (service.__name__, protorpc_method_name)
method_id = self.__id_from_name[rosy_method]
request_response = {}
request_schema_id = self.__request_schema.get(method_id)
if request_schema_id:
request_response['request'] = {
'$ref': request_schema_id
}
response_schema_id = self.__response_schema.get(method_id)
if response_schema_id:
request_response['response'] = {
'$ref': response_schema_id
}
methods_desc[rosy_method] = request_response
descriptor = {
'methods': methods_desc,
'schemas': self.__parser.schemas(),
}
return descriptor
def __get_merged_api_info(self, services):
"""Builds a description of an API.
Args:
services: List of protorpc.remote.Service instances implementing an
api/version.
Returns:
The _ApiInfo object to use for the API that the given services implement.
Raises:
ApiConfigurationError: If there's something wrong with the API
configuration, such as a multiclass API decorated with different API
descriptors (see the docstring for api()).
"""
merged_api_info = services[0].api_info
# Verify that, if there are multiple classes here, they're allowed to
# implement the same API.
for service in services[1:]:
if not merged_api_info.is_same_api(service.api_info):
raise api_exceptions.ApiConfigurationError(
_MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name,
service.api_info.api_version))
return merged_api_info
def __auth_descriptor(self, api_info):
"""Builds an auth descriptor from API info.
Args:
api_info: An _ApiInfo object.
Returns:
A dictionary with 'allowCookieAuth' and/or 'blockedRegions' keys.
"""
if api_info.auth is None:
return None
auth_descriptor = {}
if api_info.auth.allow_cookie_auth is not None:
auth_descriptor['allowCookieAuth'] = api_info.auth.allow_cookie_auth
if api_info.auth.blocked_regions:
auth_descriptor['blockedRegions'] = api_info.auth.blocked_regions
return auth_descriptor
def __frontend_limit_descriptor(self, api_info):
"""Builds a frontend limit descriptor from API info.
Args:
api_info: An _ApiInfo object.
Returns:
A dictionary with frontend limit information.
"""
if api_info.frontend_limits is None:
return None
descriptor = {}
for propname, descname in (('unregistered_user_qps', 'unregisteredUserQps'),
('unregistered_qps', 'unregisteredQps'),
('unregistered_daily', 'unregisteredDaily')):
if getattr(api_info.frontend_limits, propname) is not None:
descriptor[descname] = getattr(api_info.frontend_limits, propname)
rules = self.__frontend_limit_rules_descriptor(api_info)
if rules:
descriptor['rules'] = rules
return descriptor
def __frontend_limit_rules_descriptor(self, api_info):
"""Builds a frontend limit rules descriptor from API info.
Args:
api_info: An _ApiInfo object.
Returns:
A list of dictionaries with frontend limit rules information.
"""
if not api_info.frontend_limits.rules:
return None
rules = []
for rule in api_info.frontend_limits.rules:
descriptor = {}
for propname, descname in (('match', 'match'),
('qps', 'qps'),
('user_qps', 'userQps'),
('daily', 'daily'),
('analytics_id', 'analyticsId')):
if getattr(rule, propname) is not None:
descriptor[descname] = getattr(rule, propname)
if descriptor:
rules.append(descriptor)
return rules
def __api_descriptor(self, services, hostname=None):
"""Builds a description of an API.
Args:
services: List of protorpc.remote.Service instances implementing an
api/version.
hostname: string, Hostname of the API, to override the value set on the
current service. Defaults to None.
Returns:
A dictionary that can be deserialized into JSON and stored as an API
description document.
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
auth_descriptor = self.__auth_descriptor(merged_api_info)
if auth_descriptor:
descriptor['auth'] = auth_descriptor
frontend_limit_descriptor = self.__frontend_limit_descriptor(
merged_api_info)
if frontend_limit_descriptor:
descriptor['frontendLimits'] = frontend_limit_descriptor
method_map = {}
method_collision_tracker = {}
rest_collision_tracker = {}
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
method_id = method_info.method_id(service.api_info)
rosy_method = '%s.%s' % (service.__name__, protorpc_meth_name)
self.__id_from_name[rosy_method] = method_id
method_map[method_id] = self.__method_descriptor(
service, method_info, rosy_method, protorpc_meth_info)
# Make sure the same method name isn't repeated.
if method_id in method_collision_tracker:
raise api_exceptions.ApiConfigurationError(
'Method %s used multiple times, in classes %s and %s' %
(method_id, method_collision_tracker[method_id],
service.__name__))
else:
method_collision_tracker[method_id] = service.__name__
# Make sure the same HTTP method & path aren't repeated.
rest_identifier = (method_info.http_method,
method_info.get_path(service.api_info))
if rest_identifier in rest_collision_tracker:
raise api_exceptions.ApiConfigurationError(
'%s path "%s" used multiple times, in classes %s and %s' %
(method_info.http_method, method_info.get_path(service.api_info),
rest_collision_tracker[rest_identifier],
service.__name__))
else:
rest_collision_tracker[rest_identifier] = service.__name__
if method_map:
descriptor['methods'] = method_map
descriptor['descriptor'] = self.__schema_descriptor(services)
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.
"""
hostname = (hostname or endpoints_util.get_app_hostname() or
api_info.hostname)
protocol = 'http' if ((hostname and hostname.startswith('localhost')) or
endpoints_util.is_running_on_devserver()) else 'https'
base_path = api_info.base_path.strip('/')
defaults = {
'extends': 'thirdParty.api',
'root': '{0}://{1}/{2}'.format(protocol, hostname, base_path),
'name': api_info.name,
'version': api_info.api_version,
'api_version': api_info.api_version,
'path_version': api_info.path_version,
'defaultVersion': True,
'abstract': False,
'adapter': {
'bns': '{0}://{1}/{2}'.format(protocol, hostname, base_path),
'type': 'lily',
'deadline': 10.0
}
}
if api_info.canonical_name:
defaults['canonicalName'] = api_info.canonical_name
if api_info.owner_domain:
defaults['ownerDomain'] = api_info.owner_domain
if api_info.owner_name:
defaults['ownerName'] = api_info.owner_name
if api_info.package_path:
defaults['packagePath'] = api_info.package_path
if api_info.title:
defaults['title'] = api_info.title
if api_info.documentation:
defaults['documentation'] = api_info.documentation
return defaults
def get_config_dict(self, services, hostname=None):
"""JSON dict description of a protorpc.remote.Service in API 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 API descriptor document as a JSON dict.
"""
if not isinstance(services, (tuple, list)):
services = [services]
# The type of a class that inherits from remote.Service is actually
# remote._ServiceClass, thanks to metaclass strangeness.
# pylint: disable=protected-access
endpoints_util.check_list_type(services, remote._ServiceClass, 'services',
allow_none=False)
return self.__api_descriptor(services, hostname=hostname)
def pretty_print_config_to_json(self, services, hostname=None):
"""JSON string description of a protorpc.remote.Service in API format.
Args:
services: Either a single protorpc.remote.Service or a list of them
that implements an api/version.
hostname: string, Hostname of the API, to override the value set on the
current service. Defaults to None.
Returns:
string, The API descriptor document as a JSON string.
"""
descriptor = self.get_config_dict(services, hostname)
return json.dumps(descriptor, sort_keys=True, indent=2,
separators=(',', ': '))