| # 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) |