Merge branch 'main' into avm99963-monorail
Merged commit 34d8229ae2b51fb1a15bd208e6fe6185c94f6266
GitOrigin-RevId: 7ee0917f93a577e475f8e09526dd144d245593f4
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)