blob: 7a1b5a8c4b75c98b28798f6e707343c15e466c91 [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2007 Google Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Helper CGI for Apiserver in the development app server.
This is a fake apiserver proxy that does simple transforms on requests that
come in to /_ah/api and then re-dispatches them to /_ah/spi. It does not do
any authentication, quota checking, DoS checking, etc.
In addition, the proxy loads api configs from
/_ah/spi/BackendService.getApiConfigs prior to making the first call to the
backend at /_ah/spi and afterwards if app.yaml is changed.
"""
from __future__ import with_statement
import base64
import cgi
import cStringIO
import httplib
try:
import json
except ImportError:
import simplejson as json
import logging
import mimetools
import re
API_SERVING_PATTERN = '/_ah/api/.*'
SPI_ROOT_FORMAT = 'http://127.0.0.1:%s/_ah/spi/%s'
_API_REST_PATH_FORMAT = '{!name}/{!version}/%s'
_PATH_VARIABLE_PATTERN = r'[a-zA-Z_][a-zA-Z_.\d]*'
_RESERVED_PATH_VARIABLE_PATTERN = r'!' + _PATH_VARIABLE_PATTERN
_PATH_VALUE_PATTERN = r'[^:/?#\[\]{}]*'
_CORS_HEADER_ORIGIN = 'Origin'.lower()
_CORS_HEADER_REQUEST_METHOD = 'Access-Control-Request-Method'.lower()
_CORS_HEADER_REQUEST_HEADERS = 'Access-Control-Request-Headers'.lower()
_CORS_HEADER_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'
_CORS_HEADER_ALLOW_METHODS = 'Access-Control-Allow-Methods'
_CORS_HEADER_ALLOW_HEADERS = 'Access-Control-Allow-Headers'
_CORS_ALLOWED_METHODS = frozenset(('DELETE', 'GET', 'PATCH', 'POST', 'PUT'))
_INVALID_ENUM_TEMPLATE = 'Invalid string value: %r. Allowed values: %r'
class RequestRejectionError(Exception):
"""Base class for rejected requests.
To be raised when parsing the request values and comparing them against the
generated discovery document.
"""
def Message(self): raise NotImplementedError
def Errors(self): raise NotImplementedError
def ToJson(self):
"""JSON string representing the rejected value.
Calling this will fail on the base class since it relies on Message and
Errors being implemented on the class. It is up to a subclass to implement
these methods.
Returns:
JSON string representing the rejected value.
"""
return json.dumps({
'error': {
'errors': self.Errors(),
'code': 400,
'message': self.Message(),
},
})
class EnumRejectionError(RequestRejectionError):
"""Custom request rejection exception for enum values."""
def __init__(self, parameter_name, value, allowed_values):
"""Constructor for EnumRejectionError.
Args:
parameter_name: String; the name of the enum parameter which had a value
rejected.
value: The actual value passed in for the enum. Usually string.
allowed_values: List of strings allowed for the enum.
"""
self.parameter_name = parameter_name
self.value = value
self.allowed_values = allowed_values
def Message(self):
"""A descriptive message describing the error."""
return _INVALID_ENUM_TEMPLATE % (self.value, self.allowed_values)
def Errors(self):
"""A list containing the errors associated with the rejection.
Intended to mimic those returned from an API in production in Google's API
infrastructure.
Returns:
A list with a single element that is a dictionary containing the error
information.
"""
return [
{
'domain': 'global',
'reason': 'invalidParameter',
'message': self.Message(),
'locationType': 'parameter',
'location': self.parameter_name,
},
]
class ApiRequest(object):
"""Simple data object representing an API request.
Takes an app_server CGI request and environment in the constructor.
Parses the request into convenient pieces and stores them as members.
"""
API_PREFIX = '/_ah/api/'
def __init__(self, base_env_dict, old_dev_appserver, request=None):
"""Constructor.
Args:
base_env_dict: Dictionary of CGI environment parameters.
old_dev_appserver: used to call standard SplitURL method.
request: AppServerRequest. Can be None.
"""
self.cgi_env = base_env_dict
self.headers = {}
self.http_method = base_env_dict['REQUEST_METHOD']
self.port = base_env_dict['SERVER_PORT']
if request:
self.path, self.query = old_dev_appserver.SplitURL(request.relative_url)
self.body = request.infile.read()
for header in request.headers.headers:
header_name, header_value = header.split(':', 1)
self.headers[header_name.strip()] = header_value.strip()
else:
self.body = ''
self.path = self.API_PREFIX
self.query = ''
assert self.path.startswith(self.API_PREFIX)
self.path = self.path[len(self.API_PREFIX):]
self.parameters = cgi.parse_qs(self.query, keep_blank_values=True)
self.body_obj = json.loads(self.body) if self.body else {}
self.request_id = None
def _IsRpc(self):
return self.path == 'rpc'
class DiscoveryApiProxy(object):
"""Proxies discovery service requests to a known cloud endpoint."""
_DISCOVERY_PROXY_HOST = 'webapis-discovery.appspot.com'
_STATIC_PROXY_HOST = 'webapis-discovery.appspot.com'
_DISCOVERY_API_PATH_PREFIX = '/_ah/api/discovery/v1/'
def _DispatchRequest(self, path, body):
"""Proxies GET request to discovery service API.
Args:
path: URL path relative to discovery service.
body: HTTP POST request body.
Returns:
HTTP response body or None if it failed.
"""
full_path = self._DISCOVERY_API_PATH_PREFIX + path
headers = {'Content-type': 'application/json'}
connection = httplib.HTTPSConnection(self._DISCOVERY_PROXY_HOST)
try:
connection.request('POST', full_path, body, headers)
response = connection.getresponse()
response_body = response.read()
if response.status != 200:
logging.error('Discovery API proxy failed on %s with %d.\r\n'
'Request: %s\r\nResponse: %s',
full_path, response.status, body, response_body)
return None
return response_body
finally:
connection.close()
def GenerateDiscoveryDoc(self, api_config, api_format):
"""Generates a discovery document from an API file.
Args:
api_config: .api file contents as string.
api_format: 'rest' or 'rpc' depending on the which kind of discvoery doc.
Returns:
Discovery doc as JSON string.
Raises:
ValueError: When api_format is invalid.
"""
if api_format not in ['rest', 'rpc']:
raise ValueError('Invalid API format')
path = 'apis/generate/' + api_format
request_dict = {'config': json.dumps(api_config)}
request_body = json.dumps(request_dict)
return self._DispatchRequest(path, request_body)
def GenerateDirectory(self, api_configs):
"""Generates an API directory from a list of API files.
Args:
api_configs: list of strings which are the .api file contents.
Returns:
API directory as JSON string.
"""
request_dict = {'configs': api_configs}
request_body = json.dumps(request_dict)
return self._DispatchRequest('apis/generate/directory', request_body)
def GetStaticFile(self, path):
"""Returns static content via a GET request.
Args:
path: URL path after the domain.
Returns:
Tuple of (response, response_body):
response: HTTPResponse object.
response_body: Response body as string.
"""
connection = httplib.HTTPSConnection(self._STATIC_PROXY_HOST)
try:
connection.request('GET', path, None, {})
response = connection.getresponse()
response_body = response.read()
finally:
connection.close()
return response, response_body
class DiscoveryService(object):
"""Implements the local devserver discovery service.
This has a static minimal version of the discoverable part of the
discovery .api file.
It only handles returning the discovery doc and directory, and ignores
directory parameters to filter the results.
The discovery docs/directory are created by calling a cloud endpoint
discovery service to generate the discovery docs/directory from an .api
file/set of .api files.
"""
_GET_REST_API = 'apisdev.getRest'
_GET_RPC_API = 'apisdev.getRpc'
_LIST_API = 'apisdev.list'
API_CONFIG = {
'name': 'discovery',
'version': 'v1',
'methods': {
'discovery.apis.getRest': {
'path': 'apis/{api}/{version}/rest',
'httpMethod': 'GET',
'rosyMethod': _GET_REST_API,
},
'discovery.apis.getRpc': {
'path': 'apis/{api}/{version}/rpc',
'httpMethod': 'GET',
'rosyMethod': _GET_RPC_API,
},
'discovery.apis.list': {
'path': 'apis',
'httpMethod': 'GET',
'rosyMethod': _LIST_API,
},
}
}
def __init__(self, config_manager, api_request, outfile):
"""Initializes an instance of the DiscoveryService.
Args:
config_manager: an instance of ApiConfigManager.
api_request: an instance of ApiRequest.
outfile: the CGI file object to write the response to.
"""
self._config_manager = config_manager
self._params = json.loads(api_request.body or '{}')
self._outfile = outfile
self._discovery_proxy = DiscoveryApiProxy()
def _SendSuccessResponse(self, response):
"""Sends an HTTP 200 json success response.
Args:
response: Response body as string to return.
Returns:
Sends back an HTTP 200 json success response.
"""
headers = {'Content-Type': 'application/json; charset=UTF-8'}
return SendCGIResponse('200', headers, response, self._outfile)
def _GetRpcOrRest(self, api_format):
"""Sends back HTTP response with API directory.
Args:
api_format: Either 'rest' or 'rpc'. Sends CGI response containing
the discovery doc for the api/version.
Returns:
None.
"""
api = self._params['api']
version = self._params['version']
lookup_key = (api, version)
api_config = self._config_manager.configs.get(lookup_key)
if not api_config:
logging.warn('No discovery doc for version %s of api %s', version, api)
SendCGINotFoundResponse(self._outfile)
return
doc = self._discovery_proxy.GenerateDiscoveryDoc(api_config, api_format)
if not doc:
error_msg = ('Failed to convert .api to discovery doc for '
'version %s of api %s') % (version, api)
logging.error('%s', error_msg)
SendCGIErrorResponse(error_msg, self._outfile)
return
self._SendSuccessResponse(doc)
def _GetRest(self):
return self._GetRpcOrRest('rest')
def _GetRpc(self):
return self._GetRpcOrRest('rpc')
def _List(self):
"""Sends HTTP response containing the API directory."""
api_configs = []
for api_config in self._config_manager.configs.itervalues():
if not api_config == self.API_CONFIG:
api_configs.append(json.dumps(api_config))
directory = self._discovery_proxy.GenerateDirectory(api_configs)
if not directory:
logging.error('Failed to get API directory')
SendCGINotFoundResponse(self._outfile)
return
self._SendSuccessResponse(directory)
def HandleDiscoveryRequest(self, path):
"""Returns the result of a discovery service request.
Args:
path: the SPI API path
Returns:
JSON string with result of discovery service API request.
"""
if path == self._GET_REST_API:
self._GetRest()
elif path == self._GET_RPC_API:
self._GetRpc()
elif path == self._LIST_API:
self._List()
else:
return False
return True
class ApiConfigManager(object):
"""Manages loading api configs and method lookup."""
def __init__(self):
self._rpc_method_dict = {}
self._rest_methods = []
self.configs = {}
@staticmethod
def HasSpiEndpoint(config):
"""Checks if an SPI is registered with this App.
Args:
config: Parsed app.yaml as an appinfo proto.
Returns:
True if any handler is registered for (/_ah/spi/.*).
"""
return any(h.url.startswith('/_ah/spi/') for h in config.handlers)
def _AddDiscoveryConfig(self):
lookup_key = (DiscoveryService.API_CONFIG['name'],
DiscoveryService.API_CONFIG['version'])
self.configs[lookup_key] = DiscoveryService.API_CONFIG
def ParseApiConfigResponse(self, body):
"""Parses a json api config and registers methods for dispatch.
Side effects:
Parses method name, etc for all methods and updates the indexing
datastructures with the information.
Args:
body: body of getApiConfigs response
"""
try:
response_obj = json.loads(body)
except ValueError, unused_err:
logging.error('Cannot parse BackendService.getApiConfigs response: %s',
body)
else:
self._AddDiscoveryConfig()
for api_config_json in response_obj.get('items', []):
try:
config = json.loads(api_config_json)
except ValueError, unused_err:
logging.error('Can not parse API config: %s',
api_config_json)
else:
lookup_key = config.get('name', ''), config.get('version', '')
self.configs[lookup_key] = config
for config in self.configs.itervalues():
version = config.get('version', '')
sorted_methods = self._GetSortedMethods(config.get('methods', {}))
for method_name, method in sorted_methods:
self.SaveRpcMethod(method_name, version, method)
self.SaveRestMethod(method_name, version, method)
def _GetSortedMethods(self, methods):
"""Get a copy of 'methods' sorted the same way AppEngine sorts them.
Args:
methods: Json configuration of an API's methods.
Returns:
The same configuration with the methods sorted based on what order
they'll be checked by the server.
"""
if not methods:
return methods
def _SortMethodsComparison(method_info1, method_info2):
"""Sort method info by path and http_method.
Args:
method_info1: Method name and info for the first method to compare.
method_info2: Method name and info for the method to compare to.
Returns:
Negative if the first method should come first, positive if the
first method should come after the second. Zero if they're
equivalent.
"""
def _ScorePath(path):
"""Calculate the score for this path, used for comparisons.
Higher scores have priority, and if scores are equal, the path text
is sorted alphabetically. Scores are based on the number and location
of the constant parts of the path. The server has some special handling
for variables with regexes, which we don't handle here.
Args:
path: The request path that we're calculating a score for.
Returns:
The score for the given path.
"""
score = 0
parts = path.split('/')
for part in parts:
score <<= 1
if not part or part[0] != '{':
score += 1
score <<= 31 - len(parts)
return score
path_score1 = _ScorePath(method_info1[1].get('path', ''))
path_score2 = _ScorePath(method_info2[1].get('path', ''))
if path_score1 != path_score2:
return path_score2 - path_score1
path_result = cmp(method_info1[1].get('path', ''),
method_info2[1].get('path', ''))
if path_result != 0:
return path_result
method_result = cmp(method_info1[1].get('httpMethod', ''),
method_info2[1].get('httpMethod', ''))
return method_result
return sorted(methods.items(), _SortMethodsComparison)
@staticmethod
def _ToSafePathParamName(matched_parameter):
"""Creates a safe string to be used as a regex group name.
Only alphanumeric characters and underscore are allowed in variable name
tokens, and numeric are not allowed as the first character.
We cast the matched_parameter to base32 (since the alphabet is safe),
strip the padding (= not safe) and prepend with _, since we know a token
can begin with underscore.
Args:
matched_parameter: String; parameter matched from URL template.
Returns:
String, safe to be used as a regex group name.
"""
return '_' + base64.b32encode(matched_parameter).rstrip('=')
@staticmethod
def _FromSafePathParamName(safe_parameter):
"""Takes a safe regex group name and converts it back to the original value.
Only alphanumeric characters and underscore are allowed in variable name
tokens, and numeric are not allowed as the first character.
The safe_parameter is a base32 representation of the actual value.
Args:
safe_parameter: String, safe regex group name.
Returns:
String; parameter matched from URL template.
"""
assert safe_parameter.startswith('_')
safe_parameter_as_base32 = safe_parameter[1:]
padding_length = - len(safe_parameter_as_base32) % 8
padding = '=' * padding_length
return base64.b32decode(safe_parameter_as_base32 + padding)
@staticmethod
def CompilePathPattern(pattern):
"""Generates a compiled regex pattern for a path pattern.
e.g. '/{!name}/{!version}/notes/{id}'
returns re.compile(r'/([^:/?#\[\]{}]*)'
r'/([^:/?#\[\]{}]*)'
r'/notes/(?P<id>[^:/?#\[\]{}]*)')
Note in this example that !name and !version are reserved variable names
used to match the API name and version that should not be migrated into the
method argument namespace. As such they are not named in the regex, so
groupdict() excludes them.
Args:
pattern: parameterized path pattern to be checked
Returns:
compiled regex to match this path pattern
"""
def ReplaceReservedVariable(match):
"""Replaces a {!variable} with a regex to match it not by name.
Args:
match: The matching regex group as sent by re.sub()
Returns:
Regex to match the variable by name, if the full pattern was matched.
"""
if match.lastindex > 1:
return '%s(%s)' % (match.group(1), _PATH_VALUE_PATTERN)
return match.group(0)
def ReplaceVariable(match):
"""Replaces a {variable} with a regex to match it by name.
Changes the string corresponding to the variable name to the base32
representation of the string, prepended by an underscore. This is
necessary because we can have message variable names in URL patterns
(e.g. via {x.y}) but the character '.' can't be in a regex group name.
Args:
match: The matching regex group as sent by re.sub()
Returns:
Regex to match the variable by name, if the full pattern was matched.
"""
if match.lastindex > 1:
var_name = ApiConfigManager._ToSafePathParamName(match.group(2))
return '%s(?P<%s>%s)' % (match.group(1), var_name,
_PATH_VALUE_PATTERN)
return match.group(0)
pattern = re.sub('(/|^){(%s)}(?=/|$)' % _RESERVED_PATH_VARIABLE_PATTERN,
ReplaceReservedVariable, pattern, 2)
pattern = re.sub('(/|^){(%s)}(?=/|$)' % _PATH_VARIABLE_PATTERN,
ReplaceVariable, pattern)
return re.compile(pattern + '/?$')
def SaveRpcMethod(self, method_name, version, method):
"""Store JsonRpc api methods in a map for lookup at call time.
(rpcMethodName, apiVersion) => method.
Args:
method_name: Name of the API method
version: Version of the API
method: method descriptor (as in the api config file).
"""
self._rpc_method_dict[(method_name, version)] = method
def LookupRpcMethod(self, method_name, version):
"""Lookup the JsonRPC method at call time.
The method is looked up in self._rpc_method_dict, the dictionary that
it is saved in for SaveRpcMethod().
Args:
method_name: String name of the method
version: String version of the API
Returns:
Method descriptor as specified in the API configuration.
"""
method = self._rpc_method_dict.get((method_name, version))
return method
def SaveRestMethod(self, method_name, version, method):
"""Store Rest api methods in a list for lookup at call time.
The list is self._rest_methods, a list of tuples:
[(<compiled_path>, <path_pattern>, <method_dict>), ...]
where:
<compiled_path> is a compiled regex to match against the incoming URL
<path_pattern> is a string representing the original path pattern,
checked on insertion to prevent duplicates. -and-
<method_dict> is a dict (httpMethod, apiVersion) => (method_name, method)
This structure is a bit complex, it supports use in two contexts:
Creation time:
- SaveRestMethod is called repeatedly, each method will have a path,
which we want to be compiled for fast lookup at call time
- We want to prevent duplicate incoming path patterns, so store the
un-compiled path, not counting on a compiled regex being a stable
comparison as it is not documented as being stable for this use.
- Need to store the method that will be mapped at calltime.
- Different methods may have the same path but different http method.
and/or API versions.
Call time:
- Quickly scan through the list attempting .match(path) on each
compiled regex to find the path that matches.
- When a path is matched, look up the API version and method from the
request and get the method name and method config for the matching
API method and method name.
Args:
method_name: Name of the API method
version: Version of the API
method: method descriptor (as in the api config file).
"""
path_pattern = _API_REST_PATH_FORMAT % method.get('path', '')
http_method = method.get('httpMethod', '').lower()
for _, path, methods in self._rest_methods:
if path == path_pattern:
methods[(http_method, version)] = method_name, method
break
else:
self._rest_methods.append(
(self.CompilePathPattern(path_pattern),
path_pattern,
{(http_method, version): (method_name, method)}))
@staticmethod
def _GetPathParams(match):
"""Gets path parameters from a regular expression match.
Args:
match: _sre.SRE_Match object for a path.
Returns:
A dictionary containing the variable names converted from base64
"""
result = {}
for var_name, value in match.groupdict().iteritems():
actual_var_name = ApiConfigManager._FromSafePathParamName(var_name)
result[actual_var_name] = value
return result
def LookupRestMethod(self, path, http_method):
"""Look up the rest method at call time.
The method is looked up in self._rest_methods, the list it is saved
in for SaveRestMethod.
Args:
path: Path from the URL of the request.
http_method: HTTP method of the request.
Returns:
Tuple of (<method name>, <method>, <params>)
Where:
<method name> is the string name of the method that was matched.
<method> is the descriptor as specified in the API configuration. -and-
<params> is a dict of path parameters matched in the rest request.
"""
for compiled_path_pattern, unused_path, methods in self._rest_methods:
match = compiled_path_pattern.match(path)
if match:
params = self._GetPathParams(match)
version = match.group(2)
method_key = (http_method.lower(), version)
method_name, method = methods.get(method_key, (None, None))
if method is not None:
break
else:
logging.warn('No endpoint found for path: %s', path)
method_name = None
method = None
params = None
return method_name, method, params
def CreateApiserverDispatcher(config_manager=None):
"""Function to create Apiserver dispatcher.
Args:
config_manager: Allow setting of ApiConfigManager for testing.
Returns:
New dispatcher capable of handling requests to the built-in apiserver
handlers.
"""
from google.appengine.tools import old_dev_appserver
class ApiserverDispatcher(old_dev_appserver.URLDispatcher):
"""Dispatcher that handles requests to the built-in apiserver handlers."""
_API_EXPLORER_URL = 'http://apis-explorer.appspot.com/apis-explorer/?base='
class RequestState(object):
"""Enum tracking request state."""
INIT = 0
GET_API_CONFIGS = 1
SPI_CALL = 2
END = 3
def __init__(self, config_manager=None, *args, **kwargs):
self._is_rpc = None
self.request = None
self._request_stage = self.RequestState.INIT
self._is_batch = False
if config_manager is None:
config_manager = ApiConfigManager()
self.config_manager = config_manager
self._dispatchers = []
self._AddDispatcher('/_ah/api/explorer/?$',
self.HandleApiExplorerRequest)
self._AddDispatcher('/_ah/api/static/.*$',
self.HandleApiStaticRequest)
old_dev_appserver.URLDispatcher.__init__(self, *args, **kwargs)
def _AddDispatcher(self, path_regex, dispatch_function):
"""Add a request path and dispatch handler.
Args:
path_regex: Regex path to match against incoming requests.
dispatch_function: Function to call for these requests. The function
should take (request, outfile, base_env_dict) as arguments and
return True.
"""
self._dispatchers.append((re.compile(path_regex), dispatch_function))
def _EndRequest(self):
"""End the request and clean up.
Sets the request state to END and cleans up any variables that
need it.
"""
self._request_stage = self.RequestState.END
self._is_batch = False
def IsRpc(self):
"""Check if the current request is an RPC request.
This should only be used after Dispatch, where this info is cached.
Returns:
True if the current request is an RPC. False if not.
"""
assert self._is_rpc is not None
return self._is_rpc
def DispatchNonApiRequests(self, request, outfile, base_env_dict):
"""Dispatch this request if this is a request to a reserved URL.
Args:
request: AppServerRequest.
outfile: The response file.
base_env_dict: Dictionary of CGI environment parameters if available.
Defaults to None.
Returns:
False if the request doesn't match one of the reserved URLs this
handles. True if it is handled.
"""
for path_regex, dispatch_function in self._dispatchers:
if path_regex.match(request.relative_url):
return dispatch_function(request, outfile, base_env_dict)
return False
def Dispatch(self,
request,
outfile,
base_env_dict=None):
"""Handles dispatch to apiserver handlers.
base_env_dict should contain at least:
REQUEST_METHOD, REMOTE_ADDR, SERVER_SOFTWARE, SERVER_NAME,
SERVER_PROTOCOL, SERVER_PORT
Args:
request: AppServerRequest.
outfile: The response file.
base_env_dict: Dictionary of CGI environment parameters if available.
Defaults to None.
Returns:
AppServerRequest internal redirect for normal API calls or
None for error conditions (e.g. method not found -> 404) and
other calls not requiring the GetApiConfigs redirect.
"""
if self._request_stage != self.RequestState.INIT:
return self.FailRequest('Dispatch in unexpected state', outfile)
if not base_env_dict:
return self.FailRequest('CGI Environment Not Available', outfile)
if self.DispatchNonApiRequests(request, outfile, base_env_dict):
return None
self.request = ApiRequest(base_env_dict, old_dev_appserver, request)
self._is_rpc = self.request._IsRpc()
self._request_stage = self.RequestState.GET_API_CONFIGS
return self.GetApiConfigs(base_env_dict, old_dev_appserver)
def HandleApiExplorerRequest(self, unused_request, outfile, base_env_dict):
"""Handler for requests to _ah/api/explorer.
Args:
unused_request: AppServerRequest.
outfile: The response file.
base_env_dict: Dictionary of CGI environment parameters
if available. Defaults to None.
Returns:
True
We will redirect these requests to the google apis explorer.
"""
base_url = 'http://%s:%s/_ah/api' % (base_env_dict['SERVER_NAME'],
base_env_dict['SERVER_PORT'])
redirect_url = self._API_EXPLORER_URL + base_url
SendCGIRedirectResponse(redirect_url, outfile)
return True
def HandleApiStaticRequest(self, request, outfile, unused_base_env_dict):
"""Handler for requests to _ah/api/static/.*.
Args:
request: AppServerRequest.
outfile: The response file.
unused_base_env_dict: Dictionary of CGI environment parameters
if available. Defaults to None.
Returns:
True
We will redirect these requests to an endpoint proxy.
"""
discovery_api_proxy = DiscoveryApiProxy()
response, body = discovery_api_proxy.GetStaticFile(request.relative_url)
if response.status == 200:
SendCGIResponse('200',
{'Content-Type': response.getheader('Content-Type')},
body, outfile)
else:
logging.error('Discovery API proxy failed on %s with %d. Details: %s',
request.relative_url, response.status, body)
SendCGIResponse(response.status, dict(response.getheaders()), body,
outfile)
return True
def EndRedirect(self, dispatched_output, outfile):
"""Handle the end of getApiConfigs and SPI complete notification.
This EndRedirect is called twice.
The first time is upon completion of the BackendService.getApiConfigs()
call. After this call, the set of all available methods and their
parameters / paths / config is contained in dispatched_output. This is
parsed and used to dispatch the request to the SPI backend itself.
In order to cause a second dispatch and EndRedirect, this EndRedirect
will return an AppServerRequest filled out with the SPI backend request.
The second time it is called is upon completion of the call to the SPI
backend. After this call, if the initial request (sent in Dispatch, prior
to getApiConfigs) is used to reformat the response as needed. This
currently only results in changes for JsonRPC requests, where the response
body is moved into {'result': response_body_goes_here} and the request id
is copied back into the response.
Args:
dispatched_output: resulting output from the SPI
outfile: final output file for this handler
Returns:
An AppServerRequest for redirect or None for an immediate response.
"""
if self._request_stage == self.RequestState.GET_API_CONFIGS:
if self.HandleGetApiConfigsResponse(dispatched_output, outfile):
return self.CallSpi(outfile)
elif self._request_stage == self.RequestState.SPI_CALL:
return self.HandleSpiResponse(dispatched_output, outfile)
else:
return self.FailRequest('EndRedirect in unexpected state', outfile)
def GetApiConfigs(self, cgi_env, old_dev_appserver):
"""Makes a call to BackendService.getApiConfigs and parses result.
Args:
cgi_env: CGI environment dictionary as passed in by the framework
old_dev_appserver:
old_dev_appserver instance used to generate AppServerRequest.
Returns:
AppServerRequest to be returned as an internal redirect to getApiConfigs
"""
request = ApiRequest(cgi_env, old_dev_appserver)
request.path = 'BackendService.getApiConfigs'
request.body = '{}'
return BuildCGIRequest(cgi_env, request, old_dev_appserver)
@staticmethod
def VerifyResponse(response, status_code, content_type=None):
"""Verifies that a response has the expected status and content type.
Args:
response: Response to be checked.
status_code: HTTP status code to be compared with response status.
content_type: acceptable Content-Type: header value, None allows any.
Returns:
True if both status_code and content_type match, else False.
"""
if response.status_code != status_code:
return False
if content_type is None:
return True
for header in response.headers:
if header.lower() == 'content-type':
return response.headers[header].lower() == content_type
else:
return False
@staticmethod
def ParseCgiResponse(response):
"""Parses a CGI response, returning a headers dict and body.
Args:
response: a CGI response
Returns:
tuple of ({header: header_value, ...}, body)
"""
header_dict = {}
for header in response.headers.headers:
header_name, header_value = header.split(':', 1)
header_dict[header_name.strip()] = header_value.strip()
if response.body:
body = response.body.read()
else:
body = ''
return header_dict, body
def HandleGetApiConfigsResponse(self, dispatched_output, outfile):
"""Parses the result of getApiConfigs, returning True on success.
Args:
dispatched_output: Output from the getApiConfigs call handler.
outfile: CGI output handle, used for error conditions.
Returns:
True on success, False on failure
"""
response = old_dev_appserver.RewriteResponse(dispatched_output)
if self.VerifyResponse(response, 200, 'application/json'):
self.config_manager.ParseApiConfigResponse(response.body.read())
return True
else:
self.FailRequest('BackendService.getApiConfigs Error', outfile)
return False
def CallSpi(self, outfile):
"""Generate SPI call (from earlier-saved request).
Side effects:
self.request is modified from Rest/JsonRPC format to apiserving format.
Args:
outfile: File to write out CGI-style response in case of error.
Returns:
AppServerRequest for redirect or None to send immediate CGI response.
"""
if self.IsRpc():
method_config = self.LookupRpcMethod()
params = None
else:
method_config, params = self.LookupRestMethod()
if method_config:
try:
self.TransformRequest(params, method_config)
discovery_service = DiscoveryService(self.config_manager,
self.request, outfile)
if not discovery_service.HandleDiscoveryRequest(self.request.path):
self._request_stage = self.RequestState.SPI_CALL
return BuildCGIRequest(self.request.cgi_env, self.request,
old_dev_appserver)
except RequestRejectionError, rejection_error:
self._EndRequest()
return SendCGIRejectedResponse(rejection_error, outfile)
else:
self._EndRequest()
cors_handler = ApiserverDispatcher.__CheckCorsHeaders(self.request)
return SendCGINotFoundResponse(outfile, cors_handler=cors_handler)
class __CheckCorsHeaders(object):
"""Track information about CORS headers and our response to them."""
def __init__(self, request):
self.allow_cors_request = False
self.origin = None
self.cors_request_method = None
self.cors_request_headers = None
self.__CheckCorsRequest(request)
def __CheckCorsRequest(self, request):
"""Check for a CORS request, and see if it gets a CORS response."""
for orig_header, orig_value in request.headers.iteritems():
if orig_header.lower() == _CORS_HEADER_ORIGIN:
self.origin = orig_value
if orig_header.lower() == _CORS_HEADER_REQUEST_METHOD:
self.cors_request_method = orig_value
if orig_header.lower() == _CORS_HEADER_REQUEST_HEADERS:
self.cors_request_headers = orig_value
if (self.origin and
((self.cors_request_method is None) or
(self.cors_request_method.upper() in _CORS_ALLOWED_METHODS))):
self.allow_cors_request = True
def UpdateHeaders(self, headers):
"""Add CORS headers to the response, if needed."""
if not self.allow_cors_request:
return
headers[_CORS_HEADER_ALLOW_ORIGIN] = self.origin
headers[_CORS_HEADER_ALLOW_METHODS] = ','.join(
tuple(_CORS_ALLOWED_METHODS))
if self.cors_request_headers is not None:
headers[_CORS_HEADER_ALLOW_HEADERS] = self.cors_request_headers
def HandleSpiResponse(self, dispatched_output, outfile):
"""Handle SPI response, transforming output as needed.
Args:
dispatched_output: Response returned by SPI backend.
outfile: File-like object to write transformed result.
Returns:
None
"""
response = old_dev_appserver.AppServerResponse(
response_file=dispatched_output)
response_headers, body = self.ParseCgiResponse(response)
headers = {}
for header, value in response_headers.items():
if (header.lower() == 'content-type' and
not value.lower().startswith('application/json')):
return self.FailRequest('Non-JSON reply: %s' % body, outfile)
elif header.lower() not in ('content-length', 'content-type'):
headers[header] = value
if self.IsRpc():
body = self.TransformJsonrpcResponse(body)
self._EndRequest()
cors_handler = ApiserverDispatcher.__CheckCorsHeaders(self.request)
return SendCGIResponse(response.status_code, headers, body, outfile,
cors_handler=cors_handler)
def FailRequest(self, message, outfile):
"""Write an immediate failure response to outfile, no redirect.
Args:
message: Error message to be displayed to user (plain text).
outfile: File-like object to write CGI response to.
Returns:
None
"""
self._EndRequest()
if self.request:
cors_handler = ApiserverDispatcher.__CheckCorsHeaders(self.request)
else:
cors_handler = None
return SendCGIErrorResponse(message, outfile, cors_handler=cors_handler)
def LookupRestMethod(self):
"""Looks up and returns rest method for the currently-pending request.
This method uses self.request as the currently-pending request.
Returns:
tuple of (method, parameters)
"""
method_name, method, params = self.config_manager.LookupRestMethod(
self.request.path, self.request.http_method)
self.request.method_name = method_name
return method, params
def LookupRpcMethod(self):
"""Looks up and returns RPC method for the currently-pending request.
This method uses self.request as the currently-pending request.
Returns:
RPC method that was found for the current request.
"""
if not self.request.body_obj:
return None
try:
method_name = self.request.body_obj.get('method', '')
except AttributeError:
if len(self.request.body_obj) != 1:
raise NotImplementedError('Batch requests with more than 1 element '
'not supported in dev_appserver. Found '
'%d elements.' % len(self.request.body_obj))
logging.info('Converting batch request to single request.')
self.request.body_obj = self.request.body_obj[0]
method_name = self.request.body_obj.get('method', '')
self._is_batch = True
version = self.request.body_obj.get('apiVersion', '')
self.request.method_name = method_name
return self.config_manager.LookupRpcMethod(method_name, version)
def TransformRequest(self, params, method_config):
"""Transforms self.request to apiserving request.
This method uses self.request to determine the currently-pending request.
This method accepts a rest-style or RPC-style request.
Side effects:
Updates self.request to apiserving format. (e.g. updating path to be the
method name, and moving request parameters to the body.)
Args:
params: Path parameters dictionary for rest request
method_config: API config of the method to be called
"""
if self.IsRpc():
self.TransformJsonrpcRequest()
else:
method_params = method_config.get('request', {}).get('parameters', {})
self.TransformRestRequest(params, method_params)
self.request.path = method_config.get('rosyMethod', '')
def _CheckEnum(self, parameter_name, value, field_parameter):
"""Checks if the parameter value is valid if an enum.
If the parameter is not an enum, does nothing. If it is, verifies that
its value is valid.
Args:
parameter_name: String; The name of the parameter, which is either just
a variable name or the name with the index appended. For example 'var'
or 'var[2]'.
value: String or list of Strings; The value(s) to be used as enum(s) for
the parameter.
field_parameter: The dictionary containing information specific to the
field in question. This is retrieved from request.parameters in the
method config.
Raises:
EnumRejectionError: If the given value is not among the accepted
enum values in the field parameter.
"""
if 'enum' not in field_parameter:
return
enum_values = [enum['backendValue']
for enum in field_parameter['enum'].values()
if 'backendValue' in enum]
if value not in enum_values:
raise EnumRejectionError(parameter_name, value, enum_values)
def _CheckParameter(self, parameter_name, value, field_parameter):
"""Checks if the parameter value is valid against all parameter rules.
First checks if the value is a list and recursively calls _CheckParameter
on the values in the list. Otherwise, checks all parameter rules for the
the current value.
In the list case, '[index-of-value]' is appended to the parameter name for
error reporting purposes.
Currently only checks if value adheres to enum rule, but more can be
added.
Args:
parameter_name: String; The name of the parameter, which is either just
a variable name or the name with the index appended in the recursive
case. For example 'var' or 'var[2]'.
value: String or List of values; The value(s) to be used for the
parameter.
field_parameter: The dictionary containing information specific to the
field in question. This is retrieved from request.parameters in the
method config.
"""
if isinstance(value, list):
for index, element in enumerate(value):
parameter_name_index = '%s[%d]' % (parameter_name, index)
self._CheckParameter(parameter_name_index, element, field_parameter)
return
self._CheckEnum(parameter_name, value, field_parameter)
def _AddMessageField(self, field_name, value, params):
"""Converts a . delimitied field name to a message field in parameters.
For example:
{'a.b.c': ['foo']}
becomes:
{'a': {'b': {'c': ['foo']}}}
Args:
field_name: String; the . delimitied name to be converted into a
dictionary.
value: The value to be set.
params: The dictionary holding all the parameters, where the value is
eventually set.
"""
if '.' not in field_name:
params[field_name] = value
return
root, remaining = field_name.split('.', 1)
sub_params = params.setdefault(root, {})
self._AddMessageField(remaining, value, sub_params)
def _UpdateFromBody(self, destination, source):
"""Updates the dictionary for an API payload with the request body.
The values from the body should override those already in the payload, but
for nested fields (message objects), the values can be combined
recursively.
Args:
destination: A dictionary containing an API payload parsed from the
path and query parameters in a request.
source: The object parsed from the body of the request.
"""
for key, value in source.iteritems():
destination_value = destination.get(key)
if isinstance(value, dict) and isinstance(destination_value, dict):
self._UpdateFromBody(destination_value, value)
else:
destination[key] = value
def TransformRestRequest(self, params, method_parameters):
"""Translates a Rest request/response into an apiserving request/response.
The request can receive values from the path, query and body and combine
them before sending them along to the SPI server. In cases of collision,
objects from the body take precedence over those from the query, which in
turn take precedence over those from the path.
In the case that a repeated value occurs in both the query and the path,
those values can be combined, but if that value also occurred in the body,
it would override any other values.
In the case of nested values from message fields, non-colliding values
from subfields can be combined. For example, if '?a.c=10' occurs in the
query string and "{'a': {'b': 11}}" occurs in the body, then they will be
combined as
{
'a': {
'b': 11,
'c': 10,
}
}
before being sent to the SPI server.
Side effects:
Updates self.request to apiserving format. (e.g. updating path to be the
method name, and moving request parameters to the body.)
Args:
params: URL path parameter dict extracted by config_manager lookup.
method_parameters: Dictionary; The parameters for the request from the
API config of the method.
"""
body_obj = {}
for key, value in params.iteritems():
body_obj[key] = [value]
if self.request.parameters:
for key, value in self.request.parameters.iteritems():
if key in body_obj:
body_obj[key] = value + body_obj[key]
else:
body_obj[key] = value
for key, value in body_obj.items():
current_parameter = method_parameters.get(key, {})
repeated = current_parameter.get('repeated', False)
if not repeated:
body_obj[key] = body_obj[key][0]
self._CheckParameter(key, body_obj[key], current_parameter)
message_value = body_obj.pop(key)
self._AddMessageField(key, message_value, body_obj)
if self.request.body_obj:
self._UpdateFromBody(body_obj, self.request.body_obj)
self.request.body_obj = body_obj
self.request.body = json.dumps(body_obj)
def TransformJsonrpcRequest(self):
"""Translates a JsonRpc request/response into apiserving request/response.
Side effects:
Updates self.request to apiserving format. (e.g. updating path to be the
method name, and moving request parameters to the body.)
"""
body_obj = json.loads(self.request.body) if self.request.body else {}
try:
self.request.request_id = body_obj.get('id')
except AttributeError:
assert self._is_batch
if len(body_obj) != 1:
raise NotImplementedError('Batch requests with more than 1 element '
'not supported in dev_appserver. Found '
'%d elements.' % len(self.request.body_obj))
body_obj = body_obj[0]
self.request.request_id = body_obj.get('id')
body_obj = body_obj.get('params', {})
self.request.body = json.dumps(body_obj)
def TransformJsonrpcResponse(self, response_body):
"""Translates a apiserving response to a JsonRpc response.
Side effects:
Updates self.request to JsonRpc format. (e.g. restoring request id
and moving body object into {'result': body_obj}
Args:
response_body: Backend response to transform back to JsonRPC
Returns:
Updated, JsonRPC-formatted request body
"""
body_obj = {'result': json.loads(response_body)}
if self.request.request_id is not None:
body_obj['id'] = self.request.request_id
if self._is_batch:
body_obj = [body_obj]
return json.dumps(body_obj)
return ApiserverDispatcher(config_manager)
def BuildCGIRequest(base_env_dict, request, old_dev_appserver):
"""Build a CGI request to Call a method on an SPI backend.
Args:
base_env_dict: CGI environment dict
request: ApiRequest to be converted to a CGI request
old_dev_appserver: Handle to old_dev_appserver to generate CGI request.
Returns:
old_dev_appserver.AppServerRequest internal redirect object
"""
if request.headers is None:
request.headers = {}
request.headers['Content-Type'] = 'application/json'
url = SPI_ROOT_FORMAT % (request.port, request.path)
base_env_dict['REQUEST_METHOD'] = 'POST'
header_outfile = cStringIO.StringIO()
body_outfile = cStringIO.StringIO()
WriteHeaders(request.headers, header_outfile, len(request.body))
body_outfile.write(request.body)
header_outfile.seek(0)
body_outfile.seek(0)
return old_dev_appserver.AppServerRequest(
url, None, mimetools.Message(header_outfile), body_outfile)
def WriteHeaders(headers, outfile, content_len=None):
"""Write headers to the output file, updating content length if needed.
Args:
headers: Header dict to be written
outfile: File-like object to send headers to
content_len: Optional updated content length to update content-length with
"""
wrote_content_length = False
for header, value in headers.iteritems():
if header.lower() == 'content-length' and content_len is not None:
value = content_len
wrote_content_length = True
outfile.write('%s: %s\r\n' % (header, value))
if not wrote_content_length and content_len:
outfile.write('Content-Length: %s\r\n' % content_len)
def SendCGINotFoundResponse(outfile, cors_handler=None):
SendCGIResponse('404', {'Content-Type': 'text/plain'}, 'Not Found', outfile,
cors_handler=cors_handler)
def SendCGIErrorResponse(message, outfile, cors_handler=None):
body = json.dumps({'error': {'message': message}})
SendCGIResponse('500', {'Content-Type': 'application/json'}, body, outfile,
cors_handler=cors_handler)
def SendCGIRejectedResponse(rejection_error, outfile, cors_handler=None):
body = rejection_error.ToJson()
SendCGIResponse('400', {'Content-Type': 'application/json'}, body, outfile,
cors_handler=cors_handler)
def SendCGIRedirectResponse(redirect_location, outfile, cors_handler=None):
SendCGIResponse('302', {'Location': redirect_location}, None, outfile,
cors_handler=cors_handler)
def SendCGIResponse(status, headers, content, outfile, cors_handler=None):
"""Dump reformatted response to CGI outfile.
Args:
status: HTTP status code to send
headers: Headers dictionary {header_name: header_value, ...}
content: Body content to write
outfile: File-like object where response will be written.
cors_handler: A handler to process CORS request headers and update the
headers in the response. Or this can be None, to bypass CORS checks.
Returns:
None
"""
if cors_handler:
cors_handler.UpdateHeaders(headers)
outfile.write('Status: %s\r\n' % status)
WriteHeaders(headers, outfile, len(content) if content else None)
outfile.write('\r\n')
if content:
outfile.write(content)
outfile.seek(0)