OLD | NEW |
(Empty) | |
| 1 # Copyright 2016 Google Inc. All Rights Reserved. |
| 2 # |
| 3 # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 # you may not use this file except in compliance with the License. |
| 5 # You may obtain a copy of the License at |
| 6 # |
| 7 # http://www.apache.org/licenses/LICENSE-2.0 |
| 8 # |
| 9 # Unless required by applicable law or agreed to in writing, software |
| 10 # distributed under the License is distributed on an "AS IS" BASIS, |
| 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 # See the License for the specific language governing permissions and |
| 13 # limitations under the License. |
| 14 |
| 15 """A library supporting use of the Google API Server. |
| 16 |
| 17 This library helps you configure a set of ProtoRPC services to act as |
| 18 Endpoints backends. In addition to translating ProtoRPC to Endpoints |
| 19 compatible errors, it exposes a helper service that describes your services. |
| 20 |
| 21 Usage: |
| 22 1) Create an endpoints.api_server instead of a webapp.WSGIApplication. |
| 23 2) Annotate your ProtoRPC Service class with @endpoints.api to give your |
| 24 API a name, version, and short description |
| 25 3) To return an error from Google API Server raise an endpoints.*Exception |
| 26 The ServiceException classes specify the http status code returned. |
| 27 |
| 28 For example: |
| 29 raise endpoints.UnauthorizedException("Please log in as an admin user") |
| 30 |
| 31 |
| 32 Sample usage: |
| 33 - - - - app.yaml - - - - |
| 34 |
| 35 handlers: |
| 36 # Path to your API backend. |
| 37 # /_ah/api/.* is the default. Using the base_path parameter, you can |
| 38 # customize this to whichever base path you desire. |
| 39 - url: /_ah/api/.* |
| 40 # For the legacy python runtime this would be "script: services.py" |
| 41 script: services.app |
| 42 |
| 43 - - - - services.py - - - - |
| 44 |
| 45 import endpoints |
| 46 import postservice |
| 47 |
| 48 app = endpoints.api_server([postservice.PostService], debug=True) |
| 49 |
| 50 - - - - postservice.py - - - - |
| 51 |
| 52 @endpoints.api(name='guestbook', version='v0.2', description='Guestbook API') |
| 53 class PostService(remote.Service): |
| 54 ... |
| 55 @endpoints.method(GetNotesRequest, Notes, name='notes.list', path='notes', |
| 56 http_method='GET') |
| 57 def list(self, request): |
| 58 raise endpoints.UnauthorizedException("Please log in as an admin user") |
| 59 """ |
| 60 |
| 61 import cgi |
| 62 import httplib |
| 63 import json |
| 64 import logging |
| 65 import os |
| 66 |
| 67 import api_backend_service |
| 68 import api_config |
| 69 import api_exceptions |
| 70 import endpoints_dispatcher |
| 71 import protojson |
| 72 |
| 73 from google.appengine.api import app_identity |
| 74 from google.api.control import client as control_client |
| 75 from google.api.control import wsgi as control_wsgi |
| 76 |
| 77 from protorpc import messages |
| 78 from protorpc import remote |
| 79 from protorpc.wsgi import service as wsgi_service |
| 80 |
| 81 import util |
| 82 |
| 83 |
| 84 _logger = logging.getLogger(__name__) |
| 85 package = 'google.appengine.endpoints' |
| 86 |
| 87 |
| 88 __all__ = [ |
| 89 'api_server', |
| 90 'EndpointsErrorMessage', |
| 91 'package', |
| 92 ] |
| 93 |
| 94 |
| 95 class _Remapped405Exception(api_exceptions.ServiceException): |
| 96 """Method Not Allowed (405) ends up being remapped to 501. |
| 97 |
| 98 This is included here for compatibility with the Java implementation. The |
| 99 Google Cloud Endpoints server remaps HTTP 405 to 501. |
| 100 """ |
| 101 http_status = httplib.METHOD_NOT_ALLOWED |
| 102 |
| 103 |
| 104 class _Remapped408Exception(api_exceptions.ServiceException): |
| 105 """Request Timeout (408) ends up being remapped to 503. |
| 106 |
| 107 This is included here for compatibility with the Java implementation. The |
| 108 Google Cloud Endpoints server remaps HTTP 408 to 503. |
| 109 """ |
| 110 http_status = httplib.REQUEST_TIMEOUT |
| 111 |
| 112 |
| 113 _ERROR_NAME_MAP = dict((httplib.responses[c.http_status], c) for c in [ |
| 114 api_exceptions.BadRequestException, |
| 115 api_exceptions.UnauthorizedException, |
| 116 api_exceptions.ForbiddenException, |
| 117 api_exceptions.NotFoundException, |
| 118 _Remapped405Exception, |
| 119 _Remapped408Exception, |
| 120 api_exceptions.ConflictException, |
| 121 api_exceptions.GoneException, |
| 122 api_exceptions.PreconditionFailedException, |
| 123 api_exceptions.RequestEntityTooLargeException, |
| 124 api_exceptions.InternalServerErrorException |
| 125 ]) |
| 126 |
| 127 _ALL_JSON_CONTENT_TYPES = frozenset( |
| 128 [protojson.EndpointsProtoJson.CONTENT_TYPE] + |
| 129 protojson.EndpointsProtoJson.ALTERNATIVE_CONTENT_TYPES) |
| 130 |
| 131 |
| 132 # Message format for returning error back to Google Endpoints frontend. |
| 133 class EndpointsErrorMessage(messages.Message): |
| 134 """Message for returning error back to Google Endpoints frontend. |
| 135 |
| 136 Fields: |
| 137 state: State of RPC, should be 'APPLICATION_ERROR'. |
| 138 error_message: Error message associated with status. |
| 139 """ |
| 140 |
| 141 class State(messages.Enum): |
| 142 """Enumeration of possible RPC states. |
| 143 |
| 144 Values: |
| 145 OK: Completed successfully. |
| 146 RUNNING: Still running, not complete. |
| 147 REQUEST_ERROR: Request was malformed or incomplete. |
| 148 SERVER_ERROR: Server experienced an unexpected error. |
| 149 NETWORK_ERROR: An error occured on the network. |
| 150 APPLICATION_ERROR: The application is indicating an error. |
| 151 When in this state, RPC should also set application_error. |
| 152 """ |
| 153 OK = 0 |
| 154 RUNNING = 1 |
| 155 |
| 156 REQUEST_ERROR = 2 |
| 157 SERVER_ERROR = 3 |
| 158 NETWORK_ERROR = 4 |
| 159 APPLICATION_ERROR = 5 |
| 160 METHOD_NOT_FOUND_ERROR = 6 |
| 161 |
| 162 state = messages.EnumField(State, 1, required=True) |
| 163 error_message = messages.StringField(2) |
| 164 |
| 165 |
| 166 # pylint: disable=g-bad-name |
| 167 def _get_app_revision(environ=None): |
| 168 """Gets the app revision (minor app version) of the current app. |
| 169 |
| 170 Args: |
| 171 environ: A dictionary with a key CURRENT_VERSION_ID that maps to a version |
| 172 string of the format <major>.<minor>. |
| 173 |
| 174 Returns: |
| 175 The app revision (minor version) of the current app, or None if one couldn't |
| 176 be found. |
| 177 """ |
| 178 if environ is None: |
| 179 environ = os.environ |
| 180 if 'CURRENT_VERSION_ID' in environ: |
| 181 return environ['CURRENT_VERSION_ID'].split('.')[1] |
| 182 |
| 183 |
| 184 class _ApiServer(object): |
| 185 """ProtoRPC wrapper, registers APIs and formats errors for Google API Server. |
| 186 |
| 187 - - - - ProtoRPC error format - - - - |
| 188 HTTP/1.0 400 Please log in as an admin user. |
| 189 content-type: application/json |
| 190 |
| 191 { |
| 192 "state": "APPLICATION_ERROR", |
| 193 "error_message": "Please log in as an admin user", |
| 194 "error_name": "unauthorized", |
| 195 } |
| 196 |
| 197 - - - - Reformatted error format - - - - |
| 198 HTTP/1.0 401 UNAUTHORIZED |
| 199 content-type: application/json |
| 200 |
| 201 { |
| 202 "state": "APPLICATION_ERROR", |
| 203 "error_message": "Please log in as an admin user" |
| 204 } |
| 205 """ |
| 206 # Silence lint warning about invalid const name |
| 207 # pylint: disable=g-bad-name |
| 208 __SERVER_SOFTWARE = 'SERVER_SOFTWARE' |
| 209 __HEADER_NAME_PEER = 'HTTP_X_APPENGINE_PEER' |
| 210 __GOOGLE_PEER = 'apiserving' |
| 211 # A common EndpointsProtoJson for all _ApiServer instances. At the moment, |
| 212 # EndpointsProtoJson looks to be thread safe. |
| 213 __PROTOJSON = protojson.EndpointsProtoJson() |
| 214 |
| 215 def __init__(self, api_services, **kwargs): |
| 216 """Initialize an _ApiServer instance. |
| 217 |
| 218 The primary function of this method is to set up the WSGIApplication |
| 219 instance for the service handlers described by the services passed in. |
| 220 Additionally, it registers each API in ApiConfigRegistry for later use |
| 221 in the BackendService.getApiConfigs() (API config enumeration service). |
| 222 |
| 223 Args: |
| 224 api_services: List of protorpc.remote.Service classes implementing the API |
| 225 or a list of _ApiDecorator instances that decorate the service classes |
| 226 for an API. |
| 227 **kwargs: Passed through to protorpc.wsgi.service.service_handlers except: |
| 228 protocols - ProtoRPC protocols are not supported, and are disallowed. |
| 229 |
| 230 Raises: |
| 231 TypeError: if protocols are configured (this feature is not supported). |
| 232 ApiConfigurationError: if there's a problem with the API config. |
| 233 """ |
| 234 self.base_paths = set() |
| 235 |
| 236 for entry in api_services[:]: |
| 237 # pylint: disable=protected-access |
| 238 if isinstance(entry, api_config._ApiDecorator): |
| 239 api_services.remove(entry) |
| 240 api_services.extend(entry.get_api_classes()) |
| 241 self.base_paths.add(entry.base_path) |
| 242 |
| 243 # Record the API services for quick discovery doc generation |
| 244 self.api_services = api_services |
| 245 |
| 246 # Record the base paths |
| 247 for entry in api_services: |
| 248 self.base_paths.add(entry.api_info.base_path) |
| 249 |
| 250 self.api_config_registry = api_backend_service.ApiConfigRegistry() |
| 251 self.api_name_version_map = self.__create_name_version_map(api_services) |
| 252 protorpc_services = self.__register_services(self.api_name_version_map, |
| 253 self.api_config_registry) |
| 254 |
| 255 # Disallow protocol configuration for now, Lily is json-only. |
| 256 if 'protocols' in kwargs: |
| 257 raise TypeError('__init__() got an unexpected keyword argument ' |
| 258 "'protocols'") |
| 259 protocols = remote.Protocols() |
| 260 protocols.add_protocol(self.__PROTOJSON, 'protojson') |
| 261 remote.Protocols.set_default(protocols) |
| 262 |
| 263 # This variable is not used in Endpoints 1.1, but let's pop it out here |
| 264 # so it doesn't result in an unexpected keyword argument downstream. |
| 265 kwargs.pop('restricted', None) |
| 266 |
| 267 self.service_app = wsgi_service.service_mappings(protorpc_services, |
| 268 **kwargs) |
| 269 |
| 270 @staticmethod |
| 271 def __create_name_version_map(api_services): |
| 272 """Create a map from API name/version to Service class/factory. |
| 273 |
| 274 This creates a map from an API name and version to a list of remote.Service |
| 275 factories that implement that API. |
| 276 |
| 277 Args: |
| 278 api_services: A list of remote.Service-derived classes or factories |
| 279 created with remote.Service.new_factory. |
| 280 |
| 281 Returns: |
| 282 A mapping from (api name, api version) to a list of service factories, |
| 283 for service classes that implement that API. |
| 284 |
| 285 Raises: |
| 286 ApiConfigurationError: If a Service class appears more than once |
| 287 in api_services. |
| 288 """ |
| 289 api_name_version_map = {} |
| 290 for service_factory in api_services: |
| 291 try: |
| 292 service_class = service_factory.service_class |
| 293 except AttributeError: |
| 294 service_class = service_factory |
| 295 service_factory = service_class.new_factory() |
| 296 |
| 297 key = service_class.api_info.name, service_class.api_info.version |
| 298 service_factories = api_name_version_map.setdefault(key, []) |
| 299 if service_factory in service_factories: |
| 300 raise api_config.ApiConfigurationError( |
| 301 'Can\'t add the same class to an API twice: %s' % |
| 302 service_factory.service_class.__name__) |
| 303 |
| 304 service_factories.append(service_factory) |
| 305 return api_name_version_map |
| 306 |
| 307 @staticmethod |
| 308 def __register_services(api_name_version_map, api_config_registry): |
| 309 """Register & return a list of each URL and class that handles that URL. |
| 310 |
| 311 This finds every service class in api_name_version_map, registers it with |
| 312 the given ApiConfigRegistry, builds the URL for that class, and adds |
| 313 the URL and its factory to a list that's returned. |
| 314 |
| 315 Args: |
| 316 api_name_version_map: A mapping from (api name, api version) to a list of |
| 317 service factories, as returned by __create_name_version_map. |
| 318 api_config_registry: The ApiConfigRegistry where service classes will |
| 319 be registered. |
| 320 |
| 321 Returns: |
| 322 A list of (URL, service_factory) for each service class in |
| 323 api_name_version_map. |
| 324 |
| 325 Raises: |
| 326 ApiConfigurationError: If a Service class appears more than once |
| 327 in api_name_version_map. This could happen if one class is used to |
| 328 implement multiple APIs. |
| 329 """ |
| 330 generator = api_config.ApiConfigGenerator() |
| 331 protorpc_services = [] |
| 332 for service_factories in api_name_version_map.itervalues(): |
| 333 service_classes = [service_factory.service_class |
| 334 for service_factory in service_factories] |
| 335 config_file = generator.pretty_print_config_to_json(service_classes) |
| 336 api_config_registry.register_backend(config_file) |
| 337 |
| 338 for service_factory in service_factories: |
| 339 protorpc_class_name = service_factory.service_class.__name__ |
| 340 root = '%s%s' % (service_factory.service_class.api_info.base_path, |
| 341 protorpc_class_name) |
| 342 if any(service_map[0] == root or service_map[1] == service_factory |
| 343 for service_map in protorpc_services): |
| 344 raise api_config.ApiConfigurationError( |
| 345 'Can\'t reuse the same class in multiple APIs: %s' % |
| 346 protorpc_class_name) |
| 347 protorpc_services.append((root, service_factory)) |
| 348 return protorpc_services |
| 349 |
| 350 def __is_json_error(self, status, headers): |
| 351 """Determine if response is an error. |
| 352 |
| 353 Args: |
| 354 status: HTTP status code. |
| 355 headers: Dictionary of (lowercase) header name to value. |
| 356 |
| 357 Returns: |
| 358 True if the response was an error, else False. |
| 359 """ |
| 360 content_header = headers.get('content-type', '') |
| 361 content_type, unused_params = cgi.parse_header(content_header) |
| 362 return (status.startswith('400') and |
| 363 content_type.lower() in _ALL_JSON_CONTENT_TYPES) |
| 364 |
| 365 def __write_error(self, status_code, error_message=None): |
| 366 """Return the HTTP status line and body for a given error code and message. |
| 367 |
| 368 Args: |
| 369 status_code: HTTP status code to be returned. |
| 370 error_message: Error message to be returned. |
| 371 |
| 372 Returns: |
| 373 Tuple (http_status, body): |
| 374 http_status: HTTP status line, e.g. 200 OK. |
| 375 body: Body of the HTTP request. |
| 376 """ |
| 377 if error_message is None: |
| 378 error_message = httplib.responses[status_code] |
| 379 status = '%d %s' % (status_code, httplib.responses[status_code]) |
| 380 message = EndpointsErrorMessage( |
| 381 state=EndpointsErrorMessage.State.APPLICATION_ERROR, |
| 382 error_message=error_message) |
| 383 return status, self.__PROTOJSON.encode_message(message) |
| 384 |
| 385 def protorpc_to_endpoints_error(self, status, body): |
| 386 """Convert a ProtoRPC error to the format expected by Google Endpoints. |
| 387 |
| 388 If the body does not contain an ProtoRPC message in state APPLICATION_ERROR |
| 389 the status and body will be returned unchanged. |
| 390 |
| 391 Args: |
| 392 status: HTTP status of the response from the backend |
| 393 body: JSON-encoded error in format expected by Endpoints frontend. |
| 394 |
| 395 Returns: |
| 396 Tuple of (http status, body) |
| 397 """ |
| 398 try: |
| 399 rpc_error = self.__PROTOJSON.decode_message(remote.RpcStatus, body) |
| 400 except (ValueError, messages.ValidationError): |
| 401 rpc_error = remote.RpcStatus() |
| 402 |
| 403 if rpc_error.state == remote.RpcStatus.State.APPLICATION_ERROR: |
| 404 |
| 405 # Try to map to HTTP error code. |
| 406 error_class = _ERROR_NAME_MAP.get(rpc_error.error_name) |
| 407 if error_class: |
| 408 status, body = self.__write_error(error_class.http_status, |
| 409 rpc_error.error_message) |
| 410 return status, body |
| 411 |
| 412 def get_api_configs(self): |
| 413 return { |
| 414 'items': [json.loads(c) for c in |
| 415 self.api_config_registry.all_api_configs()]} |
| 416 |
| 417 def __call__(self, environ, start_response): |
| 418 """Wrapper for the Endpoints server app. |
| 419 |
| 420 Args: |
| 421 environ: WSGI request environment. |
| 422 start_response: WSGI start response function. |
| 423 |
| 424 Returns: |
| 425 Response from service_app or appropriately transformed error response. |
| 426 """ |
| 427 # Call the ProtoRPC App and capture its response |
| 428 with util.StartResponseProxy() as start_response_proxy: |
| 429 body_iter = self.service_app(environ, start_response_proxy.Proxy) |
| 430 status = start_response_proxy.response_status |
| 431 headers = start_response_proxy.response_headers |
| 432 exception = start_response_proxy.response_exc_info |
| 433 |
| 434 # Get response body |
| 435 body = start_response_proxy.response_body |
| 436 # In case standard WSGI behavior is implemented later... |
| 437 if not body: |
| 438 body = ''.join(body_iter) |
| 439 |
| 440 # Transform ProtoRPC error into format expected by endpoints. |
| 441 headers_dict = dict([(k.lower(), v) for k, v in headers]) |
| 442 if self.__is_json_error(status, headers_dict): |
| 443 status, body = self.protorpc_to_endpoints_error(status, body) |
| 444 # If the content-length header is present, update it with the new |
| 445 # body length. |
| 446 if 'content-length' in headers_dict: |
| 447 for index, (header_name, _) in enumerate(headers): |
| 448 if header_name.lower() == 'content-length': |
| 449 headers[index] = (header_name, str(len(body))) |
| 450 break |
| 451 |
| 452 start_response(status, headers, exception) |
| 453 return [body] |
| 454 |
| 455 |
| 456 # Silence lint warning about invalid function name |
| 457 # pylint: disable=g-bad-name |
| 458 def api_server(api_services, **kwargs): |
| 459 """Create an api_server. |
| 460 |
| 461 The primary function of this method is to set up the WSGIApplication |
| 462 instance for the service handlers described by the services passed in. |
| 463 Additionally, it registers each API in ApiConfigRegistry for later use |
| 464 in the BackendService.getApiConfigs() (API config enumeration service). |
| 465 It also configures service control. |
| 466 |
| 467 Args: |
| 468 api_services: List of protorpc.remote.Service classes implementing the API |
| 469 or a list of _ApiDecorator instances that decorate the service classes |
| 470 for an API. |
| 471 **kwargs: Passed through to protorpc.wsgi.service.service_handlers except: |
| 472 protocols - ProtoRPC protocols are not supported, and are disallowed. |
| 473 |
| 474 Returns: |
| 475 A new WSGIApplication that serves the API backend and config registry. |
| 476 |
| 477 Raises: |
| 478 TypeError: if protocols are configured (this feature is not supported). |
| 479 """ |
| 480 # Disallow protocol configuration for now, Lily is json-only. |
| 481 if 'protocols' in kwargs: |
| 482 raise TypeError("__init__() got an unexpected keyword argument 'protocols'") |
| 483 |
| 484 # Construct the api serving app |
| 485 apis_app = _ApiServer(api_services, **kwargs) |
| 486 dispatcher = endpoints_dispatcher.EndpointsDispatcherMiddleware(apis_app) |
| 487 |
| 488 # Determine the service name |
| 489 service_name = os.environ.get('ENDPOINTS_SERVICE_NAME') |
| 490 if not service_name: |
| 491 _logger.warn('Did not specify the ENDPOINTS_SERVICE_NAME environment' |
| 492 ' variable so service control is disabled. Please specify' |
| 493 ' the name of service in ENDPOINTS_SERVICE_NAME to enable' |
| 494 ' it.') |
| 495 return dispatcher |
| 496 |
| 497 # If we're using a local server, just return the dispatcher now to bypass |
| 498 # control client. |
| 499 if control_wsgi.running_on_devserver(): |
| 500 _logger.warn('Running on local devserver, so service control is disabled.') |
| 501 return dispatcher |
| 502 |
| 503 # The DEFAULT 'config' should be tuned so that it's always OK for python |
| 504 # App Engine workloads. The config can be adjusted, but that's probably |
| 505 # unnecessary on App Engine. |
| 506 controller = control_client.Loaders.DEFAULT.load(service_name) |
| 507 |
| 508 # Start the GAE background thread that powers the control client's cache. |
| 509 control_client.use_gae_thread() |
| 510 controller.start() |
| 511 |
| 512 return control_wsgi.add_all( |
| 513 dispatcher, |
| 514 app_identity.get_application_id(), |
| 515 controller) |
OLD | NEW |