| #!/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. |
| # |
| |
| |
| |
| |
| """A handler that exports various App Engine services over HTTP. |
| |
| You can export this handler in your app by adding it to the builtins section: |
| |
| builtins: |
| - remote_api: on |
| |
| This will add remote_api serving to the path /_ah/remote_api. |
| |
| You can also add it to your handlers section, e.g.: |
| |
| handlers: |
| - url: /remote_api(/.*)? |
| script: $PYTHON_LIB/google/appengine/ext/remote_api/handler.py |
| |
| You can use remote_api_stub to remotely access services exported by this |
| handler. See the documentation in remote_api_stub.py for details on how to do |
| this. |
| |
| The handler supports several forms of authentication. By default, it |
| checks that the user is an admin using the Users API, similar to specifying |
| "login: admin" in the app.yaml file. It also supports a 'custom header' mode |
| which can be used in certain scenarios. |
| |
| To configure the custom header mode, edit an appengine_config file (the same |
| one you may use to configure appstats) to include a line like this: |
| |
| remoteapi_CUSTOM_ENVIRONMENT_AUTHENTICATION = ( |
| 'HTTP_X_APPENGINE_INBOUND_APPID', ['otherappid'] ) |
| |
| See the ConfigDefaults class below for the full set of options available. |
| """ |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import google |
| import hashlib |
| import logging |
| import os |
| import pickle |
| import wsgiref.handlers |
| import yaml |
| |
| from google.appengine.api import api_base_pb |
| from google.appengine.api import apiproxy_stub |
| from google.appengine.api import apiproxy_stub_map |
| from google.appengine.api import datastore_types |
| from google.appengine.api import lib_config |
| from google.appengine.api import oauth |
| from google.appengine.api import users |
| from google.appengine.datastore import datastore_pb |
| from google.appengine.datastore import datastore_rpc |
| from google.appengine.ext import webapp |
| from google.appengine.ext.db import metadata |
| from google.appengine.ext.remote_api import remote_api_pb |
| from google.appengine.ext.remote_api import remote_api_services |
| from google.appengine.runtime import apiproxy_errors |
| from google.appengine.datastore import entity_pb |
| |
| |
| class ConfigDefaults(object): |
| """Configurable constants. |
| |
| To override remote_api configuration values, define values like this |
| in your appengine_config.py file (in the root of your app): |
| |
| remoteapi_CUSTOM_ENVIRONMENT_AUTHENTICATION = ( |
| 'HTTP_X_APPENGINE_INBOUND_APPID', ['otherappid'] ) |
| |
| You may wish to base this file on sample_appengine_config.py. |
| """ |
| |
| # Allow other App Engine applications to use remote_api with special forms |
| # of authentication which appear in the environment. This is a pair, |
| # ( environment variable name, [ list of valid values ] ). Some examples: |
| # * Allow other applications to use remote_api: |
| # remoteapi_CUSTOM_ENVIRONMENT_AUTHENTICATION = ( |
| # 'HTTP_X_APPENGINE_INBOUND_APPID', ['otherappid'] ) |
| # * Allow two specific users (who need not be admins): |
| # remoteapi_CUSTOM_ENVIRONMENT_AUTHENTICATION = ('USER_ID', |
| # [ '1234', '1111' ] ) |
| |
| |
| |
| |
| |
| # Note that this an alternate to the normal users.is_current_user_admin |
| # check--either one may pass. |
| CUSTOM_ENVIRONMENT_AUTHENTICATION = () |
| _ALLOW_OAUTH = False |
| |
| |
| config = lib_config.register('remoteapi', ConfigDefaults.__dict__) |
| |
| |
| class RemoteDatastoreStub(apiproxy_stub.APIProxyStub): |
| """Provides a stub that permits execution of stateful datastore queries. |
| |
| Some operations aren't possible using the standard interface. Notably, |
| datastore RunQuery operations internally store a cursor that is referenced in |
| later Next calls, and cleaned up at the end of each request. Because every |
| call to ApiCallHandler takes place in its own request, this isn't possible. |
| |
| To work around this, RemoteDatastoreStub provides its own implementation of |
| RunQuery that immediately returns the query results. |
| """ |
| |
| def __init__(self, service='datastore_v3', _test_stub_map=None): |
| """Constructor. |
| |
| Args: |
| service: The name of the service |
| _test_stub_map: An APIProxyStubMap to use for testing purposes. |
| """ |
| super(RemoteDatastoreStub, self).__init__(service) |
| if _test_stub_map: |
| self.__call = _test_stub_map.MakeSyncCall |
| else: |
| self.__call = apiproxy_stub_map.MakeSyncCall |
| |
| def _Dynamic_RunQuery(self, request, response): |
| """Handle a RunQuery request. |
| |
| We handle RunQuery by executing a Query and a Next and returning the result |
| of the Next request. |
| |
| This method is DEPRECATED, but left in place for older clients. |
| """ |
| runquery_response = datastore_pb.QueryResult() |
| self.__call('datastore_v3', 'RunQuery', request, runquery_response) |
| if runquery_response.result_size() > 0: |
| |
| response.CopyFrom(runquery_response) |
| return |
| |
| |
| next_request = datastore_pb.NextRequest() |
| next_request.mutable_cursor().CopyFrom(runquery_response.cursor()) |
| next_request.set_count(request.limit()) |
| self.__call('datastore_v3', 'Next', next_request, response) |
| |
| def _Dynamic_TransactionQuery(self, request, response): |
| if not request.has_ancestor(): |
| raise apiproxy_errors.ApplicationError( |
| datastore_pb.Error.BAD_REQUEST, |
| 'No ancestor in transactional query.') |
| |
| app_id = datastore_types.ResolveAppId(None) |
| if (datastore_rpc._GetDatastoreType(app_id) != |
| datastore_rpc.BaseConnection.HIGH_REPLICATION_DATASTORE): |
| raise apiproxy_errors.ApplicationError( |
| datastore_pb.Error.BAD_REQUEST, |
| 'remote_api supports transactional queries only in the ' |
| 'high-replication datastore.') |
| |
| |
| entity_group_key = entity_pb.Reference() |
| entity_group_key.CopyFrom(request.ancestor()) |
| group_path = entity_group_key.mutable_path() |
| root = entity_pb.Path_Element() |
| root.MergeFrom(group_path.element(0)) |
| group_path.clear_element() |
| group_path.add_element().CopyFrom(root) |
| eg_element = group_path.add_element() |
| eg_element.set_type(metadata.EntityGroup.KIND_NAME) |
| eg_element.set_id(metadata.EntityGroup.ID) |
| |
| |
| begin_request = datastore_pb.BeginTransactionRequest() |
| begin_request.set_app(app_id) |
| tx = datastore_pb.Transaction() |
| self.__call('datastore_v3', 'BeginTransaction', begin_request, tx) |
| |
| |
| request.mutable_transaction().CopyFrom(tx) |
| self.__call('datastore_v3', 'RunQuery', request, response.mutable_result()) |
| |
| |
| get_request = datastore_pb.GetRequest() |
| get_request.mutable_transaction().CopyFrom(tx) |
| get_request.add_key().CopyFrom(entity_group_key) |
| get_response = datastore_pb.GetResponse() |
| self.__call('datastore_v3', 'Get', get_request, get_response) |
| entity_group = get_response.entity(0) |
| |
| |
| response.mutable_entity_group_key().CopyFrom(entity_group_key) |
| if entity_group.has_entity(): |
| response.mutable_entity_group().CopyFrom(entity_group.entity()) |
| |
| |
| self.__call('datastore_v3', 'Commit', tx, datastore_pb.CommitResponse()) |
| |
| def _Dynamic_Transaction(self, request, response): |
| """Handle a Transaction request. |
| |
| We handle transactions by accumulating Put and Delete requests on the client |
| end, as well as recording the key and hash of Get requests. When Commit is |
| called, Transaction is invoked, which verifies that all the entities in the |
| precondition list still exist and their hashes match, then performs a |
| transaction of its own to make the updates. |
| """ |
| |
| begin_request = datastore_pb.BeginTransactionRequest() |
| begin_request.set_app(os.environ['APPLICATION_ID']) |
| begin_request.set_allow_multiple_eg(request.allow_multiple_eg()) |
| tx = datastore_pb.Transaction() |
| self.__call('datastore_v3', 'BeginTransaction', begin_request, tx) |
| |
| |
| preconditions = request.precondition_list() |
| if preconditions: |
| get_request = datastore_pb.GetRequest() |
| get_request.mutable_transaction().CopyFrom(tx) |
| for precondition in preconditions: |
| key = get_request.add_key() |
| key.CopyFrom(precondition.key()) |
| get_response = datastore_pb.GetResponse() |
| self.__call('datastore_v3', 'Get', get_request, get_response) |
| entities = get_response.entity_list() |
| assert len(entities) == request.precondition_size() |
| for precondition, entity in zip(preconditions, entities): |
| if precondition.has_hash() != entity.has_entity(): |
| raise apiproxy_errors.ApplicationError( |
| datastore_pb.Error.CONCURRENT_TRANSACTION, |
| "Transaction precondition failed.") |
| elif entity.has_entity(): |
| entity_hash = hashlib.sha1(entity.entity().Encode()).digest() |
| if precondition.hash() != entity_hash: |
| raise apiproxy_errors.ApplicationError( |
| datastore_pb.Error.CONCURRENT_TRANSACTION, |
| "Transaction precondition failed.") |
| |
| |
| if request.has_puts(): |
| put_request = request.puts() |
| put_request.mutable_transaction().CopyFrom(tx) |
| self.__call('datastore_v3', 'Put', put_request, response) |
| |
| |
| if request.has_deletes(): |
| delete_request = request.deletes() |
| delete_request.mutable_transaction().CopyFrom(tx) |
| self.__call('datastore_v3', 'Delete', delete_request, |
| datastore_pb.DeleteResponse()) |
| |
| |
| self.__call('datastore_v3', 'Commit', tx, datastore_pb.CommitResponse()) |
| |
| def _Dynamic_GetIDsXG(self, request, response): |
| self._Dynamic_GetIDs(request, response, is_xg=True) |
| |
| def _Dynamic_GetIDs(self, request, response, is_xg=False): |
| """Fetch unique IDs for a set of paths.""" |
| |
| for entity in request.entity_list(): |
| assert entity.property_size() == 0 |
| assert entity.raw_property_size() == 0 |
| assert entity.entity_group().element_size() == 0 |
| lastpart = entity.key().path().element_list()[-1] |
| assert lastpart.id() == 0 and not lastpart.has_name() |
| |
| |
| begin_request = datastore_pb.BeginTransactionRequest() |
| begin_request.set_app(os.environ['APPLICATION_ID']) |
| begin_request.set_allow_multiple_eg(is_xg) |
| tx = datastore_pb.Transaction() |
| self.__call('datastore_v3', 'BeginTransaction', begin_request, tx) |
| |
| |
| self.__call('datastore_v3', 'Put', request, response) |
| |
| |
| self.__call('datastore_v3', 'Rollback', tx, api_base_pb.VoidProto()) |
| |
| |
| |
| SERVICE_PB_MAP = remote_api_services.SERVICE_PB_MAP |
| |
| class ApiCallHandler(webapp.RequestHandler): |
| """A webapp handler that accepts API calls over HTTP and executes them.""" |
| |
| LOCAL_STUBS = { |
| 'remote_datastore': RemoteDatastoreStub('remote_datastore'), |
| } |
| |
| OAUTH_SCOPE = 'https://www.googleapis.com/auth/appengine.apis' |
| |
| def CheckIsAdmin(self): |
| user_is_authorized = False |
| if users.is_current_user_admin(): |
| user_is_authorized = True |
| if not user_is_authorized and config.CUSTOM_ENVIRONMENT_AUTHENTICATION: |
| if len(config.CUSTOM_ENVIRONMENT_AUTHENTICATION) == 2: |
| var, values = config.CUSTOM_ENVIRONMENT_AUTHENTICATION |
| if os.getenv(var) in values: |
| user_is_authorized = True |
| else: |
| logging.warning('remoteapi_CUSTOM_ENVIRONMENT_AUTHENTICATION is ' |
| 'configured incorrectly.') |
| |
| if not user_is_authorized and config._ALLOW_OAUTH: |
| try: |
| user_is_authorized = ( |
| oauth.is_current_user_admin(_scope=self.OAUTH_SCOPE)) |
| except oauth.OAuthRequestError: |
| |
| pass |
| if not user_is_authorized: |
| self.response.set_status(401) |
| self.response.out.write( |
| 'You must be logged in as an administrator to access this.') |
| self.response.headers['Content-Type'] = 'text/plain' |
| return False |
| if 'X-appcfg-api-version' not in self.request.headers: |
| self.response.set_status(403) |
| self.response.out.write('This request did not contain a necessary header') |
| self.response.headers['Content-Type'] = 'text/plain' |
| return False |
| return True |
| |
| def CheckConfigIsValid(self): |
| |
| if config.CUSTOM_ENVIRONMENT_AUTHENTICATION and config._ALLOW_OAUTH: |
| self.response.set_status(400) |
| self.response.out.write('You cannot enable both OAuth authentication ' |
| '(remoteapi__ALLOW_OAUTH) and ' |
| 'custom authentication ' |
| '(remoteapi_CUSTOM_ENVIRONMENT_AUTHENTICATION).') |
| return False |
| return True |
| |
| |
| def get(self): |
| """Handle a GET. Just show an info page.""" |
| if not self.CheckConfigIsValid() or not self.CheckIsAdmin(): |
| return |
| |
| rtok = self.request.get('rtok', '0') |
| app_info = { |
| 'app_id': os.environ['APPLICATION_ID'], |
| 'rtok': rtok |
| } |
| |
| self.response.headers['Content-Type'] = 'text/plain' |
| self.response.out.write(yaml.dump(app_info)) |
| |
| def post(self): |
| """Handle POST requests by executing the API call.""" |
| if not self.CheckConfigIsValid() or not self.CheckIsAdmin(): |
| return |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| self.response.headers['Content-Type'] = 'text/plain' |
| |
| response = remote_api_pb.Response() |
| try: |
| request = remote_api_pb.Request() |
| |
| |
| |
| request.ParseFromString(self.request.body) |
| response_data = self.ExecuteRequest(request) |
| response.set_response(response_data.Encode()) |
| self.response.set_status(200) |
| except Exception, e: |
| logging.exception('Exception while handling %s', request) |
| self.response.set_status(200) |
| |
| |
| |
| response.set_exception(pickle.dumps(e)) |
| if isinstance(e, apiproxy_errors.ApplicationError): |
| application_error = response.mutable_application_error() |
| application_error.set_code(e.application_error) |
| application_error.set_detail(e.error_detail) |
| self.response.out.write(response.Encode()) |
| |
| def ExecuteRequest(self, request): |
| """Executes an API invocation and returns the response object.""" |
| service = request.service_name() |
| method = request.method() |
| service_methods = SERVICE_PB_MAP.get(service, {}) |
| request_class, response_class = service_methods.get(method, (None, None)) |
| if not request_class: |
| raise apiproxy_errors.CallNotFoundError() |
| |
| request_data = request_class() |
| request_data.ParseFromString(request.request()) |
| response_data = response_class() |
| |
| if service in self.LOCAL_STUBS: |
| self.LOCAL_STUBS[service].MakeSyncCall(service, method, request_data, |
| response_data) |
| else: |
| apiproxy_stub_map.MakeSyncCall(service, method, request_data, |
| response_data) |
| |
| return response_data |
| |
| def InfoPage(self): |
| """Renders an information page.""" |
| return """ |
| <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" |
| "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> |
| <html><head> |
| <title>App Engine API endpoint.</title> |
| </head><body> |
| <h1>App Engine API endpoint.</h1> |
| <p>This is an endpoint for the App Engine remote API interface. |
| Point your stubs (google.appengine.ext.remote_api.remote_api_stub) here.</p> |
| </body> |
| </html>""" |
| |
| application = webapp.WSGIApplication([('.*', ApiCallHandler)]) |
| |
| |
| def main(): |
| wsgiref.handlers.CGIHandler().run(application) |
| |
| |
| if __name__ == '__main__': |
| main() |