blob: 9c8cfcadbbe8736c05e41b1221623a78fa10b367 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# 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
17This library helps you configure a set of ProtoRPC services to act as
18Endpoints backends. In addition to translating ProtoRPC to Endpoints
19compatible 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
61from __future__ import absolute_import
62
63import cgi
64from six.moves import http_client
65import json
66import logging
67import os
68
69from google.appengine.api import app_identity
70
71from . import api_config
72from . import api_exceptions
73from . import endpoints_dispatcher
74from . import message_types
75from . import messages
76from . import protojson
77from . import remote
78from . import util
79
80_logger = logging.getLogger(__name__)
81package = 'google.appengine.endpoints'
82
83
84__all__ = [
85 'ApiConfigRegistry',
86 'api_server',
87 'EndpointsErrorMessage',
88 'package',
89]
90
91
92class _Remapped405Exception(api_exceptions.ServiceException):
93 """Method Not Allowed (405) ends up being remapped to 501.
94
95 This is included here for compatibility with the Java implementation. The
96 Google Cloud Endpoints server remaps HTTP 405 to 501.
97 """
98 http_status = http_client.METHOD_NOT_ALLOWED
99
100
101class _Remapped408Exception(api_exceptions.ServiceException):
102 """Request Timeout (408) ends up being remapped to 503.
103
104 This is included here for compatibility with the Java implementation. The
105 Google Cloud Endpoints server remaps HTTP 408 to 503.
106 """
107 http_status = http_client.REQUEST_TIMEOUT
108
109
110_ERROR_NAME_MAP = dict((http_client.responses[c.http_status], c) for c in [
111 api_exceptions.BadRequestException,
112 api_exceptions.UnauthorizedException,
113 api_exceptions.ForbiddenException,
114 api_exceptions.NotFoundException,
115 _Remapped405Exception,
116 _Remapped408Exception,
117 api_exceptions.ConflictException,
118 api_exceptions.GoneException,
119 api_exceptions.PreconditionFailedException,
120 api_exceptions.RequestEntityTooLargeException,
121 api_exceptions.InternalServerErrorException
122 ])
123
124_ALL_JSON_CONTENT_TYPES = frozenset(
125 [protojson.EndpointsProtoJson.CONTENT_TYPE] +
126 protojson.EndpointsProtoJson.ALTERNATIVE_CONTENT_TYPES)
127
128
129# Message format for returning error back to Google Endpoints frontend.
130class EndpointsErrorMessage(messages.Message):
131 """Message for returning error back to Google Endpoints frontend.
132
133 Fields:
134 state: State of RPC, should be 'APPLICATION_ERROR'.
135 error_message: Error message associated with status.
136 """
137
138 class State(messages.Enum):
139 """Enumeration of possible RPC states.
140
141 Values:
142 OK: Completed successfully.
143 RUNNING: Still running, not complete.
144 REQUEST_ERROR: Request was malformed or incomplete.
145 SERVER_ERROR: Server experienced an unexpected error.
146 NETWORK_ERROR: An error occured on the network.
147 APPLICATION_ERROR: The application is indicating an error.
148 When in this state, RPC should also set application_error.
149 """
150 OK = 0
151 RUNNING = 1
152
153 REQUEST_ERROR = 2
154 SERVER_ERROR = 3
155 NETWORK_ERROR = 4
156 APPLICATION_ERROR = 5
157 METHOD_NOT_FOUND_ERROR = 6
158
159 state = messages.EnumField(State, 1, required=True)
160 error_message = messages.StringField(2)
161
162
163# pylint: disable=g-bad-name
164def _get_app_revision(environ=None):
165 """Gets the app revision (minor app version) of the current app.
166
167 Args:
168 environ: A dictionary with a key CURRENT_VERSION_ID that maps to a version
169 string of the format <major>.<minor>.
170
171 Returns:
172 The app revision (minor version) of the current app, or None if one couldn't
173 be found.
174 """
175 if environ is None:
176 environ = os.environ
177 if 'CURRENT_VERSION_ID' in environ:
178 return environ['CURRENT_VERSION_ID'].split('.')[1]
179
180
181class ApiConfigRegistry(object):
182 """Registry of active APIs"""
183
184 def __init__(self):
185 # Set of API classes that have been registered.
186 self.__registered_classes = set()
187 # Set of API config contents served by this App Engine AppId/version
188 self.__api_configs = []
189 # Map of API method name to ProtoRPC method name.
190 self.__api_methods = {}
191
192 # pylint: disable=g-bad-name
193 def register_backend(self, config_contents):
194 """Register a single API and its config contents.
195
196 Args:
197 config_contents: Dict containing API configuration.
198 """
199 if config_contents is None:
200 return
201 self.__register_class(config_contents)
202 self.__api_configs.append(config_contents)
203 self.__register_methods(config_contents)
204
205 def __register_class(self, parsed_config):
206 """Register the class implementing this config, so we only add it once.
207
208 Args:
209 parsed_config: The JSON object with the API configuration being added.
210
211 Raises:
212 ApiConfigurationError: If the class has already been registered.
213 """
214 methods = parsed_config.get('methods')
215 if not methods:
216 return
217
218 # Determine the name of the class that implements this configuration.
219 service_classes = set()
220 for method in methods.values():
221 rosy_method = method.get('rosyMethod')
222 if rosy_method and '.' in rosy_method:
223 method_class = rosy_method.split('.', 1)[0]
224 service_classes.add(method_class)
225
226 for service_class in service_classes:
227 if service_class in self.__registered_classes:
228 raise api_exceptions.ApiConfigurationError(
229 'API class %s has already been registered.' % service_class)
230 self.__registered_classes.add(service_class)
231
232 def __register_methods(self, parsed_config):
233 """Register all methods from the given api config file.
234
235 Methods are stored in a map from method_name to rosyMethod,
236 the name of the ProtoRPC method to be called on the backend.
237 If no rosyMethod was specified the value will be None.
238
239 Args:
240 parsed_config: The JSON object with the API configuration being added.
241 """
242 methods = parsed_config.get('methods')
243 if not methods:
244 return
245
246 for method_name, method in methods.items():
247 self.__api_methods[method_name] = method.get('rosyMethod')
248
249 def lookup_api_method(self, api_method_name):
250 """Looks an API method up by name to find the backend method to call.
251
252 Args:
253 api_method_name: Name of the method in the API that was called.
254
255 Returns:
256 Name of the ProtoRPC method called on the backend, or None if not found.
257 """
258 return self.__api_methods.get(api_method_name)
259
260 def all_api_configs(self):
261 """Return a list of all API configration specs as registered above."""
262 return self.__api_configs
263
264
265class _ApiServer(object):
266 """ProtoRPC wrapper, registers APIs and formats errors for Google API Server.
267
268 - - - - ProtoRPC error format - - - -
269 HTTP/1.0 400 Please log in as an admin user.
270 content-type: application/json
271
272 {
273 "state": "APPLICATION_ERROR",
274 "error_message": "Please log in as an admin user",
275 "error_name": "unauthorized",
276 }
277
278 - - - - Reformatted error format - - - -
279 HTTP/1.0 401 UNAUTHORIZED
280 content-type: application/json
281
282 {
283 "state": "APPLICATION_ERROR",
284 "error_message": "Please log in as an admin user"
285 }
286 """
287 # Silence lint warning about invalid const name
288 # pylint: disable=g-bad-name
289 __SERVER_SOFTWARE = 'SERVER_SOFTWARE'
290 __HEADER_NAME_PEER = 'HTTP_X_APPENGINE_PEER'
291 __GOOGLE_PEER = 'apiserving'
292 # A common EndpointsProtoJson for all _ApiServer instances. At the moment,
293 # EndpointsProtoJson looks to be thread safe.
294 __PROTOJSON = protojson.EndpointsProtoJson()
295
296 def __init__(self, api_services, **kwargs):
297 """Initialize an _ApiServer instance.
298
299 The primary function of this method is to set up the WSGIApplication
300 instance for the service handlers described by the services passed in.
301 Additionally, it registers each API in ApiConfigRegistry for later use
302 in the BackendService.getApiConfigs() (API config enumeration service).
303
304 Args:
305 api_services: List of protorpc.remote.Service classes implementing the API
306 or a list of _ApiDecorator instances that decorate the service classes
307 for an API.
308 **kwargs: Passed through to protorpc.wsgi.service.service_handlers except:
309 protocols - ProtoRPC protocols are not supported, and are disallowed.
310
311 Raises:
312 TypeError: if protocols are configured (this feature is not supported).
313 ApiConfigurationError: if there's a problem with the API config.
314 """
315 self.base_paths = set()
316
317 for entry in api_services[:]:
318 # pylint: disable=protected-access
319 if isinstance(entry, api_config._ApiDecorator):
320 api_services.remove(entry)
321 api_services.extend(entry.get_api_classes())
322
323 # Record the API services for quick discovery doc generation
324 self.api_services = api_services
325
326 # Record the base paths
327 for entry in api_services:
328 self.base_paths.add(entry.api_info.base_path)
329
330 self.api_config_registry = ApiConfigRegistry()
331 self.api_name_version_map = self.__create_name_version_map(api_services)
332 protorpc_services = self.__register_services(self.api_name_version_map,
333 self.api_config_registry)
334
335 # Disallow protocol configuration for now, Lily is json-only.
336 if 'protocols' in kwargs:
337 raise TypeError('__init__() got an unexpected keyword argument '
338 "'protocols'")
339 protocols = remote.Protocols()
340 protocols.add_protocol(self.__PROTOJSON, 'protojson')
341 remote.Protocols.set_default(protocols)
342
343 # This variable is not used in Endpoints 1.1, but let's pop it out here
344 # so it doesn't result in an unexpected keyword argument downstream.
345 kwargs.pop('restricted', None)
346
347 from protorpc.wsgi import service as wsgi_service
348 self.service_app = wsgi_service.service_mappings(protorpc_services,
349 **kwargs)
350
351 @staticmethod
352 def __create_name_version_map(api_services):
353 """Create a map from API name/version to Service class/factory.
354
355 This creates a map from an API name and version to a list of remote.Service
356 factories that implement that API.
357
358 Args:
359 api_services: A list of remote.Service-derived classes or factories
360 created with remote.Service.new_factory.
361
362 Returns:
363 A mapping from (api name, api version) to a list of service factories,
364 for service classes that implement that API.
365
366 Raises:
367 ApiConfigurationError: If a Service class appears more than once
368 in api_services.
369 """
370 api_name_version_map = {}
371 for service_factory in api_services:
372 try:
373 service_class = service_factory.service_class
374 except AttributeError:
375 service_class = service_factory
376 service_factory = service_class.new_factory()
377
378 key = service_class.api_info.name, service_class.api_info.api_version
379 service_factories = api_name_version_map.setdefault(key, [])
380 if service_factory in service_factories:
381 raise api_config.ApiConfigurationError(
382 'Can\'t add the same class to an API twice: %s' %
383 service_factory.service_class.__name__)
384
385 service_factories.append(service_factory)
386 return api_name_version_map
387
388 @staticmethod
389 def __register_services(api_name_version_map, api_config_registry):
390 """Register & return a list of each URL and class that handles that URL.
391
392 This finds every service class in api_name_version_map, registers it with
393 the given ApiConfigRegistry, builds the URL for that class, and adds
394 the URL and its factory to a list that's returned.
395
396 Args:
397 api_name_version_map: A mapping from (api name, api version) to a list of
398 service factories, as returned by __create_name_version_map.
399 api_config_registry: The ApiConfigRegistry where service classes will
400 be registered.
401
402 Returns:
403 A list of (URL, service_factory) for each service class in
404 api_name_version_map.
405
406 Raises:
407 ApiConfigurationError: If a Service class appears more than once
408 in api_name_version_map. This could happen if one class is used to
409 implement multiple APIs.
410 """
411 generator = api_config.ApiConfigGenerator()
412 protorpc_services = []
413 for service_factories in api_name_version_map.values():
414 service_classes = [service_factory.service_class
415 for service_factory in service_factories]
416 config_dict = generator.get_config_dict(service_classes)
417 api_config_registry.register_backend(config_dict)
418
419 for service_factory in service_factories:
420 protorpc_class_name = service_factory.service_class.__name__
421 root = '%s%s' % (service_factory.service_class.api_info.base_path,
422 protorpc_class_name)
423 if any(service_map[0] == root or service_map[1] == service_factory
424 for service_map in protorpc_services):
425 raise api_config.ApiConfigurationError(
426 'Can\'t reuse the same class in multiple APIs: %s' %
427 protorpc_class_name)
428 protorpc_services.append((root, service_factory))
429 return protorpc_services
430
431 def __is_json_error(self, status, headers):
432 """Determine if response is an error.
433
434 Args:
435 status: HTTP status code.
436 headers: Dictionary of (lowercase) header name to value.
437
438 Returns:
439 True if the response was an error, else False.
440 """
441 content_header = headers.get('content-type', '')
442 content_type, unused_params = cgi.parse_header(content_header)
443 return (status.startswith('400') and
444 content_type.lower() in _ALL_JSON_CONTENT_TYPES)
445
446 def __write_error(self, status_code, error_message=None):
447 """Return the HTTP status line and body for a given error code and message.
448
449 Args:
450 status_code: HTTP status code to be returned.
451 error_message: Error message to be returned.
452
453 Returns:
454 Tuple (http_status, body):
455 http_status: HTTP status line, e.g. 200 OK.
456 body: Body of the HTTP request.
457 """
458 if error_message is None:
459 error_message = http_client.responses[status_code]
460 status = '%d %s' % (status_code, http_client.responses[status_code])
461 message = EndpointsErrorMessage(
462 state=EndpointsErrorMessage.State.APPLICATION_ERROR,
463 error_message=error_message)
464 return status, self.__PROTOJSON.encode_message(message)
465
466 def protorpc_to_endpoints_error(self, status, body):
467 """Convert a ProtoRPC error to the format expected by Google Endpoints.
468
469 If the body does not contain an ProtoRPC message in state APPLICATION_ERROR
470 the status and body will be returned unchanged.
471
472 Args:
473 status: HTTP status of the response from the backend
474 body: JSON-encoded error in format expected by Endpoints frontend.
475
476 Returns:
477 Tuple of (http status, body)
478 """
479 try:
480 rpc_error = self.__PROTOJSON.decode_message(remote.RpcStatus, body)
481 except (ValueError, messages.ValidationError):
482 rpc_error = remote.RpcStatus()
483
484 if rpc_error.state == remote.RpcStatus.State.APPLICATION_ERROR:
485
486 # Try to map to HTTP error code.
487 error_class = _ERROR_NAME_MAP.get(rpc_error.error_name)
488 if error_class:
489 status, body = self.__write_error(error_class.http_status,
490 rpc_error.error_message)
491 return status, body
492
493 def get_api_configs(self):
494 return {
495 'items': self.api_config_registry.all_api_configs()}
496
497 def __call__(self, environ, start_response):
498 """Wrapper for the Endpoints server app.
499
500 Args:
501 environ: WSGI request environment.
502 start_response: WSGI start response function.
503
504 Returns:
505 Response from service_app or appropriately transformed error response.
506 """
507 # Call the ProtoRPC App and capture its response
508 with util.StartResponseProxy() as start_response_proxy:
509 body_iter = self.service_app(environ, start_response_proxy.Proxy)
510 status = start_response_proxy.response_status
511 headers = start_response_proxy.response_headers
512 exception = start_response_proxy.response_exc_info
513
514 # Get response body
515 body = start_response_proxy.response_body
516 # In case standard WSGI behavior is implemented later...
517 if not body:
518 body = ''.join(body_iter)
519
520 # Transform ProtoRPC error into format expected by endpoints.
521 headers_dict = dict([(k.lower(), v) for k, v in headers])
522 if self.__is_json_error(status, headers_dict):
523 status, body = self.protorpc_to_endpoints_error(status, body)
524 # If the content-length header is present, update it with the new
525 # body length.
526 if 'content-length' in headers_dict:
527 for index, (header_name, _) in enumerate(headers):
528 if header_name.lower() == 'content-length':
529 headers[index] = (header_name, str(len(body)))
530 break
531
532 start_response(status, headers, exception)
533 return [body]
534
535
536# Silence lint warning about invalid function name
537# pylint: disable=g-bad-name
538def api_server(api_services, **kwargs):
539 """Create an api_server.
540
541 The primary function of this method is to set up the WSGIApplication
542 instance for the service handlers described by the services passed in.
543 Additionally, it registers each API in ApiConfigRegistry for later use
544 in the BackendService.getApiConfigs() (API config enumeration service).
545 It also configures service control.
546
547 Args:
548 api_services: List of protorpc.remote.Service classes implementing the API
549 or a list of _ApiDecorator instances that decorate the service classes
550 for an API.
551 **kwargs: Passed through to protorpc.wsgi.service.service_handlers except:
552 protocols - ProtoRPC protocols are not supported, and are disallowed.
553
554 Returns:
555 A new WSGIApplication that serves the API backend and config registry.
556
557 Raises:
558 TypeError: if protocols are configured (this feature is not supported).
559 """
560 # Disallow protocol configuration for now, Lily is json-only.
561 if 'protocols' in kwargs:
562 raise TypeError("__init__() got an unexpected keyword argument 'protocols'")
563
564 from . import _logger as endpoints_logger
565 from . import __version__ as endpoints_version
566 endpoints_logger.info('Initializing Endpoints Framework version %s', endpoints_version)
567
568 # Construct the api serving app
569 apis_app = _ApiServer(api_services, **kwargs)
570 dispatcher = endpoints_dispatcher.EndpointsDispatcherMiddleware(apis_app)
571
572 # Determine the service name
573 service_name = os.environ.get('ENDPOINTS_SERVICE_NAME')
574 if not service_name:
575 _logger.warn('Did not specify the ENDPOINTS_SERVICE_NAME environment'
576 ' variable so service control is disabled. Please specify'
577 ' the name of service in ENDPOINTS_SERVICE_NAME to enable'
578 ' it.')
579 return dispatcher
580
581 from endpoints_management.control import client as control_client
582 from endpoints_management.control import wsgi as control_wsgi
583
584 # If we're using a local server, just return the dispatcher now to bypass
585 # control client.
586 if control_wsgi.running_on_devserver():
587 _logger.warn('Running on local devserver, so service control is disabled.')
588 return dispatcher
589
590 from endpoints_management import _logger as management_logger
591 from endpoints_management import __version__ as management_version
592 management_logger.info('Initializing Endpoints Management Framework version %s', management_version)
593
594 # The DEFAULT 'config' should be tuned so that it's always OK for python
595 # App Engine workloads. The config can be adjusted, but that's probably
596 # unnecessary on App Engine.
597 controller = control_client.Loaders.DEFAULT.load(service_name)
598
599 # Start the GAE background thread that powers the control client's cache.
600 control_client.use_gae_thread()
601 controller.start()
602
603 return control_wsgi.add_all(
604 dispatcher,
605 app_identity.get_application_id(),
606 controller)