App Engine Python SDK version 1.7.7
[gae.git] / python / google / appengine / tools / dev_appserver_apiserver.py
blobb318f5aca571119e2573a68da541541710408676
1 #!/usr/bin/env python
3 # Copyright 2007 Google Inc.
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
21 """Helper CGI for Apiserver in the development app server.
23 This is a fake apiserver proxy that does simple transforms on requests that
24 come in to /_ah/api and then re-dispatches them to /_ah/spi. It does not do
25 any authentication, quota checking, DoS checking, etc.
27 In addition, the proxy loads api configs from
28 /_ah/spi/BackendService.getApiConfigs prior to making the first call to the
29 backend at /_ah/spi and afterwards if app.yaml is changed.
30 """
32 from __future__ import with_statement
37 import base64
38 import cgi
39 import cStringIO
40 import httplib
41 try:
43 import json
44 except ImportError:
46 import simplejson as json
48 import logging
49 import mimetools
50 import re
53 API_SERVING_PATTERN = '/_ah/api/.*'
59 SPI_ROOT_FORMAT = 'http://127.0.0.1:%s/_ah/spi/%s'
62 _API_REST_PATH_FORMAT = '{!name}/{!version}/%s'
63 _PATH_VARIABLE_PATTERN = r'[a-zA-Z_][a-zA-Z_.\d]*'
64 _RESERVED_PATH_VARIABLE_PATTERN = r'!' + _PATH_VARIABLE_PATTERN
65 _PATH_VALUE_PATTERN = r'[^:/?#\[\]{}]*'
66 _CORS_HEADER_ORIGIN = 'Origin'.lower()
67 _CORS_HEADER_REQUEST_METHOD = 'Access-Control-Request-Method'.lower()
68 _CORS_HEADER_REQUEST_HEADERS = 'Access-Control-Request-Headers'.lower()
69 _CORS_HEADER_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'
70 _CORS_HEADER_ALLOW_METHODS = 'Access-Control-Allow-Methods'
71 _CORS_HEADER_ALLOW_HEADERS = 'Access-Control-Allow-Headers'
72 _CORS_ALLOWED_METHODS = frozenset(('DELETE', 'GET', 'PATCH', 'POST', 'PUT'))
73 _INVALID_ENUM_TEMPLATE = 'Invalid string value: %r. Allowed values: %r'
76 class RequestRejectionError(Exception):
77 """Base class for rejected requests.
79 To be raised when parsing the request values and comparing them against the
80 generated discovery document.
81 """
83 def Message(self): raise NotImplementedError
84 def Errors(self): raise NotImplementedError
86 def ToJson(self):
87 """JSON string representing the rejected value.
89 Calling this will fail on the base class since it relies on Message and
90 Errors being implemented on the class. It is up to a subclass to implement
91 these methods.
93 Returns:
94 JSON string representing the rejected value.
95 """
96 return json.dumps({
97 'error': {
98 'errors': self.Errors(),
99 'code': 400,
100 'message': self.Message(),
105 class EnumRejectionError(RequestRejectionError):
106 """Custom request rejection exception for enum values."""
109 def __init__(self, parameter_name, value, allowed_values):
110 """Constructor for EnumRejectionError.
112 Args:
113 parameter_name: String; the name of the enum parameter which had a value
114 rejected.
115 value: The actual value passed in for the enum. Usually string.
116 allowed_values: List of strings allowed for the enum.
118 self.parameter_name = parameter_name
119 self.value = value
120 self.allowed_values = allowed_values
123 def Message(self):
124 """A descriptive message describing the error."""
125 return _INVALID_ENUM_TEMPLATE % (self.value, self.allowed_values)
129 def Errors(self):
130 """A list containing the errors associated with the rejection.
132 Intended to mimic those returned from an API in production in Google's API
133 infrastructure.
135 Returns:
136 A list with a single element that is a dictionary containing the error
137 information.
139 return [
141 'domain': 'global',
142 'reason': 'invalidParameter',
143 'message': self.Message(),
144 'locationType': 'parameter',
145 'location': self.parameter_name,
150 class ApiRequest(object):
151 """Simple data object representing an API request.
153 Takes an app_server CGI request and environment in the constructor.
154 Parses the request into convenient pieces and stores them as members.
156 API_PREFIX = '/_ah/api/'
158 def __init__(self, base_env_dict, dev_appserver, request=None):
159 """Constructor.
161 Args:
162 base_env_dict: Dictionary of CGI environment parameters.
163 dev_appserver: used to call standard SplitURL method.
164 request: AppServerRequest. Can be None.
166 self.cgi_env = base_env_dict
167 self.headers = {}
168 self.http_method = base_env_dict['REQUEST_METHOD']
169 self.port = base_env_dict['SERVER_PORT']
170 if request:
171 self.path, self.query = dev_appserver.SplitURL(request.relative_url)
174 self.body = request.infile.read()
175 for header in request.headers.headers:
176 header_name, header_value = header.split(':', 1)
177 self.headers[header_name.strip()] = header_value.strip()
178 else:
179 self.body = ''
180 self.path = self.API_PREFIX
181 self.query = ''
182 assert self.path.startswith(self.API_PREFIX)
183 self.path = self.path[len(self.API_PREFIX):]
184 self.parameters = cgi.parse_qs(self.query, keep_blank_values=True)
185 self.body_obj = json.loads(self.body) if self.body else {}
186 self.request_id = None
188 def _IsRpc(self):
197 return self.path == 'rpc'
200 class DiscoveryApiProxy(object):
201 """Proxies discovery service requests to a known cloud endpoint."""
205 _DISCOVERY_PROXY_HOST = 'webapis-discovery.appspot.com'
206 _STATIC_PROXY_HOST = 'webapis-discovery.appspot.com'
207 _DISCOVERY_API_PATH_PREFIX = '/_ah/api/discovery/v1/'
209 def _DispatchRequest(self, path, body):
210 """Proxies GET request to discovery service API.
212 Args:
213 path: URL path relative to discovery service.
214 body: HTTP POST request body.
216 Returns:
217 HTTP response body or None if it failed.
219 full_path = self._DISCOVERY_API_PATH_PREFIX + path
220 headers = {'Content-type': 'application/json'}
221 connection = httplib.HTTPSConnection(self._DISCOVERY_PROXY_HOST)
222 try:
223 connection.request('POST', full_path, body, headers)
224 response = connection.getresponse()
225 response_body = response.read()
226 if response.status != 200:
227 logging.error('Discovery API proxy failed on %s with %d.\r\n'
228 'Request: %s\r\nResponse: %s',
229 full_path, response.status, body, response_body)
230 return None
231 return response_body
232 finally:
233 connection.close()
235 def GenerateDiscoveryDoc(self, api_config, api_format):
236 """Generates a discovery document from an API file.
238 Args:
239 api_config: .api file contents as string.
240 api_format: 'rest' or 'rpc' depending on the which kind of discvoery doc.
242 Returns:
243 Discovery doc as JSON string.
245 Raises:
246 ValueError: When api_format is invalid.
248 if api_format not in ['rest', 'rpc']:
249 raise ValueError('Invalid API format')
250 path = 'apis/generate/' + api_format
251 request_dict = {'config': json.dumps(api_config)}
252 request_body = json.dumps(request_dict)
253 return self._DispatchRequest(path, request_body)
255 def GenerateDirectory(self, api_configs):
256 """Generates an API directory from a list of API files.
258 Args:
259 api_configs: list of strings which are the .api file contents.
261 Returns:
262 API directory as JSON string.
264 request_dict = {'configs': api_configs}
265 request_body = json.dumps(request_dict)
266 return self._DispatchRequest('apis/generate/directory', request_body)
268 def GetStaticFile(self, path):
269 """Returns static content via a GET request.
271 Args:
272 path: URL path after the domain.
274 Returns:
275 Tuple of (response, response_body):
276 response: HTTPResponse object.
277 response_body: Response body as string.
279 connection = httplib.HTTPSConnection(self._STATIC_PROXY_HOST)
280 try:
281 connection.request('GET', path, None, {})
282 response = connection.getresponse()
283 response_body = response.read()
284 finally:
285 connection.close()
286 return response, response_body
289 class DiscoveryService(object):
290 """Implements the local devserver discovery service.
292 This has a static minimal version of the discoverable part of the
293 discovery .api file.
294 It only handles returning the discovery doc and directory, and ignores
295 directory parameters to filter the results.
297 The discovery docs/directory are created by calling a cloud endpoint
298 discovery service to generate the discovery docs/directory from an .api
299 file/set of .api files.
302 _GET_REST_API = 'apisdev.getRest'
303 _GET_RPC_API = 'apisdev.getRpc'
304 _LIST_API = 'apisdev.list'
305 API_CONFIG = {
306 'name': 'discovery',
307 'version': 'v1',
308 'methods': {
309 'discovery.apis.getRest': {
310 'path': 'apis/{api}/{version}/rest',
311 'httpMethod': 'GET',
312 'rosyMethod': _GET_REST_API,
314 'discovery.apis.getRpc': {
315 'path': 'apis/{api}/{version}/rpc',
316 'httpMethod': 'GET',
317 'rosyMethod': _GET_RPC_API,
319 'discovery.apis.list': {
320 'path': 'apis',
321 'httpMethod': 'GET',
322 'rosyMethod': _LIST_API,
327 def __init__(self, config_manager, api_request, outfile):
328 """Initializes an instance of the DiscoveryService.
330 Args:
331 config_manager: an instance of ApiConfigManager.
332 api_request: an instance of ApiRequest.
333 outfile: the CGI file object to write the response to.
335 self._config_manager = config_manager
336 self._params = json.loads(api_request.body or '{}')
337 self._outfile = outfile
338 self._discovery_proxy = DiscoveryApiProxy()
340 def _SendSuccessResponse(self, response):
341 """Sends an HTTP 200 json success response.
343 Args:
344 response: Response body as string to return.
346 Returns:
347 Sends back an HTTP 200 json success response.
349 headers = {'Content-Type': 'application/json; charset=UTF-8'}
350 return SendCGIResponse('200', headers, response, self._outfile)
352 def _GetRpcOrRest(self, api_format):
353 """Sends back HTTP response with API directory.
355 Args:
356 api_format: Either 'rest' or 'rpc'. Sends CGI response containing
357 the discovery doc for the api/version.
359 Returns:
360 None.
362 api = self._params['api']
363 version = self._params['version']
364 lookup_key = (api, version)
365 api_config = self._config_manager.configs.get(lookup_key)
366 if not api_config:
367 logging.warn('No discovery doc for version %s of api %s', version, api)
368 SendCGINotFoundResponse(self._outfile)
369 return
370 doc = self._discovery_proxy.GenerateDiscoveryDoc(api_config, api_format)
371 if not doc:
372 error_msg = ('Failed to convert .api to discovery doc for '
373 'version %s of api %s') % (version, api)
374 logging.error('%s', error_msg)
375 SendCGIErrorResponse(error_msg, self._outfile)
376 return
377 self._SendSuccessResponse(doc)
379 def _GetRest(self):
380 return self._GetRpcOrRest('rest')
382 def _GetRpc(self):
383 return self._GetRpcOrRest('rpc')
385 def _List(self):
386 """Sends HTTP response containing the API directory."""
387 api_configs = []
388 for api_config in self._config_manager.configs.itervalues():
392 if not api_config == self.API_CONFIG:
393 api_configs.append(json.dumps(api_config))
394 directory = self._discovery_proxy.GenerateDirectory(api_configs)
395 if not directory:
396 logging.error('Failed to get API directory')
399 SendCGINotFoundResponse(self._outfile)
400 return
401 self._SendSuccessResponse(directory)
403 def HandleDiscoveryRequest(self, path):
404 """Returns the result of a discovery service request.
406 Args:
407 path: the SPI API path
409 Returns:
410 JSON string with result of discovery service API request.
412 if path == self._GET_REST_API:
413 self._GetRest()
414 elif path == self._GET_RPC_API:
415 self._GetRpc()
416 elif path == self._LIST_API:
417 self._List()
418 else:
419 return False
420 return True
423 class ApiConfigManager(object):
424 """Manages loading api configs and method lookup."""
426 def __init__(self):
427 self._rpc_method_dict = {}
428 self._rest_methods = []
429 self.configs = {}
431 @staticmethod
432 def HasSpiEndpoint(config):
433 """Checks if an SPI is registered with this App.
435 Args:
436 config: Parsed app.yaml as an appinfo proto.
438 Returns:
439 True if any handler is registered for (/_ah/spi/.*).
441 return any(h.url.startswith('/_ah/spi/') for h in config.handlers)
443 def _AddDiscoveryConfig(self):
444 lookup_key = (DiscoveryService.API_CONFIG['name'],
445 DiscoveryService.API_CONFIG['version'])
446 self.configs[lookup_key] = DiscoveryService.API_CONFIG
448 def ParseApiConfigResponse(self, body):
449 """Parses a json api config and registers methods for dispatch.
451 Side effects:
452 Parses method name, etc for all methods and updates the indexing
453 datastructures with the information.
455 Args:
456 body: body of getApiConfigs response
460 try:
461 response_obj = json.loads(body)
462 except ValueError, unused_err:
463 logging.error('Cannot parse BackendService.getApiConfigs response: %s',
464 body)
465 else:
466 self._AddDiscoveryConfig()
467 for api_config_json in response_obj.get('items', []):
468 try:
469 config = json.loads(api_config_json)
470 except ValueError, unused_err:
471 logging.error('Can not parse API config: %s',
472 api_config_json)
473 else:
474 lookup_key = config.get('name', ''), config.get('version', '')
475 self.configs[lookup_key] = config
477 for config in self.configs.itervalues():
478 version = config.get('version', '')
484 sorted_methods = self._GetSortedMethods(config.get('methods', {}))
486 for method_name, method in sorted_methods:
487 self.SaveRpcMethod(method_name, version, method)
488 self.SaveRestMethod(method_name, version, method)
490 def _GetSortedMethods(self, methods):
491 """Get a copy of 'methods' sorted the same way AppEngine sorts them.
493 Args:
494 methods: Json configuration of an API's methods.
496 Returns:
497 The same configuration with the methods sorted based on what order
498 they'll be checked by the server.
500 if not methods:
501 return methods
504 def _SortMethodsComparison(method_info1, method_info2):
505 """Sort method info by path and http_method.
507 Args:
508 method_info1: Method name and info for the first method to compare.
509 method_info2: Method name and info for the method to compare to.
511 Returns:
512 Negative if the first method should come first, positive if the
513 first method should come after the second. Zero if they're
514 equivalent.
517 def _ScorePath(path):
518 """Calculate the score for this path, used for comparisons.
520 Higher scores have priority, and if scores are equal, the path text
521 is sorted alphabetically. Scores are based on the number and location
522 of the constant parts of the path. The server has some special handling
523 for variables with regexes, which we don't handle here.
525 Args:
526 path: The request path that we're calculating a score for.
528 Returns:
529 The score for the given path.
536 score = 0
537 parts = path.split('/')
538 for part in parts:
539 score <<= 1
540 if not part or part[0] != '{':
542 score += 1
546 score <<= 31 - len(parts)
547 return score
550 path_score1 = _ScorePath(method_info1[1].get('path', ''))
551 path_score2 = _ScorePath(method_info2[1].get('path', ''))
552 if path_score1 != path_score2:
553 return path_score2 - path_score1
556 path_result = cmp(method_info1[1].get('path', ''),
557 method_info2[1].get('path', ''))
558 if path_result != 0:
559 return path_result
562 method_result = cmp(method_info1[1].get('httpMethod', ''),
563 method_info2[1].get('httpMethod', ''))
564 return method_result
566 return sorted(methods.items(), _SortMethodsComparison)
568 @staticmethod
569 def _ToSafePathParamName(matched_parameter):
570 """Creates a safe string to be used as a regex group name.
572 Only alphanumeric characters and underscore are allowed in variable name
573 tokens, and numeric are not allowed as the first character.
575 We cast the matched_parameter to base32 (since the alphabet is safe),
576 strip the padding (= not safe) and prepend with _, since we know a token
577 can begin with underscore.
579 Args:
580 matched_parameter: String; parameter matched from URL template.
582 Returns:
583 String, safe to be used as a regex group name.
585 return '_' + base64.b32encode(matched_parameter).rstrip('=')
587 @staticmethod
588 def _FromSafePathParamName(safe_parameter):
589 """Takes a safe regex group name and converts it back to the original value.
591 Only alphanumeric characters and underscore are allowed in variable name
592 tokens, and numeric are not allowed as the first character.
594 The safe_parameter is a base32 representation of the actual value.
596 Args:
597 safe_parameter: String, safe regex group name.
599 Returns:
600 String; parameter matched from URL template.
602 assert safe_parameter.startswith('_')
603 safe_parameter_as_base32 = safe_parameter[1:]
605 padding_length = - len(safe_parameter_as_base32) % 8
606 padding = '=' * padding_length
607 return base64.b32decode(safe_parameter_as_base32 + padding)
609 @staticmethod
610 def CompilePathPattern(pattern):
611 """Generates a compiled regex pattern for a path pattern.
613 e.g. '/{!name}/{!version}/notes/{id}'
614 returns re.compile(r'/([^:/?#\[\]{}]*)'
615 r'/([^:/?#\[\]{}]*)'
616 r'/notes/(?P<id>[^:/?#\[\]{}]*)')
617 Note in this example that !name and !version are reserved variable names
618 used to match the API name and version that should not be migrated into the
619 method argument namespace. As such they are not named in the regex, so
620 groupdict() excludes them.
622 Args:
623 pattern: parameterized path pattern to be checked
625 Returns:
626 compiled regex to match this path pattern
629 def ReplaceReservedVariable(match):
630 """Replaces a {!variable} with a regex to match it not by name.
632 Args:
633 match: The matching regex group as sent by re.sub()
635 Returns:
636 Regex to match the variable by name, if the full pattern was matched.
638 if match.lastindex > 1:
639 return '%s(%s)' % (match.group(1), _PATH_VALUE_PATTERN)
640 return match.group(0)
642 def ReplaceVariable(match):
643 """Replaces a {variable} with a regex to match it by name.
645 Changes the string corresponding to the variable name to the base32
646 representation of the string, prepended by an underscore. This is
647 necessary because we can have message variable names in URL patterns
648 (e.g. via {x.y}) but the character '.' can't be in a regex group name.
650 Args:
651 match: The matching regex group as sent by re.sub()
653 Returns:
654 Regex to match the variable by name, if the full pattern was matched.
656 if match.lastindex > 1:
657 var_name = ApiConfigManager._ToSafePathParamName(match.group(2))
658 return '%s(?P<%s>%s)' % (match.group(1), var_name,
659 _PATH_VALUE_PATTERN)
660 return match.group(0)
665 pattern = re.sub('(/|^){(%s)}(?=/|$)' % _RESERVED_PATH_VARIABLE_PATTERN,
666 ReplaceReservedVariable, pattern, 2)
667 pattern = re.sub('(/|^){(%s)}(?=/|$)' % _PATH_VARIABLE_PATTERN,
668 ReplaceVariable, pattern)
669 return re.compile(pattern + '/?$')
671 def SaveRpcMethod(self, method_name, version, method):
672 """Store JsonRpc api methods in a map for lookup at call time.
674 (rpcMethodName, apiVersion) => method.
676 Args:
677 method_name: Name of the API method
678 version: Version of the API
679 method: method descriptor (as in the api config file).
681 self._rpc_method_dict[(method_name, version)] = method
683 def LookupRpcMethod(self, method_name, version):
684 """Lookup the JsonRPC method at call time.
686 The method is looked up in self._rpc_method_dict, the dictionary that
687 it is saved in for SaveRpcMethod().
689 Args:
690 method_name: String name of the method
691 version: String version of the API
693 Returns:
694 Method descriptor as specified in the API configuration.
696 method = self._rpc_method_dict.get((method_name, version))
697 return method
699 def SaveRestMethod(self, method_name, version, method):
700 """Store Rest api methods in a list for lookup at call time.
702 The list is self._rest_methods, a list of tuples:
703 [(<compiled_path>, <path_pattern>, <method_dict>), ...]
704 where:
705 <compiled_path> is a compiled regex to match against the incoming URL
706 <path_pattern> is a string representing the original path pattern,
707 checked on insertion to prevent duplicates. -and-
708 <method_dict> is a dict (httpMethod, apiVersion) => (method_name, method)
710 This structure is a bit complex, it supports use in two contexts:
711 Creation time:
712 - SaveRestMethod is called repeatedly, each method will have a path,
713 which we want to be compiled for fast lookup at call time
714 - We want to prevent duplicate incoming path patterns, so store the
715 un-compiled path, not counting on a compiled regex being a stable
716 comparison as it is not documented as being stable for this use.
717 - Need to store the method that will be mapped at calltime.
718 - Different methods may have the same path but different http method.
719 and/or API versions.
720 Call time:
721 - Quickly scan through the list attempting .match(path) on each
722 compiled regex to find the path that matches.
723 - When a path is matched, look up the API version and method from the
724 request and get the method name and method config for the matching
725 API method and method name.
727 Args:
728 method_name: Name of the API method
729 version: Version of the API
730 method: method descriptor (as in the api config file).
732 path_pattern = _API_REST_PATH_FORMAT % method.get('path', '')
733 http_method = method.get('httpMethod', '').lower()
734 for _, path, methods in self._rest_methods:
735 if path == path_pattern:
736 methods[(http_method, version)] = method_name, method
737 break
738 else:
739 self._rest_methods.append(
740 (self.CompilePathPattern(path_pattern),
741 path_pattern,
742 {(http_method, version): (method_name, method)}))
744 @staticmethod
745 def _GetPathParams(match):
746 """Gets path parameters from a regular expression match.
748 Args:
749 match: _sre.SRE_Match object for a path.
751 Returns:
752 A dictionary containing the variable names converted from base64
754 result = {}
755 for var_name, value in match.groupdict().iteritems():
756 actual_var_name = ApiConfigManager._FromSafePathParamName(var_name)
757 result[actual_var_name] = value
758 return result
760 def LookupRestMethod(self, path, http_method):
761 """Look up the rest method at call time.
763 The method is looked up in self._rest_methods, the list it is saved
764 in for SaveRestMethod.
766 Args:
767 path: Path from the URL of the request.
768 http_method: HTTP method of the request.
770 Returns:
771 Tuple of (<method name>, <method>, <params>)
772 Where:
773 <method name> is the string name of the method that was matched.
774 <method> is the descriptor as specified in the API configuration. -and-
775 <params> is a dict of path parameters matched in the rest request.
777 for compiled_path_pattern, unused_path, methods in self._rest_methods:
778 match = compiled_path_pattern.match(path)
779 if match:
780 params = self._GetPathParams(match)
781 version = match.group(2)
782 method_key = (http_method.lower(), version)
783 method_name, method = methods.get(method_key, (None, None))
784 if method is not None:
785 break
786 else:
787 logging.warn('No endpoint found for path: %s', path)
788 method_name = None
789 method = None
790 params = None
791 return method_name, method, params
794 def CreateApiserverDispatcher(config_manager=None):
795 """Function to create Apiserver dispatcher.
797 Args:
798 config_manager: Allow setting of ApiConfigManager for testing.
800 Returns:
801 New dispatcher capable of handling requests to the built-in apiserver
802 handlers.
807 from google.appengine.tools import dev_appserver
809 class ApiserverDispatcher(dev_appserver.URLDispatcher):
810 """Dispatcher that handles requests to the built-in apiserver handlers."""
812 _API_EXPLORER_URL = 'https://developers.google.com/apis-explorer/?base='
814 class RequestState(object):
815 """Enum tracking request state."""
816 INIT = 0
817 GET_API_CONFIGS = 1
818 SPI_CALL = 2
819 END = 3
823 def __init__(self, config_manager=None, *args, **kwargs):
824 self._is_rpc = None
825 self.request = None
826 self._request_stage = self.RequestState.INIT
827 self._is_batch = False
828 if config_manager is None:
829 config_manager = ApiConfigManager()
830 self.config_manager = config_manager
831 self._dispatchers = []
832 self._AddDispatcher('/_ah/api/explorer/?$',
833 self.HandleApiExplorerRequest)
834 self._AddDispatcher('/_ah/api/static/.*$',
835 self.HandleApiStaticRequest)
836 dev_appserver.URLDispatcher.__init__(self, *args, **kwargs)
838 def _AddDispatcher(self, path_regex, dispatch_function):
839 """Add a request path and dispatch handler.
841 Args:
842 path_regex: Regex path to match against incoming requests.
843 dispatch_function: Function to call for these requests. The function
844 should take (request, outfile, base_env_dict) as arguments and
845 return True.
847 self._dispatchers.append((re.compile(path_regex), dispatch_function))
849 def _EndRequest(self):
850 """End the request and clean up.
852 Sets the request state to END and cleans up any variables that
853 need it.
855 self._request_stage = self.RequestState.END
856 self._is_batch = False
858 def IsRpc(self):
859 """Check if the current request is an RPC request.
861 This should only be used after Dispatch, where this info is cached.
863 Returns:
864 True if the current request is an RPC. False if not.
866 assert self._is_rpc is not None
867 return self._is_rpc
869 def DispatchNonApiRequests(self, request, outfile, base_env_dict):
870 """Dispatch this request if this is a request to a reserved URL.
872 Args:
873 request: AppServerRequest.
874 outfile: The response file.
875 base_env_dict: Dictionary of CGI environment parameters if available.
876 Defaults to None.
878 Returns:
879 False if the request doesn't match one of the reserved URLs this
880 handles. True if it is handled.
882 for path_regex, dispatch_function in self._dispatchers:
883 if path_regex.match(request.relative_url):
884 return dispatch_function(request, outfile, base_env_dict)
885 return False
887 def Dispatch(self,
888 request,
889 outfile,
890 base_env_dict=None):
891 """Handles dispatch to apiserver handlers.
893 base_env_dict should contain at least:
894 REQUEST_METHOD, REMOTE_ADDR, SERVER_SOFTWARE, SERVER_NAME,
895 SERVER_PROTOCOL, SERVER_PORT
897 Args:
898 request: AppServerRequest.
899 outfile: The response file.
900 base_env_dict: Dictionary of CGI environment parameters if available.
901 Defaults to None.
903 Returns:
904 AppServerRequest internal redirect for normal API calls or
905 None for error conditions (e.g. method not found -> 404) and
906 other calls not requiring the GetApiConfigs redirect.
908 if self._request_stage != self.RequestState.INIT:
909 return self.FailRequest('Dispatch in unexpected state', outfile)
911 if not base_env_dict:
912 return self.FailRequest('CGI Environment Not Available', outfile)
914 if self.DispatchNonApiRequests(request, outfile, base_env_dict):
915 return None
918 self.request = ApiRequest(base_env_dict, dev_appserver, request)
924 self._is_rpc = self.request._IsRpc()
927 self._request_stage = self.RequestState.GET_API_CONFIGS
928 return self.GetApiConfigs(base_env_dict, dev_appserver)
930 def HandleApiExplorerRequest(self, unused_request, outfile, base_env_dict):
931 """Handler for requests to _ah/api/explorer.
933 Args:
934 unused_request: AppServerRequest.
935 outfile: The response file.
936 base_env_dict: Dictionary of CGI environment parameters
937 if available. Defaults to None.
939 Returns:
940 True
941 We will redirect these requests to the google apis explorer.
943 base_url = 'http://%s:%s/_ah/api' % (base_env_dict['SERVER_NAME'],
944 base_env_dict['SERVER_PORT'])
945 redirect_url = self._API_EXPLORER_URL + base_url
946 SendCGIRedirectResponse(redirect_url, outfile)
947 return True
949 def HandleApiStaticRequest(self, request, outfile, unused_base_env_dict):
950 """Handler for requests to _ah/api/static/.*.
952 Args:
953 request: AppServerRequest.
954 outfile: The response file.
955 unused_base_env_dict: Dictionary of CGI environment parameters
956 if available. Defaults to None.
958 Returns:
959 True
960 We will redirect these requests to an endpoint proxy.
962 discovery_api_proxy = DiscoveryApiProxy()
963 response, body = discovery_api_proxy.GetStaticFile(request.relative_url)
964 if response.status == 200:
965 SendCGIResponse('200',
966 {'Content-Type': response.getheader('Content-Type')},
967 body, outfile)
968 else:
969 logging.error('Discovery API proxy failed on %s with %d. Details: %s',
970 request.relative_url, response.status, body)
971 SendCGIResponse(response.status, dict(response.getheaders()), body,
972 outfile)
973 return True
975 def EndRedirect(self, dispatched_output, outfile):
976 """Handle the end of getApiConfigs and SPI complete notification.
978 This EndRedirect is called twice.
980 The first time is upon completion of the BackendService.getApiConfigs()
981 call. After this call, the set of all available methods and their
982 parameters / paths / config is contained in dispatched_output. This is
983 parsed and used to dispatch the request to the SPI backend itself.
985 In order to cause a second dispatch and EndRedirect, this EndRedirect
986 will return an AppServerRequest filled out with the SPI backend request.
988 The second time it is called is upon completion of the call to the SPI
989 backend. After this call, if the initial request (sent in Dispatch, prior
990 to getApiConfigs) is used to reformat the response as needed. This
991 currently only results in changes for JsonRPC requests, where the response
992 body is moved into {'result': response_body_goes_here} and the request id
993 is copied back into the response.
995 Args:
996 dispatched_output: resulting output from the SPI
997 outfile: final output file for this handler
999 Returns:
1000 An AppServerRequest for redirect or None for an immediate response.
1002 if self._request_stage == self.RequestState.GET_API_CONFIGS:
1003 if self.HandleGetApiConfigsResponse(dispatched_output, outfile):
1004 return self.CallSpi(outfile)
1005 elif self._request_stage == self.RequestState.SPI_CALL:
1006 return self.HandleSpiResponse(dispatched_output, outfile)
1007 else:
1008 return self.FailRequest('EndRedirect in unexpected state', outfile)
1010 def GetApiConfigs(self, cgi_env, dev_appserver):
1011 """Makes a call to BackendService.getApiConfigs and parses result.
1013 Args:
1014 cgi_env: CGI environment dictionary as passed in by the framework
1015 dev_appserver: dev_appserver instance used to generate AppServerRequest.
1017 Returns:
1018 AppServerRequest to be returned as an internal redirect to getApiConfigs
1020 request = ApiRequest(cgi_env, dev_appserver)
1021 request.path = 'BackendService.getApiConfigs'
1022 request.body = '{}'
1023 return BuildCGIRequest(cgi_env, request, dev_appserver)
1025 @staticmethod
1026 def VerifyResponse(response, status_code, content_type=None):
1027 """Verifies that a response has the expected status and content type.
1029 Args:
1030 response: Response to be checked.
1031 status_code: HTTP status code to be compared with response status.
1032 content_type: acceptable Content-Type: header value, None allows any.
1034 Returns:
1035 True if both status_code and content_type match, else False.
1037 if response.status_code != status_code:
1038 return False
1039 if content_type is None:
1040 return True
1041 for header in response.headers:
1042 if header.lower() == 'content-type':
1043 return response.headers[header].lower() == content_type
1044 else:
1045 return False
1047 @staticmethod
1048 def ParseCgiResponse(response):
1049 """Parses a CGI response, returning a headers dict and body.
1051 Args:
1052 response: a CGI response
1054 Returns:
1055 tuple of ({header: header_value, ...}, body)
1057 header_dict = {}
1058 for header in response.headers.headers:
1059 header_name, header_value = header.split(':', 1)
1060 header_dict[header_name.strip()] = header_value.strip()
1062 if response.body:
1063 body = response.body.read()
1064 else:
1065 body = ''
1066 return header_dict, body
1068 def HandleGetApiConfigsResponse(self, dispatched_output, outfile):
1069 """Parses the result of getApiConfigs, returning True on success.
1071 Args:
1072 dispatched_output: Output from the getApiConfigs call handler.
1073 outfile: CGI output handle, used for error conditions.
1075 Returns:
1076 True on success, False on failure
1078 response = dev_appserver.RewriteResponse(dispatched_output)
1079 if self.VerifyResponse(response, 200, 'application/json'):
1080 self.config_manager.ParseApiConfigResponse(response.body.read())
1081 return True
1082 else:
1083 self.FailRequest('BackendService.getApiConfigs Error', outfile)
1084 return False
1086 def CallSpi(self, outfile):
1087 """Generate SPI call (from earlier-saved request).
1089 Side effects:
1090 self.request is modified from Rest/JsonRPC format to apiserving format.
1092 Args:
1093 outfile: File to write out CGI-style response in case of error.
1095 Returns:
1096 AppServerRequest for redirect or None to send immediate CGI response.
1098 if self.IsRpc():
1099 method_config = self.LookupRpcMethod()
1100 params = None
1101 else:
1102 method_config, params = self.LookupRestMethod()
1103 if method_config:
1104 try:
1105 self.TransformRequest(params, method_config)
1106 discovery_service = DiscoveryService(self.config_manager,
1107 self.request, outfile)
1109 if not discovery_service.HandleDiscoveryRequest(self.request.path):
1110 self._request_stage = self.RequestState.SPI_CALL
1111 return BuildCGIRequest(self.request.cgi_env, self.request,
1112 dev_appserver)
1113 except RequestRejectionError, rejection_error:
1114 self._EndRequest()
1115 return SendCGIRejectedResponse(rejection_error, outfile)
1116 else:
1117 self._EndRequest()
1118 cors_handler = ApiserverDispatcher.__CheckCorsHeaders(self.request)
1119 return SendCGINotFoundResponse(outfile, cors_handler=cors_handler)
1121 class __CheckCorsHeaders(object):
1122 """Track information about CORS headers and our response to them."""
1124 def __init__(self, request):
1125 self.allow_cors_request = False
1126 self.origin = None
1127 self.cors_request_method = None
1128 self.cors_request_headers = None
1130 self.__CheckCorsRequest(request)
1132 def __CheckCorsRequest(self, request):
1133 """Check for a CORS request, and see if it gets a CORS response."""
1135 for orig_header, orig_value in request.headers.iteritems():
1136 if orig_header.lower() == _CORS_HEADER_ORIGIN:
1137 self.origin = orig_value
1138 if orig_header.lower() == _CORS_HEADER_REQUEST_METHOD:
1139 self.cors_request_method = orig_value
1140 if orig_header.lower() == _CORS_HEADER_REQUEST_HEADERS:
1141 self.cors_request_headers = orig_value
1144 if (self.origin and
1145 ((self.cors_request_method is None) or
1146 (self.cors_request_method.upper() in _CORS_ALLOWED_METHODS))):
1147 self.allow_cors_request = True
1149 def UpdateHeaders(self, headers):
1150 """Add CORS headers to the response, if needed."""
1151 if not self.allow_cors_request:
1152 return
1155 headers[_CORS_HEADER_ALLOW_ORIGIN] = self.origin
1156 headers[_CORS_HEADER_ALLOW_METHODS] = ','.join(
1157 tuple(_CORS_ALLOWED_METHODS))
1158 if self.cors_request_headers is not None:
1159 headers[_CORS_HEADER_ALLOW_HEADERS] = self.cors_request_headers
1161 def HandleSpiResponse(self, dispatched_output, outfile):
1162 """Handle SPI response, transforming output as needed.
1164 Args:
1165 dispatched_output: Response returned by SPI backend.
1166 outfile: File-like object to write transformed result.
1168 Returns:
1169 None
1172 response = dev_appserver.AppServerResponse(
1173 response_file=dispatched_output)
1174 response_headers, body = self.ParseCgiResponse(response)
1178 headers = {}
1179 for header, value in response_headers.items():
1180 if (header.lower() == 'content-type' and
1181 not value.lower().startswith('application/json')):
1182 return self.FailRequest('Non-JSON reply: %s' % body, outfile)
1183 elif header.lower() not in ('content-length', 'content-type'):
1184 headers[header] = value
1186 if self.IsRpc():
1187 body = self.TransformJsonrpcResponse(body)
1188 self._EndRequest()
1190 cors_handler = ApiserverDispatcher.__CheckCorsHeaders(self.request)
1191 return SendCGIResponse(response.status_code, headers, body, outfile,
1192 cors_handler=cors_handler)
1194 def FailRequest(self, message, outfile):
1195 """Write an immediate failure response to outfile, no redirect.
1197 Args:
1198 message: Error message to be displayed to user (plain text).
1199 outfile: File-like object to write CGI response to.
1201 Returns:
1202 None
1204 self._EndRequest()
1205 if self.request:
1206 cors_handler = ApiserverDispatcher.__CheckCorsHeaders(self.request)
1207 else:
1208 cors_handler = None
1209 return SendCGIErrorResponse(message, outfile, cors_handler=cors_handler)
1211 def LookupRestMethod(self):
1212 """Looks up and returns rest method for the currently-pending request.
1214 This method uses self.request as the currently-pending request.
1216 Returns:
1217 tuple of (method, parameters)
1219 method_name, method, params = self.config_manager.LookupRestMethod(
1220 self.request.path, self.request.http_method)
1221 self.request.method_name = method_name
1222 return method, params
1224 def LookupRpcMethod(self):
1225 """Looks up and returns RPC method for the currently-pending request.
1227 This method uses self.request as the currently-pending request.
1229 Returns:
1230 RPC method that was found for the current request.
1232 if not self.request.body_obj:
1233 return None
1234 try:
1235 method_name = self.request.body_obj.get('method', '')
1236 except AttributeError:
1241 if len(self.request.body_obj) != 1:
1242 raise NotImplementedError('Batch requests with more than 1 element '
1243 'not supported in dev_appserver. Found '
1244 '%d elements.' % len(self.request.body_obj))
1245 logging.info('Converting batch request to single request.')
1246 self.request.body_obj = self.request.body_obj[0]
1247 method_name = self.request.body_obj.get('method', '')
1248 self._is_batch = True
1250 version = self.request.body_obj.get('apiVersion', '')
1251 self.request.method_name = method_name
1252 return self.config_manager.LookupRpcMethod(method_name, version)
1254 def TransformRequest(self, params, method_config):
1255 """Transforms self.request to apiserving request.
1257 This method uses self.request to determine the currently-pending request.
1258 This method accepts a rest-style or RPC-style request.
1260 Side effects:
1261 Updates self.request to apiserving format. (e.g. updating path to be the
1262 method name, and moving request parameters to the body.)
1264 Args:
1265 params: Path parameters dictionary for rest request
1266 method_config: API config of the method to be called
1268 if self.IsRpc():
1269 self.TransformJsonrpcRequest()
1270 else:
1271 method_params = method_config.get('request', {}).get('parameters', {})
1272 self.TransformRestRequest(params, method_params)
1273 self.request.path = method_config.get('rosyMethod', '')
1275 def _CheckEnum(self, parameter_name, value, field_parameter):
1276 """Checks if the parameter value is valid if an enum.
1278 If the parameter is not an enum, does nothing. If it is, verifies that
1279 its value is valid.
1281 Args:
1282 parameter_name: String; The name of the parameter, which is either just
1283 a variable name or the name with the index appended. For example 'var'
1284 or 'var[2]'.
1285 value: String or list of Strings; The value(s) to be used as enum(s) for
1286 the parameter.
1287 field_parameter: The dictionary containing information specific to the
1288 field in question. This is retrieved from request.parameters in the
1289 method config.
1291 Raises:
1292 EnumRejectionError: If the given value is not among the accepted
1293 enum values in the field parameter.
1295 if 'enum' not in field_parameter:
1296 return
1298 enum_values = [enum['backendValue']
1299 for enum in field_parameter['enum'].values()
1300 if 'backendValue' in enum]
1301 if value not in enum_values:
1302 raise EnumRejectionError(parameter_name, value, enum_values)
1304 def _CheckParameter(self, parameter_name, value, field_parameter):
1305 """Checks if the parameter value is valid against all parameter rules.
1307 First checks if the value is a list and recursively calls _CheckParameter
1308 on the values in the list. Otherwise, checks all parameter rules for the
1309 the current value.
1311 In the list case, '[index-of-value]' is appended to the parameter name for
1312 error reporting purposes.
1314 Currently only checks if value adheres to enum rule, but more can be
1315 added.
1317 Args:
1318 parameter_name: String; The name of the parameter, which is either just
1319 a variable name or the name with the index appended in the recursive
1320 case. For example 'var' or 'var[2]'.
1321 value: String or List of values; The value(s) to be used for the
1322 parameter.
1323 field_parameter: The dictionary containing information specific to the
1324 field in question. This is retrieved from request.parameters in the
1325 method config.
1327 if isinstance(value, list):
1328 for index, element in enumerate(value):
1329 parameter_name_index = '%s[%d]' % (parameter_name, index)
1330 self._CheckParameter(parameter_name_index, element, field_parameter)
1331 return
1333 self._CheckEnum(parameter_name, value, field_parameter)
1335 def _AddMessageField(self, field_name, value, params):
1336 """Converts a . delimitied field name to a message field in parameters.
1338 For example:
1339 {'a.b.c': ['foo']}
1340 becomes:
1341 {'a': {'b': {'c': ['foo']}}}
1343 Args:
1344 field_name: String; the . delimitied name to be converted into a
1345 dictionary.
1346 value: The value to be set.
1347 params: The dictionary holding all the parameters, where the value is
1348 eventually set.
1350 if '.' not in field_name:
1351 params[field_name] = value
1352 return
1354 root, remaining = field_name.split('.', 1)
1355 sub_params = params.setdefault(root, {})
1356 self._AddMessageField(remaining, value, sub_params)
1358 def _UpdateFromBody(self, destination, source):
1359 """Updates the dictionary for an API payload with the request body.
1361 The values from the body should override those already in the payload, but
1362 for nested fields (message objects), the values can be combined
1363 recursively.
1365 Args:
1366 destination: A dictionary containing an API payload parsed from the
1367 path and query parameters in a request.
1368 source: The object parsed from the body of the request.
1370 for key, value in source.iteritems():
1371 destination_value = destination.get(key)
1372 if isinstance(value, dict) and isinstance(destination_value, dict):
1373 self._UpdateFromBody(destination_value, value)
1374 else:
1375 destination[key] = value
1377 def TransformRestRequest(self, params, method_parameters):
1378 """Translates a Rest request/response into an apiserving request/response.
1380 The request can receive values from the path, query and body and combine
1381 them before sending them along to the SPI server. In cases of collision,
1382 objects from the body take precedence over those from the query, which in
1383 turn take precedence over those from the path.
1385 In the case that a repeated value occurs in both the query and the path,
1386 those values can be combined, but if that value also occurred in the body,
1387 it would override any other values.
1389 In the case of nested values from message fields, non-colliding values
1390 from subfields can be combined. For example, if '?a.c=10' occurs in the
1391 query string and "{'a': {'b': 11}}" occurs in the body, then they will be
1392 combined as
1395 'a': {
1396 'b': 11,
1397 'c': 10,
1401 before being sent to the SPI server.
1403 Side effects:
1404 Updates self.request to apiserving format. (e.g. updating path to be the
1405 method name, and moving request parameters to the body.)
1407 Args:
1408 params: URL path parameter dict extracted by config_manager lookup.
1409 method_parameters: Dictionary; The parameters for the request from the
1410 API config of the method.
1412 body_obj = {}
1414 for key, value in params.iteritems():
1417 body_obj[key] = [value]
1419 if self.request.parameters:
1421 for key, value in self.request.parameters.iteritems():
1422 if key in body_obj:
1423 body_obj[key] = value + body_obj[key]
1424 else:
1425 body_obj[key] = value
1429 for key, value in body_obj.items():
1430 current_parameter = method_parameters.get(key, {})
1431 repeated = current_parameter.get('repeated', False)
1433 if not repeated:
1434 body_obj[key] = body_obj[key][0]
1442 self._CheckParameter(key, body_obj[key], current_parameter)
1444 message_value = body_obj.pop(key)
1445 self._AddMessageField(key, message_value, body_obj)
1447 if self.request.body_obj:
1448 self._UpdateFromBody(body_obj, self.request.body_obj)
1450 self.request.body_obj = body_obj
1451 self.request.body = json.dumps(body_obj)
1453 def TransformJsonrpcRequest(self):
1454 """Translates a JsonRpc request/response into apiserving request/response.
1456 Side effects:
1457 Updates self.request to apiserving format. (e.g. updating path to be the
1458 method name, and moving request parameters to the body.)
1460 body_obj = json.loads(self.request.body) if self.request.body else {}
1461 try:
1462 self.request.request_id = body_obj.get('id')
1463 except AttributeError:
1468 assert self._is_batch
1469 if len(body_obj) != 1:
1470 raise NotImplementedError('Batch requests with more than 1 element '
1471 'not supported in dev_appserver. Found '
1472 '%d elements.' % len(self.request.body_obj))
1473 body_obj = body_obj[0]
1474 self.request.request_id = body_obj.get('id')
1475 body_obj = body_obj.get('params', {})
1476 self.request.body = json.dumps(body_obj)
1478 def TransformJsonrpcResponse(self, response_body):
1479 """Translates a apiserving response to a JsonRpc response.
1481 Side effects:
1482 Updates self.request to JsonRpc format. (e.g. restoring request id
1483 and moving body object into {'result': body_obj}
1485 Args:
1486 response_body: Backend response to transform back to JsonRPC
1488 Returns:
1489 Updated, JsonRPC-formatted request body
1491 body_obj = {'result': json.loads(response_body)}
1492 if self.request.request_id is not None:
1493 body_obj['id'] = self.request.request_id
1494 if self._is_batch:
1495 body_obj = [body_obj]
1496 return json.dumps(body_obj)
1498 return ApiserverDispatcher(config_manager)
1501 def BuildCGIRequest(base_env_dict, request, dev_appserver):
1502 """Build a CGI request to Call a method on an SPI backend.
1504 Args:
1505 base_env_dict: CGI environment dict
1506 request: ApiRequest to be converted to a CGI request
1507 dev_appserver: Handle to dev_appserver to generate CGI request.
1509 Returns:
1510 dev_appserver.AppServerRequest internal redirect object
1512 if request.headers is None:
1513 request.headers = {}
1516 request.headers['Content-Type'] = 'application/json'
1517 url = SPI_ROOT_FORMAT % (request.port, request.path)
1518 base_env_dict['REQUEST_METHOD'] = 'POST'
1525 header_outfile = cStringIO.StringIO()
1526 body_outfile = cStringIO.StringIO()
1527 WriteHeaders(request.headers, header_outfile, len(request.body))
1528 body_outfile.write(request.body)
1529 header_outfile.seek(0)
1530 body_outfile.seek(0)
1531 return dev_appserver.AppServerRequest(
1532 url, None, mimetools.Message(header_outfile), body_outfile)
1535 def WriteHeaders(headers, outfile, content_len=None):
1536 """Write headers to the output file, updating content length if needed.
1538 Args:
1539 headers: Header dict to be written
1540 outfile: File-like object to send headers to
1541 content_len: Optional updated content length to update content-length with
1543 wrote_content_length = False
1544 for header, value in headers.iteritems():
1545 if header.lower() == 'content-length' and content_len is not None:
1546 value = content_len
1547 wrote_content_length = True
1548 outfile.write('%s: %s\r\n' % (header, value))
1549 if not wrote_content_length and content_len:
1550 outfile.write('Content-Length: %s\r\n' % content_len)
1553 def SendCGINotFoundResponse(outfile, cors_handler=None):
1554 SendCGIResponse('404', {'Content-Type': 'text/plain'}, 'Not Found', outfile,
1555 cors_handler=cors_handler)
1558 def SendCGIErrorResponse(message, outfile, cors_handler=None):
1559 body = json.dumps({'error': {'message': message}})
1560 SendCGIResponse('500', {'Content-Type': 'application/json'}, body, outfile,
1561 cors_handler=cors_handler)
1564 def SendCGIRejectedResponse(rejection_error, outfile, cors_handler=None):
1565 body = rejection_error.ToJson()
1566 SendCGIResponse('400', {'Content-Type': 'application/json'}, body, outfile,
1567 cors_handler=cors_handler)
1570 def SendCGIRedirectResponse(redirect_location, outfile, cors_handler=None):
1571 SendCGIResponse('302', {'Location': redirect_location}, None, outfile,
1572 cors_handler=cors_handler)
1575 def SendCGIResponse(status, headers, content, outfile, cors_handler=None):
1576 """Dump reformatted response to CGI outfile.
1578 Args:
1579 status: HTTP status code to send
1580 headers: Headers dictionary {header_name: header_value, ...}
1581 content: Body content to write
1582 outfile: File-like object where response will be written.
1583 cors_handler: A handler to process CORS request headers and update the
1584 headers in the response. Or this can be None, to bypass CORS checks.
1586 Returns:
1587 None
1589 if cors_handler:
1590 cors_handler.UpdateHeaders(headers)
1592 outfile.write('Status: %s\r\n' % status)
1593 WriteHeaders(headers, outfile, len(content) if content else None)
1594 outfile.write('\r\n')
1595 if content:
1596 outfile.write(content)
1597 outfile.seek(0)