Merge branch 'main' into avm99963-monorail
Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266
GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
diff --git a/third_party/endpoints/LICENSE.txt b/third_party/endpoints/LICENSE.txt
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/third_party/endpoints/LICENSE.txt
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
diff --git a/third_party/endpoints/README.monorail b/third_party/endpoints/README.monorail
new file mode 100644
index 0000000..aa55400
--- /dev/null
+++ b/third_party/endpoints/README.monorail
@@ -0,0 +1,36 @@
+Short Name: endpoints
+URL: https://github.com/cloudendpoints/endpoints-python
+Version: 4.8.0
+License: Apache 2.0
+License File: LICENSE.txt
+Security Critical: no
+Description:
+Google Cloud Endpoints is a solution for creating RESTful web APIs.
+Local Modifications:
+1. Retain only the endpoints/ directory and LICENSE.txt file.
+2. Remove dependency on semver and move endpoints_management and
+ protorpc.wsgi imports into the functions where they're being used.
+3. Update files for Python 3.
+ Syntax changes:
+ * except Exception, e: --> except Exception as e:
+
+ Import moves:
+ * from collections import Foo -> from collections.abc import Foo
+ * import cStringIO --> from six.moves import cStringIO
+ * import httplib --> from six.moves import http_client
+ * import urllib --> from six.moves import urllib
+ * import urlparse --> from six.moves import urllib
+
+ String changes:
+ * basestring --> six.string_types
+ * if isinstance(s, unicode): s = s.encode() -> s = six.ensure_str(s)
+ * s.encode('ascii') --> six.ensure_binary(s, 'ascii')
+ * s.encode('hex') --> binascii.hexlify(s)
+
+ Integer changes:
+ * long() --> int()
+
+ Iterator changes:
+ * iteritems() --> items()
+ * iterkeys() -> keys()
+ * itervalues() --> values()
diff --git a/third_party/endpoints/__init__.py b/third_party/endpoints/__init__.py
new file mode 100644
index 0000000..76af4f8
--- /dev/null
+++ b/third_party/endpoints/__init__.py
@@ -0,0 +1,45 @@
+#!/usr/bin/python
+#
+# 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.
+
+
+"""Google Cloud Endpoints module."""
+
+# pylint: disable=wildcard-import
+from __future__ import absolute_import
+
+import logging
+
+from protorpc import message_types
+from protorpc import messages
+from protorpc import remote
+
+from .api_config import api, method
+from .api_config import AUTH_LEVEL, EMAIL_SCOPE
+from .api_config import Issuer, LimitDefinition, Namespace
+from .api_exceptions import *
+from .apiserving import *
+from .constants import API_EXPLORER_CLIENT_ID
+from .endpoints_dispatcher import *
+from . import message_parser
+from .resource_container import ResourceContainer
+from .users_id_token import get_current_user, get_verified_jwt, convert_jwks_uri
+from .users_id_token import InvalidGetUserCall
+from .users_id_token import SKIP_CLIENT_ID_CHECK
+
+__version__ = '4.8.0'
+
+_logger = logging.getLogger(__name__)
+_logger.setLevel(logging.INFO)
diff --git a/third_party/endpoints/_endpointscfg_impl.py b/third_party/endpoints/_endpointscfg_impl.py
new file mode 100644
index 0000000..2d3f740
--- /dev/null
+++ b/third_party/endpoints/_endpointscfg_impl.py
@@ -0,0 +1,617 @@
+#!/usr/bin/python
+# Copyright 2017 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.
+r"""External script for generating Cloud Endpoints related files.
+
+The gen_discovery_doc subcommand takes a list of fully qualified ProtoRPC
+service names and calls a cloud service which generates a discovery document in
+REST or RPC style.
+
+Example:
+ endpointscfg.py gen_discovery_doc -o . -f rest postservice.GreetingsV1
+
+The gen_client_lib subcommand takes a discovery document and calls a cloud
+service to generate a client library for a target language (currently just Java)
+
+Example:
+ endpointscfg.py gen_client_lib java -o . greetings-v0.1.discovery
+
+The get_client_lib subcommand does both of the above commands at once.
+
+Example:
+ endpointscfg.py get_client_lib java -o . postservice.GreetingsV1
+
+The gen_api_config command outputs an .api configuration file for a service.
+
+Example:
+ endpointscfg.py gen_api_config -o . -a /path/to/app \
+ --hostname myhost.appspot.com postservice.GreetingsV1
+"""
+
+from __future__ import absolute_import
+
+import argparse
+import collections
+import contextlib
+import logging
+import os
+import re
+import sys
+from six.moves import urllib
+import urllib2
+
+import yaml
+from google.appengine.ext import testbed
+
+from . import api_config
+from . import discovery_generator
+from . import openapi_generator
+from . import remote
+
+# Conditional import, pylint: disable=g-import-not-at-top
+try:
+ import json
+except ImportError:
+ # If we can't find json packaged with Python import simplejson, which is
+ # packaged with the SDK.
+ import simplejson as json
+
+
+CLIENT_LIBRARY_BASE = 'https://google-api-client-libraries.appspot.com/generate'
+_VISIBLE_COMMANDS = ('get_client_lib', 'get_discovery_doc', 'get_openapi_spec')
+
+
+class ServerRequestException(Exception):
+ """Exception for problems with the request to a server."""
+
+ def __init__(self, http_error):
+ """Create a ServerRequestException from a given urllib2.HTTPError.
+
+ Args:
+ http_error: The HTTPError that the ServerRequestException will be
+ based on.
+ """
+ error_details = None
+ error_response = None
+ if http_error.fp:
+ try:
+ error_response = http_error.fp.read()
+ error_body = json.loads(error_response)
+ error_details = ['%s: %s' % (detail['message'], detail['debug_info'])
+ for detail in error_body['error']['errors']]
+ except (ValueError, TypeError, KeyError):
+ pass
+ if error_details:
+ error_details_str = ', '.join(error_details)
+ error_message = ('HTTP %s (%s) error when communicating with URL: %s. '
+ 'Details: %s' % (http_error.code, http_error.reason,
+ http_error.filename, error_details_str))
+ else:
+ error_message = ('HTTP %s (%s) error when communicating with URL: %s. '
+ 'Response: %s' % (http_error.code, http_error.reason,
+ http_error.filename,
+ error_response))
+ super(ServerRequestException, self).__init__(error_message)
+
+
+class _EndpointsParser(argparse.ArgumentParser):
+ """Create a subclass of argparse.ArgumentParser for Endpoints."""
+
+ def error(self, message):
+ """Override superclass to support customized error message.
+
+ Error message needs to be rewritten in order to display visible commands
+ only, when invalid command is called by user. Otherwise, hidden commands
+ will be displayed in stderr, which is not expected.
+
+ Refer the following argparse python documentation for detailed method
+ information:
+ http://docs.python.org/2/library/argparse.html#exiting-methods
+
+ Args:
+ message: original error message that will be printed to stderr
+ """
+ # subcommands_quoted is the same as subcommands, except each value is
+ # surrounded with double quotes. This is done to match the standard
+ # output of the ArgumentParser, while hiding commands we don't want users
+ # to use, as they are no longer documented and only here for legacy use.
+ subcommands_quoted = ', '.join(
+ [repr(command) for command in _VISIBLE_COMMANDS])
+ subcommands = ', '.join(_VISIBLE_COMMANDS)
+ message = re.sub(
+ r'(argument {%s}: invalid choice: .*) \(choose from (.*)\)$'
+ % subcommands, r'\1 (choose from %s)' % subcommands_quoted, message)
+ super(_EndpointsParser, self).error(message)
+
+
+def _WriteFile(output_path, name, content):
+ """Write given content to a file in a given directory.
+
+ Args:
+ output_path: The directory to store the file in.
+ name: The name of the file to store the content in.
+ content: The content to write to the file.close
+
+ Returns:
+ The full path to the written file.
+ """
+ path = os.path.join(output_path, name)
+ with open(path, 'wb') as f:
+ f.write(content)
+ return path
+
+
+def GenApiConfig(service_class_names, config_string_generator=None,
+ hostname=None, application_path=None, **additional_kwargs):
+ """Write an API configuration for endpoints annotated ProtoRPC services.
+
+ Args:
+ service_class_names: A list of fully qualified ProtoRPC service classes.
+ config_string_generator: A generator object that produces API config strings
+ using its pretty_print_config_to_json method.
+ hostname: A string hostname which will be used as the default version
+ hostname. If no hostname is specificied in the @endpoints.api decorator,
+ this value is the fallback.
+ application_path: A string with the path to the AppEngine application.
+
+ Raises:
+ TypeError: If any service classes don't inherit from remote.Service.
+ messages.DefinitionNotFoundError: If a service can't be found.
+
+ Returns:
+ A map from service names to a string containing the API configuration of the
+ service in JSON format.
+ """
+ # First, gather together all the different APIs implemented by these
+ # classes. There may be fewer APIs than service classes. Each API is
+ # uniquely identified by (name, version). Order needs to be preserved here,
+ # so APIs that were listed first are returned first.
+ api_service_map = collections.OrderedDict()
+ resolved_services = []
+
+ for service_class_name in service_class_names:
+ module_name, base_service_class_name = service_class_name.rsplit('.', 1)
+ module = __import__(module_name, fromlist=base_service_class_name)
+ service = getattr(module, base_service_class_name)
+ if hasattr(service, 'get_api_classes'):
+ resolved_services.extend(service.get_api_classes())
+ elif (not isinstance(service, type) or
+ not issubclass(service, remote.Service)):
+ raise TypeError('%s is not a ProtoRPC service' % service_class_name)
+ else:
+ resolved_services.append(service)
+
+ for resolved_service in resolved_services:
+ services = api_service_map.setdefault(
+ (resolved_service.api_info.name, resolved_service.api_info.api_version), [])
+ services.append(resolved_service)
+
+ # If hostname isn't specified in the API or on the command line, we'll
+ # try to build it from information in app.yaml.
+ app_yaml_hostname = _GetAppYamlHostname(application_path)
+
+ service_map = collections.OrderedDict()
+ config_string_generator = (
+ config_string_generator or api_config.ApiConfigGenerator())
+ for api_info, services in api_service_map.items():
+ assert services, 'An API must have at least one ProtoRPC service'
+ # Only override hostname if None. Hostname will be the same for all
+ # services within an API, since it's stored in common info.
+ hostname = services[0].api_info.hostname or hostname or app_yaml_hostname
+
+ # Map each API by name-version.
+ service_map['%s-%s' % api_info] = (
+ config_string_generator.pretty_print_config_to_json(
+ services, hostname=hostname, **additional_kwargs))
+
+ return service_map
+
+
+def _GetAppYamlHostname(application_path, open_func=open):
+ """Build the hostname for this app based on the name in app.yaml.
+
+ Args:
+ application_path: A string with the path to the AppEngine application. This
+ should be the directory containing the app.yaml file.
+ open_func: Function to call to open a file. Used to override the default
+ open function in unit tests.
+
+ Returns:
+ A hostname, usually in the form of "myapp.appspot.com", based on the
+ application name in the app.yaml file. If the file can't be found or
+ there's a problem building the name, this will return None.
+ """
+ try:
+ app_yaml_file = open_func(os.path.join(application_path or '.', 'app.yaml'))
+ config = yaml.safe_load(app_yaml_file.read())
+ except IOError:
+ # Couldn't open/read app.yaml.
+ return None
+
+ application = config.get('application')
+ if not application:
+ return None
+
+ if ':' in application:
+ # Don't try to deal with alternate domains.
+ return None
+
+ # If there's a prefix ending in a '~', strip it.
+ tilde_index = application.rfind('~')
+ if tilde_index >= 0:
+ application = application[tilde_index + 1:]
+ if not application:
+ return None
+
+ return '%s.appspot.com' % application
+
+
+def _GenDiscoveryDoc(service_class_names,
+ output_path, hostname=None,
+ application_path=None):
+ """Write discovery documents generated from the service classes to file.
+
+ Args:
+ service_class_names: A list of fully qualified ProtoRPC service names.
+ output_path: The directory to output the discovery docs to.
+ hostname: A string hostname which will be used as the default version
+ hostname. If no hostname is specificied in the @endpoints.api decorator,
+ this value is the fallback. Defaults to None.
+ application_path: A string containing the path to the AppEngine app.
+
+ Returns:
+ A list of discovery doc filenames.
+ """
+ output_files = []
+ service_configs = GenApiConfig(
+ service_class_names, hostname=hostname,
+ config_string_generator=discovery_generator.DiscoveryGenerator(),
+ application_path=application_path)
+ for api_name_version, config in service_configs.items():
+ discovery_name = api_name_version + '.discovery'
+ output_files.append(_WriteFile(output_path, discovery_name, config))
+
+ return output_files
+
+
+def _GenOpenApiSpec(service_class_names, output_path, hostname=None,
+ application_path=None, x_google_api_name=False):
+ """Write openapi documents generated from the service classes to file.
+
+ Args:
+ service_class_names: A list of fully qualified ProtoRPC service names.
+ output_path: The directory to which to output the OpenAPI specs.
+ hostname: A string hostname which will be used as the default version
+ hostname. If no hostname is specified in the @endpoints.api decorator,
+ this value is the fallback. Defaults to None.
+ application_path: A string containing the path to the AppEngine app.
+
+ Returns:
+ A list of OpenAPI spec filenames.
+ """
+ output_files = []
+ service_configs = GenApiConfig(
+ service_class_names, hostname=hostname,
+ config_string_generator=openapi_generator.OpenApiGenerator(),
+ application_path=application_path,
+ x_google_api_name=x_google_api_name)
+ for api_name_version, config in service_configs.items():
+ openapi_name = api_name_version.replace('-', '') + 'openapi.json'
+ output_files.append(_WriteFile(output_path, openapi_name, config))
+
+ return output_files
+
+
+def _GenClientLib(discovery_path, language, output_path, build_system):
+ """Write a client library from a discovery doc.
+
+ Args:
+ discovery_path: Path to the discovery doc used to generate the client
+ library.
+ language: The client library language to generate. (java)
+ output_path: The directory to output the client library zip to.
+ build_system: The target build system for the client library language.
+
+ Raises:
+ IOError: If reading the discovery doc fails.
+ ServerRequestException: If fetching the generated client library fails.
+
+ Returns:
+ The path to the zipped client library.
+ """
+ with open(discovery_path) as f:
+ discovery_doc = f.read()
+
+ client_name = re.sub(r'\.discovery$', '.zip',
+ os.path.basename(discovery_path))
+
+ return _GenClientLibFromContents(discovery_doc, language, output_path,
+ build_system, client_name)
+
+
+def _GenClientLibFromContents(discovery_doc, language, output_path,
+ build_system, client_name):
+ """Write a client library from a discovery doc.
+
+ Args:
+ discovery_doc: A string, the contents of the discovery doc used to
+ generate the client library.
+ language: A string, the client library language to generate. (java)
+ output_path: A string, the directory to output the client library zip to.
+ build_system: A string, the target build system for the client language.
+ client_name: A string, the filename used to save the client lib.
+
+ Raises:
+ IOError: If reading the discovery doc fails.
+ ServerRequestException: If fetching the generated client library fails.
+
+ Returns:
+ The path to the zipped client library.
+ """
+
+ body = urllib.parse.urlencode({'lang': language, 'content': discovery_doc,
+ 'layout': build_system})
+ request = urllib2.Request(CLIENT_LIBRARY_BASE, body)
+ try:
+ with contextlib.closing(urllib2.urlopen(request)) as response:
+ content = response.read()
+ return _WriteFile(output_path, client_name, content)
+ except urllib2.HTTPError as error:
+ raise ServerRequestException(error)
+
+
+def _GetClientLib(service_class_names, language, output_path, build_system,
+ hostname=None, application_path=None):
+ """Fetch client libraries from a cloud service.
+
+ Args:
+ service_class_names: A list of fully qualified ProtoRPC service names.
+ language: The client library language to generate. (java)
+ output_path: The directory to output the discovery docs to.
+ build_system: The target build system for the client library language.
+ hostname: A string hostname which will be used as the default version
+ hostname. If no hostname is specificied in the @endpoints.api decorator,
+ this value is the fallback. Defaults to None.
+ application_path: A string containing the path to the AppEngine app.
+
+ Returns:
+ A list of paths to client libraries.
+ """
+ client_libs = []
+ service_configs = GenApiConfig(
+ service_class_names, hostname=hostname,
+ config_string_generator=discovery_generator.DiscoveryGenerator(),
+ application_path=application_path)
+ for api_name_version, config in service_configs.items():
+ client_name = api_name_version + '.zip'
+ client_libs.append(
+ _GenClientLibFromContents(config, language, output_path,
+ build_system, client_name))
+ return client_libs
+
+
+def _GenApiConfigCallback(args, api_func=GenApiConfig):
+ """Generate an api file.
+
+ Args:
+ args: An argparse.Namespace object to extract parameters from.
+ api_func: A function that generates and returns an API configuration
+ for a list of services.
+ """
+ service_configs = api_func(args.service,
+ hostname=args.hostname,
+ application_path=args.application)
+
+ for api_name_version, config in service_configs.items():
+ _WriteFile(args.output, api_name_version + '.api', config)
+
+
+def _GetClientLibCallback(args, client_func=_GetClientLib):
+ """Generate discovery docs and client libraries to files.
+
+ Args:
+ args: An argparse.Namespace object to extract parameters from.
+ client_func: A function that generates client libraries and stores them to
+ files, accepting a list of service names, a client library language,
+ an output directory, a build system for the client library language, and
+ a hostname.
+ """
+ client_paths = client_func(
+ args.service, args.language, args.output, args.build_system,
+ hostname=args.hostname, application_path=args.application)
+
+ for client_path in client_paths:
+ print 'API client library written to %s' % client_path
+
+
+def _GenDiscoveryDocCallback(args, discovery_func=_GenDiscoveryDoc):
+ """Generate discovery docs to files.
+
+ Args:
+ args: An argparse.Namespace object to extract parameters from
+ discovery_func: A function that generates discovery docs and stores them to
+ files, accepting a list of service names, a discovery doc format, and an
+ output directory.
+ """
+ discovery_paths = discovery_func(args.service, args.output,
+ hostname=args.hostname,
+ application_path=args.application)
+ for discovery_path in discovery_paths:
+ print 'API discovery document written to %s' % discovery_path
+
+
+def _GenOpenApiSpecCallback(args, openapi_func=_GenOpenApiSpec):
+ """Generate OpenAPI (Swagger) specs to files.
+
+ Args:
+ args: An argparse.Namespace object to extract parameters from
+ openapi_func: A function that generates OpenAPI specs and stores them to
+ files, accepting a list of service names and an output directory.
+ """
+ openapi_paths = openapi_func(args.service, args.output,
+ hostname=args.hostname,
+ application_path=args.application,
+ x_google_api_name=args.x_google_api_name)
+ for openapi_path in openapi_paths:
+ print 'OpenAPI spec written to %s' % openapi_path
+
+
+def _GenClientLibCallback(args, client_func=_GenClientLib):
+ """Generate a client library to file.
+
+ Args:
+ args: An argparse.Namespace object to extract parameters from
+ client_func: A function that generates client libraries and stores them to
+ files, accepting a path to a discovery doc, a client library language, an
+ output directory, and a build system for the client library language.
+ """
+ client_path = client_func(args.discovery_doc[0], args.language, args.output,
+ args.build_system)
+ print 'API client library written to %s' % client_path
+
+
+def MakeParser(prog):
+ """Create an argument parser.
+
+ Args:
+ prog: The name of the program to use when outputting help text.
+
+ Returns:
+ An argparse.ArgumentParser built to specification.
+ """
+
+ def AddStandardOptions(parser, *args):
+ """Add common endpoints options to a parser.
+
+ Args:
+ parser: The parser to add options to.
+ *args: A list of option names to add. Possible names are: application,
+ format, output, language, service, and discovery_doc.
+ """
+ if 'application' in args:
+ parser.add_argument('-a', '--application', default='.',
+ help='The path to the Python App Engine App')
+ if 'format' in args:
+ # This used to be a valid option, allowing the user to select 'rest' or 'rpc',
+ # but now 'rest' is the only valid type. The argument remains so scripts using it
+ # won't break.
+ parser.add_argument('-f', '--format', default='rest',
+ choices=['rest'],
+ help='The requested API protocol type (ignored)')
+ if 'hostname' in args:
+ help_text = ('Default application hostname, if none is specified '
+ 'for API service.')
+ parser.add_argument('--hostname', help=help_text)
+ if 'output' in args:
+ parser.add_argument('-o', '--output', default='.',
+ help='The directory to store output files')
+ if 'language' in args:
+ parser.add_argument('language',
+ help='The target output programming language')
+ if 'service' in args:
+ parser.add_argument('service', nargs='+',
+ help='Fully qualified service class name')
+ if 'discovery_doc' in args:
+ parser.add_argument('discovery_doc', nargs=1,
+ help='Path to the discovery document')
+ if 'build_system' in args:
+ parser.add_argument('-bs', '--build_system', default='default',
+ help='The target build system')
+
+ parser = _EndpointsParser(prog=prog)
+ subparsers = parser.add_subparsers(
+ title='subcommands', metavar='{%s}' % ', '.join(_VISIBLE_COMMANDS))
+
+ get_client_lib = subparsers.add_parser(
+ 'get_client_lib', help=('Generates discovery documents and client '
+ 'libraries from service classes'))
+ get_client_lib.set_defaults(callback=_GetClientLibCallback)
+ AddStandardOptions(get_client_lib, 'application', 'hostname', 'output',
+ 'language', 'service', 'build_system')
+
+ get_discovery_doc = subparsers.add_parser(
+ 'get_discovery_doc',
+ help='Generates discovery documents from service classes')
+ get_discovery_doc.set_defaults(callback=_GenDiscoveryDocCallback)
+ AddStandardOptions(get_discovery_doc, 'application', 'format', 'hostname',
+ 'output', 'service')
+
+ get_openapi_spec = subparsers.add_parser(
+ 'get_openapi_spec',
+ help='Generates OpenAPI (Swagger) specs from service classes')
+ get_openapi_spec.set_defaults(callback=_GenOpenApiSpecCallback)
+ AddStandardOptions(get_openapi_spec, 'application', 'hostname', 'output',
+ 'service')
+ get_openapi_spec.add_argument('--x-google-api-name', action='store_true',
+ help="Add the 'x-google-api-name' field to the generated spec")
+
+ # Create an alias for get_openapi_spec called get_swagger_spec to support
+ # the old-style naming. This won't be a visible command, but it will still
+ # function to support legacy scripts.
+ get_swagger_spec = subparsers.add_parser(
+ 'get_swagger_spec',
+ help='Generates OpenAPI (Swagger) specs from service classes')
+ get_swagger_spec.set_defaults(callback=_GenOpenApiSpecCallback)
+ AddStandardOptions(get_swagger_spec, 'application', 'hostname', 'output',
+ 'service')
+
+ # By removing the help attribute, the following three actions won't be
+ # displayed in usage message
+ gen_api_config = subparsers.add_parser('gen_api_config')
+ gen_api_config.set_defaults(callback=_GenApiConfigCallback)
+ AddStandardOptions(gen_api_config, 'application', 'hostname', 'output',
+ 'service')
+
+ gen_discovery_doc = subparsers.add_parser('gen_discovery_doc')
+ gen_discovery_doc.set_defaults(callback=_GenDiscoveryDocCallback)
+ AddStandardOptions(gen_discovery_doc, 'application', 'format', 'hostname',
+ 'output', 'service')
+
+ gen_client_lib = subparsers.add_parser('gen_client_lib')
+ gen_client_lib.set_defaults(callback=_GenClientLibCallback)
+ AddStandardOptions(gen_client_lib, 'output', 'language', 'discovery_doc',
+ 'build_system')
+
+ return parser
+
+
+def _SetupStubs():
+ tb = testbed.Testbed()
+ tb.setup_env(CURRENT_VERSION_ID='1.0')
+ tb.activate()
+ for k, v in testbed.INIT_STUB_METHOD_NAMES.items():
+ # The old stub initialization code didn't support the image service at all
+ # so we just ignore it here.
+ if k != 'images':
+ getattr(tb, v)()
+
+
+def main(argv):
+ logging.basicConfig()
+ # silence warnings from endpoints.apiserving; they're not relevant
+ # to command-line operation.
+ logging.getLogger('endpoints.apiserving').setLevel(logging.ERROR)
+
+ _SetupStubs()
+
+ parser = MakeParser(argv[0])
+ args = parser.parse_args(argv[1:])
+
+ # Handle the common "application" argument here, since most of the handlers
+ # use this.
+ application_path = getattr(args, 'application', None)
+ if application_path is not None:
+ sys.path.insert(0, os.path.abspath(application_path))
+
+ args.callback(args)
diff --git a/third_party/endpoints/_endpointscfg_setup.py b/third_party/endpoints/_endpointscfg_setup.py
new file mode 100644
index 0000000..a286056
--- /dev/null
+++ b/third_party/endpoints/_endpointscfg_setup.py
@@ -0,0 +1,107 @@
+# 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.
+"""Module for setting up App Engine library paths.
+
+This module searches for the root of the App Engine Python SDK or Google Cloud
+SDK and computes a list of library paths and adds them to sys.path. This is
+necessary for two reasons:
+
+1. The endpointscfg tool imports user code and therefore must be able to
+ import modules used in the app.
+2. As a consequence of the first item, we must call an App Engine method to
+ set up service stubs in case an app's initialization code utilizes an App
+ Engine service. For example, there exists an App Engine version of pytz
+ which uses memcache and users may use it at the global level because it
+ seems to be declarative.
+"""
+import logging
+import os
+import sys
+
+_PYTHON_EXTENSIONS_WARNING = """
+Found Cloud SDK, but App Engine Python Extensions are not
+installed. If you encounter errors, please run:
+ $ gcloud components install app-engine-python
+""".strip()
+
+
+_IMPORT_ERROR_WARNING = """
+Could not import App Engine Python libraries. If you encounter
+errors, please make sure that the SDK binary path is in your PATH environment
+variable or that the ENDPOINTS_GAE_SDK variable points to a valid SDK root.
+""".strip()
+
+
+_NOT_FOUND_WARNING = """
+Could not find either the Cloud SDK or the App Engine Python SDK.
+If you encounter errors, please make sure that the SDK binary path is in your
+PATH environment variable or that the ENDPOINTS_GAE_SDK variable points to a
+valid SDK root.""".strip()
+
+
+_NO_FIX_SYS_PATH_WARNING = """
+Could not find the fix_sys_path() function in dev_appserver.
+If you encounter errors, please make sure that your Google App Engine SDK is
+up-to-date.""".strip()
+
+
+def _FindSdkPath():
+ environ_sdk = os.environ.get('ENDPOINTS_GAE_SDK')
+ if environ_sdk:
+ maybe_cloud_sdk = os.path.join(environ_sdk, 'platform', 'google_appengine')
+ if os.path.exists(maybe_cloud_sdk):
+ return maybe_cloud_sdk
+ return environ_sdk
+
+ for path in os.environ['PATH'].split(os.pathsep):
+ if os.path.exists(os.path.join(path, 'dev_appserver.py')):
+ if (path.endswith('bin') and
+ os.path.exists(os.path.join(path, 'gcloud'))):
+ # Cloud SDK ships with dev_appserver.py in a bin directory. In the
+ # root directory, we can find the Python SDK in
+ # platform/google_appengine provided that it's installed.
+ sdk_path = os.path.join(os.path.dirname(path),
+ 'platform',
+ 'google_appengine')
+ if not os.path.exists(sdk_path):
+ logging.warning(_PYTHON_EXTENSIONS_WARNING)
+ return sdk_path
+ # App Engine SDK ships withd dev_appserver.py in the root directory.
+ return path
+
+
+def _SetupPaths():
+ """Sets up the sys.path with special directories for endpointscfg.py."""
+ sdk_path = _FindSdkPath()
+ if sdk_path:
+ sys.path.append(sdk_path)
+ try:
+ import dev_appserver # pylint: disable=g-import-not-at-top
+ if hasattr(dev_appserver, 'fix_sys_path'):
+ dev_appserver.fix_sys_path()
+ else:
+ logging.warning(_NO_FIX_SYS_PATH_WARNING)
+ except ImportError:
+ logging.warning(_IMPORT_ERROR_WARNING)
+ else:
+ logging.warning(_NOT_FOUND_WARNING)
+
+ # Add the path above this directory, so we can import the endpoints package
+ # from the user's app code (rather than from another, possibly outdated SDK).
+ # pylint: disable=g-import-not-at-top
+ from google.appengine.ext import vendor
+ vendor.add(os.path.dirname(os.path.dirname(__file__)))
+
+
+_SetupPaths()
diff --git a/third_party/endpoints/api_config.py b/third_party/endpoints/api_config.py
new file mode 100644
index 0000000..e24cd57
--- /dev/null
+++ b/third_party/endpoints/api_config.py
@@ -0,0 +1,2257 @@
+# 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=(',', ': '))
diff --git a/third_party/endpoints/api_config_manager.py b/third_party/endpoints/api_config_manager.py
new file mode 100644
index 0000000..08f2c8e
--- /dev/null
+++ b/third_party/endpoints/api_config_manager.py
@@ -0,0 +1,350 @@
+# 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.
+
+"""Configuration manager to store API configurations."""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+import base64
+import logging
+import re
+import threading
+from six.moves import urllib
+
+from . import discovery_service
+
+_logger = logging.getLogger(__name__)
+
+# Internal constants
+_PATH_VARIABLE_PATTERN = r'[a-zA-Z_][a-zA-Z_.\d]*'
+_PATH_VALUE_PATTERN = r'[^/?#\[\]{}]*'
+
+
+class ApiConfigManager(object):
+ """Manages loading api configs and method lookup."""
+
+ def __init__(self):
+ self._rest_methods = []
+ self._configs = {}
+ self._config_lock = threading.Lock()
+
+ @property
+ def configs(self):
+ """Return a dict with the current configuration mappings.
+
+ Returns:
+ A dict with the current configuration mappings.
+ """
+ with self._config_lock:
+ return self._configs.copy()
+
+ def process_api_config_response(self, config_json):
+ """Parses a JSON API config and registers methods for dispatch.
+
+ Side effects:
+ Parses method name, etc. for all methods and updates the indexing
+ data structures with the information.
+
+ Args:
+ config_json: A dict, the JSON body of the getApiConfigs response.
+ """
+ with self._config_lock:
+ self._add_discovery_config()
+ for config in config_json.get('items', []):
+ lookup_key = config.get('name', ''), config.get('version', '')
+ self._configs[lookup_key] = config
+
+ for config in self._configs.values():
+ name = config.get('name', '')
+ api_version = config.get('api_version', '')
+ path_version = config.get('path_version', '')
+ sorted_methods = self._get_sorted_methods(config.get('methods', {}))
+
+
+ for method_name, method in sorted_methods:
+ self._save_rest_method(method_name, name, path_version, method)
+
+ def _get_sorted_methods(self, methods):
+ """Get a copy of 'methods' sorted the way they would be on the live server.
+
+ Args:
+ methods: JSON configuration of an API's methods.
+
+ Returns:
+ The same configuration with the methods sorted based on what order
+ they'll be checked by the server.
+ """
+ if not methods:
+ return methods
+
+ # Comparison function we'll use to sort the methods:
+ def _sorted_methods_comparison(method_info1, method_info2):
+ """Sort method info by path and http_method.
+
+ Args:
+ method_info1: Method name and info for the first method to compare.
+ method_info2: Method name and info for the method to compare to.
+
+ Returns:
+ Negative if the first method should come first, positive if the
+ first method should come after the second. Zero if they're
+ equivalent.
+ """
+
+ def _score_path(path):
+ """Calculate the score for this path, used for comparisons.
+
+ Higher scores have priority, and if scores are equal, the path text
+ is sorted alphabetically. Scores are based on the number and location
+ of the constant parts of the path. The server has some special handling
+ for variables with regexes, which we don't handle here.
+
+ Args:
+ path: The request path that we're calculating a score for.
+
+ Returns:
+ The score for the given path.
+ """
+ score = 0
+ parts = path.split('/')
+ for part in parts:
+ score <<= 1
+ if not part or part[0] != '{':
+ # Found a constant.
+ score += 1
+ # Shift by 31 instead of 32 because some (!) versions of Python like
+ # to convert the int to a long if we shift by 32, and the sorted()
+ # function that uses this blows up if it receives anything but an int.
+ score <<= 31 - len(parts)
+ return score
+
+ # Higher path scores come first.
+ path_score1 = _score_path(method_info1[1].get('path', ''))
+ path_score2 = _score_path(method_info2[1].get('path', ''))
+ if path_score1 != path_score2:
+ return path_score2 - path_score1
+
+ # Compare by path text next, sorted alphabetically.
+ path_result = cmp(method_info1[1].get('path', ''),
+ method_info2[1].get('path', ''))
+ if path_result != 0:
+ return path_result
+
+ # All else being equal, sort by HTTP method.
+ method_result = cmp(method_info1[1].get('httpMethod', ''),
+ method_info2[1].get('httpMethod', ''))
+ return method_result
+
+ return sorted(methods.items(), _sorted_methods_comparison)
+
+ @staticmethod
+ def _get_path_params(match):
+ """Gets path parameters from a regular expression match.
+
+ Args:
+ match: A regular expression Match object for a path.
+
+ Returns:
+ A dictionary containing the variable names converted from base64.
+ """
+ result = {}
+ for var_name, value in match.groupdict().items():
+ actual_var_name = ApiConfigManager._from_safe_path_param_name(var_name)
+ result[actual_var_name] = urllib.parse.unquote_plus(value)
+ return result
+
+ def lookup_rest_method(self, path, request_uri, http_method):
+ """Look up the rest method at call time.
+
+ The method is looked up in self._rest_methods, the list it is saved
+ in for SaveRestMethod.
+
+ Args:
+ path: A string containing the path from the URL of the request.
+ http_method: A string containing HTTP method of the request.
+
+ Returns:
+ Tuple of (<method name>, <method>, <params>)
+ Where:
+ <method name> is the string name of the method that was matched.
+ <method> is the descriptor as specified in the API configuration. -and-
+ <params> is a dict of path parameters matched in the rest request.
+ """
+ method_key = http_method.lower()
+ with self._config_lock:
+ for compiled_path_pattern, unused_path, methods in self._rest_methods:
+ if method_key not in methods:
+ continue
+ candidate_method_info = methods[method_key]
+ match_against = request_uri if candidate_method_info[1].get('useRequestUri') else path
+ match = compiled_path_pattern.match(match_against)
+ if match:
+ params = self._get_path_params(match)
+ method_name, method = candidate_method_info
+ break
+ else:
+ _logger.warn('No endpoint found for path: %r, method: %r', path, http_method)
+ method_name = None
+ method = None
+ params = None
+ return method_name, method, params
+
+ def _add_discovery_config(self):
+ """Add the Discovery configuration to our list of configs.
+
+ This should only be called with self._config_lock. The code here assumes
+ the lock is held.
+ """
+ lookup_key = (discovery_service.DiscoveryService.API_CONFIG['name'],
+ discovery_service.DiscoveryService.API_CONFIG['version'])
+ self._configs[lookup_key] = discovery_service.DiscoveryService.API_CONFIG
+
+ def save_config(self, lookup_key, config):
+ """Save a configuration to the cache of configs.
+
+ Args:
+ lookup_key: A string containing the cache lookup key.
+ config: The dict containing the configuration to save to the cache.
+ """
+ with self._config_lock:
+ self._configs[lookup_key] = config
+
+ @staticmethod
+ def _to_safe_path_param_name(matched_parameter):
+ """Creates a safe string to be used as a regex group name.
+
+ Only alphanumeric characters and underscore are allowed in variable name
+ tokens, and numeric are not allowed as the first character.
+
+ We cast the matched_parameter to base32 (since the alphabet is safe),
+ strip the padding (= not safe) and prepend with _, since we know a token
+ can begin with underscore.
+
+ Args:
+ matched_parameter: A string containing the parameter matched from the URL
+ template.
+
+ Returns:
+ A string that's safe to be used as a regex group name.
+ """
+ return '_' + base64.b32encode(matched_parameter).rstrip('=')
+
+ @staticmethod
+ def _from_safe_path_param_name(safe_parameter):
+ """Takes a safe regex group name and converts it back to the original value.
+
+ Only alphanumeric characters and underscore are allowed in variable name
+ tokens, and numeric are not allowed as the first character.
+
+ The safe_parameter is a base32 representation of the actual value.
+
+ Args:
+ safe_parameter: A string that was generated by _to_safe_path_param_name.
+
+ Returns:
+ A string, the parameter matched from the URL template.
+ """
+ assert safe_parameter.startswith('_')
+ safe_parameter_as_base32 = safe_parameter[1:]
+
+ padding_length = - len(safe_parameter_as_base32) % 8
+ padding = '=' * padding_length
+ return base64.b32decode(safe_parameter_as_base32 + padding)
+
+ @staticmethod
+ def _compile_path_pattern(pattern):
+ r"""Generates a compiled regex pattern for a path pattern.
+
+ e.g. '/MyApi/v1/notes/{id}'
+ returns re.compile(r'/MyApi/v1/notes/(?P<id>[^/?#\[\]{}]*)')
+
+ Args:
+ pattern: A string, the parameterized path pattern to be checked.
+
+ Returns:
+ A compiled regex object to match this path pattern.
+ """
+
+ def replace_variable(match):
+ """Replaces a {variable} with a regex to match it by name.
+
+ Changes the string corresponding to the variable name to the base32
+ representation of the string, prepended by an underscore. This is
+ necessary because we can have message variable names in URL patterns
+ (e.g. via {x.y}) but the character '.' can't be in a regex group name.
+
+ Args:
+ match: A regex match object, the matching regex group as sent by
+ re.sub().
+
+ Returns:
+ A string regex to match the variable by name, if the full pattern was
+ matched.
+ """
+ if match.lastindex > 1:
+ var_name = ApiConfigManager._to_safe_path_param_name(match.group(2))
+ return '%s(?P<%s>%s)' % (match.group(1), var_name,
+ _PATH_VALUE_PATTERN)
+ return match.group(0)
+
+ pattern = re.sub('(/|^){(%s)}(?=/|$|:)' % _PATH_VARIABLE_PATTERN,
+ replace_variable, pattern)
+ return re.compile(pattern + '/?$')
+
+ def _save_rest_method(self, method_name, api_name, version, method):
+ """Store Rest api methods in a list for lookup at call time.
+
+ The list is self._rest_methods, a list of tuples:
+ [(<compiled_path>, <path_pattern>, <method_dict>), ...]
+ where:
+ <compiled_path> is a compiled regex to match against the incoming URL
+ <path_pattern> is a string representing the original path pattern,
+ checked on insertion to prevent duplicates. -and-
+ <method_dict> is a dict of httpMethod => (method_name, method)
+
+ This structure is a bit complex, it supports use in two contexts:
+ Creation time:
+ - SaveRestMethod is called repeatedly, each method will have a path,
+ which we want to be compiled for fast lookup at call time
+ - We want to prevent duplicate incoming path patterns, so store the
+ un-compiled path, not counting on a compiled regex being a stable
+ comparison as it is not documented as being stable for this use.
+ - Need to store the method that will be mapped at calltime.
+ - Different methods may have the same path but different http method.
+ Call time:
+ - Quickly scan through the list attempting .match(path) on each
+ compiled regex to find the path that matches.
+ - When a path is matched, look up the API method from the request
+ and get the method name and method config for the matching
+ API method and method name.
+
+ Args:
+ method_name: A string containing the name of the API method.
+ api_name: A string containing the name of the API.
+ version: A string containing the version of the API.
+ method: A dict containing the method descriptor (as in the api config
+ file).
+ """
+ path_pattern = '/'.join((api_name, version, method.get('path', '')))
+ http_method = method.get('httpMethod', '').lower()
+ for _, path, methods in self._rest_methods:
+ if path == path_pattern:
+ methods[http_method] = method_name, method
+ break
+ else:
+ self._rest_methods.append(
+ (self._compile_path_pattern(path_pattern),
+ path_pattern,
+ {http_method: (method_name, method)}))
diff --git a/third_party/endpoints/api_exceptions.py b/third_party/endpoints/api_exceptions.py
new file mode 100644
index 0000000..66fbe86
--- /dev/null
+++ b/third_party/endpoints/api_exceptions.py
@@ -0,0 +1,94 @@
+# 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 containing exception types used by Endpoints."""
+
+from __future__ import absolute_import
+
+from six.moves import http_client
+
+from . import remote
+
+
+class ServiceException(remote.ApplicationError):
+ """Base class for request/service exceptions in Endpoints."""
+
+ def __init__(self, message=None):
+ super(ServiceException, self).__init__(message,
+ http_client.responses[self.http_status])
+
+
+class BadRequestException(ServiceException):
+ """Bad request exception that is mapped to a 400 response."""
+ http_status = http_client.BAD_REQUEST
+
+
+class UnauthorizedException(ServiceException):
+ """Unauthorized exception that is mapped to a 401 response."""
+ http_status = http_client.UNAUTHORIZED
+
+
+class ForbiddenException(ServiceException):
+ """Forbidden exception that is mapped to a 403 response."""
+ http_status = http_client.FORBIDDEN
+
+
+class NotFoundException(ServiceException):
+ """Not found exception that is mapped to a 404 response."""
+ http_status = http_client.NOT_FOUND
+
+
+class ConflictException(ServiceException):
+ """Conflict exception that is mapped to a 409 response."""
+ http_status = http_client.CONFLICT
+
+
+class GoneException(ServiceException):
+ """Resource Gone exception that is mapped to a 410 response."""
+ http_status = http_client.GONE
+
+
+class PreconditionFailedException(ServiceException):
+ """Precondition Failed exception that is mapped to a 412 response."""
+ http_status = http_client.PRECONDITION_FAILED
+
+
+class RequestEntityTooLargeException(ServiceException):
+ """Request entity too large exception that is mapped to a 413 response."""
+ http_status = http_client.REQUEST_ENTITY_TOO_LARGE
+
+
+class InternalServerErrorException(ServiceException):
+ """Internal server exception that is mapped to a 500 response."""
+ http_status = http_client.INTERNAL_SERVER_ERROR
+
+
+class ApiConfigurationError(Exception):
+ """Exception thrown if there's an error in the configuration/annotations."""
+
+
+class InvalidNamespaceException(Exception):
+ """Exception thrown if there's an invalid namespace declaration."""
+
+
+class InvalidLimitDefinitionException(Exception):
+ """Exception thrown if there's an invalid rate limit definition."""
+
+
+class InvalidApiNameException(Exception):
+ """Exception thrown if the api name does not match the required character set."""
+
+
+class ToolError(Exception):
+ """Exception thrown if there's a general error in the endpointscfg.py tool."""
diff --git a/third_party/endpoints/api_request.py b/third_party/endpoints/api_request.py
new file mode 100644
index 0000000..8b95047
--- /dev/null
+++ b/third_party/endpoints/api_request.py
@@ -0,0 +1,193 @@
+# 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.
+
+"""Cloud Endpoints API request-related data and functions."""
+
+from __future__ import absolute_import
+
+# pylint: disable=g-bad-name
+import copy
+import json
+import logging
+from six.moves import urllib
+import zlib
+
+from . import util
+
+_logger = logging.getLogger(__name__)
+
+_METHOD_OVERRIDE = 'X-HTTP-METHOD-OVERRIDE'
+
+
+class ApiRequest(object):
+ """Simple data object representing an API request.
+
+ Parses the request from environment variables into convenient pieces
+ and stores them as members.
+ """
+ def __init__(self, environ, base_paths=None):
+ """Constructor.
+
+ Args:
+ environ: An environ dict for the request as defined in PEP-333.
+
+ Raises:
+ ValueError: If the path for the request is invalid.
+ """
+ self.headers = util.get_headers_from_environ(environ)
+ self.http_method = environ['REQUEST_METHOD']
+ self.url_scheme = environ['wsgi.url_scheme']
+ self.server = environ['SERVER_NAME']
+ self.port = environ['SERVER_PORT']
+ self.path = environ['PATH_INFO']
+ self.request_uri = environ.get('REQUEST_URI')
+ if self.request_uri is not None and len(self.request_uri) < len(self.path):
+ self.request_uri = None
+ self.query = environ.get('QUERY_STRING')
+ self.body = environ['wsgi.input'].read()
+ if self.body and self.headers.get('CONTENT-ENCODING') == 'gzip':
+ # Increasing wbits to 16 + MAX_WBITS is necessary to be able to decode
+ # gzipped content (as opposed to zlib-encoded content).
+ # If there's an error in the decompression, it could be due to another
+ # part of the serving chain that already decompressed it without clearing
+ # the header. If so, just ignore it and continue.
+ try:
+ self.body = zlib.decompress(self.body, 16 + zlib.MAX_WBITS)
+ except zlib.error:
+ pass
+ if _METHOD_OVERRIDE in self.headers:
+ # the query arguments in the body will be handled by ._process_req_body()
+ self.http_method = self.headers[_METHOD_OVERRIDE]
+ del self.headers[_METHOD_OVERRIDE] # wsgiref.headers.Headers doesn't implement .pop()
+ self.source_ip = environ.get('REMOTE_ADDR')
+ self.relative_url = self._reconstruct_relative_url(environ)
+
+ if not base_paths:
+ base_paths = set()
+ elif isinstance(base_paths, list):
+ base_paths = set(base_paths)
+
+ # Find a base_path in the path
+ for base_path in base_paths:
+ if self.path.startswith(base_path):
+ self.path = self.path[len(base_path):]
+ if self.request_uri is not None:
+ self.request_uri = self.request_uri[len(base_path):]
+ self.base_path = base_path
+ break
+ else:
+ raise ValueError('Invalid request path: %s' % self.path)
+
+ if self.query:
+ self.parameters = urllib.parse.parse_qs(self.query, keep_blank_values=True)
+ else:
+ self.parameters = {}
+ self.body_json = self._process_req_body(self.body) if self.body else {}
+ self.request_id = None
+
+ # Check if it's a batch request. We'll only handle single-element batch
+ # requests on the dev server (and we need to handle them because that's
+ # what RPC and JS calls typically show up as). Pull the request out of the
+ # list and record the fact that we're processing a batch.
+ if isinstance(self.body_json, list):
+ if len(self.body_json) != 1:
+ _logger.warning('Batch requests with more than 1 element aren\'t '
+ 'supported in devappserver2. Only the first element '
+ 'will be handled. Found %d elements.',
+ len(self.body_json))
+ else:
+ _logger.info('Converting batch request to single request.')
+ self.body_json = self.body_json[0]
+ self.body = json.dumps(self.body_json)
+ self._is_batch = True
+ else:
+ self._is_batch = False
+
+ def _process_req_body(self, body):
+ """Process the body of the HTTP request.
+
+ If the body is valid JSON, return the JSON as a dict.
+ Else, convert the key=value format to a dict and return that.
+
+ Args:
+ body: The body of the HTTP request.
+ """
+ try:
+ return json.loads(body)
+ except ValueError:
+ return urllib.parse.parse_qs(body, keep_blank_values=True)
+
+ def _reconstruct_relative_url(self, environ):
+ """Reconstruct the relative URL of this request.
+
+ This is based on the URL reconstruction code in Python PEP 333:
+ http://www.python.org/dev/peps/pep-0333/#url-reconstruction. Rebuild the
+ URL from the pieces available in the environment.
+
+ Args:
+ environ: An environ dict for the request as defined in PEP-333
+
+ Returns:
+ The portion of the URL from the request after the server and port.
+ """
+ url = urllib.parse.quote(environ.get('SCRIPT_NAME', ''))
+ url += urllib.parse.quote(environ.get('PATH_INFO', ''))
+ if environ.get('QUERY_STRING'):
+ url += '?' + environ['QUERY_STRING']
+ return url
+
+ def reconstruct_hostname(self, port_override=None):
+ """Reconstruct the hostname of a request.
+
+ This is based on the URL reconstruction code in Python PEP 333:
+ http://www.python.org/dev/peps/pep-0333/#url-reconstruction. Rebuild the
+ hostname from the pieces available in the environment.
+
+ Args:
+ port_override: str, An override for the port on the returned hostname.
+
+ Returns:
+ The hostname portion of the URL from the request, not including the
+ URL scheme.
+ """
+ url = self.server
+ port = port_override or self.port
+ if port and ((self.url_scheme == 'https' and str(port) != '443') or
+ (self.url_scheme != 'https' and str(port) != '80')):
+ url += ':{0}'.format(port)
+
+ return url
+
+ def reconstruct_full_url(self, port_override=None):
+ """Reconstruct the full URL of a request.
+
+ This is based on the URL reconstruction code in Python PEP 333:
+ http://www.python.org/dev/peps/pep-0333/#url-reconstruction. Rebuild the
+ hostname from the pieces available in the environment.
+
+ Args:
+ port_override: str, An override for the port on the returned full URL.
+
+ Returns:
+ The full URL from the request, including the URL scheme.
+ """
+ return '{0}://{1}{2}'.format(self.url_scheme,
+ self.reconstruct_hostname(port_override),
+ self.relative_url)
+
+ def copy(self):
+ return copy.deepcopy(self)
+
+ def is_batch(self):
+ return self._is_batch
diff --git a/third_party/endpoints/apiserving.py b/third_party/endpoints/apiserving.py
new file mode 100644
index 0000000..9c8cfca
--- /dev/null
+++ b/third_party/endpoints/apiserving.py
@@ -0,0 +1,606 @@
+# 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 supporting use of the Google API Server.
+
+This library helps you configure a set of ProtoRPC services to act as
+Endpoints backends. In addition to translating ProtoRPC to Endpoints
+compatible errors, it exposes a helper service that describes your services.
+
+ Usage:
+ 1) Create an endpoints.api_server instead of a webapp.WSGIApplication.
+ 2) Annotate your ProtoRPC Service class with @endpoints.api to give your
+ API a name, version, and short description
+ 3) To return an error from Google API Server raise an endpoints.*Exception
+ The ServiceException classes specify the http status code returned.
+
+ For example:
+ raise endpoints.UnauthorizedException("Please log in as an admin user")
+
+
+ Sample usage:
+ - - - - app.yaml - - - -
+
+ handlers:
+ # Path to your API backend.
+ # /_ah/api/.* is the default. Using the base_path parameter, you can
+ # customize this to whichever base path you desire.
+ - url: /_ah/api/.*
+ # For the legacy python runtime this would be "script: services.py"
+ script: services.app
+
+ - - - - services.py - - - -
+
+ import endpoints
+ import postservice
+
+ app = endpoints.api_server([postservice.PostService], debug=True)
+
+ - - - - postservice.py - - - -
+
+ @endpoints.api(name='guestbook', version='v0.2', description='Guestbook API')
+ class PostService(remote.Service):
+ ...
+ @endpoints.method(GetNotesRequest, Notes, name='notes.list', path='notes',
+ http_method='GET')
+ def list(self, request):
+ raise endpoints.UnauthorizedException("Please log in as an admin user")
+"""
+
+from __future__ import absolute_import
+
+import cgi
+from six.moves import http_client
+import json
+import logging
+import os
+
+from google.appengine.api import app_identity
+
+from . import api_config
+from . import api_exceptions
+from . import endpoints_dispatcher
+from . import message_types
+from . import messages
+from . import protojson
+from . import remote
+from . import util
+
+_logger = logging.getLogger(__name__)
+package = 'google.appengine.endpoints'
+
+
+__all__ = [
+ 'ApiConfigRegistry',
+ 'api_server',
+ 'EndpointsErrorMessage',
+ 'package',
+]
+
+
+class _Remapped405Exception(api_exceptions.ServiceException):
+ """Method Not Allowed (405) ends up being remapped to 501.
+
+ This is included here for compatibility with the Java implementation. The
+ Google Cloud Endpoints server remaps HTTP 405 to 501.
+ """
+ http_status = http_client.METHOD_NOT_ALLOWED
+
+
+class _Remapped408Exception(api_exceptions.ServiceException):
+ """Request Timeout (408) ends up being remapped to 503.
+
+ This is included here for compatibility with the Java implementation. The
+ Google Cloud Endpoints server remaps HTTP 408 to 503.
+ """
+ http_status = http_client.REQUEST_TIMEOUT
+
+
+_ERROR_NAME_MAP = dict((http_client.responses[c.http_status], c) for c in [
+ api_exceptions.BadRequestException,
+ api_exceptions.UnauthorizedException,
+ api_exceptions.ForbiddenException,
+ api_exceptions.NotFoundException,
+ _Remapped405Exception,
+ _Remapped408Exception,
+ api_exceptions.ConflictException,
+ api_exceptions.GoneException,
+ api_exceptions.PreconditionFailedException,
+ api_exceptions.RequestEntityTooLargeException,
+ api_exceptions.InternalServerErrorException
+ ])
+
+_ALL_JSON_CONTENT_TYPES = frozenset(
+ [protojson.EndpointsProtoJson.CONTENT_TYPE] +
+ protojson.EndpointsProtoJson.ALTERNATIVE_CONTENT_TYPES)
+
+
+# Message format for returning error back to Google Endpoints frontend.
+class EndpointsErrorMessage(messages.Message):
+ """Message for returning error back to Google Endpoints frontend.
+
+ Fields:
+ state: State of RPC, should be 'APPLICATION_ERROR'.
+ error_message: Error message associated with status.
+ """
+
+ class State(messages.Enum):
+ """Enumeration of possible RPC states.
+
+ Values:
+ OK: Completed successfully.
+ RUNNING: Still running, not complete.
+ REQUEST_ERROR: Request was malformed or incomplete.
+ SERVER_ERROR: Server experienced an unexpected error.
+ NETWORK_ERROR: An error occured on the network.
+ APPLICATION_ERROR: The application is indicating an error.
+ When in this state, RPC should also set application_error.
+ """
+ OK = 0
+ RUNNING = 1
+
+ REQUEST_ERROR = 2
+ SERVER_ERROR = 3
+ NETWORK_ERROR = 4
+ APPLICATION_ERROR = 5
+ METHOD_NOT_FOUND_ERROR = 6
+
+ state = messages.EnumField(State, 1, required=True)
+ error_message = messages.StringField(2)
+
+
+# pylint: disable=g-bad-name
+def _get_app_revision(environ=None):
+ """Gets the app revision (minor app version) of the current app.
+
+ Args:
+ environ: A dictionary with a key CURRENT_VERSION_ID that maps to a version
+ string of the format <major>.<minor>.
+
+ Returns:
+ The app revision (minor version) of the current app, or None if one couldn't
+ be found.
+ """
+ if environ is None:
+ environ = os.environ
+ if 'CURRENT_VERSION_ID' in environ:
+ return environ['CURRENT_VERSION_ID'].split('.')[1]
+
+
+class ApiConfigRegistry(object):
+ """Registry of active APIs"""
+
+ def __init__(self):
+ # Set of API classes that have been registered.
+ self.__registered_classes = set()
+ # Set of API config contents served by this App Engine AppId/version
+ self.__api_configs = []
+ # Map of API method name to ProtoRPC method name.
+ self.__api_methods = {}
+
+ # pylint: disable=g-bad-name
+ def register_backend(self, config_contents):
+ """Register a single API and its config contents.
+
+ Args:
+ config_contents: Dict containing API configuration.
+ """
+ if config_contents is None:
+ return
+ self.__register_class(config_contents)
+ self.__api_configs.append(config_contents)
+ self.__register_methods(config_contents)
+
+ def __register_class(self, parsed_config):
+ """Register the class implementing this config, so we only add it once.
+
+ Args:
+ parsed_config: The JSON object with the API configuration being added.
+
+ Raises:
+ ApiConfigurationError: If the class has already been registered.
+ """
+ methods = parsed_config.get('methods')
+ if not methods:
+ return
+
+ # Determine the name of the class that implements this configuration.
+ service_classes = set()
+ for method in methods.values():
+ rosy_method = method.get('rosyMethod')
+ if rosy_method and '.' in rosy_method:
+ method_class = rosy_method.split('.', 1)[0]
+ service_classes.add(method_class)
+
+ for service_class in service_classes:
+ if service_class in self.__registered_classes:
+ raise api_exceptions.ApiConfigurationError(
+ 'API class %s has already been registered.' % service_class)
+ self.__registered_classes.add(service_class)
+
+ def __register_methods(self, parsed_config):
+ """Register all methods from the given api config file.
+
+ Methods are stored in a map from method_name to rosyMethod,
+ the name of the ProtoRPC method to be called on the backend.
+ If no rosyMethod was specified the value will be None.
+
+ Args:
+ parsed_config: The JSON object with the API configuration being added.
+ """
+ methods = parsed_config.get('methods')
+ if not methods:
+ return
+
+ for method_name, method in methods.items():
+ self.__api_methods[method_name] = method.get('rosyMethod')
+
+ def lookup_api_method(self, api_method_name):
+ """Looks an API method up by name to find the backend method to call.
+
+ Args:
+ api_method_name: Name of the method in the API that was called.
+
+ Returns:
+ Name of the ProtoRPC method called on the backend, or None if not found.
+ """
+ return self.__api_methods.get(api_method_name)
+
+ def all_api_configs(self):
+ """Return a list of all API configration specs as registered above."""
+ return self.__api_configs
+
+
+class _ApiServer(object):
+ """ProtoRPC wrapper, registers APIs and formats errors for Google API Server.
+
+ - - - - ProtoRPC error format - - - -
+ HTTP/1.0 400 Please log in as an admin user.
+ content-type: application/json
+
+ {
+ "state": "APPLICATION_ERROR",
+ "error_message": "Please log in as an admin user",
+ "error_name": "unauthorized",
+ }
+
+ - - - - Reformatted error format - - - -
+ HTTP/1.0 401 UNAUTHORIZED
+ content-type: application/json
+
+ {
+ "state": "APPLICATION_ERROR",
+ "error_message": "Please log in as an admin user"
+ }
+ """
+ # Silence lint warning about invalid const name
+ # pylint: disable=g-bad-name
+ __SERVER_SOFTWARE = 'SERVER_SOFTWARE'
+ __HEADER_NAME_PEER = 'HTTP_X_APPENGINE_PEER'
+ __GOOGLE_PEER = 'apiserving'
+ # A common EndpointsProtoJson for all _ApiServer instances. At the moment,
+ # EndpointsProtoJson looks to be thread safe.
+ __PROTOJSON = protojson.EndpointsProtoJson()
+
+ def __init__(self, api_services, **kwargs):
+ """Initialize an _ApiServer instance.
+
+ The primary function of this method is to set up the WSGIApplication
+ instance for the service handlers described by the services passed in.
+ Additionally, it registers each API in ApiConfigRegistry for later use
+ in the BackendService.getApiConfigs() (API config enumeration service).
+
+ Args:
+ api_services: List of protorpc.remote.Service classes implementing the API
+ or a list of _ApiDecorator instances that decorate the service classes
+ for an API.
+ **kwargs: Passed through to protorpc.wsgi.service.service_handlers except:
+ protocols - ProtoRPC protocols are not supported, and are disallowed.
+
+ Raises:
+ TypeError: if protocols are configured (this feature is not supported).
+ ApiConfigurationError: if there's a problem with the API config.
+ """
+ self.base_paths = set()
+
+ for entry in api_services[:]:
+ # pylint: disable=protected-access
+ if isinstance(entry, api_config._ApiDecorator):
+ api_services.remove(entry)
+ api_services.extend(entry.get_api_classes())
+
+ # Record the API services for quick discovery doc generation
+ self.api_services = api_services
+
+ # Record the base paths
+ for entry in api_services:
+ self.base_paths.add(entry.api_info.base_path)
+
+ self.api_config_registry = ApiConfigRegistry()
+ self.api_name_version_map = self.__create_name_version_map(api_services)
+ protorpc_services = self.__register_services(self.api_name_version_map,
+ self.api_config_registry)
+
+ # Disallow protocol configuration for now, Lily is json-only.
+ if 'protocols' in kwargs:
+ raise TypeError('__init__() got an unexpected keyword argument '
+ "'protocols'")
+ protocols = remote.Protocols()
+ protocols.add_protocol(self.__PROTOJSON, 'protojson')
+ remote.Protocols.set_default(protocols)
+
+ # This variable is not used in Endpoints 1.1, but let's pop it out here
+ # so it doesn't result in an unexpected keyword argument downstream.
+ kwargs.pop('restricted', None)
+
+ from protorpc.wsgi import service as wsgi_service
+ self.service_app = wsgi_service.service_mappings(protorpc_services,
+ **kwargs)
+
+ @staticmethod
+ def __create_name_version_map(api_services):
+ """Create a map from API name/version to Service class/factory.
+
+ This creates a map from an API name and version to a list of remote.Service
+ factories that implement that API.
+
+ Args:
+ api_services: A list of remote.Service-derived classes or factories
+ created with remote.Service.new_factory.
+
+ Returns:
+ A mapping from (api name, api version) to a list of service factories,
+ for service classes that implement that API.
+
+ Raises:
+ ApiConfigurationError: If a Service class appears more than once
+ in api_services.
+ """
+ api_name_version_map = {}
+ for service_factory in api_services:
+ try:
+ service_class = service_factory.service_class
+ except AttributeError:
+ service_class = service_factory
+ service_factory = service_class.new_factory()
+
+ key = service_class.api_info.name, service_class.api_info.api_version
+ service_factories = api_name_version_map.setdefault(key, [])
+ if service_factory in service_factories:
+ raise api_config.ApiConfigurationError(
+ 'Can\'t add the same class to an API twice: %s' %
+ service_factory.service_class.__name__)
+
+ service_factories.append(service_factory)
+ return api_name_version_map
+
+ @staticmethod
+ def __register_services(api_name_version_map, api_config_registry):
+ """Register & return a list of each URL and class that handles that URL.
+
+ This finds every service class in api_name_version_map, registers it with
+ the given ApiConfigRegistry, builds the URL for that class, and adds
+ the URL and its factory to a list that's returned.
+
+ Args:
+ api_name_version_map: A mapping from (api name, api version) to a list of
+ service factories, as returned by __create_name_version_map.
+ api_config_registry: The ApiConfigRegistry where service classes will
+ be registered.
+
+ Returns:
+ A list of (URL, service_factory) for each service class in
+ api_name_version_map.
+
+ Raises:
+ ApiConfigurationError: If a Service class appears more than once
+ in api_name_version_map. This could happen if one class is used to
+ implement multiple APIs.
+ """
+ generator = api_config.ApiConfigGenerator()
+ protorpc_services = []
+ for service_factories in api_name_version_map.values():
+ service_classes = [service_factory.service_class
+ for service_factory in service_factories]
+ config_dict = generator.get_config_dict(service_classes)
+ api_config_registry.register_backend(config_dict)
+
+ for service_factory in service_factories:
+ protorpc_class_name = service_factory.service_class.__name__
+ root = '%s%s' % (service_factory.service_class.api_info.base_path,
+ protorpc_class_name)
+ if any(service_map[0] == root or service_map[1] == service_factory
+ for service_map in protorpc_services):
+ raise api_config.ApiConfigurationError(
+ 'Can\'t reuse the same class in multiple APIs: %s' %
+ protorpc_class_name)
+ protorpc_services.append((root, service_factory))
+ return protorpc_services
+
+ def __is_json_error(self, status, headers):
+ """Determine if response is an error.
+
+ Args:
+ status: HTTP status code.
+ headers: Dictionary of (lowercase) header name to value.
+
+ Returns:
+ True if the response was an error, else False.
+ """
+ content_header = headers.get('content-type', '')
+ content_type, unused_params = cgi.parse_header(content_header)
+ return (status.startswith('400') and
+ content_type.lower() in _ALL_JSON_CONTENT_TYPES)
+
+ def __write_error(self, status_code, error_message=None):
+ """Return the HTTP status line and body for a given error code and message.
+
+ Args:
+ status_code: HTTP status code to be returned.
+ error_message: Error message to be returned.
+
+ Returns:
+ Tuple (http_status, body):
+ http_status: HTTP status line, e.g. 200 OK.
+ body: Body of the HTTP request.
+ """
+ if error_message is None:
+ error_message = http_client.responses[status_code]
+ status = '%d %s' % (status_code, http_client.responses[status_code])
+ message = EndpointsErrorMessage(
+ state=EndpointsErrorMessage.State.APPLICATION_ERROR,
+ error_message=error_message)
+ return status, self.__PROTOJSON.encode_message(message)
+
+ def protorpc_to_endpoints_error(self, status, body):
+ """Convert a ProtoRPC error to the format expected by Google Endpoints.
+
+ If the body does not contain an ProtoRPC message in state APPLICATION_ERROR
+ the status and body will be returned unchanged.
+
+ Args:
+ status: HTTP status of the response from the backend
+ body: JSON-encoded error in format expected by Endpoints frontend.
+
+ Returns:
+ Tuple of (http status, body)
+ """
+ try:
+ rpc_error = self.__PROTOJSON.decode_message(remote.RpcStatus, body)
+ except (ValueError, messages.ValidationError):
+ rpc_error = remote.RpcStatus()
+
+ if rpc_error.state == remote.RpcStatus.State.APPLICATION_ERROR:
+
+ # Try to map to HTTP error code.
+ error_class = _ERROR_NAME_MAP.get(rpc_error.error_name)
+ if error_class:
+ status, body = self.__write_error(error_class.http_status,
+ rpc_error.error_message)
+ return status, body
+
+ def get_api_configs(self):
+ return {
+ 'items': self.api_config_registry.all_api_configs()}
+
+ def __call__(self, environ, start_response):
+ """Wrapper for the Endpoints server app.
+
+ Args:
+ environ: WSGI request environment.
+ start_response: WSGI start response function.
+
+ Returns:
+ Response from service_app or appropriately transformed error response.
+ """
+ # Call the ProtoRPC App and capture its response
+ with util.StartResponseProxy() as start_response_proxy:
+ body_iter = self.service_app(environ, start_response_proxy.Proxy)
+ status = start_response_proxy.response_status
+ headers = start_response_proxy.response_headers
+ exception = start_response_proxy.response_exc_info
+
+ # Get response body
+ body = start_response_proxy.response_body
+ # In case standard WSGI behavior is implemented later...
+ if not body:
+ body = ''.join(body_iter)
+
+ # Transform ProtoRPC error into format expected by endpoints.
+ headers_dict = dict([(k.lower(), v) for k, v in headers])
+ if self.__is_json_error(status, headers_dict):
+ status, body = self.protorpc_to_endpoints_error(status, body)
+ # If the content-length header is present, update it with the new
+ # body length.
+ if 'content-length' in headers_dict:
+ for index, (header_name, _) in enumerate(headers):
+ if header_name.lower() == 'content-length':
+ headers[index] = (header_name, str(len(body)))
+ break
+
+ start_response(status, headers, exception)
+ return [body]
+
+
+# Silence lint warning about invalid function name
+# pylint: disable=g-bad-name
+def api_server(api_services, **kwargs):
+ """Create an api_server.
+
+ The primary function of this method is to set up the WSGIApplication
+ instance for the service handlers described by the services passed in.
+ Additionally, it registers each API in ApiConfigRegistry for later use
+ in the BackendService.getApiConfigs() (API config enumeration service).
+ It also configures service control.
+
+ Args:
+ api_services: List of protorpc.remote.Service classes implementing the API
+ or a list of _ApiDecorator instances that decorate the service classes
+ for an API.
+ **kwargs: Passed through to protorpc.wsgi.service.service_handlers except:
+ protocols - ProtoRPC protocols are not supported, and are disallowed.
+
+ Returns:
+ A new WSGIApplication that serves the API backend and config registry.
+
+ Raises:
+ TypeError: if protocols are configured (this feature is not supported).
+ """
+ # Disallow protocol configuration for now, Lily is json-only.
+ if 'protocols' in kwargs:
+ raise TypeError("__init__() got an unexpected keyword argument 'protocols'")
+
+ from . import _logger as endpoints_logger
+ from . import __version__ as endpoints_version
+ endpoints_logger.info('Initializing Endpoints Framework version %s', endpoints_version)
+
+ # Construct the api serving app
+ apis_app = _ApiServer(api_services, **kwargs)
+ dispatcher = endpoints_dispatcher.EndpointsDispatcherMiddleware(apis_app)
+
+ # Determine the service name
+ service_name = os.environ.get('ENDPOINTS_SERVICE_NAME')
+ if not service_name:
+ _logger.warn('Did not specify the ENDPOINTS_SERVICE_NAME environment'
+ ' variable so service control is disabled. Please specify'
+ ' the name of service in ENDPOINTS_SERVICE_NAME to enable'
+ ' it.')
+ return dispatcher
+
+ from endpoints_management.control import client as control_client
+ from endpoints_management.control import wsgi as control_wsgi
+
+ # If we're using a local server, just return the dispatcher now to bypass
+ # control client.
+ if control_wsgi.running_on_devserver():
+ _logger.warn('Running on local devserver, so service control is disabled.')
+ return dispatcher
+
+ from endpoints_management import _logger as management_logger
+ from endpoints_management import __version__ as management_version
+ management_logger.info('Initializing Endpoints Management Framework version %s', management_version)
+
+ # The DEFAULT 'config' should be tuned so that it's always OK for python
+ # App Engine workloads. The config can be adjusted, but that's probably
+ # unnecessary on App Engine.
+ controller = control_client.Loaders.DEFAULT.load(service_name)
+
+ # Start the GAE background thread that powers the control client's cache.
+ control_client.use_gae_thread()
+ controller.start()
+
+ return control_wsgi.add_all(
+ dispatcher,
+ app_identity.get_application_id(),
+ controller)
diff --git a/third_party/endpoints/constants.py b/third_party/endpoints/constants.py
new file mode 100644
index 0000000..29b683e
--- /dev/null
+++ b/third_party/endpoints/constants.py
@@ -0,0 +1,29 @@
+# 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.
+
+"""Provide various constants needed by Endpoints Framework.
+
+Putting them in this file makes it easier to avoid circular imports,
+as well as keep from complicating tests due to importing code that
+uses App Engine apis.
+"""
+
+from __future__ import absolute_import
+
+__all__ = [
+ 'API_EXPLORER_CLIENT_ID',
+]
+
+
+API_EXPLORER_CLIENT_ID = '292824132082.apps.googleusercontent.com'
diff --git a/third_party/endpoints/directory_list_generator.py b/third_party/endpoints/directory_list_generator.py
new file mode 100644
index 0000000..40f26b6
--- /dev/null
+++ b/third_party/endpoints/directory_list_generator.py
@@ -0,0 +1,162 @@
+# Copyright 2017 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 directory lists."""
+
+from __future__ import absolute_import
+
+import collections
+import json
+import re
+from six.moves import urllib
+
+from . import util
+
+
+class DirectoryListGenerator(object):
+ """Generates a discovery directory list 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 = DirectoryListGenerator().pretty_print_config_to_json(
+ HelloService)
+
+ The resulting document will be a JSON directory list describing the APIs
+ implemented by HelloService.
+ """
+
+ def __init__(self, request=None):
+ # The ApiRequest that called this generator
+ self.__request = request
+
+ def __item_descriptor(self, config):
+ """Builds an item descriptor for a service configuration.
+
+ Args:
+ config: A dictionary containing the service configuration to describe.
+
+ Returns:
+ A dictionary that describes the service configuration.
+ """
+ descriptor = {
+ 'kind': 'discovery#directoryItem',
+ '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',
+ },
+ 'preferred': True,
+ }
+
+ description = config.get('description')
+ root_url = config.get('root')
+ name = config.get('name')
+ version = config.get('api_version')
+ relative_path = '/apis/{0}/{1}/rest'.format(name, version)
+
+ if description:
+ descriptor['description'] = description
+
+ descriptor['name'] = name
+ descriptor['version'] = version
+ descriptor['discoveryLink'] = '.{0}'.format(relative_path)
+
+ root_url_port = urllib.parse.urlparse(root_url).port
+
+ original_path = self.__request.reconstruct_full_url(
+ port_override=root_url_port)
+ descriptor['discoveryRestUrl'] = '{0}/{1}/{2}/rest'.format(
+ original_path, name, version)
+
+ if name and version:
+ descriptor['id'] = '{0}:{1}'.format(name, version)
+
+ return descriptor
+
+ def __directory_list_descriptor(self, configs):
+ """Builds a directory list for an API.
+
+ Args:
+ configs: List of dicts containing the service configurations to list.
+
+ Returns:
+ A dictionary that can be deserialized into JSON in discovery list 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.
+ """
+ descriptor = {
+ 'kind': 'discovery#directoryList',
+ 'discoveryVersion': 'v1',
+ }
+
+ items = []
+ for config in configs:
+ item_descriptor = self.__item_descriptor(config)
+ if item_descriptor:
+ items.append(item_descriptor)
+
+ if items:
+ descriptor['items'] = items
+
+ return descriptor
+
+ def get_directory_list_doc(self, configs):
+ """JSON dict description of a protorpc.remote.Service in list format.
+
+ Args:
+ configs: Either a single dict or a list of dicts containing the service
+ configurations to list.
+
+ Returns:
+ dict, The directory list document as a JSON dict.
+ """
+
+ if not isinstance(configs, (tuple, list)):
+ configs = [configs]
+
+ util.check_list_type(configs, dict, 'configs', allow_none=False)
+
+ return self.__directory_list_descriptor(configs)
+
+ def pretty_print_config_to_json(self, configs):
+ """JSON string description of a protorpc.remote.Service in a discovery doc.
+
+ Args:
+ configs: Either a single dict or a list of dicts containing the service
+ configurations to list.
+
+ Returns:
+ string, The directory list document as a JSON string.
+ """
+ descriptor = self.get_directory_list_doc(configs)
+ return json.dumps(descriptor, sort_keys=True, indent=2,
+ separators=(',', ': '))
diff --git a/third_party/endpoints/discovery_generator.py b/third_party/endpoints/discovery_generator.py
new file mode 100644
index 0000000..72c0533
--- /dev/null
+++ b/third_party/endpoints/discovery_generator.py
@@ -0,0 +1,1057 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A library for converting service configs to discovery docs."""
+
+from __future__ import absolute_import
+
+import collections
+import json
+import logging
+import re
+
+from . import api_exceptions
+from . import message_parser
+from . import message_types
+from . import messages
+from . import remote
+from . import resource_container
+from . import util
+
+_logger = logging.getLogger(__name__)
+_PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}'
+
+_MULTICLASS_MISMATCH_ERROR_TEMPLATE = (
+ 'Attempting to implement service %s, version %s, with multiple '
+ 'classes that are not compatible. See docstring for api() for '
+ 'examples how to implement a multi-class API.')
+
+_INVALID_AUTH_ISSUER = 'No auth issuer named %s defined in this Endpoints API.'
+
+_API_KEY = 'api_key'
+_API_KEY_PARAM = 'key'
+
+CUSTOM_VARIANT_MAP = {
+ messages.Variant.DOUBLE: ('number', 'double'),
+ messages.Variant.FLOAT: ('number', 'float'),
+ messages.Variant.INT64: ('string', 'int64'),
+ messages.Variant.SINT64: ('string', 'int64'),
+ messages.Variant.UINT64: ('string', 'uint64'),
+ messages.Variant.INT32: ('integer', 'int32'),
+ messages.Variant.SINT32: ('integer', 'int32'),
+ messages.Variant.UINT32: ('integer', 'uint32'),
+ messages.Variant.BOOL: ('boolean', None),
+ messages.Variant.STRING: ('string', None),
+ messages.Variant.BYTES: ('string', 'byte'),
+ messages.Variant.ENUM: ('string', None),
+}
+
+
+
+class DiscoveryGenerator(object):
+ """Generates a discovery doc from a ProtoRPC service.
+
+ Example:
+
+ class HelloRequest(messages.Message):
+ my_name = messages.StringField(1, required=True)
+
+ class HelloResponse(messages.Message):
+ hello = messages.StringField(1, required=True)
+
+ class HelloService(remote.Service):
+
+ @remote.method(HelloRequest, HelloResponse)
+ def hello(self, request):
+ return HelloResponse(hello='Hello there, %s!' %
+ request.my_name)
+
+ api_config = DiscoveryGenerator().pretty_print_config_to_json(HelloService)
+
+ The resulting api_config will be a JSON discovery document describing the API
+ implemented by HelloService.
+ """
+
+ # Constants for categorizing a request method.
+ # __NO_BODY - Request without a request body, such as GET and DELETE methods.
+ # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body.
+ __NO_BODY = 1 # pylint: disable=invalid-name
+ __HAS_BODY = 2 # pylint: disable=invalid-name
+
+ def __init__(self, request=None):
+ self.__parser = message_parser.MessageTypeToJsonSchema()
+
+ # Maps method id to the request schema id.
+ self.__request_schema = {}
+
+ # Maps method id to the response schema id.
+ self.__response_schema = {}
+
+ # The ApiRequest that called this generator
+ self.__request = request
+
+ def _get_resource_path(self, method_id):
+ """Return the resource path for a method or an empty array if none."""
+ return method_id.split('.')[1:-1]
+
+ def _get_canonical_method_id(self, method_id):
+ return method_id.split('.')[-1]
+
+ def __get_request_kind(self, method_info):
+ """Categorize the type of the request.
+
+ Args:
+ method_info: _MethodInfo, method information.
+
+ Returns:
+ The kind of request.
+ """
+ if method_info.http_method in ('GET', 'DELETE'):
+ return self.__NO_BODY
+ else:
+ return self.__HAS_BODY
+
+ def __field_to_subfields(self, field, cycle=tuple()):
+ """Fully describes data represented by field, including the nested case.
+
+ In the case that the field is not a message field, we have no fields nested
+ within a message definition, so we can simply return that field. However, in
+ the nested case, we can't simply describe the data with one field or even
+ with one chain of fields.
+
+ For example, if we have a message field
+
+ m_field = messages.MessageField(RefClass, 1)
+
+ which references a class with two fields:
+
+ class RefClass(messages.Message):
+ one = messages.StringField(1)
+ two = messages.IntegerField(2)
+
+ then we would need to include both one and two to represent all the
+ data contained.
+
+ Calling __field_to_subfields(m_field) would return:
+ [
+ [<MessageField "m_field">, <StringField "one">],
+ [<MessageField "m_field">, <StringField "two">],
+ ]
+
+ If the second field was instead a message field
+
+ class RefClass(messages.Message):
+ one = messages.StringField(1)
+ two = messages.MessageField(OtherRefClass, 2)
+
+ referencing another class with two fields
+
+ class OtherRefClass(messages.Message):
+ three = messages.BooleanField(1)
+ four = messages.FloatField(2)
+
+ then we would need to recurse one level deeper for two.
+
+ With this change, calling __field_to_subfields(m_field) would return:
+ [
+ [<MessageField "m_field">, <StringField "one">],
+ [<MessageField "m_field">, <StringField "two">, <StringField "three">],
+ [<MessageField "m_field">, <StringField "two">, <StringField "four">],
+ ]
+
+ Args:
+ field: An instance of a subclass of messages.Field.
+
+ Returns:
+ A list of lists, where each sublist is a list of fields.
+ """
+ # Termination condition
+ if not isinstance(field, messages.MessageField):
+ return [[field]]
+
+ if field.message_type.__name__ in cycle:
+ # We have a recursive cycle of messages. Call it quits.
+ return []
+
+ result = []
+ for subfield in sorted(field.message_type.all_fields(),
+ key=lambda f: f.number):
+ cycle = cycle + (field.message_type.__name__, )
+ subfield_results = self.__field_to_subfields(subfield, cycle=cycle)
+ for subfields_list in subfield_results:
+ subfields_list.insert(0, field)
+ result.append(subfields_list)
+ return result
+
+ def __field_to_parameter_type_and_format(self, field):
+ """Converts the field variant type into a tuple describing the parameter.
+
+ Args:
+ field: An instance of a subclass of messages.Field.
+
+ Returns:
+ A tuple with the type and format of the field, respectively.
+
+ Raises:
+ TypeError: if the field variant is a message variant.
+ """
+ # We use lowercase values for types (e.g. 'string' instead of 'STRING').
+ variant = field.variant
+ if variant == messages.Variant.MESSAGE:
+ raise TypeError('A message variant cannot be used in a parameter.')
+
+ # Note that the 64-bit integers are marked as strings -- this is to
+ # accommodate JavaScript, which would otherwise demote them to 32-bit
+ # integers.
+
+ return CUSTOM_VARIANT_MAP.get(variant) or (variant.name.lower(), None)
+
+ def __get_path_parameters(self, path):
+ """Parses path paremeters from a URI path and organizes them by parameter.
+
+ Some of the parameters may correspond to message fields, and so will be
+ represented as segments corresponding to each subfield; e.g. first.second if
+ the field "second" in the message field "first" is pulled from the path.
+
+ The resulting dictionary uses the first segments as keys and each key has as
+ value the list of full parameter values with first segment equal to the key.
+
+ If the match path parameter is null, that part of the path template is
+ ignored; this occurs if '{}' is used in a template.
+
+ Args:
+ path: String; a URI path, potentially with some parameters.
+
+ Returns:
+ A dictionary with strings as keys and list of strings as values.
+ """
+ path_parameters_by_segment = {}
+ for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path):
+ first_segment = format_var_name.split('.', 1)[0]
+ matches = path_parameters_by_segment.setdefault(first_segment, [])
+ matches.append(format_var_name)
+
+ return path_parameters_by_segment
+
+ def __validate_simple_subfield(self, parameter, field, segment_list,
+ segment_index=0):
+ """Verifies that a proposed subfield actually exists and is a simple field.
+
+ Here, simple means it is not a MessageField (nested).
+
+ Args:
+ parameter: String; the '.' delimited name of the current field being
+ considered. This is relative to some root.
+ field: An instance of a subclass of messages.Field. Corresponds to the
+ previous segment in the path (previous relative to _segment_index),
+ since this field should be a message field with the current segment
+ as a field in the message class.
+ segment_list: The full list of segments from the '.' delimited subfield
+ being validated.
+ segment_index: Integer; used to hold the position of current segment so
+ that segment_list can be passed as a reference instead of having to
+ copy using segment_list[1:] at each step.
+
+ Raises:
+ TypeError: If the final subfield (indicated by _segment_index relative
+ to the length of segment_list) is a MessageField.
+ TypeError: If at any stage the lookup at a segment fails, e.g if a.b
+ exists but a.b.c does not exist. This can happen either if a.b is not
+ a message field or if a.b.c is not a property on the message class from
+ a.b.
+ """
+ if segment_index >= len(segment_list):
+ # In this case, the field is the final one, so should be simple type
+ if isinstance(field, messages.MessageField):
+ field_class = field.__class__.__name__
+ raise TypeError('Can\'t use messages in path. Subfield %r was '
+ 'included but is a %s.' % (parameter, field_class))
+ return
+
+ segment = segment_list[segment_index]
+ parameter += '.' + segment
+ try:
+ field = field.type.field_by_name(segment)
+ except (AttributeError, KeyError):
+ raise TypeError('Subfield %r from path does not exist.' % (parameter,))
+
+ self.__validate_simple_subfield(parameter, field, segment_list,
+ segment_index=segment_index + 1)
+
+ def __validate_path_parameters(self, field, path_parameters):
+ """Verifies that all path parameters correspond to an existing subfield.
+
+ Args:
+ field: An instance of a subclass of messages.Field. Should be the root
+ level property name in each path parameter in path_parameters. For
+ example, if the field is called 'foo', then each path parameter should
+ begin with 'foo.'.
+ path_parameters: A list of Strings representing URI parameter variables.
+
+ Raises:
+ TypeError: If one of the path parameters does not start with field.name.
+ """
+ for param in path_parameters:
+ segment_list = param.split('.')
+ if segment_list[0] != field.name:
+ raise TypeError('Subfield %r can\'t come from field %r.'
+ % (param, field.name))
+ self.__validate_simple_subfield(field.name, field, segment_list[1:])
+
+ def __parameter_default(self, field):
+ """Returns default value of field if it has one.
+
+ Args:
+ field: A simple field.
+
+ Returns:
+ The default value of the field, if any exists, with the exception of an
+ enum field, which will have its value cast to a string.
+ """
+ if field.default:
+ if isinstance(field, messages.EnumField):
+ return field.default.name
+ elif isinstance(field, messages.BooleanField):
+ # The Python standard representation of a boolean value causes problems
+ # when generating client code.
+ return 'true' if field.default else 'false'
+ else:
+ return str(field.default)
+
+ def __parameter_enum(self, param):
+ """Returns enum descriptor of a parameter if it is an enum.
+
+ An enum descriptor is a list of keys.
+
+ Args:
+ param: A simple field.
+
+ Returns:
+ The enum descriptor for the field, if it's an enum descriptor, else
+ returns None.
+ """
+ if isinstance(param, messages.EnumField):
+ return [enum_entry[0] for enum_entry in sorted(
+ param.type.to_dict().items(), key=lambda v: v[1])]
+
+ def __parameter_descriptor(self, param):
+ """Creates descriptor for a parameter.
+
+ Args:
+ param: The parameter to be described.
+
+ Returns:
+ Dictionary containing a descriptor for the parameter.
+ """
+ descriptor = {}
+
+ param_type, param_format = self.__field_to_parameter_type_and_format(param)
+
+ # Required
+ if param.required:
+ descriptor['required'] = True
+
+ # Type
+ descriptor['type'] = param_type
+
+ # Format (optional)
+ if param_format:
+ descriptor['format'] = param_format
+
+ # Default
+ default = self.__parameter_default(param)
+ if default is not None:
+ descriptor['default'] = default
+
+ # Repeated
+ if param.repeated:
+ descriptor['repeated'] = True
+
+ # Enum
+ # Note that enumDescriptions are not currently supported using the
+ # framework's annotations, so just insert blank strings.
+ enum_descriptor = self.__parameter_enum(param)
+ if enum_descriptor is not None:
+ descriptor['enum'] = enum_descriptor
+ descriptor['enumDescriptions'] = [''] * len(enum_descriptor)
+
+ return descriptor
+
+ def __add_parameter(self, param, path_parameters, params):
+ """Adds all parameters in a field to a method parameters descriptor.
+
+ Simple fields will only have one parameter, but a message field 'x' that
+ corresponds to a message class with fields 'y' and 'z' will result in
+ parameters 'x.y' and 'x.z', for example. The mapping from field to
+ parameters is mostly handled by __field_to_subfields.
+
+ Args:
+ param: Parameter to be added to the descriptor.
+ path_parameters: A list of parameters matched from a path for this field.
+ For example for the hypothetical 'x' from above if the path was
+ '/a/{x.z}/b/{other}' then this list would contain only the element
+ 'x.z' since 'other' does not match to this field.
+ params: List of parameters. Each parameter in the field.
+ """
+ # If this is a simple field, just build the descriptor and append it.
+ # Otherwise, build a schema and assign it to this descriptor
+ descriptor = None
+ if not isinstance(param, messages.MessageField):
+ name = param.name
+ descriptor = self.__parameter_descriptor(param)
+ descriptor['location'] = 'path' if name in path_parameters else 'query'
+
+ if descriptor:
+ params[name] = descriptor
+ else:
+ for subfield_list in self.__field_to_subfields(param):
+ name = '.'.join(subfield.name for subfield in subfield_list)
+ descriptor = self.__parameter_descriptor(subfield_list[-1])
+ if name in path_parameters:
+ descriptor['required'] = True
+ descriptor['location'] = 'path'
+ else:
+ descriptor.pop('required', None)
+ descriptor['location'] = 'query'
+
+ if descriptor:
+ params[name] = descriptor
+
+
+ def __params_descriptor_without_container(self, message_type,
+ request_kind, path):
+ """Describe parameters of a method which does not use a ResourceContainer.
+
+ Makes sure that the path parameters are included in the message definition
+ and adds any required fields and URL query parameters.
+
+ This method is to preserve backwards compatibility and will be removed in
+ a future release.
+
+ Args:
+ message_type: messages.Message class, Message with parameters to describe.
+ request_kind: The type of request being made.
+ path: string, HTTP path to method.
+
+ Returns:
+ A list of dicts: Descriptors of the parameters
+ """
+ params = {}
+
+ path_parameter_dict = self.__get_path_parameters(path)
+ for field in sorted(message_type.all_fields(), key=lambda f: f.number):
+ matched_path_parameters = path_parameter_dict.get(field.name, [])
+ self.__validate_path_parameters(field, matched_path_parameters)
+ if matched_path_parameters or request_kind == self.__NO_BODY:
+ self.__add_parameter(field, matched_path_parameters, params)
+
+ return params
+
+ def __params_descriptor(self, message_type, request_kind, path, method_id,
+ request_params_class):
+ """Describe the parameters of a method.
+
+ If the message_type is not a ResourceContainer, will fall back to
+ __params_descriptor_without_container (which will eventually be deprecated).
+
+ If the message type is a ResourceContainer, then all path/query parameters
+ will come from the ResourceContainer. This method will also make sure all
+ path parameters are covered by the message fields.
+
+ Args:
+ message_type: messages.Message or ResourceContainer class, Message with
+ parameters to describe.
+ request_kind: The type of request being made.
+ path: string, HTTP path to method.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+ request_params_class: messages.Message, the original params message when
+ using a ResourceContainer. Otherwise, this should be null.
+
+ Returns:
+ A tuple (dict, list of string): Descriptor of the parameters, Order of the
+ parameters.
+ """
+ path_parameter_dict = self.__get_path_parameters(path)
+
+ if request_params_class is None:
+ if path_parameter_dict:
+ _logger.warning('Method %s specifies path parameters but you are not '
+ 'using a ResourceContainer; instead, you are using %r. '
+ 'This will fail in future releases; please switch to '
+ 'using ResourceContainer as soon as possible.',
+ method_id, type(message_type))
+ return self.__params_descriptor_without_container(
+ message_type, request_kind, path)
+
+ # From here, we can assume message_type is from a ResourceContainer.
+ message_type = request_params_class
+
+ params = {}
+
+ # Make sure all path parameters are covered.
+ for field_name, matched_path_parameters in path_parameter_dict.items():
+ field = message_type.field_by_name(field_name)
+ self.__validate_path_parameters(field, matched_path_parameters)
+
+ # Add all fields, sort by field.number since we have parameterOrder.
+ for field in sorted(message_type.all_fields(), key=lambda f: f.number):
+ matched_path_parameters = path_parameter_dict.get(field.name, [])
+ self.__add_parameter(field, matched_path_parameters, params)
+
+ return params
+
+ def __params_order_descriptor(self, message_type, path, is_params_class=False):
+ """Describe the order of path parameters.
+
+ Args:
+ message_type: messages.Message class, Message with parameters to describe.
+ path: string, HTTP path to method.
+ is_params_class: boolean, Whether the message represents URL parameters.
+
+ Returns:
+ Descriptor list for the parameter order.
+ """
+ path_params = []
+ query_params = []
+ path_parameter_dict = self.__get_path_parameters(path)
+
+ for field in sorted(message_type.all_fields(), key=lambda f: f.number):
+ matched_path_parameters = path_parameter_dict.get(field.name, [])
+ if not isinstance(field, messages.MessageField):
+ name = field.name
+ if name in matched_path_parameters:
+ path_params.append(name)
+ elif is_params_class and field.required:
+ query_params.append(name)
+ else:
+ for subfield_list in self.__field_to_subfields(field):
+ name = '.'.join(subfield.name for subfield in subfield_list)
+ if name in matched_path_parameters:
+ path_params.append(name)
+ elif is_params_class and field.required:
+ query_params.append(name)
+
+ return path_params + sorted(query_params)
+
+ def __schemas_descriptor(self):
+ """Describes the schemas section of the discovery document.
+
+ Returns:
+ Dictionary describing the schemas of the document.
+ """
+ # Filter out any keys that aren't 'properties', 'type', or 'id'
+ result = {}
+ for schema_key, schema_value in self.__parser.schemas().items():
+ field_keys = schema_value.keys()
+ key_result = {}
+
+ # Some special processing for the properties value
+ if 'properties' in field_keys:
+ key_result['properties'] = schema_value['properties'].copy()
+ # Add in enumDescriptions for any enum properties and strip out
+ # the required tag for consistency with Java framework
+ for prop_key, prop_value in schema_value['properties'].items():
+ if 'enum' in prop_value:
+ num_enums = len(prop_value['enum'])
+ key_result['properties'][prop_key]['enumDescriptions'] = (
+ [''] * num_enums)
+ elif 'default' in prop_value:
+ # stringify default values
+ if prop_value.get('type') == 'boolean':
+ prop_value['default'] = 'true' if prop_value['default'] else 'false'
+ else:
+ prop_value['default'] = str(prop_value['default'])
+ key_result['properties'][prop_key].pop('required', None)
+
+ for key in ('type', 'id', 'description'):
+ if key in field_keys:
+ key_result[key] = schema_value[key]
+
+ if key_result:
+ result[schema_key] = key_result
+
+ # Add 'type': 'object' to all object properties
+ for schema_value in result.values():
+ for field_value in schema_value.values():
+ if isinstance(field_value, dict):
+ if '$ref' in field_value:
+ field_value['type'] = 'object'
+
+ return result
+
+ def __request_message_descriptor(self, request_kind, message_type, method_id,
+ request_body_class):
+ """Describes the parameters and body of the request.
+
+ Args:
+ request_kind: The type of request being made.
+ message_type: messages.Message or ResourceContainer class. The message to
+ describe.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+ request_body_class: messages.Message of the original body when using
+ a ResourceContainer. Otherwise, this should be null.
+
+ Returns:
+ Dictionary describing the request.
+
+ Raises:
+ ValueError: if the method path and request required fields do not match
+ """
+ if request_body_class:
+ message_type = request_body_class
+
+ if (request_kind != self.__NO_BODY and
+ message_type != message_types.VoidMessage()):
+ self.__request_schema[method_id] = self.__parser.add_message(
+ message_type.__class__)
+ return {
+ '$ref': self.__request_schema[method_id],
+ 'parameterName': 'resource',
+ }
+
+ def __response_message_descriptor(self, message_type, method_id):
+ """Describes the response.
+
+ Args:
+ message_type: messages.Message class, The message to describe.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+
+ Returns:
+ Dictionary describing the response.
+ """
+ if message_type != message_types.VoidMessage():
+ self.__parser.add_message(message_type.__class__)
+ self.__response_schema[method_id] = self.__parser.ref_for_message_type(
+ message_type.__class__)
+ return {'$ref': self.__response_schema[method_id]}
+ else:
+ return None
+
+ def __method_descriptor(self, service, method_info,
+ protorpc_method_info):
+ """Describes a method.
+
+ Args:
+ service: endpoints.Service, Implementation of the API as a service.
+ method_info: _MethodInfo, Configuration for the method.
+ protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC
+ description of the method.
+
+ Returns:
+ Dictionary describing the method.
+ """
+ descriptor = {}
+
+ request_message_type = (resource_container.ResourceContainer.
+ get_request_message(protorpc_method_info.remote))
+ request_kind = self.__get_request_kind(method_info)
+ remote_method = protorpc_method_info.remote
+
+ method_id = method_info.method_id(service.api_info)
+
+ path = method_info.get_path(service.api_info)
+
+ description = protorpc_method_info.remote.method.__doc__
+
+ descriptor['id'] = method_id
+ descriptor['path'] = path
+ descriptor['httpMethod'] = method_info.http_method
+
+ if description:
+ descriptor['description'] = description
+
+ descriptor['scopes'] = [
+ 'https://www.googleapis.com/auth/userinfo.email'
+ ]
+
+ parameters = self.__params_descriptor(
+ request_message_type, request_kind, path, method_id,
+ method_info.request_params_class)
+ if parameters:
+ descriptor['parameters'] = parameters
+
+ if method_info.request_params_class:
+ parameter_order = self.__params_order_descriptor(
+ method_info.request_params_class, path, is_params_class=True)
+ else:
+ parameter_order = self.__params_order_descriptor(
+ request_message_type, path, is_params_class=False)
+ if parameter_order:
+ descriptor['parameterOrder'] = parameter_order
+
+ request_descriptor = self.__request_message_descriptor(
+ request_kind, request_message_type, method_id,
+ method_info.request_body_class)
+ if request_descriptor is not None:
+ descriptor['request'] = request_descriptor
+
+ response_descriptor = self.__response_message_descriptor(
+ remote_method.response_type(), method_info.method_id(service.api_info))
+ if response_descriptor is not None:
+ descriptor['response'] = response_descriptor
+
+ return descriptor
+
+ def __resource_descriptor(self, resource_path, methods):
+ """Describes a resource.
+
+ Args:
+ resource_path: string, the path of the resource (e.g., 'entries.items')
+ methods: list of tuples of type
+ (endpoints.Service, protorpc.remote._RemoteMethodInfo), the methods
+ that serve this resource.
+
+ Returns:
+ Dictionary describing the resource.
+ """
+ descriptor = {}
+ method_map = {}
+ sub_resource_index = collections.defaultdict(list)
+ sub_resource_map = {}
+
+ resource_path_tokens = resource_path.split('.')
+ for service, protorpc_meth_info in methods:
+ method_info = getattr(protorpc_meth_info, 'method_info', None)
+ path = method_info.get_path(service.api_info)
+ method_id = method_info.method_id(service.api_info)
+ canonical_method_id = self._get_canonical_method_id(method_id)
+
+ current_resource_path = self._get_resource_path(method_id)
+
+ # Sanity-check that this method belongs to the resource path
+ if (current_resource_path[:len(resource_path_tokens)] !=
+ resource_path_tokens):
+ raise api_exceptions.ToolError(
+ 'Internal consistency error in resource path {0}'.format(
+ current_resource_path))
+
+ # Remove the portion of the current method's resource path that's already
+ # part of the resource path at this level.
+ effective_resource_path = current_resource_path[
+ len(resource_path_tokens):]
+
+ # If this method is part of a sub-resource, note it and skip it for now
+ if effective_resource_path:
+ sub_resource_name = effective_resource_path[0]
+ new_resource_path = '.'.join([resource_path, sub_resource_name])
+ sub_resource_index[new_resource_path].append(
+ (service, protorpc_meth_info))
+ else:
+ method_map[canonical_method_id] = self.__method_descriptor(
+ service, method_info, protorpc_meth_info)
+
+ # Process any sub-resources
+ for sub_resource, sub_resource_methods in sub_resource_index.items():
+ sub_resource_name = sub_resource.split('.')[-1]
+ sub_resource_map[sub_resource_name] = self.__resource_descriptor(
+ sub_resource, sub_resource_methods)
+
+ if method_map:
+ descriptor['methods'] = method_map
+
+ if sub_resource_map:
+ descriptor['resources'] = sub_resource_map
+
+ return descriptor
+
+ def __standard_parameters_descriptor(self):
+ return {
+ 'alt': {
+ 'type': 'string',
+ 'description': 'Data format for the response.',
+ 'default': 'json',
+ 'enum': ['json'],
+ 'enumDescriptions': [
+ 'Responses with Content-Type of application/json'
+ ],
+ 'location': 'query',
+ },
+ 'fields': {
+ 'type': 'string',
+ 'description': 'Selector specifying which fields to include in a '
+ 'partial response.',
+ 'location': 'query',
+ },
+ 'key': {
+ 'type': 'string',
+ 'description': 'API key. Your API key identifies your project and '
+ 'provides you with API access, quota, and reports. '
+ 'Required unless you provide an OAuth 2.0 token.',
+ 'location': 'query',
+ },
+ 'oauth_token': {
+ 'type': 'string',
+ 'description': 'OAuth 2.0 token for the current user.',
+ 'location': 'query',
+ },
+ 'prettyPrint': {
+ 'type': 'boolean',
+ 'description': 'Returns response with indentations and line '
+ 'breaks.',
+ 'default': 'true',
+ 'location': 'query',
+ },
+ 'quotaUser': {
+ 'type': 'string',
+ 'description': 'Available to use for quota purposes for '
+ 'server-side applications. Can be any arbitrary '
+ 'string assigned to a user, but should not exceed '
+ '40 characters. Overrides userIp if both are '
+ 'provided.',
+ 'location': 'query',
+ },
+ 'userIp': {
+ 'type': 'string',
+ 'description': 'IP address of the site where the request '
+ 'originates. Use this if you want to enforce '
+ 'per-user limits.',
+ 'location': 'query',
+ },
+ }
+
+ def __standard_auth_descriptor(self, services):
+ scopes = {}
+ for service in services:
+ for scope in service.api_info.scope_objs:
+ scopes[scope.scope] = {'description': scope.description}
+ return {
+ 'oauth2': {
+ 'scopes': scopes
+ }
+ }
+
+ def __get_merged_api_info(self, services):
+ """Builds a description of an API.
+
+ Args:
+ services: List of protorpc.remote.Service instances implementing an
+ api/version.
+
+ Returns:
+ The _ApiInfo object to use for the API that the given services implement.
+ """
+ base_paths = sorted(set(s.api_info.base_path for s in services))
+ if len(base_paths) != 1:
+ raise api_exceptions.ApiConfigurationError(
+ 'Multiple base_paths found: {!r}'.format(base_paths))
+ names_versions = sorted(set(
+ (s.api_info.name, s.api_info.api_version) for s in services))
+ if len(names_versions) != 1:
+ raise api_exceptions.ApiConfigurationError(
+ 'Multiple apis/versions found: {!r}'.format(names_versions))
+ return services[0].api_info
+
+ def __discovery_doc_descriptor(self, services, hostname=None):
+ """Builds a discovery doc for an API.
+
+ Args:
+ services: List of protorpc.remote.Service instances implementing an
+ api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ A dictionary that can be deserialized into JSON in discovery doc format.
+
+ Raises:
+ ApiConfigurationError: If there's something wrong with the API
+ configuration, such as a multiclass API decorated with different API
+ descriptors (see the docstring for api()), or a repeated method
+ signature.
+ """
+ merged_api_info = self.__get_merged_api_info(services)
+ descriptor = self.get_descriptor_defaults(merged_api_info,
+ hostname=hostname)
+
+ description = merged_api_info.description
+ if not description and len(services) == 1:
+ description = services[0].__doc__
+ if description:
+ descriptor['description'] = description
+
+ descriptor['parameters'] = self.__standard_parameters_descriptor()
+ descriptor['auth'] = self.__standard_auth_descriptor(services)
+
+ # Add namespace information, if provided
+ if merged_api_info.namespace:
+ descriptor['ownerDomain'] = merged_api_info.namespace.owner_domain
+ descriptor['ownerName'] = merged_api_info.namespace.owner_name
+ descriptor['packagePath'] = merged_api_info.namespace.package_path or ''
+ else:
+ if merged_api_info.owner_domain is not None:
+ descriptor['ownerDomain'] = merged_api_info.owner_domain
+ if merged_api_info.owner_name is not None:
+ descriptor['ownerName'] = merged_api_info.owner_name
+ if merged_api_info.package_path is not None:
+ descriptor['packagePath'] = merged_api_info.package_path
+
+ method_map = {}
+ method_collision_tracker = {}
+ rest_collision_tracker = {}
+
+ resource_index = collections.defaultdict(list)
+ resource_map = {}
+
+ # For the first pass, only process top-level methods (that is, those methods
+ # that are unattached to a resource).
+ for service in services:
+ remote_methods = service.all_remote_methods()
+
+ for protorpc_meth_name, protorpc_meth_info in remote_methods.items():
+ method_info = getattr(protorpc_meth_info, 'method_info', None)
+ # Skip methods that are not decorated with @method
+ if method_info is None:
+ continue
+ path = method_info.get_path(service.api_info)
+ method_id = method_info.method_id(service.api_info)
+ canonical_method_id = self._get_canonical_method_id(method_id)
+ resource_path = self._get_resource_path(method_id)
+
+ # Make sure the same method name isn't repeated.
+ if method_id in method_collision_tracker:
+ raise api_exceptions.ApiConfigurationError(
+ 'Method %s used multiple times, in classes %s and %s' %
+ (method_id, method_collision_tracker[method_id],
+ service.__name__))
+ else:
+ method_collision_tracker[method_id] = service.__name__
+
+ # Make sure the same HTTP method & path aren't repeated.
+ rest_identifier = (method_info.http_method, path)
+ if rest_identifier in rest_collision_tracker:
+ raise api_exceptions.ApiConfigurationError(
+ '%s path "%s" used multiple times, in classes %s and %s' %
+ (method_info.http_method, path,
+ rest_collision_tracker[rest_identifier],
+ service.__name__))
+ else:
+ rest_collision_tracker[rest_identifier] = service.__name__
+
+ # If this method is part of a resource, note it and skip it for now
+ if resource_path:
+ resource_index[resource_path[0]].append((service, protorpc_meth_info))
+ else:
+ method_map[canonical_method_id] = self.__method_descriptor(
+ service, method_info, protorpc_meth_info)
+
+ # Do another pass for methods attached to resources
+ for resource, resource_methods in resource_index.items():
+ resource_map[resource] = self.__resource_descriptor(resource,
+ resource_methods)
+
+ if method_map:
+ descriptor['methods'] = method_map
+
+ if resource_map:
+ descriptor['resources'] = resource_map
+
+ # Add schemas, if any
+ schemas = self.__schemas_descriptor()
+ if schemas:
+ descriptor['schemas'] = schemas
+
+ return descriptor
+
+ def get_descriptor_defaults(self, api_info, hostname=None):
+ """Gets a default configuration for a service.
+
+ Args:
+ api_info: _ApiInfo object for this service.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ A dictionary with the default configuration.
+ """
+ if self.__request:
+ hostname = self.__request.reconstruct_hostname()
+ protocol = self.__request.url_scheme
+ else:
+ hostname = (hostname or util.get_app_hostname() or
+ api_info.hostname)
+ protocol = 'http' if ((hostname and hostname.startswith('localhost')) or
+ util.is_running_on_devserver()) else 'https'
+ full_base_path = '{0}{1}/{2}/'.format(api_info.base_path,
+ api_info.name,
+ api_info.path_version)
+ base_url = '{0}://{1}{2}'.format(protocol, hostname, full_base_path)
+ root_url = '{0}://{1}{2}'.format(protocol, hostname, api_info.base_path)
+ defaults = {
+ 'kind': 'discovery#restDescription',
+ 'discoveryVersion': 'v1',
+ 'id': '{0}:{1}'.format(api_info.name, api_info.path_version),
+ 'name': api_info.name,
+ 'version': api_info.api_version,
+ 'icons': {
+ 'x16': 'https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png',
+ 'x32': 'https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png'
+ },
+ 'protocol': 'rest',
+ 'servicePath': '{0}/{1}/'.format(api_info.name, api_info.path_version),
+ 'batchPath': 'batch',
+ 'basePath': full_base_path,
+ 'rootUrl': root_url,
+ 'baseUrl': base_url,
+ 'description': 'This is an API',
+ }
+ if api_info.description:
+ defaults['description'] = api_info.description
+ if api_info.title:
+ defaults['title'] = api_info.title
+ if api_info.documentation:
+ defaults['documentationLink'] = api_info.documentation
+ if api_info.canonical_name:
+ defaults['canonicalName'] = api_info.canonical_name
+
+ return defaults
+
+ def get_discovery_doc(self, services, hostname=None):
+ """JSON dict description of a protorpc.remote.Service in discovery format.
+
+ Args:
+ services: Either a single protorpc.remote.Service or a list of them
+ that implements an api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ dict, The discovery document as a JSON dict.
+ """
+
+ if not isinstance(services, (tuple, list)):
+ services = [services]
+
+ # The type of a class that inherits from remote.Service is actually
+ # remote._ServiceClass, thanks to metaclass strangeness.
+ # pylint: disable=protected-access
+ util.check_list_type(services, remote._ServiceClass, 'services',
+ allow_none=False)
+
+ return self.__discovery_doc_descriptor(services, hostname=hostname)
+
+ def pretty_print_config_to_json(self, services, hostname=None):
+ """JSON string description of a protorpc.remote.Service in a discovery doc.
+
+ Args:
+ services: Either a single protorpc.remote.Service or a list of them
+ that implements an api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ string, The discovery doc descriptor document as a JSON string.
+ """
+ descriptor = self.get_discovery_doc(services, hostname)
+ return json.dumps(descriptor, sort_keys=True, indent=2,
+ separators=(',', ': '))
diff --git a/third_party/endpoints/discovery_service.py b/third_party/endpoints/discovery_service.py
new file mode 100644
index 0000000..51409a5
--- /dev/null
+++ b/third_party/endpoints/discovery_service.py
@@ -0,0 +1,220 @@
+# 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.
+
+"""Hook into the live Discovery service and get API configuration info."""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+import json
+import logging
+
+from . import api_config
+from . import directory_list_generator
+from . import discovery_generator
+from . import util
+
+_logger = logging.getLogger(__name__)
+
+
+class DiscoveryService(object):
+ """Implements the local discovery service.
+
+ This has a static minimal version of the discoverable part of the
+ discovery .api file.
+
+ It only handles returning the discovery doc and directory, and ignores
+ directory parameters to filter the results.
+
+ The discovery docs/directory are created by calling a Cloud Endpoints
+ discovery service to generate the discovery docs/directory from an .api
+ file/set of .api files.
+ """
+
+ _GET_REST_API = 'apisdev.getRest'
+ _GET_RPC_API = 'apisdev.getRpc'
+ _LIST_API = 'apisdev.list'
+ API_CONFIG = {
+ 'name': 'discovery',
+ 'version': 'v1',
+ 'api_version': 'v1',
+ 'path_version': 'v1',
+ 'methods': {
+ 'discovery.apis.getRest': {
+ 'path': 'apis/{api}/{version}/rest',
+ 'httpMethod': 'GET',
+ 'rosyMethod': _GET_REST_API,
+ },
+ 'discovery.apis.getRpc': {
+ 'path': 'apis/{api}/{version}/rpc',
+ 'httpMethod': 'GET',
+ 'rosyMethod': _GET_RPC_API,
+ },
+ 'discovery.apis.list': {
+ 'path': 'apis',
+ 'httpMethod': 'GET',
+ 'rosyMethod': _LIST_API,
+ },
+ }
+ }
+
+ def __init__(self, config_manager, backend):
+ """Initializes an instance of the DiscoveryService.
+
+ Args:
+ config_manager: An instance of ApiConfigManager.
+ backend: An _ApiServer instance for API config generation.
+ """
+ self._config_manager = config_manager
+ self._backend = backend
+
+ def _send_success_response(self, response, start_response):
+ """Sends an HTTP 200 json success response.
+
+ This calls start_response and returns the response body.
+
+ Args:
+ response: A string containing the response body to return.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string, the response body.
+ """
+ headers = [('Content-Type', 'application/json; charset=UTF-8')]
+ return util.send_wsgi_response('200 OK', headers, response, start_response)
+
+ def _get_rest_doc(self, request, start_response):
+ """Sends back HTTP response with API directory.
+
+ This calls start_response and returns the response body. It will return
+ the discovery doc for the requested api/version.
+
+ Args:
+ request: An ApiRequest, the transformed request sent to the Discovery API.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string, the response body.
+ """
+ api = request.body_json['api']
+ version = request.body_json['version']
+
+ generator = discovery_generator.DiscoveryGenerator(request=request)
+ services = [s for s in self._backend.api_services if
+ s.api_info.name == api and s.api_info.api_version == version]
+ doc = generator.pretty_print_config_to_json(services)
+ if not doc:
+ error_msg = ('Failed to convert .api to discovery doc for '
+ 'version %s of api %s') % (version, api)
+ _logger.error('%s', error_msg)
+ return util.send_wsgi_error_response(error_msg, start_response)
+ return self._send_success_response(doc, start_response)
+
+ def _generate_api_config_with_root(self, request):
+ """Generate an API config with a specific root hostname.
+
+ This uses the backend object and the ApiConfigGenerator to create an API
+ config specific to the hostname of the incoming request. This allows for
+ flexible API configs for non-standard environments, such as localhost.
+
+ Args:
+ request: An ApiRequest, the transformed request sent to the Discovery API.
+
+ Returns:
+ A string representation of the generated API config.
+ """
+ actual_root = self._get_actual_root(request)
+ generator = api_config.ApiConfigGenerator()
+ api = request.body_json['api']
+ version = request.body_json['version']
+ lookup_key = (api, version)
+
+ service_factories = self._backend.api_name_version_map.get(lookup_key)
+ if not service_factories:
+ return None
+
+ service_classes = [service_factory.service_class
+ for service_factory in service_factories]
+ config_dict = generator.get_config_dict(
+ service_classes, hostname=actual_root)
+
+ # Save to cache
+ for config in config_dict.get('items', []):
+ lookup_key_with_root = (
+ config.get('name', ''), config.get('version', ''), actual_root)
+ self._config_manager.save_config(lookup_key_with_root, config)
+
+ return config_dict
+
+ def _get_actual_root(self, request):
+ url = request.server
+
+ # Append the port if not the default
+ if ((request.url_scheme == 'https' and request.port != '443') or
+ (request.url_scheme != 'https' and request.port != '80')):
+ url += ':%s' % request.port
+
+ return url
+
+ def _list(self, request, start_response):
+ """Sends HTTP response containing the API directory.
+
+ This calls start_response and returns the response body.
+
+ Args:
+ request: An ApiRequest, the transformed request sent to the Discovery API.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string containing the response body.
+ """
+ configs = []
+ generator = directory_list_generator.DirectoryListGenerator(request)
+ for config in self._config_manager.configs.values():
+ if config != self.API_CONFIG:
+ configs.append(config)
+ directory = generator.pretty_print_config_to_json(configs)
+ if not directory:
+ _logger.error('Failed to get API directory')
+ # By returning a 404, code explorer still works if you select the
+ # API in the URL
+ return util.send_wsgi_not_found_response(start_response)
+ return self._send_success_response(directory, start_response)
+
+ def handle_discovery_request(self, path, request, start_response):
+ """Returns the result of a discovery service request.
+
+ This calls start_response and returns the response body.
+
+ Args:
+ path: A string containing the API path (the portion of the path
+ after /_ah/api/).
+ request: An ApiRequest, the transformed request sent to the Discovery API.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ The response body. Or returns False if the request wasn't handled by
+ DiscoveryService.
+ """
+ if path == self._GET_REST_API:
+ return self._get_rest_doc(request, start_response)
+ elif path == self._GET_RPC_API:
+ error_msg = ('RPC format documents are no longer supported with the '
+ 'Endpoints Framework for Python. Please use the REST '
+ 'format.')
+ _logger.error('%s', error_msg)
+ return util.send_wsgi_error_response(error_msg, start_response)
+ elif path == self._LIST_API:
+ return self._list(request, start_response)
+ return False
diff --git a/third_party/endpoints/endpoints_dispatcher.py b/third_party/endpoints/endpoints_dispatcher.py
new file mode 100644
index 0000000..83e7acb
--- /dev/null
+++ b/third_party/endpoints/endpoints_dispatcher.py
@@ -0,0 +1,718 @@
+# 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.
+
+"""Dispatcher middleware for Cloud Endpoints API server.
+
+This middleware does simple transforms on requests that come into the base path
+and then re-dispatches them to the main backend. It does not do any
+authentication, quota checking, DoS checking, etc.
+
+In addition, the middleware loads API configs prior to each call, in case the
+configuration has changed.
+"""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+from six.moves import cStringIO
+from six.moves import http_client
+import json
+import logging
+import re
+import six
+from six.moves import urllib
+import wsgiref
+
+import pkg_resources
+
+from . import api_config_manager
+from . import api_exceptions
+from . import api_request
+from . import discovery_service
+from . import errors
+from . import parameter_converter
+from . import util
+
+_logger = logging.getLogger(__name__)
+
+
+__all__ = ['EndpointsDispatcherMiddleware']
+
+_SERVER_SOURCE_IP = '0.2.0.3'
+
+# Internal constants
+_CORS_HEADER_ORIGIN = 'Origin'
+_CORS_HEADER_REQUEST_METHOD = 'Access-Control-Request-Method'
+_CORS_HEADER_REQUEST_HEADERS = 'Access-Control-Request-Headers'
+_CORS_HEADER_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'
+_CORS_HEADER_ALLOW_METHODS = 'Access-Control-Allow-Methods'
+_CORS_HEADER_ALLOW_HEADERS = 'Access-Control-Allow-Headers'
+_CORS_HEADER_ALLOW_CREDS = 'Access-Control-Allow-Credentials'
+_CORS_HEADER_EXPOSE_HEADERS = 'Access-Control-Expose-Headers'
+_CORS_ALLOWED_METHODS = frozenset(('DELETE', 'GET', 'PATCH', 'POST', 'PUT'))
+_CORS_EXPOSED_HEADERS = frozenset(
+ ('Content-Encoding', 'Content-Length', 'Date', 'ETag', 'Server')
+)
+
+PROXY_HTML = pkg_resources.resource_string('endpoints', 'proxy.html')
+PROXY_PATH = 'static/proxy.html'
+
+
+class EndpointsDispatcherMiddleware(object):
+ """Dispatcher that handles requests to the built-in apiserver handlers."""
+
+ _API_EXPLORER_URL = 'https://apis-explorer.appspot.com/apis-explorer/?base='
+
+ def __init__(self, backend_wsgi_app, config_manager=None):
+ """Constructor for EndpointsDispatcherMiddleware.
+
+ Args:
+ backend_wsgi_app: A WSGI server that serves the app's endpoints.
+ config_manager: An ApiConfigManager instance that allows a caller to
+ set up an existing configuration for testing.
+ """
+ if config_manager is None:
+ config_manager = api_config_manager.ApiConfigManager()
+ self.config_manager = config_manager
+
+ self._backend = backend_wsgi_app
+ self._dispatchers = []
+ for base_path in self._backend.base_paths:
+ self._add_dispatcher('%sexplorer/?$' % base_path,
+ self.handle_api_explorer_request)
+ self._add_dispatcher('%sstatic/.*$' % base_path,
+ self.handle_api_static_request)
+
+ # Get API configuration so we know how to call the backend.
+ api_config_response = self.get_api_configs()
+ if api_config_response:
+ self.config_manager.process_api_config_response(api_config_response)
+ else:
+ raise api_exceptions.ApiConfigurationError('get_api_configs() returned no configs')
+
+ def _add_dispatcher(self, path_regex, dispatch_function):
+ """Add a request path and dispatch handler.
+
+ Args:
+ path_regex: A string regex, the path to match against incoming requests.
+ dispatch_function: The function to call for these requests. The function
+ should take (request, start_response) as arguments and
+ return the contents of the response body.
+ """
+ self._dispatchers.append((re.compile(path_regex), dispatch_function))
+
+ def _get_explorer_base_url(self, protocol, server, port, base_path):
+ show_port = ((protocol == 'http' and port != 80) or
+ (protocol != 'http' and port != 443))
+ url = ('{0}://{1}:{2}/{3}'.format(
+ protocol, server, port, base_path.lstrip('/\\')) if show_port else
+ '{0}://{1}/{2}'.format(protocol, server, base_path.lstrip('/\\')))
+
+ return url.rstrip('/\\')
+
+ def _get_explorer_redirect_url(self, server, port, base_path):
+ protocol = 'http' if 'localhost' in server else 'https'
+ base_url = self._get_explorer_base_url(protocol, server, port, base_path)
+ return self._API_EXPLORER_URL + base_url
+
+ def __call__(self, environ, start_response):
+ """Handle an incoming request.
+
+ Args:
+ environ: An environ dict for the request as defined in PEP-333.
+ start_response: A function used to begin the response to the caller.
+ This follows the semantics defined in PEP-333. In particular, it's
+ called with (status, response_headers, exc_info=None), and it returns
+ an object with a write(body_data) function that can be used to write
+ the body of the response.
+
+ Yields:
+ An iterable over strings containing the body of the HTTP response.
+ """
+ request = api_request.ApiRequest(environ,
+ base_paths=self._backend.base_paths)
+
+ # PEP-333 requires that we return an iterator that iterates over the
+ # response body. Yielding the returned body accomplishes this.
+ yield self.dispatch(request, start_response)
+
+ def dispatch(self, request, start_response):
+ """Handles dispatch to apiserver handlers.
+
+ This typically ends up calling start_response and returning the entire
+ body of the response.
+
+ Args:
+ request: An ApiRequest, the request from the user.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string, the body of the response.
+ """
+ # Check if this matches any of our special handlers.
+ dispatched_response = self.dispatch_non_api_requests(request,
+ start_response)
+ if dispatched_response is not None:
+ return dispatched_response
+
+ # Call the service.
+ try:
+ return self.call_backend(request, start_response)
+ except errors.RequestError as error:
+ return self._handle_request_error(request, error, start_response)
+
+ def dispatch_non_api_requests(self, request, start_response):
+ """Dispatch this request if this is a request to a reserved URL.
+
+ If the request matches one of our reserved URLs, this calls
+ start_response and returns the response body. This also handles OPTIONS
+ CORS requests.
+
+ Args:
+ request: An ApiRequest, the request from the user.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ None if the request doesn't match one of the reserved URLs this
+ handles. Otherwise, returns the response body.
+ """
+ for path_regex, dispatch_function in self._dispatchers:
+ if path_regex.match(request.relative_url):
+ return dispatch_function(request, start_response)
+
+ if request.http_method == 'OPTIONS':
+ cors_handler = self._create_cors_handler(request)
+ if cors_handler.allow_cors_request:
+ # The server returns 200 rather than 204, for some reason.
+ return util.send_wsgi_response('200', [], '', start_response,
+ cors_handler)
+
+ return None
+
+ def handle_api_explorer_request(self, request, start_response):
+ """Handler for requests to {base_path}/explorer.
+
+ This calls start_response and returns the response body.
+
+ Args:
+ request: An ApiRequest, the request from the user.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string containing the response body (which is empty, in this case).
+ """
+ redirect_url = self._get_explorer_redirect_url(
+ request.server, request.port, request.base_path)
+ return util.send_wsgi_redirect_response(redirect_url, start_response)
+
+ def handle_api_static_request(self, request, start_response):
+ """Handler for requests to {base_path}/static/.*.
+
+ This calls start_response and returns the response body.
+
+ Args:
+ request: An ApiRequest, the request from the user.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string containing the response body.
+ """
+ if request.path == PROXY_PATH:
+ return util.send_wsgi_response('200 OK',
+ [('Content-Type',
+ 'text/html')],
+ PROXY_HTML, start_response)
+ else:
+ _logger.debug('Unknown static url requested: %s',
+ request.relative_url)
+ return util.send_wsgi_response('404 Not Found', [('Content-Type',
+ 'text/plain')], 'Not Found',
+ start_response)
+
+ def get_api_configs(self):
+ return self._backend.get_api_configs()
+
+ @staticmethod
+ def verify_response(response, status_code, content_type=None):
+ """Verifies that a response has the expected status and content type.
+
+ Args:
+ response: The ResponseTuple to be checked.
+ status_code: An int, the HTTP status code to be compared with response
+ status.
+ content_type: A string with the acceptable Content-Type header value.
+ None allows any content type.
+
+ Returns:
+ True if both status_code and content_type match, else False.
+ """
+ status = int(response.status.split(' ', 1)[0])
+ if status != status_code:
+ return False
+
+ if content_type is None:
+ return True
+
+ for header, value in response.headers:
+ if header.lower() == 'content-type':
+ return value == content_type
+
+ # If we fall through to here, the verification has failed, so return False.
+ return False
+
+ def prepare_backend_environ(self, host, method, relative_url, headers, body,
+ source_ip, port):
+ """Build an environ object for the backend to consume.
+
+ Args:
+ host: A string containing the host serving the request.
+ method: A string containing the HTTP method of the request.
+ relative_url: A string containing path and query string of the request.
+ headers: A list of (key, value) tuples where key and value are both
+ strings.
+ body: A string containing the request body.
+ source_ip: The source IP address for the request.
+ port: The port to which to direct the request.
+
+ Returns:
+ An environ object with all the information necessary for the backend to
+ process the request.
+ """
+ body = six.ensure_str(body, 'ascii')
+
+ url = urllib.parse.urlsplit(relative_url)
+ if port != 80:
+ host = '%s:%s' % (host, port)
+ else:
+ host = host
+ environ = {'CONTENT_LENGTH': str(len(body)),
+ 'PATH_INFO': url.path,
+ 'QUERY_STRING': url.query,
+ 'REQUEST_METHOD': method,
+ 'REMOTE_ADDR': source_ip,
+ 'SERVER_NAME': host,
+ 'SERVER_PORT': str(port),
+ 'SERVER_PROTOCOL': 'HTTP/1.1',
+ 'wsgi.version': (1, 0),
+ 'wsgi.url_scheme': 'http',
+ 'wsgi.errors': cStringIO.StringIO(),
+ 'wsgi.multithread': True,
+ 'wsgi.multiprocess': True,
+ 'wsgi.input': cStringIO.StringIO(body)}
+ util.put_headers_in_environ(headers, environ)
+ environ['HTTP_HOST'] = host
+ return environ
+
+ def call_backend(self, orig_request, start_response):
+ """Generate API call (from earlier-saved request).
+
+ This calls start_response and returns the response body.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string containing the response body.
+ """
+ method_config, params = self.lookup_rest_method(orig_request)
+ if not method_config:
+ cors_handler = self._create_cors_handler(orig_request)
+ return util.send_wsgi_not_found_response(start_response,
+ cors_handler=cors_handler)
+
+ # Prepare the request for the back end.
+ transformed_request = self.transform_request(
+ orig_request, params, method_config)
+
+ # Check if this call is for the Discovery service. If so, route
+ # it to our Discovery handler.
+ discovery = discovery_service.DiscoveryService(
+ self.config_manager, self._backend)
+ discovery_response = discovery.handle_discovery_request(
+ transformed_request.path, transformed_request, start_response)
+ if discovery_response:
+ return discovery_response
+
+ url = transformed_request.base_path + transformed_request.path
+ transformed_request.headers['Content-Type'] = 'application/json'
+ transformed_environ = self.prepare_backend_environ(
+ orig_request.server, 'POST', url, transformed_request.headers.items(),
+ transformed_request.body, transformed_request.source_ip,
+ orig_request.port)
+
+ # Send the transformed request to the backend app and capture the response.
+ with util.StartResponseProxy() as start_response_proxy:
+ body_iter = self._backend(transformed_environ, start_response_proxy.Proxy)
+ status = start_response_proxy.response_status
+ headers = start_response_proxy.response_headers
+
+ # Get response body
+ body = start_response_proxy.response_body
+ # In case standard WSGI behavior is implemented later...
+ if not body:
+ body = ''.join(body_iter)
+
+ return self.handle_backend_response(orig_request, transformed_request,
+ status, headers, body, method_config,
+ start_response)
+
+ class __CheckCorsHeaders(object):
+ """Track information about CORS headers and our response to them."""
+
+ def __init__(self, request):
+ self.allow_cors_request = False
+ self.origin = None
+ self.cors_request_method = None
+ self.cors_request_headers = None
+
+ self.__check_cors_request(request)
+
+ def __check_cors_request(self, request):
+ """Check for a CORS request, and see if it gets a CORS response."""
+ # Check for incoming CORS headers.
+ self.origin = request.headers[_CORS_HEADER_ORIGIN]
+ self.cors_request_method = request.headers[_CORS_HEADER_REQUEST_METHOD]
+ self.cors_request_headers = request.headers[
+ _CORS_HEADER_REQUEST_HEADERS]
+
+ # Check if the request should get a CORS response.
+ if (self.origin and
+ ((self.cors_request_method is None) or
+ (self.cors_request_method.upper() in _CORS_ALLOWED_METHODS))):
+ self.allow_cors_request = True
+
+ def update_headers(self, headers_in):
+ """Add CORS headers to the response, if needed."""
+ if not self.allow_cors_request:
+ return
+
+ # Add CORS headers.
+ headers = wsgiref.headers.Headers(headers_in)
+ headers[_CORS_HEADER_ALLOW_CREDS] = 'true'
+ headers[_CORS_HEADER_ALLOW_ORIGIN] = self.origin
+ headers[_CORS_HEADER_ALLOW_METHODS] = ','.join(tuple(
+ _CORS_ALLOWED_METHODS))
+ headers[_CORS_HEADER_EXPOSE_HEADERS] = ','.join(tuple(
+ _CORS_EXPOSED_HEADERS))
+ if self.cors_request_headers is not None:
+ headers[_CORS_HEADER_ALLOW_HEADERS] = self.cors_request_headers
+
+ def _create_cors_handler(self, request):
+ return EndpointsDispatcherMiddleware.__CheckCorsHeaders(request)
+
+ def handle_backend_response(self, orig_request, backend_request,
+ response_status, response_headers,
+ response_body, method_config, start_response):
+ """Handle backend response, transforming output as needed.
+
+ This calls start_response and returns the response body.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+ backend_request: An ApiRequest, the transformed request that was
+ sent to the backend handler.
+ response_status: A string, the status from the response.
+ response_headers: A dict, the headers from the response.
+ response_body: A string, the body of the response.
+ method_config: A dict, the API config of the method to be called.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string containing the response body.
+ """
+ # Verify that the response is json. If it isn't treat, the body as an
+ # error message and wrap it in a json error response.
+ for header, value in response_headers:
+ if (header.lower() == 'content-type' and
+ not value.lower().startswith('application/json')):
+ return self.fail_request(orig_request,
+ 'Non-JSON reply: %s' % response_body,
+ start_response)
+
+ self.check_error_response(response_body, response_status)
+
+ # Check if the response from the API was empty. Empty REST responses
+ # generate a HTTP 204.
+ empty_response = self.check_empty_response(orig_request, method_config,
+ start_response)
+ if empty_response is not None:
+ return empty_response
+
+ body = self.transform_rest_response(response_body)
+
+ cors_handler = self._create_cors_handler(orig_request)
+ return util.send_wsgi_response(response_status, response_headers, body,
+ start_response, cors_handler=cors_handler)
+
+ def fail_request(self, orig_request, message, start_response):
+ """Write an immediate failure response to outfile, no redirect.
+
+ This calls start_response and returns the error body.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+ message: A string containing the error message to be displayed to user.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string containing the body of the error response.
+ """
+ cors_handler = self._create_cors_handler(orig_request)
+ return util.send_wsgi_error_response(
+ message, start_response, cors_handler=cors_handler)
+
+ def lookup_rest_method(self, orig_request):
+ """Looks up and returns rest method for the currently-pending request.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+
+ Returns:
+ A tuple of (method descriptor, parameters), or (None, None) if no method
+ was found for the current request.
+ """
+ method_name, method, params = self.config_manager.lookup_rest_method(
+ orig_request.path, orig_request.request_uri, orig_request.http_method)
+ orig_request.method_name = method_name
+ return method, params
+
+ def transform_request(self, orig_request, params, method_config):
+ """Transforms orig_request to apiserving request.
+
+ This method uses orig_request to determine the currently-pending request
+ and returns a new transformed request ready to send to the backend. This
+ method accepts a rest-style or RPC-style request.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+ params: A dictionary containing path parameters for rest requests, or
+ None for an RPC request.
+ method_config: A dict, the API config of the method to be called.
+
+ Returns:
+ An ApiRequest that's a copy of the current request, modified so it can
+ be sent to the backend. The path is updated and parts of the body or
+ other properties may also be changed.
+ """
+ method_params = method_config.get('request', {}).get('parameters', {})
+ request = self.transform_rest_request(orig_request, params, method_params)
+ request.path = method_config.get('rosyMethod', '')
+ return request
+
+ def _add_message_field(self, field_name, value, params):
+ """Converts a . delimitied field name to a message field in parameters.
+
+ This adds the field to the params dict, broken out so that message
+ parameters appear as sub-dicts within the outer param.
+
+ For example:
+ {'a.b.c': ['foo']}
+ becomes:
+ {'a': {'b': {'c': ['foo']}}}
+
+ Args:
+ field_name: A string containing the '.' delimitied name to be converted
+ into a dictionary.
+ value: The value to be set.
+ params: The dictionary holding all the parameters, where the value is
+ eventually set.
+ """
+ if '.' not in field_name:
+ params[field_name] = value
+ return
+
+ root, remaining = field_name.split('.', 1)
+ sub_params = params.setdefault(root, {})
+ self._add_message_field(remaining, value, sub_params)
+
+ def _update_from_body(self, destination, source):
+ """Updates the dictionary for an API payload with the request body.
+
+ The values from the body should override those already in the payload, but
+ for nested fields (message objects) the values can be combined
+ recursively.
+
+ Args:
+ destination: A dictionary containing an API payload parsed from the
+ path and query parameters in a request.
+ source: A dictionary parsed from the body of the request.
+ """
+ for key, value in source.items():
+ destination_value = destination.get(key)
+ if isinstance(value, dict) and isinstance(destination_value, dict):
+ self._update_from_body(destination_value, value)
+ else:
+ destination[key] = value
+
+ def transform_rest_request(self, orig_request, params, method_parameters):
+ """Translates a Rest request into an apiserving request.
+
+ This makes a copy of orig_request and transforms it to apiserving
+ format (moving request parameters to the body).
+
+ The request can receive values from the path, query and body and combine
+ them before sending them along to the backend. In cases of collision,
+ objects from the body take precedence over those from the query, which in
+ turn take precedence over those from the path.
+
+ In the case that a repeated value occurs in both the query and the path,
+ those values can be combined, but if that value also occurred in the body,
+ it would override any other values.
+
+ In the case of nested values from message fields, non-colliding values
+ from subfields can be combined. For example, if '?a.c=10' occurs in the
+ query string and "{'a': {'b': 11}}" occurs in the body, then they will be
+ combined as
+
+ {
+ 'a': {
+ 'b': 11,
+ 'c': 10,
+ }
+ }
+
+ before being sent to the backend.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+ params: A dict with URL path parameters extracted by the config_manager
+ lookup.
+ method_parameters: A dictionary containing the API configuration for the
+ parameters for the request.
+
+ Returns:
+ A copy of the current request that's been modified so it can be sent
+ to the backend. The body is updated to include parameters from the
+ URL.
+ """
+ request = orig_request.copy()
+ body_json = {}
+
+ # Handle parameters from the URL path.
+ for key, value in params.items():
+ # Values need to be in a list to interact with query parameter values
+ # and to account for case of repeated parameters
+ body_json[key] = [value]
+
+ # Add in parameters from the query string.
+ if request.parameters:
+ # For repeated elements, query and path work together
+ for key, value in request.parameters.items():
+ if key in body_json:
+ body_json[key] = value + body_json[key]
+ else:
+ body_json[key] = value
+
+ # Validate all parameters we've merged so far and convert any '.' delimited
+ # parameters to nested parameters. We don't use items since we may
+ # modify body_json within the loop. For instance, 'a.b' is not a valid key
+ # and would be replaced with 'a'.
+ for key, value in body_json.items():
+ current_parameter = method_parameters.get(key, {})
+ repeated = current_parameter.get('repeated', False)
+
+ if not repeated:
+ body_json[key] = body_json[key][0]
+
+ # Order is important here. Parameter names are dot-delimited in
+ # parameters instead of nested in dictionaries as a message field is, so
+ # we need to call transform_parameter_value on them before calling
+ # _add_message_field.
+ body_json[key] = parameter_converter.transform_parameter_value(
+ key, body_json[key], current_parameter)
+ # Remove the old key and try to convert to nested message value
+ message_value = body_json.pop(key)
+ self._add_message_field(key, message_value, body_json)
+
+ # Add in values from the body of the request.
+ if request.body_json:
+ self._update_from_body(body_json, request.body_json)
+
+ request.body_json = body_json
+ request.body = json.dumps(request.body_json)
+ return request
+
+ def check_error_response(self, body, status):
+ """Raise an exception if the response from the backend was an error.
+
+ Args:
+ body: A string containing the backend response body.
+ status: A string containing the backend response status.
+
+ Raises:
+ BackendError if the response is an error.
+ """
+ status_code = int(status.split(' ', 1)[0])
+ if status_code >= 300:
+ raise errors.BackendError(body, status)
+
+ def check_empty_response(self, orig_request, method_config, start_response):
+ """If the response from the backend is empty, return a HTTP 204 No Content.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+ method_config: A dict, the API config of the method to be called.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ If the backend response was empty, this returns a string containing the
+ response body that should be returned to the user. If the backend
+ response wasn't empty, this returns None, indicating that we should not
+ exit early with a 204.
+ """
+ response_config = method_config.get('response', {}).get('body')
+ if response_config == 'empty':
+ # The response to this function should be empty. We should return a 204.
+ # Note that it's possible that the backend returned something, but we'll
+ # ignore it. This matches the behavior in the Endpoints server.
+ cors_handler = self._create_cors_handler(orig_request)
+ return util.send_wsgi_no_content_response(start_response, cors_handler)
+
+ def transform_rest_response(self, response_body):
+ """Translates an apiserving REST response so it's ready to return.
+
+ Currently, the only thing that needs to be fixed here is indentation,
+ so it's consistent with what the live app will return.
+
+ Args:
+ response_body: A string containing the backend response.
+
+ Returns:
+ A reformatted version of the response JSON.
+ """
+ body_json = json.loads(response_body)
+ return json.dumps(body_json, indent=1, sort_keys=True)
+
+ def _handle_request_error(self, orig_request, error, start_response):
+ """Handle a request error, converting it to a WSGI response.
+
+ Args:
+ orig_request: An ApiRequest, the original request from the user.
+ error: A RequestError containing information about the error.
+ start_response: A function with semantics defined in PEP-333.
+
+ Returns:
+ A string containing the response body.
+ """
+ headers = [('Content-Type', 'application/json')]
+ status_code = error.status_code()
+ body = error.rest_error()
+
+ response_status = '%d %s' % (status_code,
+ http_client.responses.get(status_code,
+ 'Unknown Error'))
+ cors_handler = self._create_cors_handler(orig_request)
+ return util.send_wsgi_response(response_status, headers, body,
+ start_response, cors_handler=cors_handler)
diff --git a/third_party/endpoints/endpointscfg.py b/third_party/endpoints/endpointscfg.py
new file mode 100755
index 0000000..1557cb7
--- /dev/null
+++ b/third_party/endpoints/endpointscfg.py
@@ -0,0 +1,31 @@
+#!/usr/bin/python
+# Copyright 2017 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.
+
+r"""Wrapper script to set up import paths for endpointscfg.
+
+The actual implementation is in _endpointscfg_impl, but we have to set
+up import paths properly before we can import that module.
+
+See the docstring for endpoints._endpointscfg_impl for more
+information about this script's capabilities.
+"""
+
+import sys
+
+import _endpointscfg_setup # pylint: disable=unused-import
+from endpoints._endpointscfg_impl import main
+
+if __name__ == '__main__':
+ main(sys.argv)
diff --git a/third_party/endpoints/errors.py b/third_party/endpoints/errors.py
new file mode 100644
index 0000000..e98c76d
--- /dev/null
+++ b/third_party/endpoints/errors.py
@@ -0,0 +1,285 @@
+# 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.
+
+"""Error handling and exceptions used in the local Cloud Endpoints server."""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+import json
+import logging
+
+from . import generated_error_info
+
+__all__ = ['BackendError',
+ 'BasicTypeParameterError',
+ 'EnumRejectionError',
+ 'InvalidParameterError',
+ 'RequestError',
+ 'RequestRejectionError']
+
+_logger = logging.getLogger(__name__)
+
+_INVALID_ENUM_TEMPLATE = 'Invalid string value: %r. Allowed values: %r'
+_INVALID_BASIC_PARAM_TEMPLATE = 'Invalid %s value: %r.'
+
+
+class RequestError(Exception):
+ """Base class for errors that happen while processing a request."""
+
+ def status_code(self):
+ """HTTP status code number associated with this error.
+
+ Subclasses must implement this, returning an integer with the status
+ code number for the error.
+
+ Example: 400
+
+ Raises:
+ NotImplementedError: Subclasses must override this function.
+ """
+ raise NotImplementedError
+
+ def message(self):
+ """Text message explaining the error.
+
+ Subclasses must implement this, returning a string that explains the
+ error.
+
+ Raises:
+ NotImplementedError: Subclasses must override this function.
+ """
+ raise NotImplementedError
+
+ def reason(self):
+ """Get the reason for the error.
+
+ Error reason is a custom string in the Cloud Endpoints server. When
+ possible, this should match the reason that the live server will generate,
+ based on the error's status code. If this returns None, the error formatter
+ will attempt to generate a reason from the status code.
+
+ Returns:
+ None, by default. Subclasses can override this if they have a specific
+ error reason.
+ """
+ raise NotImplementedError
+
+ def domain(self):
+ """Get the domain for this error.
+
+ Returns:
+ The string 'global' by default. Subclasses can override this if they have
+ a different domain.
+ """
+ return 'global'
+
+ def extra_fields(self):
+ """Return a dict of extra fields to add to the error response.
+
+ Some errors have additional information. This provides a way for subclasses
+ to provide that information.
+
+ Returns:
+ None, by default. Subclasses can return a dict with values to add
+ to the error response.
+ """
+ return None
+
+ def __format_error(self, error_list_tag):
+ """Format this error into a JSON response.
+
+ Args:
+ error_list_tag: A string specifying the name of the tag to use for the
+ error list.
+
+ Returns:
+ A dict containing the reformatted JSON error response.
+ """
+ error = {'domain': self.domain(),
+ 'reason': self.reason(),
+ 'message': self.message()}
+ error.update(self.extra_fields() or {})
+ return {'error': {error_list_tag: [error],
+ 'code': self.status_code(),
+ 'message': self.message()}}
+
+ def rest_error(self):
+ """Format this error into a response to a REST request.
+
+ Returns:
+ A string containing the reformatted error response.
+ """
+ error_json = self.__format_error('errors')
+ return json.dumps(error_json, indent=1, sort_keys=True)
+
+ def rpc_error(self):
+ """Format this error into a response to a JSON RPC request.
+
+
+ Returns:
+ A dict containing the reformatted JSON error response.
+ """
+ return self.__format_error('data')
+
+
+class RequestRejectionError(RequestError):
+ """Base class for invalid/rejected requests.
+
+ To be raised when parsing the request values and comparing them against the
+ generated discovery document.
+ """
+
+ def status_code(self):
+ return 400
+
+
+class InvalidParameterError(RequestRejectionError):
+ """Base class for invalid parameter errors.
+
+ Child classes only need to implement the message() function.
+ """
+
+ def __init__(self, parameter_name, value):
+ """Constructor for InvalidParameterError.
+
+ Args:
+ parameter_name: String; the name of the parameter which had a value
+ rejected.
+ value: The actual value passed in for the parameter. Usually string.
+ """
+ super(InvalidParameterError, self).__init__()
+ self.parameter_name = parameter_name
+ self.value = value
+
+ def reason(self):
+ """Returns the server's reason for this error.
+
+ Returns:
+ A string containing a short error reason.
+ """
+ return 'invalidParameter'
+
+ def extra_fields(self):
+ """Returns extra fields to add to the error response.
+
+ Returns:
+ A dict containing extra fields to add to the error response.
+ """
+ return {'locationType': 'parameter',
+ 'location': self.parameter_name}
+
+
+class BasicTypeParameterError(InvalidParameterError):
+ """Request rejection exception for basic types (int, float)."""
+
+ def __init__(self, parameter_name, value, type_name):
+ """Constructor for BasicTypeParameterError.
+
+ Args:
+ parameter_name: String; the name of the parameter which had a value
+ rejected.
+ value: The actual value passed in for the enum. Usually string.
+ type_name: Descriptive name of the data type expected.
+ """
+ super(BasicTypeParameterError, self).__init__(parameter_name, value)
+ self.type_name = type_name
+
+ def message(self):
+ """A descriptive message describing the error."""
+ return _INVALID_BASIC_PARAM_TEMPLATE % (self.type_name, self.value)
+
+
+class EnumRejectionError(InvalidParameterError):
+ """Custom request rejection exception for enum values."""
+
+ def __init__(self, parameter_name, value, allowed_values):
+ """Constructor for EnumRejectionError.
+
+ Args:
+ parameter_name: String; the name of the enum parameter which had a value
+ rejected.
+ value: The actual value passed in for the enum. Usually string.
+ allowed_values: List of strings allowed for the enum.
+ """
+ super(EnumRejectionError, self).__init__(parameter_name, value)
+ self.allowed_values = allowed_values
+
+ def message(self):
+ """A descriptive message describing the error."""
+ return _INVALID_ENUM_TEMPLATE % (self.value, self.allowed_values)
+
+
+class BackendError(RequestError):
+ """Exception raised when the backend returns an error code."""
+
+ def __init__(self, body, status):
+ super(BackendError, self).__init__()
+ # Convert backend error status to whatever the live server would return.
+ status_code = self._get_status_code(status)
+ self._error_info = generated_error_info.get_error_info(status_code)
+
+ try:
+ error_json = json.loads(body)
+ self._message = error_json.get('error_message')
+ except TypeError:
+ self._message = body
+
+ def _get_status_code(self, http_status):
+ """Get the HTTP status code from an HTTP status string.
+
+ Args:
+ http_status: A string containing a HTTP status code and reason.
+
+ Returns:
+ An integer with the status code number from http_status.
+ """
+ try:
+ return int(http_status.split(' ', 1)[0])
+ except TypeError:
+ _logger.warning('Unable to find status code in HTTP status %r.',
+ http_status)
+ return 500
+
+ def status_code(self):
+ """Return the HTTP status code number for this error.
+
+ Returns:
+ An integer containing the status code for this error.
+ """
+ return self._error_info.http_status
+
+ def message(self):
+ """Return a descriptive message for this error.
+
+ Returns:
+ A string containing a descriptive message for this error.
+ """
+ return self._message
+
+ def reason(self):
+ """Return the short reason for this error.
+
+ Returns:
+ A string with the reason for this error.
+ """
+ return self._error_info.reason
+
+ def domain(self):
+ """Return the remapped domain for this error.
+
+ Returns:
+ A string containing the remapped domain for this error.
+ """
+ return self._error_info.domain
diff --git a/third_party/endpoints/generated_error_info.py b/third_party/endpoints/generated_error_info.py
new file mode 100644
index 0000000..d0c31c3
--- /dev/null
+++ b/third_party/endpoints/generated_error_info.py
@@ -0,0 +1,69 @@
+# 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.
+
+"""Automatically generated mapping of error codes."""
+
+# pylint: disable=g-bad-name
+
+from __future__ import absolute_import
+
+import collections
+
+_ErrorInfo = collections.namedtuple(
+ '_ErrorInfo', ['http_status', 'rpc_status', 'reason', 'domain'])
+
+_UNSUPPORTED_ERROR = _ErrorInfo(404,
+ 404,
+ 'unsupportedProtocol',
+ 'global')
+_BACKEND_ERROR = _ErrorInfo(503,
+ -32099,
+ 'backendError',
+ 'global')
+_ERROR_MAP = {
+ 400: _ErrorInfo(400, 400, 'badRequest', 'global'),
+ 401: _ErrorInfo(401, 401, 'required', 'global'),
+ 402: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ 403: _ErrorInfo(403, 403, 'forbidden', 'global'),
+ 404: _ErrorInfo(404, 404, 'notFound', 'global'),
+ 405: _ErrorInfo(501, 501, 'unsupportedMethod', 'global'),
+ 406: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ 407: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ 408: _ErrorInfo(503, -32099, 'backendError', 'global'),
+ 409: _ErrorInfo(409, 409, 'conflict', 'global'),
+ 410: _ErrorInfo(410, 410, 'deleted', 'global'),
+ 411: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ 412: _ErrorInfo(412, 412, 'conditionNotMet', 'global'),
+ 413: _ErrorInfo(413, 413, 'uploadTooLarge', 'global'),
+ 414: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ 415: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ 416: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ 417: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'),
+ }
+
+
+def get_error_info(lily_status):
+ """Get info that would be returned by the server for this HTTP status.
+
+ Args:
+ lily_status: An integer containing the HTTP status returned by the SPI.
+
+ Returns:
+ An _ErrorInfo object containing information that would be returned by the
+ live server for the provided lily_status.
+ """
+ if lily_status >= 500:
+ return _BACKEND_ERROR
+
+ return _ERROR_MAP.get(lily_status, _UNSUPPORTED_ERROR)
diff --git a/third_party/endpoints/message_parser.py b/third_party/endpoints/message_parser.py
new file mode 100644
index 0000000..28d6f47
--- /dev/null
+++ b/third_party/endpoints/message_parser.py
@@ -0,0 +1,227 @@
+# 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.
+
+"""Describe ProtoRPC Messages in JSON Schema.
+
+Add protorpc.message subclasses to MessageTypeToJsonSchema and get a JSON
+Schema description of all the messages.
+"""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+import re
+
+from . import message_types
+from . import messages
+
+__all__ = ['MessageTypeToJsonSchema']
+
+
+class MessageTypeToJsonSchema(object):
+ """Describe ProtoRPC messages in JSON Schema.
+
+ Add protorpc.message subclasses to MessageTypeToJsonSchema and get a JSON
+ Schema description of all the messages. MessageTypeToJsonSchema handles
+ all the types of fields that can appear in a message.
+ """
+
+ # Field to schema type and format. If the field maps to tuple, the
+ # first entry is set as the type, the second the format (or left alone if
+ # None). If the field maps to a dictionary, we'll grab the value from the
+ # field's Variant in that dictionary.
+ # The variant dictionary should include an element that None maps to,
+ # to fall back on as a default.
+ __FIELD_TO_SCHEMA_TYPE_MAP = {
+ messages.IntegerField: {messages.Variant.INT32: ('integer', 'int32'),
+ messages.Variant.INT64: ('string', 'int64'),
+ messages.Variant.UINT32: ('integer', 'uint32'),
+ messages.Variant.UINT64: ('string', 'uint64'),
+ messages.Variant.SINT32: ('integer', 'int32'),
+ messages.Variant.SINT64: ('string', 'int64'),
+ None: ('integer', 'int64')},
+ messages.FloatField: {messages.Variant.FLOAT: ('number', 'float'),
+ messages.Variant.DOUBLE: ('number', 'double'),
+ None: ('number', 'float')},
+ messages.BooleanField: ('boolean', None),
+ messages.BytesField: ('string', 'byte'),
+ message_types.DateTimeField: ('string', 'date-time'),
+ messages.StringField: ('string', None),
+ messages.MessageField: ('object', None),
+ messages.EnumField: ('string', None),
+ }
+
+ __DEFAULT_SCHEMA_TYPE = ('string', None)
+
+ def __init__(self):
+ # A map of schema ids to schemas.
+ self.__schemas = {}
+
+ # A map from schema id to non-normalized definition name.
+ self.__normalized_names = {}
+
+ def add_message(self, message_type):
+ """Add a new message.
+
+ Args:
+ message_type: protorpc.message.Message class to be parsed.
+
+ Returns:
+ string, The JSON Schema id.
+
+ Raises:
+ KeyError if the Schema id for this message_type would collide with the
+ Schema id of a different message_type that was already added.
+ """
+ name = self.__normalized_name(message_type)
+ if name not in self.__schemas:
+ # Set a placeholder to prevent infinite recursion.
+ self.__schemas[name] = None
+ schema = self.__message_to_schema(message_type)
+ self.__schemas[name] = schema
+ return name
+
+ def ref_for_message_type(self, message_type):
+ """Returns the JSON Schema id for the given message.
+
+ Args:
+ message_type: protorpc.message.Message class to be parsed.
+
+ Returns:
+ string, The JSON Schema id.
+
+ Raises:
+ KeyError: if the message hasn't been parsed via add_message().
+ """
+ name = self.__normalized_name(message_type)
+ if name not in self.__schemas:
+ raise KeyError('Message has not been parsed: %s', name)
+ return name
+
+ def schemas(self):
+ """Returns the JSON Schema of all the messages.
+
+ Returns:
+ object: JSON Schema description of all messages.
+ """
+ return self.__schemas.copy()
+
+ def __normalized_name(self, message_type):
+ """Normalized schema name.
+
+ Generate a normalized schema name, taking the class name and stripping out
+ everything but alphanumerics, and camel casing the remaining words.
+ A normalized schema name is a name that matches [a-zA-Z][a-zA-Z0-9]*
+
+ Args:
+ message_type: protorpc.message.Message class being parsed.
+
+ Returns:
+ A string, the normalized schema name.
+
+ Raises:
+ KeyError: A collision was found between normalized names.
+ """
+ # Normalization is applied to match the constraints that Discovery applies
+ # to Schema names.
+ name = message_type.definition_name()
+
+ split_name = re.split(r'[^0-9a-zA-Z]', name)
+ normalized = ''.join(
+ part[0].upper() + part[1:] for part in split_name if part)
+
+ previous = self.__normalized_names.get(normalized)
+ if previous:
+ if previous != name:
+ raise KeyError('Both %s and %s normalize to the same schema name: %s' %
+ (name, previous, normalized))
+ else:
+ self.__normalized_names[normalized] = name
+
+ return normalized
+
+ def __message_to_schema(self, message_type):
+ """Parse a single message into JSON Schema.
+
+ Will recursively descend the message structure
+ and also parse other messages references via MessageFields.
+
+ Args:
+ message_type: protorpc.messages.Message class to parse.
+
+ Returns:
+ An object representation of the schema.
+ """
+ name = self.__normalized_name(message_type)
+ schema = {
+ 'id': name,
+ 'type': 'object',
+ }
+ if message_type.__doc__:
+ schema['description'] = message_type.__doc__
+ properties = {}
+ for field in message_type.all_fields():
+ descriptor = {}
+ # Info about the type of this field. This is either merged with
+ # the descriptor or it's placed within the descriptor's 'items'
+ # property, depending on whether this is a repeated field or not.
+ type_info = {}
+
+ if type(field) == messages.MessageField:
+ field_type = field.type().__class__
+ type_info['$ref'] = self.add_message(field_type)
+ if field_type.__doc__:
+ descriptor['description'] = field_type.__doc__
+ else:
+ schema_type = self.__FIELD_TO_SCHEMA_TYPE_MAP.get(
+ type(field), self.__DEFAULT_SCHEMA_TYPE)
+ # If the map pointed to a dictionary, check if the field's variant
+ # is in that dictionary and use the type specified there.
+ if isinstance(schema_type, dict):
+ variant_map = schema_type
+ variant = getattr(field, 'variant', None)
+ if variant in variant_map:
+ schema_type = variant_map[variant]
+ else:
+ # The variant map needs to specify a default value, mapped by None.
+ schema_type = variant_map[None]
+ type_info['type'] = schema_type[0]
+ if schema_type[1]:
+ type_info['format'] = schema_type[1]
+
+ if type(field) == messages.EnumField:
+ sorted_enums = sorted([enum_info for enum_info in field.type],
+ key=lambda enum_info: enum_info.number)
+ type_info['enum'] = [enum_info.name for enum_info in sorted_enums]
+
+ if field.required:
+ descriptor['required'] = True
+
+ if field.default:
+ if type(field) == messages.EnumField:
+ descriptor['default'] = str(field.default)
+ else:
+ descriptor['default'] = field.default
+
+ if field.repeated:
+ descriptor['items'] = type_info
+ descriptor['type'] = 'array'
+ else:
+ descriptor.update(type_info)
+
+ properties[field.name] = descriptor
+
+ schema['properties'] = properties
+
+ return schema
diff --git a/third_party/endpoints/openapi_generator.py b/third_party/endpoints/openapi_generator.py
new file mode 100644
index 0000000..058bf8d
--- /dev/null
+++ b/third_party/endpoints/openapi_generator.py
@@ -0,0 +1,1073 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A library for converting service configs to OpenAPI (Swagger) specs."""
+from __future__ import absolute_import
+
+import hashlib
+import json
+import logging
+import re
+
+from . import api_exceptions
+from . import message_parser
+from . import message_types
+from . import messages
+from . import remote
+from . import resource_container
+from . import util
+
+_logger = logging.getLogger(__name__)
+
+_PATH_VARIABLE_PATTERN = r'{([a-zA-Z_][a-zA-Z_.\d]*)}'
+
+_MULTICLASS_MISMATCH_ERROR_TEMPLATE = (
+ 'Attempting to implement service %s, version %s, with multiple '
+ 'classes that aren\'t compatible. See docstring for api() for '
+ 'examples how to implement a multi-class API.')
+
+_INVALID_AUTH_ISSUER = 'No auth issuer named %s defined in this Endpoints API.'
+
+_API_KEY = 'api_key'
+_API_KEY_PARAM = 'key'
+_DEFAULT_SECURITY_DEFINITION = 'google_id_token'
+
+
+_VALID_API_NAME = re.compile('^[a-z][a-z0-9]{0,39}$')
+
+
+def _validate_api_name(name):
+ valid = (_VALID_API_NAME.match(name) is not None)
+ if not valid:
+ raise api_exceptions.InvalidApiNameException(
+ 'The API name must match the regular expression {}'.format(
+ _VALID_API_NAME.pattern[1:-1]))
+ return name
+
+
+class OpenApiGenerator(object):
+ """Generates an OpenAPI spec from a ProtoRPC service.
+
+ Example:
+
+ class HelloRequest(messages.Message):
+ my_name = messages.StringField(1, required=True)
+
+ class HelloResponse(messages.Message):
+ hello = messages.StringField(1, required=True)
+
+ class HelloService(remote.Service):
+
+ @remote.method(HelloRequest, HelloResponse)
+ def hello(self, request):
+ return HelloResponse(hello='Hello there, %s!' %
+ request.my_name)
+
+ api_config = OpenApiGenerator().pretty_print_config_to_json(HelloService)
+
+ The resulting api_config will be a JSON OpenAPI document describing the API
+ implemented by HelloService.
+ """
+
+ # Constants for categorizing a request method.
+ # __NO_BODY - Request without a request body, such as GET and DELETE methods.
+ # __HAS_BODY - Request (such as POST/PUT/PATCH) with info in the request body.
+ __NO_BODY = 1 # pylint: disable=invalid-name
+ __HAS_BODY = 2 # pylint: disable=invalid-name
+
+ def __init__(self):
+ self.__parser = message_parser.MessageTypeToJsonSchema()
+
+ # Maps method id to the request schema id.
+ self.__request_schema = {}
+
+ # Maps method id to the response schema id.
+ self.__response_schema = {}
+
+ def _add_def_paths(self, prop_dict):
+ """Recursive method to add relative paths for any $ref objects.
+
+ Args:
+ prop_dict: The property dict to alter.
+
+ Side Effects:
+ Alters prop_dict in-place.
+ """
+ for prop_key, prop_value in prop_dict.items():
+ if prop_key == '$ref' and not 'prop_value'.startswith('#'):
+ prop_dict[prop_key] = '#/definitions/' + prop_dict[prop_key]
+ elif isinstance(prop_value, dict):
+ self._add_def_paths(prop_value)
+
+ def _construct_operation_id(self, service_name, protorpc_method_name):
+ """Return an operation id for a service method.
+
+ Args:
+ service_name: The name of the service.
+ protorpc_method_name: The ProtoRPC method name.
+
+ Returns:
+ A string representing the operation id.
+ """
+
+ # camelCase the ProtoRPC method name
+ method_name_camel = util.snake_case_to_headless_camel_case(
+ protorpc_method_name)
+
+ return '{0}_{1}'.format(service_name, method_name_camel)
+
+ def __get_request_kind(self, method_info):
+ """Categorize the type of the request.
+
+ Args:
+ method_info: _MethodInfo, method information.
+
+ Returns:
+ The kind of request.
+ """
+ if method_info.http_method in ('GET', 'DELETE'):
+ return self.__NO_BODY
+ else:
+ return self.__HAS_BODY
+
+ def __field_to_subfields(self, field):
+ """Fully describes data represented by field, including the nested case.
+
+ In the case that the field is not a message field, we have no fields nested
+ within a message definition, so we can simply return that field. However, in
+ the nested case, we can't simply describe the data with one field or even
+ with one chain of fields.
+
+ For example, if we have a message field
+
+ m_field = messages.MessageField(RefClass, 1)
+
+ which references a class with two fields:
+
+ class RefClass(messages.Message):
+ one = messages.StringField(1)
+ two = messages.IntegerField(2)
+
+ then we would need to include both one and two to represent all the
+ data contained.
+
+ Calling __field_to_subfields(m_field) would return:
+ [
+ [<MessageField "m_field">, <StringField "one">],
+ [<MessageField "m_field">, <StringField "two">],
+ ]
+
+ If the second field was instead a message field
+
+ class RefClass(messages.Message):
+ one = messages.StringField(1)
+ two = messages.MessageField(OtherRefClass, 2)
+
+ referencing another class with two fields
+
+ class OtherRefClass(messages.Message):
+ three = messages.BooleanField(1)
+ four = messages.FloatField(2)
+
+ then we would need to recurse one level deeper for two.
+
+ With this change, calling __field_to_subfields(m_field) would return:
+ [
+ [<MessageField "m_field">, <StringField "one">],
+ [<MessageField "m_field">, <StringField "two">, <StringField "three">],
+ [<MessageField "m_field">, <StringField "two">, <StringField "four">],
+ ]
+
+ Args:
+ field: An instance of a subclass of messages.Field.
+
+ Returns:
+ A list of lists, where each sublist is a list of fields.
+ """
+ # Termination condition
+ if not isinstance(field, messages.MessageField):
+ return [[field]]
+
+ result = []
+ for subfield in sorted(field.message_type.all_fields(),
+ key=lambda f: f.number):
+ subfield_results = self.__field_to_subfields(subfield)
+ for subfields_list in subfield_results:
+ subfields_list.insert(0, field)
+ result.append(subfields_list)
+ return result
+
+ def __field_to_parameter_type_and_format(self, field):
+ """Converts the field variant type into a tuple describing the parameter.
+
+ Args:
+ field: An instance of a subclass of messages.Field.
+
+ Returns:
+ A tuple with the type and format of the field, respectively.
+
+ Raises:
+ TypeError: if the field variant is a message variant.
+ """
+ # We use lowercase values for types (e.g. 'string' instead of 'STRING').
+ variant = field.variant
+ if variant == messages.Variant.MESSAGE:
+ raise TypeError('A message variant can\'t be used in a parameter.')
+
+ # Note that the 64-bit integers are marked as strings -- this is to
+ # accommodate JavaScript, which would otherwise demote them to 32-bit
+ # integers.
+
+ custom_variant_map = {
+ messages.Variant.DOUBLE: ('number', 'double'),
+ messages.Variant.FLOAT: ('number', 'float'),
+ messages.Variant.INT64: ('string', 'int64'),
+ messages.Variant.SINT64: ('string', 'int64'),
+ messages.Variant.UINT64: ('string', 'uint64'),
+ messages.Variant.INT32: ('integer', 'int32'),
+ messages.Variant.SINT32: ('integer', 'int32'),
+ messages.Variant.UINT32: ('integer', 'uint32'),
+ messages.Variant.BOOL: ('boolean', None),
+ messages.Variant.STRING: ('string', None),
+ messages.Variant.BYTES: ('string', 'byte'),
+ messages.Variant.ENUM: ('string', None),
+ }
+ return custom_variant_map.get(variant) or (variant.name.lower(), None)
+
+ def __get_path_parameters(self, path):
+ """Parses path paremeters from a URI path and organizes them by parameter.
+
+ Some of the parameters may correspond to message fields, and so will be
+ represented as segments corresponding to each subfield; e.g. first.second if
+ the field "second" in the message field "first" is pulled from the path.
+
+ The resulting dictionary uses the first segments as keys and each key has as
+ value the list of full parameter values with first segment equal to the key.
+
+ If the match path parameter is null, that part of the path template is
+ ignored; this occurs if '{}' is used in a template.
+
+ Args:
+ path: String; a URI path, potentially with some parameters.
+
+ Returns:
+ A dictionary with strings as keys and list of strings as values.
+ """
+ path_parameters_by_segment = {}
+ for format_var_name in re.findall(_PATH_VARIABLE_PATTERN, path):
+ first_segment = format_var_name.split('.', 1)[0]
+ matches = path_parameters_by_segment.setdefault(first_segment, [])
+ matches.append(format_var_name)
+
+ return path_parameters_by_segment
+
+ def __validate_simple_subfield(self, parameter, field, segment_list,
+ segment_index=0):
+ """Verifies that a proposed subfield actually exists and is a simple field.
+
+ Here, simple means it is not a MessageField (nested).
+
+ Args:
+ parameter: String; the '.' delimited name of the current field being
+ considered. This is relative to some root.
+ field: An instance of a subclass of messages.Field. Corresponds to the
+ previous segment in the path (previous relative to _segment_index),
+ since this field should be a message field with the current segment
+ as a field in the message class.
+ segment_list: The full list of segments from the '.' delimited subfield
+ being validated.
+ segment_index: Integer; used to hold the position of current segment so
+ that segment_list can be passed as a reference instead of having to
+ copy using segment_list[1:] at each step.
+
+ Raises:
+ TypeError: If the final subfield (indicated by _segment_index relative
+ to the length of segment_list) is a MessageField.
+ TypeError: If at any stage the lookup at a segment fails, e.g if a.b
+ exists but a.b.c does not exist. This can happen either if a.b is not
+ a message field or if a.b.c is not a property on the message class from
+ a.b.
+ """
+ if segment_index >= len(segment_list):
+ # In this case, the field is the final one, so should be simple type
+ if isinstance(field, messages.MessageField):
+ field_class = field.__class__.__name__
+ raise TypeError('Can\'t use messages in path. Subfield %r was '
+ 'included but is a %s.' % (parameter, field_class))
+ return
+
+ segment = segment_list[segment_index]
+ parameter += '.' + segment
+ try:
+ field = field.type.field_by_name(segment)
+ except (AttributeError, KeyError):
+ raise TypeError('Subfield %r from path does not exist.' % (parameter,))
+
+ self.__validate_simple_subfield(parameter, field, segment_list,
+ segment_index=segment_index + 1)
+
+ def __validate_path_parameters(self, field, path_parameters):
+ """Verifies that all path parameters correspond to an existing subfield.
+
+ Args:
+ field: An instance of a subclass of messages.Field. Should be the root
+ level property name in each path parameter in path_parameters. For
+ example, if the field is called 'foo', then each path parameter should
+ begin with 'foo.'.
+ path_parameters: A list of Strings representing URI parameter variables.
+
+ Raises:
+ TypeError: If one of the path parameters does not start with field.name.
+ """
+ for param in path_parameters:
+ segment_list = param.split('.')
+ if segment_list[0] != field.name:
+ raise TypeError('Subfield %r can\'t come from field %r.'
+ % (param, field.name))
+ self.__validate_simple_subfield(field.name, field, segment_list[1:])
+
+ def __parameter_default(self, field):
+ """Returns default value of field if it has one.
+
+ Args:
+ field: A simple field.
+
+ Returns:
+ The default value of the field, if any exists, with the exception of an
+ enum field, which will have its value cast to a string.
+ """
+ if field.default:
+ if isinstance(field, messages.EnumField):
+ return field.default.name
+ else:
+ return field.default
+
+ def __parameter_enum(self, param):
+ """Returns enum descriptor of a parameter if it is an enum.
+
+ An enum descriptor is a list of keys.
+
+ Args:
+ param: A simple field.
+
+ Returns:
+ The enum descriptor for the field, if it's an enum descriptor, else
+ returns None.
+ """
+ if isinstance(param, messages.EnumField):
+ return [enum_entry[0] for enum_entry in sorted(
+ param.type.to_dict().items(), key=lambda v: v[1])]
+
+ def __body_parameter_descriptor(self, method_id):
+ return {
+ 'name': 'body',
+ 'in': 'body',
+ 'required': True,
+ 'schema': {
+ '$ref': '#/definitions/{0}'.format(
+ self.__request_schema[method_id])
+ }
+ }
+
+ def __non_body_parameter_descriptor(self, param):
+ """Creates descriptor for a parameter.
+
+ Args:
+ param: The parameter to be described.
+
+ Returns:
+ Dictionary containing a descriptor for the parameter.
+ """
+ descriptor = {}
+
+ descriptor['name'] = param.name
+
+ param_type, param_format = self.__field_to_parameter_type_and_format(param)
+
+ # Required
+ if param.required:
+ descriptor['required'] = True
+
+ # Type
+ descriptor['type'] = param_type
+
+ # Format (optional)
+ if param_format:
+ descriptor['format'] = param_format
+
+ # Default
+ default = self.__parameter_default(param)
+ if default is not None:
+ descriptor['default'] = default
+
+ # Repeated
+ if param.repeated:
+ descriptor['repeated'] = True
+
+ # Enum
+ enum_descriptor = self.__parameter_enum(param)
+ if enum_descriptor is not None:
+ descriptor['enum'] = enum_descriptor
+
+ return descriptor
+
+ def __path_parameter_descriptor(self, param):
+ descriptor = self.__non_body_parameter_descriptor(param)
+ descriptor['required'] = True
+ descriptor['in'] = 'path'
+
+ return descriptor
+
+ def __query_parameter_descriptor(self, param):
+ descriptor = self.__non_body_parameter_descriptor(param)
+ descriptor['in'] = 'query'
+
+ # If this is a repeated field, convert it to the collectionFormat: multi
+ # style.
+ if param.repeated:
+ descriptor['collectionFormat'] = 'multi'
+ descriptor['items'] = {
+ 'type': descriptor['type']
+ }
+ descriptor['type'] = 'array'
+ descriptor.pop('repeated', None)
+
+ return descriptor
+
+ def __add_parameter(self, param, path_parameters, params):
+ """Adds all parameters in a field to a method parameters descriptor.
+
+ Simple fields will only have one parameter, but a message field 'x' that
+ corresponds to a message class with fields 'y' and 'z' will result in
+ parameters 'x.y' and 'x.z', for example. The mapping from field to
+ parameters is mostly handled by __field_to_subfields.
+
+ Args:
+ param: Parameter to be added to the descriptor.
+ path_parameters: A list of parameters matched from a path for this field.
+ For example for the hypothetical 'x' from above if the path was
+ '/a/{x.z}/b/{other}' then this list would contain only the element
+ 'x.z' since 'other' does not match to this field.
+ params: List of parameters. Each parameter in the field.
+ """
+ # If this is a simple field, just build the descriptor and append it.
+ # Otherwise, build a schema and assign it to this descriptor
+ if not isinstance(param, messages.MessageField):
+ if param.name in path_parameters:
+ descriptor = self.__path_parameter_descriptor(param)
+ else:
+ descriptor = self.__query_parameter_descriptor(param)
+
+ params.append(descriptor)
+ else:
+ # If a subfield of a MessageField is found in the path, build a descriptor
+ # for the path parameter.
+ for subfield_list in self.__field_to_subfields(param):
+ qualified_name = '.'.join(subfield.name for subfield in subfield_list)
+ if qualified_name in path_parameters:
+ descriptor = self.__path_parameter_descriptor(subfield_list[-1])
+ descriptor['required'] = True
+
+ params.append(descriptor)
+
+ def __params_descriptor_without_container(self, message_type,
+ request_kind, method_id, path):
+ """Describe parameters of a method which does not use a ResourceContainer.
+
+ Makes sure that the path parameters are included in the message definition
+ and adds any required fields and URL query parameters.
+
+ This method is to preserve backwards compatibility and will be removed in
+ a future release.
+
+ Args:
+ message_type: messages.Message class, Message with parameters to describe.
+ request_kind: The type of request being made.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+ path: string, HTTP path to method.
+
+ Returns:
+ A list of dicts: Descriptors of the parameters
+ """
+ params = []
+
+ path_parameter_dict = self.__get_path_parameters(path)
+ for field in sorted(message_type.all_fields(), key=lambda f: f.number):
+ matched_path_parameters = path_parameter_dict.get(field.name, [])
+ self.__validate_path_parameters(field, matched_path_parameters)
+
+ if matched_path_parameters or request_kind == self.__NO_BODY:
+ self.__add_parameter(field, matched_path_parameters, params)
+
+ # If the request has a body, add the body parameter
+ if (message_type != message_types.VoidMessage() and
+ request_kind == self.__HAS_BODY):
+ params.append(self.__body_parameter_descriptor(method_id))
+
+ return params
+
+ def __params_descriptor(self, message_type, request_kind, path, method_id):
+ """Describe the parameters of a method.
+
+ If the message_type is not a ResourceContainer, will fall back to
+ __params_descriptor_without_container (which will eventually be deprecated).
+
+ If the message type is a ResourceContainer, then all path/query parameters
+ will come from the ResourceContainer. This method will also make sure all
+ path parameters are covered by the message fields.
+
+ Args:
+ message_type: messages.Message or ResourceContainer class, Message with
+ parameters to describe.
+ request_kind: The type of request being made.
+ path: string, HTTP path to method.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+
+ Returns:
+ A tuple (dict, list of string): Descriptor of the parameters, Order of the
+ parameters.
+ """
+ path_parameter_dict = self.__get_path_parameters(path)
+
+ if not isinstance(message_type, resource_container.ResourceContainer):
+ if path_parameter_dict:
+ _logger.warning('Method %s specifies path parameters but you are not '
+ 'using a ResourceContainer; instead, you are using %r. '
+ 'This will fail in future releases; please switch to '
+ 'using ResourceContainer as soon as possible.',
+ method_id, type(message_type))
+ return self.__params_descriptor_without_container(
+ message_type, request_kind, method_id, path)
+
+ # From here, we can assume message_type is a ResourceContainer.
+ params = []
+
+ # Process body parameter, if any
+ if message_type.body_message_class != message_types.VoidMessage:
+ params.append(self.__body_parameter_descriptor(method_id))
+
+ # Process path/querystring parameters
+ params_message_type = message_type.parameters_message_class()
+
+ # Make sure all path parameters are covered.
+ for field_name, matched_path_parameters in path_parameter_dict.items():
+ field = params_message_type.field_by_name(field_name)
+ self.__validate_path_parameters(field, matched_path_parameters)
+
+ # Add all fields, sort by field.number since we have parameterOrder.
+ for field in sorted(params_message_type.all_fields(),
+ key=lambda f: f.number):
+ matched_path_parameters = path_parameter_dict.get(field.name, [])
+ self.__add_parameter(field, matched_path_parameters, params)
+
+ return params
+
+ def __request_message_descriptor(self, request_kind, message_type, method_id,
+ path):
+ """Describes the parameters and body of the request.
+
+ Args:
+ request_kind: The type of request being made.
+ message_type: messages.Message or ResourceContainer class. The message to
+ describe.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+ path: string, HTTP path to method.
+
+ Returns:
+ Dictionary describing the request.
+
+ Raises:
+ ValueError: if the method path and request required fields do not match
+ """
+ if isinstance(message_type, resource_container.ResourceContainer):
+ base_message_type = message_type.body_message_class()
+ if (request_kind == self.__NO_BODY and
+ base_message_type != message_types.VoidMessage()):
+ msg = ('Method %s specifies a body message in its ResourceContainer, but '
+ 'is a HTTP method type that cannot accept a body.') % method_id
+ raise api_exceptions.ApiConfigurationError(msg)
+ else:
+ base_message_type = message_type
+
+ if (request_kind != self.__NO_BODY and
+ base_message_type != message_types.VoidMessage()):
+ self.__request_schema[method_id] = self.__parser.add_message(
+ base_message_type.__class__)
+
+ params = self.__params_descriptor(message_type, request_kind, path,
+ method_id)
+
+ return params
+
+ def __definitions_descriptor(self):
+ """Describes the definitions section of the OpenAPI spec.
+
+ Returns:
+ Dictionary describing the definitions of the spec.
+ """
+ # Filter out any keys that aren't 'properties' or 'type'
+ result = {}
+ for def_key, def_value in self.__parser.schemas().items():
+ if 'properties' in def_value or 'type' in def_value:
+ key_result = {}
+ required_keys = set()
+ if 'type' in def_value:
+ key_result['type'] = def_value['type']
+ if 'properties' in def_value:
+ for prop_key, prop_value in def_value['properties'].items():
+ if isinstance(prop_value, dict) and 'required' in prop_value:
+ required_keys.add(prop_key)
+ del prop_value['required']
+ key_result['properties'] = def_value['properties']
+ # Add in the required fields, if any
+ if required_keys:
+ key_result['required'] = sorted(required_keys)
+ result[def_key] = key_result
+
+ # Add 'type': 'object' to all object properties
+ # Also, recursively add relative path to all $ref values
+ for def_value in result.values():
+ for prop_value in def_value.values():
+ if isinstance(prop_value, dict):
+ if '$ref' in prop_value:
+ prop_value['type'] = 'object'
+ self._add_def_paths(prop_value)
+
+ return result
+
+ def __response_message_descriptor(self, message_type, method_id):
+ """Describes the response.
+
+ Args:
+ message_type: messages.Message class, The message to describe.
+ method_id: string, Unique method identifier (e.g. 'myapi.items.method')
+
+ Returns:
+ Dictionary describing the response.
+ """
+
+ # Skeleton response descriptor, common to all response objects
+ descriptor = {'200': {'description': 'A successful response'}}
+
+ if message_type != message_types.VoidMessage():
+ self.__parser.add_message(message_type.__class__)
+ self.__response_schema[method_id] = self.__parser.ref_for_message_type(
+ message_type.__class__)
+ descriptor['200']['schema'] = {'$ref': '#/definitions/{0}'.format(
+ self.__response_schema[method_id])}
+
+ return dict(descriptor)
+
+ def __x_google_quota_descriptor(self, metric_costs):
+ """Describes the metric costs for a call.
+
+ Args:
+ metric_costs: Dict of metric definitions to the integer cost value against
+ that metric.
+
+ Returns:
+ A dict descriptor describing the Quota limits for the endpoint.
+ """
+ return {
+ 'metricCosts': {
+ metric: cost for (metric, cost) in metric_costs.items()
+ }
+ } if metric_costs else None
+
+ def __x_google_quota_definitions_descriptor(self, limit_definitions):
+ """Describes the quota limit definitions for an API.
+
+ Args:
+ limit_definitions: List of endpoints.LimitDefinition tuples
+
+ Returns:
+ A dict descriptor of the API's quota limit definitions.
+ """
+ if not limit_definitions:
+ return None
+
+ definitions_list = [{
+ 'name': ld.metric_name,
+ 'metric': ld.metric_name,
+ 'unit': '1/min/{project}',
+ 'values': {'STANDARD': ld.default_limit},
+ 'displayName': ld.display_name,
+ } for ld in limit_definitions]
+
+ metrics = [{
+ 'name': ld.metric_name,
+ 'valueType': 'INT64',
+ 'metricKind': 'GAUGE',
+ } for ld in limit_definitions]
+
+ return {
+ 'quota': {'limits': definitions_list},
+ 'metrics': metrics,
+ }
+
+ def __method_descriptor(self, service, method_info, operation_id,
+ protorpc_method_info, security_definitions):
+ """Describes a method.
+
+ Args:
+ service: endpoints.Service, Implementation of the API as a service.
+ method_info: _MethodInfo, Configuration for the method.
+ operation_id: string, Operation ID of the method
+ protorpc_method_info: protorpc.remote._RemoteMethodInfo, ProtoRPC
+ description of the method.
+ security_definitions: list of dicts, security definitions for the API.
+
+ Returns:
+ Dictionary describing the method.
+ """
+ descriptor = {}
+
+ request_message_type = (resource_container.ResourceContainer.
+ get_request_message(protorpc_method_info.remote))
+ request_kind = self.__get_request_kind(method_info)
+ remote_method = protorpc_method_info.remote
+
+ path = method_info.get_path(service.api_info)
+
+ descriptor['parameters'] = self.__request_message_descriptor(
+ request_kind, request_message_type,
+ method_info.method_id(service.api_info),
+ path)
+ descriptor['responses'] = self.__response_message_descriptor(
+ remote_method.response_type(), method_info.method_id(service.api_info))
+ descriptor['operationId'] = operation_id
+
+ # Insert the auth audiences, if any
+ api_key_required = method_info.is_api_key_required(service.api_info)
+ if method_info.audiences is not None:
+ descriptor['security'] = self.__security_descriptor(
+ method_info.audiences, security_definitions,
+ api_key_required=api_key_required)
+ elif service.api_info.audiences is not None or api_key_required:
+ descriptor['security'] = self.__security_descriptor(
+ service.api_info.audiences, security_definitions,
+ api_key_required=api_key_required)
+
+ # Insert the metric costs, if any
+ if method_info.metric_costs:
+ descriptor['x-google-quota'] = self.__x_google_quota_descriptor(
+ method_info.metric_costs)
+
+ return descriptor
+
+ def __security_descriptor(self, audiences, security_definitions,
+ api_key_required=False):
+ if not audiences:
+ if not api_key_required:
+ # no security
+ return []
+ # api key only
+ return [{_API_KEY: []}]
+
+ if isinstance(audiences, (tuple, list)):
+ # security_definitions includes not just the base issuers, but also the
+ # hash-appended versions, so we need to filter them out
+ security_issuers = set()
+ for definition_key in security_definitions.keys():
+ if definition_key == _API_KEY:
+ # API key definitions don't count for these purposes
+ continue
+ if '-' in definition_key:
+ split_key = definition_key.rsplit('-', 1)[0]
+ if split_key in security_definitions:
+ continue
+ security_issuers.add(definition_key)
+
+ if security_issuers != {_DEFAULT_SECURITY_DEFINITION}:
+ raise api_exceptions.ApiConfigurationError(
+ 'audiences must be a dict when third-party issuers '
+ '(auth0, firebase, etc) are in use.'
+ )
+ audiences = {_DEFAULT_SECURITY_DEFINITION: audiences}
+
+ results = []
+ for issuer, issuer_audiences in audiences.items():
+ result_dict = {}
+ if issuer not in security_definitions:
+ raise TypeError('Missing issuer {}'.format(issuer))
+ audience_string = ','.join(sorted(issuer_audiences))
+ audience_hash = hashfunc(audience_string)
+ full_definition_key = '-'.join([issuer, audience_hash])
+ result_dict[full_definition_key] = []
+ if api_key_required:
+ result_dict[_API_KEY] = []
+ if full_definition_key not in security_definitions:
+ new_definition = dict(security_definitions[issuer])
+ new_definition['x-google-audiences'] = audience_string
+ security_definitions[full_definition_key] = new_definition
+ results.append(result_dict)
+
+ return results
+
+ def __security_definitions_descriptor(self, issuers):
+ """Create a descriptor for the security definitions.
+
+ Args:
+ issuers: dict, mapping issuer names to Issuer tuples
+
+ Returns:
+ The dict representing the security definitions descriptor.
+ """
+ if not issuers:
+ result = {
+ _DEFAULT_SECURITY_DEFINITION: {
+ 'authorizationUrl': '',
+ 'flow': 'implicit',
+ 'type': 'oauth2',
+ 'x-google-issuer': 'https://accounts.google.com',
+ 'x-google-jwks_uri': 'https://www.googleapis.com/oauth2/v3/certs',
+ }
+ }
+ return result
+
+ result = {}
+
+ for issuer_key, issuer_value in issuers.items():
+ result[issuer_key] = {
+ 'authorizationUrl': '',
+ 'flow': 'implicit',
+ 'type': 'oauth2',
+ 'x-google-issuer': issuer_value.issuer,
+ }
+
+ # If jwks_uri is omitted, the auth library will use OpenID discovery
+ # to find it. Otherwise, include it in the descriptor explicitly.
+ if issuer_value.jwks_uri:
+ result[issuer_key]['x-google-jwks_uri'] = issuer_value.jwks_uri
+
+ return result
+
+ def __get_merged_api_info(self, services):
+ """Builds a description of an API.
+
+ Args:
+ services: List of protorpc.remote.Service instances implementing an
+ api/version.
+
+ Returns:
+ The _ApiInfo object to use for the API that the given services implement.
+
+ Raises:
+ ApiConfigurationError: If there's something wrong with the API
+ configuration, such as a multiclass API decorated with different API
+ descriptors (see the docstring for api()).
+ """
+ merged_api_info = services[0].api_info
+
+ # Verify that, if there are multiple classes here, they're allowed to
+ # implement the same API.
+ for service in services[1:]:
+ if not merged_api_info.is_same_api(service.api_info):
+ raise api_exceptions.ApiConfigurationError(
+ _MULTICLASS_MISMATCH_ERROR_TEMPLATE % (service.api_info.name,
+ service.api_info.api_version))
+
+ return merged_api_info
+
+ def __api_openapi_descriptor(self, services, hostname=None, x_google_api_name=False):
+ """Builds an OpenAPI description of an API.
+
+ Args:
+ services: List of protorpc.remote.Service instances implementing an
+ api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ A dictionary that can be deserialized into JSON and stored as an API
+ description document in OpenAPI format.
+
+ Raises:
+ ApiConfigurationError: If there's something wrong with the API
+ configuration, such as a multiclass API decorated with different API
+ descriptors (see the docstring for api()), or a repeated method
+ signature.
+ """
+ merged_api_info = self.__get_merged_api_info(services)
+ descriptor = self.get_descriptor_defaults(merged_api_info,
+ hostname=hostname,
+ x_google_api_name=x_google_api_name)
+
+ description = merged_api_info.description
+ if not description and len(services) == 1:
+ description = services[0].__doc__
+ if description:
+ descriptor['info']['description'] = description
+
+ security_definitions = self.__security_definitions_descriptor(
+ merged_api_info.issuers)
+
+ method_map = {}
+ method_collision_tracker = {}
+ rest_collision_tracker = {}
+
+ for service in services:
+ remote_methods = service.all_remote_methods()
+
+ for protorpc_meth_name in sorted(remote_methods.keys()):
+ protorpc_meth_info = remote_methods[protorpc_meth_name]
+ method_info = getattr(protorpc_meth_info, 'method_info', None)
+ # Skip methods that are not decorated with @method
+ if method_info is None:
+ continue
+ method_id = method_info.method_id(service.api_info)
+ is_api_key_required = method_info.is_api_key_required(service.api_info)
+ path = '/{0}/{1}/{2}'.format(merged_api_info.name,
+ merged_api_info.path_version,
+ method_info.get_path(service.api_info))
+ verb = method_info.http_method.lower()
+
+ if path not in method_map:
+ method_map[path] = {}
+
+ # If an API key is required and the security definitions don't already
+ # have the apiKey issuer, add the appropriate notation now
+ if is_api_key_required and _API_KEY not in security_definitions:
+ security_definitions[_API_KEY] = {
+ 'type': 'apiKey',
+ 'name': _API_KEY_PARAM,
+ 'in': 'query'
+ }
+
+ # Derive an OperationId from the method name data
+ operation_id = self._construct_operation_id(
+ service.__name__, protorpc_meth_name)
+
+ method_map[path][verb] = self.__method_descriptor(
+ service, method_info, operation_id, protorpc_meth_info,
+ security_definitions)
+
+ # Make sure the same method name isn't repeated.
+ if method_id in method_collision_tracker:
+ raise api_exceptions.ApiConfigurationError(
+ 'Method %s used multiple times, in classes %s and %s' %
+ (method_id, method_collision_tracker[method_id],
+ service.__name__))
+ else:
+ method_collision_tracker[method_id] = service.__name__
+
+ # Make sure the same HTTP method & path aren't repeated.
+ rest_identifier = (method_info.http_method,
+ method_info.get_path(service.api_info))
+ if rest_identifier in rest_collision_tracker:
+ raise api_exceptions.ApiConfigurationError(
+ '%s path "%s" used multiple times, in classes %s and %s' %
+ (method_info.http_method, method_info.get_path(service.api_info),
+ rest_collision_tracker[rest_identifier],
+ service.__name__))
+ else:
+ rest_collision_tracker[rest_identifier] = service.__name__
+
+ if method_map:
+ descriptor['paths'] = method_map
+
+ # Add request and/or response definitions, if any
+ definitions = self.__definitions_descriptor()
+ if definitions:
+ descriptor['definitions'] = definitions
+
+ descriptor['securityDefinitions'] = security_definitions
+
+ # Add quota limit metric definitions, if any
+ limit_definitions = self.__x_google_quota_definitions_descriptor(
+ merged_api_info.limit_definitions)
+ if limit_definitions:
+ descriptor['x-google-management'] = limit_definitions
+
+ return descriptor
+
+ def get_descriptor_defaults(self, api_info, hostname=None, x_google_api_name=False):
+ """Gets a default configuration for a service.
+
+ Args:
+ api_info: _ApiInfo object for this service.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ A dictionary with the default configuration.
+ """
+ hostname = (hostname or util.get_app_hostname() or
+ api_info.hostname)
+ protocol = 'http' if ((hostname and hostname.startswith('localhost')) or
+ util.is_running_on_devserver()) else 'https'
+ base_path = api_info.base_path
+ if base_path != '/':
+ base_path = base_path.rstrip('/')
+ defaults = {
+ 'swagger': '2.0',
+ 'info': {
+ 'version': api_info.api_version,
+ 'title': api_info.name
+ },
+ 'host': hostname,
+ 'consumes': ['application/json'],
+ 'produces': ['application/json'],
+ 'schemes': [protocol],
+ 'basePath': base_path,
+ }
+
+ if x_google_api_name:
+ defaults['x-google-api-name'] = _validate_api_name(api_info.name)
+
+ return defaults
+
+ def get_openapi_dict(self, services, hostname=None, x_google_api_name=False):
+ """JSON dict description of a protorpc.remote.Service in OpenAPI format.
+
+ Args:
+ services: Either a single protorpc.remote.Service or a list of them
+ that implements an api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ dict, The OpenAPI descriptor document as a JSON dict.
+ """
+
+ if not isinstance(services, (tuple, list)):
+ services = [services]
+
+ # The type of a class that inherits from remote.Service is actually
+ # remote._ServiceClass, thanks to metaclass strangeness.
+ # pylint: disable=protected-access
+ util.check_list_type(services, remote._ServiceClass, 'services',
+ allow_none=False)
+
+ return self.__api_openapi_descriptor(services, hostname=hostname, x_google_api_name=x_google_api_name)
+
+ def pretty_print_config_to_json(self, services, hostname=None, x_google_api_name=False):
+ """JSON string description of a protorpc.remote.Service in OpenAPI format.
+
+ Args:
+ services: Either a single protorpc.remote.Service or a list of them
+ that implements an api/version.
+ hostname: string, Hostname of the API, to override the value set on the
+ current service. Defaults to None.
+
+ Returns:
+ string, The OpenAPI descriptor document as a JSON string.
+ """
+ descriptor = self.get_openapi_dict(services, hostname, x_google_api_name=x_google_api_name)
+ return json.dumps(descriptor, sort_keys=True, indent=2,
+ separators=(',', ': '))
+
+
+def hashfunc(string):
+ return hashlib.md5(string).hexdigest()[:8]
diff --git a/third_party/endpoints/parameter_converter.py b/third_party/endpoints/parameter_converter.py
new file mode 100644
index 0000000..5e2743f
--- /dev/null
+++ b/third_party/endpoints/parameter_converter.py
@@ -0,0 +1,200 @@
+# 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.
+
+"""Helper that converts parameter values to the type expected by the API.
+
+Parameter values that appear in the URL and the query string are usually
+converted to native types before being passed to the backend. This code handles
+that conversion and some validation.
+"""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+from . import errors
+
+__all__ = ['transform_parameter_value']
+
+
+def _check_enum(parameter_name, value, parameter_config):
+ """Checks if an enum value is valid.
+
+ This is called by the transform_parameter_value function and shouldn't be
+ called directly.
+
+ This verifies that the value of an enum parameter is valid.
+
+ Args:
+ parameter_name: A string containing the name of the parameter, which is
+ either just a variable name or the name with the index appended. For
+ example 'var' or 'var[2]'.
+ value: A string containing the value passed in for the parameter.
+ parameter_config: The dictionary containing information specific to the
+ parameter in question. This is retrieved from request.parameters in
+ the method config.
+
+ Raises:
+ EnumRejectionError: If the given value is not among the accepted
+ enum values in the field parameter.
+ """
+ enum_values = [enum['backendValue']
+ for enum in parameter_config['enum'].values()
+ if 'backendValue' in enum]
+ if value not in enum_values:
+ raise errors.EnumRejectionError(parameter_name, value, enum_values)
+
+
+def _check_boolean(parameter_name, value, parameter_config):
+ """Checks if a boolean value is valid.
+
+ This is called by the transform_parameter_value function and shouldn't be
+ called directly.
+
+ This checks that the string value passed in can be converted to a valid
+ boolean value.
+
+ Args:
+ parameter_name: A string containing the name of the parameter, which is
+ either just a variable name or the name with the index appended. For
+ example 'var' or 'var[2]'.
+ value: A string containing the value passed in for the parameter.
+ parameter_config: The dictionary containing information specific to the
+ parameter in question. This is retrieved from request.parameters in
+ the method config.
+
+ Raises:
+ BasicTypeParameterError: If the given value is not a valid boolean
+ value.
+ """
+ if parameter_config.get('type') != 'boolean':
+ return
+
+ if value.lower() not in ('1', 'true', '0', 'false'):
+ raise errors.BasicTypeParameterError(parameter_name, value, 'boolean')
+
+
+def _convert_boolean(value):
+ """Convert a string to a boolean value the same way the server does.
+
+ This is called by the transform_parameter_value function and shouldn't be
+ called directly.
+
+ Args:
+ value: A string value to be converted to a boolean.
+
+ Returns:
+ True or False, based on whether the value in the string would be interpreted
+ as true or false by the server. In the case of an invalid entry, this
+ returns False.
+ """
+ if value.lower() in ('1', 'true'):
+ return True
+ return False
+
+
+# Map to convert parameters from strings to their desired back-end format.
+# Anything not listed here will remain a string. Note that the server
+# keeps int64 and uint64 as strings when passed to the backend.
+# This maps a type name from the .api method configuration to a (validation
+# function, conversion function, descriptive type name) tuple. The
+# descriptive type name is only used in conversion error messages, and the
+# names here are chosen to match the error messages from the server.
+# Note that the 'enum' entry is special cased. Enums have 'type': 'string',
+# so we have special case code to recognize them and use the 'enum' map
+# entry.
+_PARAM_CONVERSION_MAP = {'boolean': (_check_boolean,
+ _convert_boolean,
+ 'boolean'),
+ 'int32': (None, int, 'integer'),
+ 'uint32': (None, int, 'integer'),
+ 'float': (None, float, 'float'),
+ 'double': (None, float, 'double'),
+ 'enum': (_check_enum, None, None)}
+
+
+def _get_parameter_conversion_entry(parameter_config):
+ """Get information needed to convert the given parameter to its API type.
+
+ Args:
+ parameter_config: The dictionary containing information specific to the
+ parameter in question. This is retrieved from request.parameters in the
+ method config.
+
+ Returns:
+ The entry from _PARAM_CONVERSION_MAP with functions/information needed to
+ validate and convert the given parameter from a string to the type expected
+ by the API.
+ """
+ entry = _PARAM_CONVERSION_MAP.get(parameter_config.get('type'))
+
+ # Special handling for enum parameters. An enum's type is 'string', so we
+ # need to detect them by the presence of an 'enum' property in their
+ # configuration.
+ if entry is None and 'enum' in parameter_config:
+ entry = _PARAM_CONVERSION_MAP['enum']
+
+ return entry
+
+
+def transform_parameter_value(parameter_name, value, parameter_config):
+ """Validates and transforms parameters to the type expected by the API.
+
+ If the value is a list this will recursively call _transform_parameter_value
+ on the values in the list. Otherwise, it checks all parameter rules for the
+ the current value and converts its type from a string to whatever format
+ the API expects.
+
+ In the list case, '[index-of-value]' is appended to the parameter name for
+ error reporting purposes.
+
+ Args:
+ parameter_name: A string containing the name of the parameter, which is
+ either just a variable name or the name with the index appended, in the
+ recursive case. For example 'var' or 'var[2]'.
+ value: A string or list of strings containing the value(s) passed in for
+ the parameter. These are the values from the request, to be validated,
+ transformed, and passed along to the backend.
+ parameter_config: The dictionary containing information specific to the
+ parameter in question. This is retrieved from request.parameters in the
+ method config.
+
+ Returns:
+ The converted parameter value(s). Not all types are converted, so this
+ may be the same string that's passed in.
+ """
+ if isinstance(value, list):
+ # We're only expecting to handle path and query string parameters here.
+ # The way path and query string parameters are passed in, they'll likely
+ # only be single values or singly-nested lists (no lists nested within
+ # lists). But even if there are nested lists, we'd want to preserve that
+ # structure. These recursive calls should preserve it and convert all
+ # parameter values. See the docstring for information about the parameter
+ # renaming done here.
+ return [transform_parameter_value('%s[%d]' % (parameter_name, index),
+ element, parameter_config)
+ for index, element in enumerate(value)]
+
+ # Validate and convert the parameter value.
+ entry = _get_parameter_conversion_entry(parameter_config)
+ if entry:
+ validation_func, conversion_func, type_name = entry
+ if validation_func:
+ validation_func(parameter_name, value, parameter_config)
+ if conversion_func:
+ try:
+ return conversion_func(value)
+ except ValueError:
+ raise errors.BasicTypeParameterError(parameter_name, value, type_name)
+
+ return value
diff --git a/third_party/endpoints/protojson.py b/third_party/endpoints/protojson.py
new file mode 100644
index 0000000..6f0b2f9
--- /dev/null
+++ b/third_party/endpoints/protojson.py
@@ -0,0 +1,108 @@
+# 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.
+
+"""Endpoints-specific implementation of ProtoRPC's ProtoJson class."""
+from __future__ import absolute_import
+
+import base64
+
+from protorpc import protojson
+
+from . import messages
+
+# pylint: disable=g-bad-name
+
+
+__all__ = ['EndpointsProtoJson']
+
+
+class EndpointsProtoJson(protojson.ProtoJson):
+ """Endpoints-specific implementation of ProtoRPC's ProtoJson class.
+
+ We need to adjust the way some types of data are encoded to ensure they're
+ consistent with the existing API pipeline. This class adjusts the JSON
+ encoding as needed.
+
+ This may be used in a multithreaded environment, so take care to ensure
+ that this class (and its parent, protojson.ProtoJson) remain thread-safe.
+ """
+
+ def encode_field(self, field, value):
+ """Encode a python field value to a JSON value.
+
+ Args:
+ field: A ProtoRPC field instance.
+ value: A python value supported by field.
+
+ Returns:
+ A JSON serializable value appropriate for field.
+ """
+ # Override the handling of 64-bit integers, so they're always encoded
+ # as strings.
+ if (isinstance(field, messages.IntegerField) and
+ field.variant in (messages.Variant.INT64,
+ messages.Variant.UINT64,
+ messages.Variant.SINT64)):
+ if value not in (None, [], ()):
+ # Convert and replace the value.
+ if isinstance(value, list):
+ value = [str(subvalue) for subvalue in value]
+ else:
+ value = str(value)
+ return value
+
+ return super(EndpointsProtoJson, self).encode_field(field, value)
+
+ @staticmethod
+ def __pad_value(value, pad_len_multiple, pad_char):
+ """Add padding characters to the value if needed.
+
+ Args:
+ value: The string value to be padded.
+ pad_len_multiple: Pad the result so its length is a multiple
+ of pad_len_multiple.
+ pad_char: The character to use for padding.
+
+ Returns:
+ The string value with padding characters added.
+ """
+ assert pad_len_multiple > 0
+ assert len(pad_char) == 1
+ padding_length = (pad_len_multiple -
+ (len(value) % pad_len_multiple)) % pad_len_multiple
+ return value + pad_char * padding_length
+
+ def decode_field(self, field, value):
+ """Decode a JSON value to a python value.
+
+ Args:
+ field: A ProtoRPC field instance.
+ value: A serialized JSON value.
+
+ Returns:
+ A Python value compatible with field.
+ """
+ # Override BytesField handling. Client libraries typically use a url-safe
+ # encoding. b64decode doesn't handle these gracefully. urlsafe_b64decode
+ # handles both cases safely. Also add padding if the padding is incorrect.
+ if isinstance(field, messages.BytesField):
+ try:
+ # Need to call str(value) because ProtoRPC likes to pass values
+ # as unicode, and urlsafe_b64decode can only handle bytes.
+ padded_value = self.__pad_value(str(value), 4, '=')
+ return base64.urlsafe_b64decode(padded_value)
+ except (TypeError, UnicodeEncodeError) as err:
+ raise messages.DecodeError('Base64 decoding error: %s' % err)
+
+ return super(EndpointsProtoJson, self).decode_field(field, value)
diff --git a/third_party/endpoints/proxy.html b/third_party/endpoints/proxy.html
new file mode 100644
index 0000000..cb9d96f
--- /dev/null
+++ b/third_party/endpoints/proxy.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title></title>
+<meta http-equiv="X-UA-Compatible" content="IE=edge" />
+<!--
+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.
+-->
+<script type="text/javascript">
+ window['startup'] = function() {
+ googleapis.server.init();
+ };
+</script>
+<script type="text/javascript"
+ src="https://apis.google.com/js/googleapis.proxy.js?onload=startup" async defer></script>
+</head>
+<body>
+</body>
+</html>
diff --git a/third_party/endpoints/resource_container.py b/third_party/endpoints/resource_container.py
new file mode 100644
index 0000000..19519db
--- /dev/null
+++ b/third_party/endpoints/resource_container.py
@@ -0,0 +1,218 @@
+# 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.
+
+"""Module for a class that contains a request body resource and parameters."""
+from __future__ import absolute_import
+
+from . import message_types
+from . import messages
+
+
+class ResourceContainer(object):
+ """Container for a request body resource combined with parameters.
+
+ Used for API methods which may also have path or query parameters in addition
+ to a request body.
+
+ Attributes:
+ body_message_class: A message class to represent a request body.
+ parameters_message_class: A placeholder message class for request
+ parameters.
+ """
+
+ __remote_info_cache = {} # pylint: disable=g-bad-name
+
+ __combined_message_class = None # pylint: disable=invalid-name
+
+ def __init__(self, _body_message_class=message_types.VoidMessage, **kwargs):
+ """Constructor for ResourceContainer.
+
+ Stores a request body message class and attempts to create one from the
+ keyword arguments passed in.
+
+ Args:
+ _body_message_class: A keyword argument to be treated like a positional
+ argument. This will not conflict with the potential names of fields
+ since they can't begin with underscore. We make this a keyword
+ argument since the default VoidMessage is a very common choice given
+ the prevalence of GET methods.
+ **kwargs: Keyword arguments specifying field names (the named arguments)
+ and instances of ProtoRPC fields as the values.
+ """
+ self.body_message_class = _body_message_class
+ self.parameters_message_class = type('ParameterContainer',
+ (messages.Message,), kwargs)
+
+ @property
+ def combined_message_class(self):
+ """A ProtoRPC message class with both request and parameters fields.
+
+ Caches the result in a local private variable. Uses _CopyField to create
+ copies of the fields from the existing request and parameters classes since
+ those fields are "owned" by the message classes.
+
+ Raises:
+ TypeError: If a field name is used in both the request message and the
+ parameters but the two fields do not represent the same type.
+
+ Returns:
+ Value of combined message class for this property.
+ """
+ if self.__combined_message_class is not None:
+ return self.__combined_message_class
+
+ fields = {}
+ # We don't need to preserve field.number since this combined class is only
+ # used for the protorpc remote.method and is not needed for the API config.
+ # The only place field.number matters is in parameterOrder, but this is set
+ # based on container.parameters_message_class which will use the field
+ # numbers originally passed in.
+
+ # Counter for fields.
+ field_number = 1
+ for field in self.body_message_class.all_fields():
+ fields[field.name] = _CopyField(field, number=field_number)
+ field_number += 1
+ for field in self.parameters_message_class.all_fields():
+ if field.name in fields:
+ if not _CompareFields(field, fields[field.name]):
+ raise TypeError('Field %r contained in both parameters and request '
+ 'body, but the fields differ.' % (field.name,))
+ else:
+ # Skip a field that's already there.
+ continue
+ fields[field.name] = _CopyField(field, number=field_number)
+ field_number += 1
+
+ self.__combined_message_class = type('CombinedContainer',
+ (messages.Message,), fields)
+ return self.__combined_message_class
+
+ @classmethod
+ def add_to_cache(cls, remote_info, container): # pylint: disable=g-bad-name
+ """Adds a ResourceContainer to a cache tying it to a protorpc method.
+
+ Args:
+ remote_info: Instance of protorpc.remote._RemoteMethodInfo corresponding
+ to a method.
+ container: An instance of ResourceContainer.
+
+ Raises:
+ TypeError: if the container is not an instance of cls.
+ KeyError: if the remote method has been reference by a container before.
+ This created remote method should never occur because a remote method
+ is created once.
+ """
+ if not isinstance(container, cls):
+ raise TypeError('%r not an instance of %r, could not be added to cache.' %
+ (container, cls))
+ if remote_info in cls.__remote_info_cache:
+ raise KeyError('Cache has collision but should not.')
+ cls.__remote_info_cache[remote_info] = container
+
+ @classmethod
+ def get_request_message(cls, remote_info): # pylint: disable=g-bad-name
+ """Gets request message or container from remote info.
+
+ Args:
+ remote_info: Instance of protorpc.remote._RemoteMethodInfo corresponding
+ to a method.
+
+ Returns:
+ Either an instance of the request type from the remote or the
+ ResourceContainer that was cached with the remote method.
+ """
+ if remote_info in cls.__remote_info_cache:
+ return cls.__remote_info_cache[remote_info]
+ else:
+ return remote_info.request_type()
+
+
+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 _CompareFields(field, other_field):
+ """Checks if two ProtoRPC fields are "equal".
+
+ Compares the arguments, rather than the id of the elements (which is
+ the default __eq__ behavior) as well as the class of the fields.
+
+ Args:
+ field: A ProtoRPC message field to be compared.
+ other_field: A ProtoRPC message field to be compared.
+
+ Returns:
+ Boolean indicating whether the fields are equal.
+ """
+ field_attrs = _GetFieldAttributes(field)
+ other_field_attrs = _GetFieldAttributes(other_field)
+ if field_attrs != other_field_attrs:
+ return False
+ return field.__class__ == other_field.__class__
+
+
+def _CopyField(field, number=None):
+ """Copies a (potentially) owned ProtoRPC field instance into a new copy.
+
+ Args:
+ field: A ProtoRPC message field to be copied.
+ number: An integer for the field to override the number of the field.
+ Defaults to None.
+
+ Raises:
+ TypeError: If the field is not an instance of messages.Field.
+
+ Returns:
+ A copy of the ProtoRPC message field.
+ """
+ positional_args, kwargs = _GetFieldAttributes(field)
+ number = number or field.number
+ positional_args.append(number)
+ return field.__class__(*positional_args, **kwargs)
diff --git a/third_party/endpoints/types.py b/third_party/endpoints/types.py
new file mode 100644
index 0000000..e6291fd
--- /dev/null
+++ b/third_party/endpoints/types.py
@@ -0,0 +1,57 @@
+# 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.
+
+"""Provide various utility/container types needed by Endpoints Framework.
+
+Putting them in this file makes it easier to avoid circular imports,
+as well as keep from complicating tests due to importing code that
+uses App Engine apis.
+"""
+
+from __future__ import absolute_import
+
+import six
+
+import attr
+
+__all__ = [
+ 'OAuth2Scope', 'Issuer', 'LimitDefinition', 'Namespace',
+]
+
+
+@attr.s(frozen=True, slots=True)
+class OAuth2Scope(object):
+ scope = attr.ib(validator=attr.validators.instance_of(six.string_types))
+ description = attr.ib(validator=attr.validators.instance_of(six.string_types))
+
+ @classmethod
+ def convert_scope(cls, scope):
+ "Convert string scopes into OAuth2Scope objects."
+ if isinstance(scope, cls):
+ return scope
+ return cls(scope=scope, description=scope)
+
+ @classmethod
+ def convert_list(cls, values):
+ "Convert a list of scopes into a list of OAuth2Scope objects."
+ if values is not None:
+ return [cls.convert_scope(value) for value in values]
+
+Issuer = attr.make_class('Issuer', ['issuer', 'jwks_uri'])
+LimitDefinition = attr.make_class('LimitDefinition', ['metric_name',
+ 'display_name',
+ 'default_limit'])
+Namespace = attr.make_class('Namespace', ['owner_domain',
+ 'owner_name',
+ 'package_path'])
diff --git a/third_party/endpoints/users_id_token.py b/third_party/endpoints/users_id_token.py
new file mode 100644
index 0000000..2080805
--- /dev/null
+++ b/third_party/endpoints/users_id_token.py
@@ -0,0 +1,844 @@
+# 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.
+
+"""Utility library for reading user information from an id_token.
+
+This is an experimental library that can temporarily be used to extract
+a user from an id_token. The functionality provided by this library
+will be provided elsewhere in the future.
+"""
+
+from __future__ import absolute_import
+
+import base64
+import binascii
+import hmac
+import json
+import logging
+import os
+import re
+import six
+import time
+from six.moves import urllib
+from collections.abc import Container as _Container
+from collections.abc import Iterable as _Iterable
+from collections.abc import Mapping as _Mapping
+
+from google.appengine.api import memcache
+from google.appengine.api import oauth
+from google.appengine.api import urlfetch
+from google.appengine.api import users
+
+from . import constants
+from . import types as endpoints_types
+
+try:
+ # PyCrypto may not be installed for the import_aeta_test or in dev's
+ # individual Python installations. It is available on AppEngine in prod.
+
+ # Disable "Import not at top of file" warning.
+ # pylint: disable=g-import-not-at-top
+ from Crypto.Hash import SHA256
+ from Crypto.PublicKey import RSA
+ # pylint: enable=g-import-not-at-top
+ _CRYPTO_LOADED = True
+except ImportError:
+ _CRYPTO_LOADED = False
+
+
+__all__ = [
+ 'convert_jwks_uri',
+ 'get_current_user',
+ 'get_verified_jwt',
+ 'InvalidGetUserCall',
+ 'SKIP_CLIENT_ID_CHECK',
+]
+
+_logger = logging.getLogger(__name__)
+
+SKIP_CLIENT_ID_CHECK = ['*'] # This needs to be a list, for comparisons.
+_CLOCK_SKEW_SECS = 300 # 5 minutes in seconds
+_MAX_TOKEN_LIFETIME_SECS = 86400 # 1 day in seconds
+_DEFAULT_CERT_URI = ('https://www.googleapis.com/service_accounts/v1/metadata/'
+ 'raw/federated-signon@system.gserviceaccount.com')
+_ENDPOINTS_USER_INFO = 'google.api.auth.user_info'
+_ENV_USE_OAUTH_SCOPE = 'ENDPOINTS_USE_OAUTH_SCOPE'
+_ENV_AUTH_EMAIL = 'ENDPOINTS_AUTH_EMAIL'
+_ENV_AUTH_DOMAIN = 'ENDPOINTS_AUTH_DOMAIN'
+_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
+_TOKENINFO_URL = 'https://www.googleapis.com/oauth2/v3/tokeninfo'
+_MAX_AGE_REGEX = re.compile(r'\s*max-age\s*=\s*(\d+)\s*')
+_CERT_NAMESPACE = '__verify_jwt'
+_ISSUERS = ('accounts.google.com', 'https://accounts.google.com')
+_DEFAULT_GOOGLE_ISSUER = {
+ 'google_id_token': endpoints_types.Issuer(_ISSUERS, _DEFAULT_CERT_URI)
+}
+
+
+class _AppIdentityError(Exception):
+ pass
+
+
+class InvalidGetUserCall(Exception):
+ """Called get_current_user when the environment was not set up for it."""
+
+
+# pylint: disable=g-bad-name
+def get_current_user():
+ """Get user information from the id_token or oauth token in the request.
+
+ This should only be called from within an Endpoints request handler,
+ decorated with an @endpoints.method decorator. The decorator should include
+ the https://www.googleapis.com/auth/userinfo.email scope.
+
+ If `endpoints_management.control.wsgi.AuthenticationMiddleware` is enabled,
+ this returns the user info decoded by the middleware. Otherwise, if the
+ current request uses an id_token, this validates and parses the token against
+ the info in the current request handler and returns the user. Or, for an
+ Oauth token, this call validates the token against the tokeninfo endpoint and
+ oauth.get_current_user with the scopes provided in the method's decorator.
+
+ Returns:
+ None if there is no token or it's invalid. If the token was valid, this
+ returns a User. Only the user's email field is guaranteed to be set.
+ Other fields may be empty.
+
+ Raises:
+ InvalidGetUserCall: if the environment variables necessary to determine the
+ endpoints user are not set. These are typically set when processing a
+ request using an Endpoints handler. If they are not set, it likely
+ indicates that this function was called from outside an Endpoints request
+ handler.
+ """
+ if not _is_auth_info_available():
+ raise InvalidGetUserCall('No valid endpoints user in environment.')
+
+ if _ENDPOINTS_USER_INFO in os.environ:
+ user_info = os.environ[_ENDPOINTS_USER_INFO]
+ return users.User(user_info.email)
+
+ if _ENV_USE_OAUTH_SCOPE in os.environ:
+ # We can get more information from the oauth.get_current_user function,
+ # as long as we know what scope to use. Since that scope has been
+ # cached, we can just return this:
+ return oauth.get_current_user(os.environ[_ENV_USE_OAUTH_SCOPE].split())
+
+ if (_ENV_AUTH_EMAIL in os.environ and
+ _ENV_AUTH_DOMAIN in os.environ):
+ if not os.environ[_ENV_AUTH_EMAIL]:
+ # Either there was no id token or we were unable to validate it,
+ # so there's no user.
+ return None
+
+ return users.User(os.environ[_ENV_AUTH_EMAIL],
+ os.environ[_ENV_AUTH_DOMAIN] or None)
+
+ # Shouldn't hit this, because all the _is_auth_info_available cases were
+ # checked, but just in case.
+ return None
+
+
+# pylint: disable=g-bad-name
+def _is_auth_info_available():
+ """Check if user auth info has been set in environment variables."""
+ return (_ENDPOINTS_USER_INFO in os.environ or
+ (_ENV_AUTH_EMAIL in os.environ and _ENV_AUTH_DOMAIN in os.environ) or
+ _ENV_USE_OAUTH_SCOPE in os.environ)
+
+
+def _maybe_set_current_user_vars(method, api_info=None, request=None):
+ """Get user information from the id_token or oauth token in the request.
+
+ Used internally by Endpoints to set up environment variables for user
+ authentication.
+
+ Args:
+ method: The class method that's handling this request. This method
+ should be annotated with @endpoints.method.
+ api_info: An api_config._ApiInfo instance. Optional. If None, will attempt
+ to parse api_info from the implicit instance of the method.
+ request: The current request, or None.
+ """
+ if _is_auth_info_available():
+ return
+
+ # By default, there's no user.
+ os.environ[_ENV_AUTH_EMAIL] = ''
+ os.environ[_ENV_AUTH_DOMAIN] = ''
+
+ # Choose settings on the method, if specified. Otherwise, choose settings
+ # from the API. Specifically check for None, so that methods can override
+ # with empty lists.
+ try:
+ api_info = api_info or method.im_self.api_info
+ except AttributeError:
+ # The most common case for this is someone passing an unbound method
+ # to this function, which most likely only happens in our unit tests.
+ # We could propagate the exception, but this results in some really
+ # difficult to debug behavior. Better to log a warning and pretend
+ # there are no API-level settings.
+ _logger.warning('AttributeError when accessing %s.im_self. An unbound '
+ 'method was probably passed as an endpoints handler.',
+ method.__name__)
+ scopes = method.method_info.scopes
+ audiences = method.method_info.audiences
+ allowed_client_ids = method.method_info.allowed_client_ids
+ else:
+ scopes = (method.method_info.scopes
+ if method.method_info.scopes is not None
+ else api_info.scopes)
+ audiences = (method.method_info.audiences
+ if method.method_info.audiences is not None
+ else api_info.audiences)
+ allowed_client_ids = (method.method_info.allowed_client_ids
+ if method.method_info.allowed_client_ids is not None
+ else api_info.allowed_client_ids)
+
+ if not scopes and not audiences and not allowed_client_ids:
+ # The user hasn't provided any information to allow us to parse either
+ # an id_token or an Oauth token. They appear not to be interested in
+ # auth.
+ return
+
+ token = _get_token(request)
+ if not token:
+ return None
+
+ if allowed_client_ids and _is_local_dev():
+ allowed_client_ids = (constants.API_EXPLORER_CLIENT_ID,) + tuple(allowed_client_ids)
+
+ # When every item in the acceptable scopes list is
+ # "https://www.googleapis.com/auth/userinfo.email", and there is a non-empty
+ # allowed_client_ids list, the API code will first attempt OAuth 2/OpenID
+ # Connect ID token processing for any incoming bearer token.
+ if ((scopes == [_EMAIL_SCOPE] or scopes == (_EMAIL_SCOPE,)) and
+ allowed_client_ids):
+ _logger.debug('Checking for id_token.')
+ issuers = api_info.issuers
+ if issuers is None:
+ issuers = _DEFAULT_GOOGLE_ISSUER
+ elif 'google_id_token' not in issuers:
+ issuers.update(_DEFAULT_GOOGLE_ISSUER)
+ time_now = int(time.time())
+ user = _get_id_token_user(token, issuers, audiences, allowed_client_ids,
+ time_now, memcache)
+ if user:
+ os.environ[_ENV_AUTH_EMAIL] = user.email()
+ os.environ[_ENV_AUTH_DOMAIN] = user.auth_domain()
+ return
+
+ # Check if the user is interested in an oauth token.
+ if scopes:
+ _logger.debug('Checking for oauth token.')
+ if _is_local_dev():
+ _set_bearer_user_vars_local(token, allowed_client_ids, scopes)
+ else:
+ _set_bearer_user_vars(allowed_client_ids, scopes)
+
+
+def _get_token(
+ request=None, allowed_auth_schemes=('OAuth', 'Bearer'),
+ allowed_query_keys=('bearer_token', 'access_token')):
+ """Get the auth token for this request.
+
+ Auth token may be specified in either the Authorization header or
+ as a query param (either access_token or bearer_token). We'll check in
+ this order:
+ 1. Authorization header.
+ 2. bearer_token query param.
+ 3. access_token query param.
+
+ Args:
+ request: The current request, or None.
+
+ Returns:
+ The token in the request or None.
+ """
+ allowed_auth_schemes = _listlike_guard(
+ allowed_auth_schemes, 'allowed_auth_schemes', iterable_only=True)
+ # Check if the token is in the Authorization header.
+ auth_header = os.environ.get('HTTP_AUTHORIZATION')
+ if auth_header:
+ for auth_scheme in allowed_auth_schemes:
+ if auth_header.startswith(auth_scheme):
+ return auth_header[len(auth_scheme) + 1:]
+ # If an auth header was specified, even if it's an invalid one, we won't
+ # look for the token anywhere else.
+ return None
+
+ # Check if the token is in the query string.
+ if request:
+ allowed_query_keys = _listlike_guard(
+ allowed_query_keys, 'allowed_query_keys', iterable_only=True)
+ for key in allowed_query_keys:
+ token, _ = request.get_unrecognized_field_info(key)
+ if token:
+ return token
+
+
+def _get_id_token_user(token, issuers, audiences, allowed_client_ids, time_now, cache):
+ """Get a User for the given id token, if the token is valid.
+
+ Args:
+ token: The id_token to check.
+ issuers: dict of Issuers
+ audiences: List of audiences that are acceptable.
+ allowed_client_ids: List of client IDs that are acceptable.
+ time_now: The current time as an int (eg. int(time.time())).
+ cache: Cache to use (eg. the memcache module).
+
+ Returns:
+ A User if the token is valid, None otherwise.
+ """
+ # Verify that the token is valid before we try to extract anything from it.
+ # This verifies the signature and some of the basic info in the token.
+ for issuer_key, issuer in issuers.items():
+ issuer_cert_uri = convert_jwks_uri(issuer.jwks_uri)
+ try:
+ parsed_token = _verify_signed_jwt_with_certs(
+ token, time_now, cache, cert_uri=issuer_cert_uri)
+ except Exception: # pylint: disable=broad-except
+ _logger.debug(
+ 'id_token verification failed for issuer %s', issuer_key, exc_info=True)
+ continue
+
+ issuer_values = _listlike_guard(issuer.issuer, 'issuer', log_warning=False)
+ if isinstance(audiences, _Mapping):
+ audiences = audiences[issuer_key]
+ if _verify_parsed_token(
+ parsed_token, issuer_values, audiences, allowed_client_ids,
+ # There's some special handling we do for Google issuers.
+ # ESP doesn't do this, and it's both unnecessary and invalid for other issuers.
+ # So we'll turn it off except in the Google issuer case.
+ is_legacy_google_auth=(issuer.issuer == _ISSUERS)):
+ email = parsed_token['email']
+ # The token might have an id, but it's a Gaia ID that's been
+ # obfuscated with the Focus key, rather than the AppEngine (igoogle)
+ # key. If the developer ever put this email into the user DB
+ # and retrieved the ID from that, it'd be different from the ID we'd
+ # return here, so it's safer to not return the ID.
+ # Instead, we'll only return the email.
+ return users.User(email)
+
+
+# pylint: disable=unused-argument
+def _set_oauth_user_vars(token_info, audiences, allowed_client_ids, scopes,
+ local_dev):
+ _logger.warning('_set_oauth_user_vars is deprecated and will be removed '
+ 'soon.')
+ return _set_bearer_user_vars(allowed_client_ids, scopes)
+# pylint: enable=unused-argument
+
+
+def _process_scopes(scopes):
+ """Parse a scopes list into a set of all scopes and a set of sufficient scope sets.
+
+ scopes: A list of strings, each of which is a space-separated list of scopes.
+ Examples: ['scope1']
+ ['scope1', 'scope2']
+ ['scope1', 'scope2 scope3']
+
+ Returns:
+ all_scopes: a set of strings, each of which is one scope to check for
+ sufficient_scopes: a set of sets of strings; each inner set is
+ a set of scopes which are sufficient for access.
+ Example: {{'scope1'}, {'scope2', 'scope3'}}
+ """
+ all_scopes = set()
+ sufficient_scopes = set()
+ for scope_set in scopes:
+ scope_set_scopes = frozenset(scope_set.split())
+ all_scopes.update(scope_set_scopes)
+ sufficient_scopes.add(scope_set_scopes)
+ return all_scopes, sufficient_scopes
+
+
+def _are_scopes_sufficient(authorized_scopes, sufficient_scopes):
+ """Check if a list of authorized scopes satisfies any set of sufficient scopes.
+
+ Args:
+ authorized_scopes: a list of strings, return value from oauth.get_authorized_scopes
+ sufficient_scopes: a set of sets of strings, return value from _process_scopes
+ """
+ for sufficient_scope_set in sufficient_scopes:
+ if sufficient_scope_set.issubset(authorized_scopes):
+ return True
+ return False
+
+
+
+def _set_bearer_user_vars(allowed_client_ids, scopes):
+ """Validate the oauth bearer token and set endpoints auth user variables.
+
+ If the bearer token is valid, this sets ENDPOINTS_USE_OAUTH_SCOPE. This
+ provides enough information that our endpoints.get_current_user() function
+ can get the user.
+
+ Args:
+ allowed_client_ids: List of client IDs that are acceptable.
+ scopes: List of acceptable scopes.
+ """
+ all_scopes, sufficient_scopes = _process_scopes(scopes)
+ try:
+ authorized_scopes = oauth.get_authorized_scopes(sorted(all_scopes))
+ except oauth.Error:
+ _logger.debug('Unable to get authorized scopes.', exc_info=True)
+ return
+ if not _are_scopes_sufficient(authorized_scopes, sufficient_scopes):
+ _logger.warning('Authorized scopes did not satisfy scope requirements.')
+ return
+ client_id = oauth.get_client_id(authorized_scopes)
+
+ # The client ID must be in allowed_client_ids. If allowed_client_ids is
+ # empty, don't allow any client ID. If allowed_client_ids is set to
+ # SKIP_CLIENT_ID_CHECK, all client IDs will be allowed.
+ if (list(allowed_client_ids) != SKIP_CLIENT_ID_CHECK and
+ client_id not in allowed_client_ids):
+ _logger.warning('Client ID is not allowed: %s', client_id)
+ return
+
+ os.environ[_ENV_USE_OAUTH_SCOPE] = ' '.join(authorized_scopes)
+ _logger.debug('get_current_user() will return user from matched oauth_user.')
+
+
+def _set_bearer_user_vars_local(token, allowed_client_ids, scopes):
+ """Validate the oauth bearer token on the dev server.
+
+ Since the functions in the oauth module return only example results in local
+ development, this hits the tokeninfo endpoint and attempts to validate the
+ token. If it's valid, we'll set _ENV_AUTH_EMAIL and _ENV_AUTH_DOMAIN so we
+ can get the user from the token.
+
+ Args:
+ token: String with the oauth token to validate.
+ allowed_client_ids: List of client IDs that are acceptable.
+ scopes: List of acceptable scopes.
+ """
+ # Get token info from the tokeninfo endpoint.
+ result = urlfetch.fetch(
+ '%s?%s' % (_TOKENINFO_URL, urllib.parse.urlencode({'access_token': token})))
+ if result.status_code != 200:
+ try:
+ error_description = json.loads(result.content)['error_description']
+ except (ValueError, KeyError):
+ error_description = ''
+ _logger.error('Token info endpoint returned status %s: %s',
+ result.status_code, error_description)
+ return
+ token_info = json.loads(result.content)
+
+ # Validate email.
+ if 'email' not in token_info:
+ _logger.warning('Oauth token doesn\'t include an email address.')
+ return
+ if token_info.get('email_verified') != 'true':
+ _logger.warning('Oauth token email isn\'t verified.')
+ return
+
+ # Validate client ID.
+ client_id = token_info.get('azp')
+ if (list(allowed_client_ids) != SKIP_CLIENT_ID_CHECK and
+ client_id not in allowed_client_ids):
+ _logger.warning('Client ID is not allowed: %s', client_id)
+ return
+
+ # Verify at least one of the scopes matches.
+ _, sufficient_scopes = _process_scopes(scopes)
+ authorized_scopes = token_info.get('scope', '').split(' ')
+ if not _are_scopes_sufficient(authorized_scopes, sufficient_scopes):
+ _logger.warning('Oauth token scopes don\'t match any acceptable scopes.')
+ return
+
+ os.environ[_ENV_AUTH_EMAIL] = token_info['email']
+ os.environ[_ENV_AUTH_DOMAIN] = ''
+ _logger.debug('Local dev returning user from token.')
+
+
+def _is_local_dev():
+ return os.environ.get('SERVER_SOFTWARE', '').startswith('Development')
+
+
+def _verify_parsed_token(parsed_token, issuers, audiences, allowed_client_ids, is_legacy_google_auth=True):
+ """Verify a parsed user ID token.
+
+ Args:
+ parsed_token: The parsed token information.
+ issuers: A list of allowed issuers
+ audiences: The allowed audiences.
+ allowed_client_ids: The allowed client IDs.
+
+ Returns:
+ True if the token is verified, False otherwise.
+ """
+ # Verify the issuer.
+ if parsed_token.get('iss') not in issuers:
+ _logger.warning('Issuer was not valid: %s', parsed_token.get('iss'))
+ return False
+
+ # Check audiences.
+ aud = parsed_token.get('aud')
+ if not aud:
+ _logger.warning('No aud field in token')
+ return False
+ # Special legacy handling if aud == cid. This occurs with iOS and browsers.
+ # As long as audience == client_id and cid is allowed, we need to accept
+ # the audience for compatibility.
+ cid = parsed_token.get('azp')
+ audience_allowed = (aud in audiences) or (is_legacy_google_auth and aud == cid)
+ if not audience_allowed:
+ _logger.warning('Audience not allowed: %s', aud)
+ return False
+
+ # Check allowed client IDs, for legacy auth.
+ if is_legacy_google_auth:
+ if list(allowed_client_ids) == SKIP_CLIENT_ID_CHECK:
+ _logger.warning('Client ID check can\'t be skipped for ID tokens. '
+ 'Id_token cannot be verified.')
+ return False
+ elif not cid or cid not in allowed_client_ids:
+ _logger.warning('Client ID is not allowed: %s', cid)
+ return False
+
+ if 'email' not in parsed_token:
+ return False
+
+ return True
+
+
+def _urlsafe_b64decode(b64string):
+ # Guard against unicode strings, which base64 can't handle.
+ b64string = six.ensure_binary(b64string, 'ascii')
+ padded = b64string + '=' * ((4 - len(b64string)) % 4)
+ return base64.urlsafe_b64decode(padded)
+
+
+def _get_cert_expiration_time(headers):
+ """Get the expiration time for a cert, given the response headers.
+
+ Get expiration time from the headers in the result. If we can't get
+ a time from the headers, this returns 0, indicating that the cert
+ shouldn't be cached.
+
+ Args:
+ headers: A dict containing the response headers from the request to get
+ certs.
+
+ Returns:
+ An integer with the number of seconds the cert should be cached. This
+ value is guaranteed to be >= 0.
+ """
+ # Check the max age of the cert.
+ cache_control = headers.get('Cache-Control', '')
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 indicates only
+ # a comma-separated header is valid, so it should be fine to split this on
+ # commas.
+ for entry in cache_control.split(','):
+ match = _MAX_AGE_REGEX.match(entry)
+ if match:
+ cache_time_seconds = int(match.group(1))
+ break
+ else:
+ return 0
+
+ # Subtract the cert's age.
+ age = headers.get('Age')
+ if age is not None:
+ try:
+ age = int(age)
+ except ValueError:
+ age = 0
+ cache_time_seconds -= age
+
+ return max(0, cache_time_seconds)
+
+
+def _get_cached_certs(cert_uri, cache):
+ """Get certs from cache if present; otherwise, gets from URI and caches them.
+
+ Args:
+ cert_uri: URI from which to retrieve certs if cache is stale or empty.
+ cache: Cache of pre-fetched certs.
+
+ Returns:
+ The retrieved certs.
+ """
+ certs = cache.get(cert_uri, namespace=_CERT_NAMESPACE)
+ if certs is None:
+ _logger.debug('Cert cache miss for %s', cert_uri)
+ try:
+ result = urlfetch.fetch(cert_uri)
+ except AssertionError:
+ # This happens in unit tests. Act as if we couldn't get any certs.
+ return None
+
+ if result.status_code == 200:
+ certs = json.loads(result.content)
+ expiration_time_seconds = _get_cert_expiration_time(result.headers)
+ if expiration_time_seconds:
+ cache.set(cert_uri, certs, time=expiration_time_seconds,
+ namespace=_CERT_NAMESPACE)
+ else:
+ _logger.error(
+ 'Certs not available, HTTP request returned %d', result.status_code)
+
+ return certs
+
+
+def _b64_to_int(b):
+ b = six.ensure_binary(b, 'ascii')
+ b += b'=' * ((4 - len(b)) % 4)
+ b = base64.b64decode(b)
+ return int(binascii.hexlify(b), 16)
+
+
+def _verify_signed_jwt_with_certs(
+ jwt, time_now, cache,
+ cert_uri=_DEFAULT_CERT_URI):
+ """Verify a JWT against public certs.
+
+ See http://self-issued.info/docs/draft-jones-json-web-token.html.
+
+ The PyCrypto library included with Google App Engine is severely limited and
+ so you have to use it very carefully to verify JWT signatures. The first
+ issue is that the library can't read X.509 files, so we make a call to a
+ special URI that has the public cert in modulus/exponent form in JSON.
+
+ The second issue is that the RSA.verify method doesn't work, at least for
+ how the JWT tokens are signed, so we have to manually verify the signature
+ of the JWT, which means hashing the signed part of the JWT and comparing
+ that to the signature that's been encrypted with the public key.
+
+ Args:
+ jwt: string, A JWT.
+ time_now: The current time, as an int (eg. int(time.time())).
+ cache: Cache to use (eg. the memcache module).
+ cert_uri: string, URI to get cert modulus and exponent in JSON format.
+
+ Returns:
+ dict, The deserialized JSON payload in the JWT.
+
+ Raises:
+ _AppIdentityError: if any checks are failed.
+ """
+
+ segments = jwt.split('.')
+
+ if len(segments) != 3:
+ # Note that anywhere we print the jwt or its json body, we need to use
+ # %r instead of %s, so that non-printable characters are escaped safely.
+ raise _AppIdentityError('Token is not an id_token (Wrong number of '
+ 'segments)')
+ signed = '%s.%s' % (segments[0], segments[1])
+
+ signature = _urlsafe_b64decode(segments[2])
+
+ # pycrypto only deals in integers, so we have to convert the string of bytes
+ # into an int.
+ lsignature = int(binascii.hexlify(signature), 16)
+
+ # Verify expected header.
+ header_body = _urlsafe_b64decode(segments[0])
+ try:
+ header = json.loads(header_body)
+ except:
+ raise _AppIdentityError("Can't parse header")
+ if header.get('alg') != 'RS256':
+ raise _AppIdentityError('Unexpected encryption algorithm: %r' %
+ header.get('alg'))
+
+ # Formerly we would parse the token body here.
+ # However, it's not safe to do that without first checking the signature.
+
+ certs = _get_cached_certs(cert_uri, cache)
+ if certs is None:
+ raise _AppIdentityError(
+ 'Unable to retrieve certs needed to verify the signed JWT')
+
+ # Verify that we were able to load the Crypto libraries, before we try
+ # to use them.
+ if not _CRYPTO_LOADED:
+ raise _AppIdentityError('Unable to load pycrypto library. Can\'t verify '
+ 'id_token signature. See http://www.pycrypto.org '
+ 'for more information on pycrypto.')
+
+ # SHA256 hash of the already 'signed' segment from the JWT. Since a SHA256
+ # hash, will always have length 64.
+ local_hash = SHA256.new(signed).hexdigest()
+
+ # Check signature.
+ verified = False
+ for keyvalue in certs['keyvalues']:
+ try:
+ modulus = _b64_to_int(keyvalue['modulus'])
+ exponent = _b64_to_int(keyvalue['exponent'])
+ key = RSA.construct((modulus, exponent))
+
+ # Encrypt, and convert to a hex string.
+ hexsig = '%064x' % key.encrypt(lsignature, '')[0]
+ # Make sure we have only last 64 base64 chars
+ hexsig = hexsig[-64:]
+
+ # Check the signature on 'signed' by encrypting 'signature' with the
+ # public key and confirming the result matches the SHA256 hash of
+ # 'signed'. hmac.compare_digest(a, b) is used to avoid timing attacks.
+ verified = hmac.compare_digest(hexsig, local_hash)
+ if verified:
+ break
+ except Exception as e: # pylint: disable=broad-except
+ # Log the exception for debugging purpose.
+ _logger.debug(
+ 'Signature verification error: %s; continuing with the next cert.', e)
+ continue
+ if not verified:
+ raise _AppIdentityError('Invalid token signature')
+
+ # Parse token.
+ json_body = _urlsafe_b64decode(segments[1])
+ try:
+ parsed = json.loads(json_body)
+ except:
+ raise _AppIdentityError("Can't parse token body")
+
+ # Check creation timestamp.
+ iat = parsed.get('iat')
+ if iat is None:
+ raise _AppIdentityError('No iat field in token')
+ earliest = iat - _CLOCK_SKEW_SECS
+
+ # Check expiration timestamp.
+ exp = parsed.get('exp')
+ if exp is None:
+ raise _AppIdentityError('No exp field in token')
+ if exp >= time_now + _MAX_TOKEN_LIFETIME_SECS:
+ raise _AppIdentityError('exp field too far in future')
+ latest = exp + _CLOCK_SKEW_SECS
+
+ if time_now < earliest:
+ raise _AppIdentityError('Token used too early, %d < %d' %
+ (time_now, earliest))
+ if time_now > latest:
+ raise _AppIdentityError('Token used too late, %d > %d' %
+ (time_now, latest))
+
+ return parsed
+
+
+_TEXT_CERT_PREFIX = 'https://www.googleapis.com/robot/v1/metadata/x509/'
+_JSON_CERT_PREFIX = 'https://www.googleapis.com/service_accounts/v1/metadata/raw/'
+
+
+def convert_jwks_uri(jwks_uri):
+ """
+ The PyCrypto library included with Google App Engine is severely limited and
+ can't read X.509 files, so we change the URI to a special URI that has the
+ public cert in modulus/exponent form in JSON.
+ """
+ if not jwks_uri.startswith(_TEXT_CERT_PREFIX):
+ return jwks_uri
+ return jwks_uri.replace(_TEXT_CERT_PREFIX, _JSON_CERT_PREFIX)
+
+
+def get_verified_jwt(
+ providers, audiences,
+ check_authorization_header=True, check_query_arg=True,
+ request=None, cache=memcache):
+ """
+ This function will extract, verify, and parse a JWT token from the
+ Authorization header or access_token query argument.
+
+ The JWT is assumed to contain an issuer and audience claim, as well
+ as issued-at and expiration timestamps. The signature will be
+ cryptographically verified, the claims and timestamps will be
+ checked, and the resulting parsed JWT body is returned.
+
+ If at any point the JWT is missing or found to be invalid, the
+ return result will be None.
+
+ Arguments:
+ providers - An iterable of dicts each containing 'issuer' and 'cert_uri' keys
+ audiences - An iterable of valid audiences
+
+ check_authorization_header - Boolean; check 'Authorization: Bearer' header
+ check_query_arg - Boolean; check 'access_token' query arg
+
+ request - Must be the request object if check_query_arg is true; otherwise ignored.
+ cache - In testing, override the certificate cache
+ """
+ if not (check_authorization_header or check_query_arg):
+ raise ValueError(
+ 'Either check_authorization_header or check_query_arg must be True.')
+ if check_query_arg and request is None:
+ raise ValueError('Cannot check query arg without request object.')
+ schemes = ('Bearer',) if check_authorization_header else ()
+ keys = ('access_token',) if check_query_arg else ()
+ token = _get_token(
+ request=request, allowed_auth_schemes=schemes, allowed_query_keys=keys)
+ if token is None:
+ return None
+ time_now = int(time.time())
+ for provider in providers:
+ parsed_token = _parse_and_verify_jwt(
+ token, time_now, (provider['issuer'],), audiences, provider['cert_uri'], cache)
+ if parsed_token is not None:
+ return parsed_token
+ return None
+
+
+def _parse_and_verify_jwt(token, time_now, issuers, audiences, cert_uri, cache):
+ try:
+ parsed_token = _verify_signed_jwt_with_certs(token, time_now, cache, cert_uri)
+ except (_AppIdentityError, TypeError) as e:
+ _logger.debug('id_token verification failed: %s', e)
+ return None
+
+ issuers = _listlike_guard(issuers, 'issuers')
+ audiences = _listlike_guard(audiences, 'audiences')
+ # We can't use _verify_parsed_token because there's no client id (azp) or email in these JWTs
+ # Verify the issuer.
+ if parsed_token.get('iss') not in issuers:
+ _logger.warning('Issuer was not valid: %s', parsed_token.get('iss'))
+ return None
+
+ # Check audiences.
+ aud = parsed_token.get('aud')
+ if not aud:
+ _logger.warning('No aud field in token')
+ return None
+ if aud not in audiences:
+ _logger.warning('Audience not allowed: %s', aud)
+ return None
+
+ return parsed_token
+
+
+def _listlike_guard(obj, name, iterable_only=False, log_warning=True):
+ """
+ We frequently require passed objects to support iteration or
+ containment expressions, but not be strings. (Of course, strings
+ support iteration and containment, but not usefully.) If the passed
+ object is a string, we'll wrap it in a tuple and return it. If it's
+ already an iterable, we'll return it as-is. Otherwise, we'll raise a
+ TypeError.
+ """
+ required_type = (_Iterable,) if iterable_only else (_Container, _Iterable)
+ required_type_name = ' or '.join(t.__name__ for t in required_type)
+
+ if not isinstance(obj, required_type):
+ raise ValueError('{} must be of type {}'.format(name, required_type_name))
+ # at this point it is definitely the right type, but might be a string
+ if isinstance(obj, six.string_types):
+ if log_warning:
+ _logger.warning('{} passed as a string; should be list-like'.format(name))
+ return (obj,)
+ return obj
diff --git a/third_party/endpoints/util.py b/third_party/endpoints/util.py
new file mode 100644
index 0000000..fe883d0
--- /dev/null
+++ b/third_party/endpoints/util.py
@@ -0,0 +1,300 @@
+# 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.
+
+"""Helper utilities for the endpoints package."""
+
+# pylint: disable=g-bad-name
+from __future__ import absolute_import
+
+from six.moves import cStringIO
+import json
+import os
+import wsgiref.headers
+
+from google.appengine.api import app_identity
+from google.appengine.api.modules import modules
+
+
+class StartResponseProxy(object):
+ """Proxy for the typical WSGI start_response object."""
+
+ def __init__(self):
+ self.call_context = {}
+ self.body_buffer = cStringIO.StringIO()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ # Close out the cStringIO.StringIO buffer to prevent memory leakage.
+ if self.body_buffer:
+ self.body_buffer.close()
+
+ def Proxy(self, status, headers, exc_info=None):
+ """Save args, defer start_response until response body is parsed.
+
+ Create output buffer for body to be written into.
+ Note: this is not quite WSGI compliant: The body should come back as an
+ iterator returned from calling service_app() but instead, StartResponse
+ returns a writer that will be later called to output the body.
+ See google/appengine/ext/webapp/__init__.py::Response.wsgi_write()
+ write = start_response('%d %s' % self.__status, self.__wsgi_headers)
+ write(body)
+
+ Args:
+ status: Http status to be sent with this response
+ headers: Http headers to be sent with this response
+ exc_info: Exception info to be displayed for this response
+ Returns:
+ callable that takes as an argument the body content
+ """
+ self.call_context['status'] = status
+ self.call_context['headers'] = headers
+ self.call_context['exc_info'] = exc_info
+
+ return self.body_buffer.write
+
+ @property
+ def response_body(self):
+ return self.body_buffer.getvalue()
+
+ @property
+ def response_headers(self):
+ return self.call_context.get('headers')
+
+ @property
+ def response_status(self):
+ return self.call_context.get('status')
+
+ @property
+ def response_exc_info(self):
+ return self.call_context.get('exc_info')
+
+
+def send_wsgi_not_found_response(start_response, cors_handler=None):
+ return send_wsgi_response('404 Not Found', [('Content-Type', 'text/plain')],
+ 'Not Found', start_response,
+ cors_handler=cors_handler)
+
+
+def send_wsgi_error_response(message, start_response, cors_handler=None):
+ body = json.dumps({'error': {'message': message}})
+ return send_wsgi_response('500', [('Content-Type', 'application/json')], body,
+ start_response, cors_handler=cors_handler)
+
+
+def send_wsgi_rejected_response(rejection_error, start_response,
+ cors_handler=None):
+ body = rejection_error.to_json()
+ return send_wsgi_response('400', [('Content-Type', 'application/json')], body,
+ start_response, cors_handler=cors_handler)
+
+
+def send_wsgi_redirect_response(redirect_location, start_response,
+ cors_handler=None):
+ return send_wsgi_response('302', [('Location', redirect_location)], '',
+ start_response, cors_handler=cors_handler)
+
+
+def send_wsgi_no_content_response(start_response, cors_handler=None):
+ return send_wsgi_response('204 No Content', [], '', start_response,
+ cors_handler)
+
+
+def send_wsgi_response(status, headers, content, start_response,
+ cors_handler=None):
+ """Dump reformatted response to CGI start_response.
+
+ This calls start_response and returns the response body.
+
+ Args:
+ status: A string containing the HTTP status code to send.
+ headers: A list of (header, value) tuples, the headers to send in the
+ response.
+ content: A string containing the body content to write.
+ start_response: A function with semantics defined in PEP-333.
+ cors_handler: A handler to process CORS request headers and update the
+ headers in the response. Or this can be None, to bypass CORS checks.
+
+ Returns:
+ A string containing the response body.
+ """
+ if cors_handler:
+ cors_handler.update_headers(headers)
+
+ # Update content length.
+ content_len = len(content) if content else 0
+ headers = [(header, value) for header, value in headers
+ if header.lower() != 'content-length']
+ headers.append(('Content-Length', '%s' % content_len))
+
+ start_response(status, headers)
+ return content
+
+
+def get_headers_from_environ(environ):
+ """Get a wsgiref.headers.Headers object with headers from the environment.
+
+ Headers in environ are prefixed with 'HTTP_', are all uppercase, and have
+ had dashes replaced with underscores. This strips the HTTP_ prefix and
+ changes underscores back to dashes before adding them to the returned set
+ of headers.
+
+ Args:
+ environ: An environ dict for the request as defined in PEP-333.
+
+ Returns:
+ A wsgiref.headers.Headers object that's been filled in with any HTTP
+ headers found in environ.
+ """
+ headers = wsgiref.headers.Headers([])
+ for header, value in environ.items():
+ if header.startswith('HTTP_'):
+ headers[header[5:].replace('_', '-')] = value
+ # Content-Type is special; it does not start with 'HTTP_'.
+ if 'CONTENT_TYPE' in environ:
+ headers['CONTENT-TYPE'] = environ['CONTENT_TYPE']
+ return headers
+
+
+def put_headers_in_environ(headers, environ):
+ """Given a list of headers, put them into environ based on PEP-333.
+
+ This converts headers to uppercase, prefixes them with 'HTTP_', and
+ converts dashes to underscores before adding them to the environ dict.
+
+ Args:
+ headers: A list of (header, value) tuples. The HTTP headers to add to the
+ environment.
+ environ: An environ dict for the request as defined in PEP-333.
+ """
+ for key, value in headers:
+ environ['HTTP_%s' % key.upper().replace('-', '_')] = value
+
+
+def is_running_on_app_engine():
+ return os.environ.get('GAE_MODULE_NAME') is not None
+
+
+def is_running_on_devserver():
+ server_software = os.environ.get('SERVER_SOFTWARE', '')
+ return (server_software.startswith('Development/') and
+ server_software != 'Development/1.0 (testbed)')
+
+
+def is_running_on_localhost():
+ return os.environ.get('SERVER_NAME') == 'localhost'
+
+
+def get_hostname_prefix():
+ """Returns the hostname prefix of a running Endpoints service.
+
+ The prefix is the portion of the hostname that comes before the API name.
+ For example, if a non-default version and a non-default service are in use,
+ the returned result would be '{VERSION}-dot-{SERVICE}-'.
+
+ Returns:
+ str, the hostname prefix.
+ """
+ parts = []
+
+ # Check if this is the default version
+ version = modules.get_current_version_name()
+ default_version = modules.get_default_version()
+ if version != default_version:
+ parts.append(version)
+
+ # Check if this is the default module
+ module = modules.get_current_module_name()
+ if module != 'default':
+ parts.append(module)
+
+ # If there is anything to prepend, add an extra blank entry for the trailing
+ # -dot-
+ if parts:
+ parts.append('')
+
+ return '-dot-'.join(parts)
+
+
+def get_app_hostname():
+ """Return hostname of a running Endpoints service.
+
+ Returns hostname of an running Endpoints API. It can be 1) "localhost:PORT"
+ if running on development server, or 2) "app_id.appspot.com" if running on
+ external app engine prod, or "app_id.googleplex.com" if running as Google
+ first-party Endpoints API, or 4) None if not running on App Engine
+ (e.g. Tornado Endpoints API).
+
+ Returns:
+ A string representing the hostname of the service.
+ """
+ if not is_running_on_app_engine() or is_running_on_localhost():
+ return None
+
+ app_id = app_identity.get_application_id()
+
+ prefix = get_hostname_prefix()
+ suffix = 'appspot.com'
+
+ if ':' in app_id:
+ tokens = app_id.split(':')
+ api_name = tokens[1]
+ if tokens[0] == 'google.com':
+ suffix = 'googleplex.com'
+ else:
+ api_name = app_id
+
+ return '{0}{1}.{2}'.format(prefix, api_name, suffix)
+
+
+def check_list_type(objects, allowed_type, name, allow_none=True):
+ """Verify that objects in list are of the allowed type or raise TypeError.
+
+ Args:
+ objects: The list of objects to check.
+ allowed_type: The allowed type of items in 'settings'.
+ name: Name of the list of objects, added to the exception.
+ allow_none: If set, None is also allowed.
+
+ Raises:
+ TypeError: if object is not of the allowed type.
+
+ Returns:
+ The list of objects, for convenient use in assignment.
+ """
+ if objects is None:
+ if not allow_none:
+ raise TypeError('%s is None, which is not allowed.' % name)
+ return objects
+ if not isinstance(objects, (tuple, list)):
+ raise TypeError('%s is not a list.' % name)
+ if not all(isinstance(i, allowed_type) for i in objects):
+ type_list = sorted(list(set(type(obj) for obj in objects)))
+ raise TypeError('%s contains types that don\'t match %s: %s' %
+ (name, allowed_type.__name__, type_list))
+ return objects
+
+
+def snake_case_to_headless_camel_case(snake_string):
+ """Convert snake_case to headlessCamelCase.
+
+ Args:
+ snake_string: The string to be converted.
+ Returns:
+ The input string converted to headlessCamelCase.
+ """
+ return ''.join([snake_string.split('_')[0]] +
+ list(sub_string.capitalize()
+ for sub_string in snake_string.split('_')[1:]))