Index: third_party/google-endpoints/endpoints/apiserving.py |
diff --git a/third_party/google-endpoints/endpoints/apiserving.py b/third_party/google-endpoints/endpoints/apiserving.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..dde0ce09a05fe823aab1a4200e2ad75359face67 |
--- /dev/null |
+++ b/third_party/google-endpoints/endpoints/apiserving.py |
@@ -0,0 +1,515 @@ |
+# 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") |
+""" |
+ |
+import cgi |
+import httplib |
+import json |
+import logging |
+import os |
+ |
+import api_backend_service |
+import api_config |
+import api_exceptions |
+import endpoints_dispatcher |
+import protojson |
+ |
+from google.appengine.api import app_identity |
+from google.api.control import client as control_client |
+from google.api.control import wsgi as control_wsgi |
+ |
+from protorpc import messages |
+from protorpc import remote |
+from protorpc.wsgi import service as wsgi_service |
+ |
+import util |
+ |
+ |
+_logger = logging.getLogger(__name__) |
+package = 'google.appengine.endpoints' |
+ |
+ |
+__all__ = [ |
+ '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 = httplib.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 = httplib.REQUEST_TIMEOUT |
+ |
+ |
+_ERROR_NAME_MAP = dict((httplib.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 _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()) |
+ self.base_paths.add(entry.base_path) |
+ |
+ # 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 = api_backend_service.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) |
+ |
+ 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.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.itervalues(): |
+ service_classes = [service_factory.service_class |
+ for service_factory in service_factories] |
+ config_file = generator.pretty_print_config_to_json(service_classes) |
+ api_config_registry.register_backend(config_file) |
+ |
+ 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 = httplib.responses[status_code] |
+ status = '%d %s' % (status_code, httplib.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': [json.loads(c) for c in |
+ 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'") |
+ |
+ # 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 |
+ |
+ # 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 |
+ |
+ # 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) |