""" Wraps NDB models and provided REST APIs (GET/POST/PUT/DELETE) arounds them. Fully supports permissions. Some code is taken from: https://github.com/abahgat/webapp2-user-accounts """ import importlib import json import re from urlparse import urlparse from datetime import datetime, time, date from urllib import urlencode import webapp2 from google.appengine.ext import ndb from google.appengine.ext.ndb import Cursor from google.appengine.ext.db import BadValueError, BadRequestError from webapp2_extras import auth from webapp2_extras import sessions from webapp2_extras.routes import NamePrefixRoute from google.appengine.ext import blobstore from google.appengine.ext.webapp import blobstore_handlers from google.appengine.api import app_identity from google.net.proto.ProtocolBuffer import ProtocolBufferDecodeError try: import dateutil.parser except ImportError as e: dateutil = None # The REST permissions PERMISSION_ANYONE = 'anyone' PERMISSION_LOGGED_IN_USER = 'logged_in_user' PERMISSION_OWNER_USER = 'owner_user' PERMISSION_ADMIN = 'admin' class NDBEncoder(json.JSONEncoder): """JSON encoding for NDB models and properties""" def _decode_key(self, key): model_class = ndb.Model._kind_map.get(key.kind()) if getattr(model_class, 'RESTMeta', None) and getattr(model_class.RESTMeta, 'use_input_id', False): return key.string_id() else: return key.urlsafe() def default(self, obj): if isinstance(obj, ndb.Model): obj_dict = obj.to_dict() # Each BlobKeyProperty is represented as a dict of upload_url/download_url for (name, prop) in obj._properties.iteritems(): if isinstance(prop, ndb.BlobKeyProperty): server_host = app_identity.get_default_version_hostname() blob_property_url = 'http://%s%s/%s/%s' % (server_host, obj.RESTMeta.base_url, self._decode_key(obj.key), name) # e.g. /api/my_model/<SOME_KEY>/blob_prop obj_dict[name] = { 'upload_url': blob_property_url, 'download_url': blob_property_url if getattr(obj, name) else None # Display as null if the blob property is not set } # Filter the properties that will be returned to user included_properties = get_included_properties(obj, 'output') obj_dict = dict((k,v) for k,v in obj_dict.iteritems() if k in included_properties) # Translate the property names obj_dict = translate_property_names(obj_dict, obj, 'output') obj_dict['id'] = self._decode_key(obj.key) return obj_dict elif isinstance(obj, datetime) or isinstance(obj, date) or isinstance(obj, time): return obj.isoformat() elif isinstance(obj, ndb.Key): return self._decode_key(obj) elif isinstance(obj, ndb.GeoPt): return str(obj) else: return json.JSONEncoder.default(self, obj) class RESTException(Exception): """REST methods exception""" pass class NoResponseResult(object): """A class representing a non-response - used by rest_method_wrapper to detect when we shouldn't print any data with response.write. Used when serving blobs (for BlobKeyProperty)""" pass # # Utility functions # def get_translation_table(model, input_type): """Returns the translation table for a given `model` with a given `input_type`""" meta_class = getattr(model, 'RESTMeta', None) if not meta_class: return {} translation_table = getattr(model.RESTMeta, 'translate_property_names', {}) translation_table.update(getattr(model.RESTMeta, 'translate_%s_property_names' % input_type, {})) return translation_table def translate_property_names(data, model, input_type): """Translates property names in `data` dict from one name to another, according to what is stated in `input_type` and the model's RESTMeta.translate_property_names/translate_input_property_names/translate_output_property_name - note that the change of `data` is in-place.""" translation_table = get_translation_table(model, input_type) if not translation_table: return data # Translate from one property name to another - for output, we turn the original property names # into the new property names. For input, we convert back from the new property names to the original # property names. for old_name, new_name in translation_table.iteritems(): if input_type == 'output' and old_name not in data: continue if input_type == 'input' and new_name not in data: continue if input_type == 'output': original_value = data[old_name] del data[old_name] data[new_name] = original_value elif input_type == 'input': original_value = data[new_name] del data[new_name] data[old_name] = original_value return data def get_included_properties(model, input_type): """Gets the properties of a `model` class to use for input/output (`input_type`). Uses the model's Meta class to determine the included/excluded properties.""" meta_class = getattr(model, 'RESTMeta', None) included_properties = set() if meta_class: included_properties = set(getattr(meta_class, 'included_%s_properties' % input_type, [])) included_properties.update(set(getattr(meta_class, 'included_properties', []))) if not included_properties: # No Meta class (or no included properties defined), assume all properties are included included_properties = set(model._properties.keys()) if meta_class: excluded_properties = set(getattr(meta_class, 'excluded_%s_properties' % input_type, [])) excluded_properties.update(set(getattr(meta_class, 'excluded_properties', []))) else: # No Meta class, assume no properties are excluded excluded_properties = set() # Add some default excluded properties if input_type == 'input': excluded_properties.update(set(BaseRESTHandler.DEFAULT_EXCLUDED_INPUT_PROPERTIES)) if meta_class and getattr(meta_class, 'use_input_id', False): included_properties.update(['id']) if input_type == 'output': excluded_properties.update(set(BaseRESTHandler.DEFAULT_EXCLUDED_OUTPUT_PROPERTIES)) # Calculate the properties to include properties = included_properties - excluded_properties return properties def import_class(input_cls): """Imports a class (if given as a string) or returns as-is (if given as a class)""" if not isinstance(input_cls, str): # It's a class - return as-is return input_cls try: (module_name, class_name) = input_cls.rsplit('.', 1) module = __import__(module_name, fromlist=[class_name]) return getattr(module, class_name) except Exception, exc: # Couldn't import the class raise ValueError("Couldn't import the model class '%s'" % input_cls) class BaseRESTHandler(webapp2.RequestHandler): """Base request handler class for REST handlers (used by RESTHandlerClass and UserRESTHandlerClass)""" # The default number of results to return for a query in case `limit` parameter wasn't provided by the user DEFAULT_MAX_QUERY_RESULTS = 1000 # The names of properties that should be excluded from input/output DEFAULT_EXCLUDED_INPUT_PROPERTIES = [ 'class_' ] # 'class_' is a PolyModel attribute DEFAULT_EXCLUDED_OUTPUT_PROPERTIES = [ ] # # Session related methods/properties # def dispatch(self): """Needed in order for the webapp2 sessions to work""" # Get a session store for this request. self.session_store = sessions.get_store(request=self.request) try: if getattr(self, 'allow_http_method_override', False) and ('X-HTTP-Method-Override' in self.request.headers): # User wants to override method type overridden_method_name = self.request.headers['X-HTTP-Method-Override'].upper().strip() if overridden_method_name not in ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']: return self.method_not_allowed() self.request.method = overridden_method_name if getattr(self, 'allowed_origin', None): allowed_origin = self.allowed_origin if 'Origin' in self.request.headers: # See if the origin matches origin = self.request.headers['Origin'] if (origin != allowed_origin) and (allowed_origin != '*'): return self.permission_denied('Origin not allowed') # Dispatch the request. response = webapp2.RequestHandler.dispatch(self) except: raise else: # Save all sessions. self.session_store.save_sessions(response) return response @webapp2.cached_property def session(self): """Shortcut to access the current session.""" backend = self.app.config.get("session_backend", "datastore") return self.session_store.get_session(backend=backend) # # Authentication methods/properties # @webapp2.cached_property def auth(self): """Shortcut to access the auth instance as a property.""" return auth.get_auth() @webapp2.cached_property def user_info(self): """Shortcut to access a subset of the user attributes that are stored in the session. The list of attributes to store in the session is specified in config['webapp2_extras.auth']['user_attributes']. :returns A dictionary with most user information """ return self.auth.get_user_by_session() @webapp2.cached_property def user_model(self): """Returns the implementation of the user model. It is consistent with config['webapp2_extras.auth']['user_model'], if set. """ return self.auth.store.user_model @webapp2.cached_property def user(self): """Shortcut to access the current logged in user. Unlike user_info, it fetches information from the persistence layer and returns an instance of the underlying model. :returns The instance of the user model associated to the logged in user. """ u = self.user_info return self.user_model.get_by_id(u['user_id']) if u else None # # HTTP response helper methods # def get_response(self, status, content): """Returns an HTTP status message with JSON-encoded content (and appropriate HTTP response headers)""" # Create the JSON-encoded response response = webapp2.Response(json.dumps(content, cls=NDBEncoder)) response.status = status response.headers['Content-Type'] = 'application/json' response.headers['Access-Control-Allow-Methods'] = ', '.join(self.permissions.keys()) if getattr(self, 'allowed_origin', None): response.headers['Access-Control-Allow-Origin'] = self.allowed_origin return response def success(self, content): return self.get_response(200, content) def error(self, exception): return self.get_response(400, {'error': str(exception)}) def method_not_allowed(self): return self.get_response(405, {}) def permission_denied(self, reason=None): return self.get_response(403, { 'reason': reason}) def unauthorized(self): return self.get_response(401, {}) def redirect(self, url, **kwd): return webapp2.redirect(url, **kwd) # # Utility methods # def _model_id_to_model(self, model_id): """Returns the model according to the model_id; raises an exception if invalid ID / model not found""" if not model_id: return None try: if getattr(self.model, 'RESTMeta', None) and getattr(self.model.RESTMeta, 'use_input_id', False): model = ndb.Key(self.model, model_id).get() else: model = ndb.Key(urlsafe=model_id).get() if not model: raise Exception() except Exception, exc: # Invalid key name raise RESTException('Invalid model id - %s' % model_id) return model def _build_next_query_url(self, cursor): """Returns the next URL to fetch results for - used when paging. Returns none if no more results""" if not cursor: return None # Use all of the original query arguments - just override the cursor argument params = self.request.GET params['cursor'] = cursor.urlsafe() return self.request.path_url + '?' + urlencode(params) def _filter_query(self): """Filters the query results for given property filters (if provided by user).""" if not self.request.GET.get('q'): # No query given - return as-is return self.model.query() try: # Translate any property names translation_table = get_translation_table(self.model, 'input') query = self.request.GET.get('q') for original_name, new_name in translation_table.iteritems(): # Replace any references to the new property name with the old (original) one query = re.sub(r'\b%s\s*(<=|>=|=|<|>|!=|(\s+IN\s+))' % new_name, r'%s \1' % original_name, query, flags=re.IGNORECASE) return self.model.gql('WHERE ' + query) except Exception, exc: # Invalid query raise RESTException('Invalid query param - "%s"' % self.request.GET.get('q')) def _fetch_query(self, query): """Fetches the query results for a given limit (if provided by user) and for a specific results page (if given by user). Returns a tuple of (results, cursor_for_next_fetch). cursor_for_next_fetch will be None is no more results are available.""" if not self.request.GET.get('limit'): # No limit given - use default limit limit = BaseRESTHandler.DEFAULT_MAX_QUERY_RESULTS else: try: limit = int(self.request.GET.get('limit')) if limit <= 0: raise ValueError('Limit cannot be zero or less') except ValueError, exc: # Invalid limit value raise RESTException('Invalid "limit" parameter - %s' % self.request.GET.get('limit')) if not self.request.GET.get('cursor'): # Fetch results from scratch cursor = None else: # Continue a previous query try: cursor = Cursor(urlsafe=self.request.GET.get('cursor')) except BadValueError, exc: raise RESTException('Invalid "cursor" argument - %s' % self.request.GET.get('cursor')) try: (results, cursor, more_available) = query.fetch_page(limit, start_cursor=cursor) except BadRequestError, exc: # This happens when we're using an existing cursor and the other query arguments were messed with raise RESTException('Invalid "cursor" argument - %s' % self.request.GET.get('cursor')) if not more_available: cursor = None return (results, cursor) def _order_query(self, query): """Orders the query if input given by user. Returns the modified, sorted query""" if not self.request.GET.get('order'): # No order given orders = [] else: try: # The order parameter is formatted as 'col1, -col2, col3' orders = [o.strip() for o in self.request.GET.get('order').split(',')] orders = ['+'+o if not o.startswith('-') and not o.startswith('+') else o for o in orders] # Translate property names (if it's defined for the current model) - e.g. input 'col1' is actually 'my_col1' in MyModel translated_orders = dict([order.lstrip('-+'), order[0]] for order in orders) translated_orders = translate_property_names(translated_orders, self.model, 'input') orders = [-getattr(self.model, order) if direction == '-' else getattr(self.model, order) for order,direction in translated_orders.iteritems()] except AttributeError, exc: # Invalid column name raise RESTException('Invalid "order" parameter - %s' % self.request.GET.get('order')) # Always use a sort-by-key order at the end - this solves the case where the query uses IN or != operators - since we're using a cursor # to fetch results - there is a requirement for this solution in order for the fetch_page to work. See "Query cursors" at # https://developers.google.com/appengine/docs/python/ndb/queries orders.append(self.model.key) # Return the ordered query return query.order(*orders) def _build_model_from_data(self, data, cls, model=None): """Builds a model instance (according to `cls`) from user input and returns it. Updates an existing model instance if given. Raises exceptions if input data is invalid.""" # Translate the property names (this is done before the filtering in order to get the original property names by which the filtering is done) data = translate_property_names(data, cls, 'input') # Transform any raw input data into appropriate NDB properties - write all transformed properties # into another dict (so any other unauthorized properties will be ignored). input_properties = { } for (name, prop) in cls._properties.iteritems(): if name not in data: continue # Input not given by user if prop._repeated: # This property is repeated (i.e. an array of values) input_properties[name] = [self._value_to_property(value, prop) for value in data[name]] else: input_properties[name] = self._value_to_property(data[name], prop) if not model and getattr(cls, 'RESTMeta', None) and getattr(cls.RESTMeta, 'use_input_id', False): if 'id' not in data: raise RESTException('id field is required') input_properties['id'] = data['id'] # Filter the input properties included_properties = get_included_properties(cls, 'input') input_properties = dict((k,v) for k,v in input_properties.iteritems() if k in included_properties) # Set the user owner property to the currently logged-in user (if it's defined for the model class) - note that we're doing this check on the input `cls` parameter # and not the self.model class, since we need to support when a model has an inner StructuredProperty, and that model has its own RESTMeta definition. if hasattr(cls, 'RESTMeta') and hasattr(cls.RESTMeta, 'user_owner_property'): if not model and self.user: # Only perform this update when creating a new model - otherwise, each update might change this (very problematic in case an # admin updates another user's model instance - it'll change model ownership from that user to the admin) input_properties[cls.RESTMeta.user_owner_property] = self.user.key if not model: # Create a new model instance model = cls(**input_properties) else: # Update an existing model instance model.populate(**input_properties) return model def _value_to_property(self, value, prop): """Converts raw data value into an appropriate NDB property""" if isinstance(prop, ndb.KeyProperty): if value is None: return None try: return ndb.Key(urlsafe=value) except ProtocolBufferDecodeError as e: if prop._kind is not None: model_class = ndb.Model._kind_map.get(prop._kind) if getattr(model_class, 'RESTMeta', None) and getattr(model_class.RESTMeta, 'use_input_id', False): return ndb.Key(model_class, value) raise RESTException('invalid key: {}'.format(value) ) elif isinstance(prop, ndb.TimeProperty): if dateutil is None: try: return datetime.strptime(value, "%H:%M:%S").time() except ValueError as e: raise RESTException("Invalid time. Must be in ISO 8601 format.") else: return dateutil.parser.parse(value).time() elif isinstance(prop, ndb.DateProperty): if dateutil is None: try: return datetime.strptime(value, "%Y-%m-%d").date() except ValueError as e: raise RESTException("Invalid date. Must be in ISO 8601 format.") else: return dateutil.parser.parse(value).date() elif isinstance(prop, ndb.DateTimeProperty): if dateutil is None: try: return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S") except ValueError as e: raise RESTException("Invalid datetime. Must be in ISO 8601 format.") else: return dateutil.parser.parse(value) elif isinstance(prop, ndb.GeoPtProperty): # Convert from string (formatted as '52.37, 4.88') to GeoPt return ndb.GeoPt(value) elif isinstance(prop, ndb.StructuredProperty): # It's a structured property - the input data is a dict - recursively parse it as well return self._build_model_from_data(value, prop._modelclass) else: # Return as-is (no need for further manipulation) return value def get_rest_class(ndb_model, base_url, **kwd): """Returns a RESTHandlerClass with the ndb_model and permissions set according to input""" class RESTHandlerClass(BaseRESTHandler, blobstore_handlers.BlobstoreUploadHandler, blobstore_handlers.BlobstoreDownloadHandler): model = import_class(ndb_model) # Save the base API URL for the model (used for BlobKeyProperty) if not hasattr(model, 'RESTMeta'): class NewRESTMeta: pass model.RESTMeta = NewRESTMeta model.RESTMeta.base_url = base_url permissions = { 'OPTIONS': PERMISSION_ANYONE } permissions.update(kwd.get('permissions', {})) allow_http_method_override = kwd.get('allow_http_method_override', True) allowed_origin = kwd.get('allowed_origin', None) # Wrapping in a list so the functions won't be turned into bound methods after_get_callback = [kwd.get('after_get_callback', None)] before_post_callback = [kwd.get('before_post_callback', None)] after_post_callback = [kwd.get('after_post_callback', None)] before_put_callback = [kwd.get('before_put_callback', None)] after_put_callback = [kwd.get('after_put_callback', None)] before_delete_callback = [kwd.get('before_delete_callback', None)] after_delete_callback = [kwd.get('after_delete_callback', None)] # Validate arguments (we do this at this stage in order to raise exceptions immediately rather than while the app is running) if PERMISSION_OWNER_USER in permissions.values(): if not hasattr(model, 'RESTMeta') or not hasattr(model.RESTMeta, 'user_owner_property'): raise ValueError('Must define a RESTMeta.user_owner_property for the model class %s if user-owner permission is used' % (model)) if not hasattr(model, model.RESTMeta.user_owner_property): raise ValueError('The user_owner_property "%s" (defined in RESTMeta.user_owner_property) does not exist in the given model %s' % (model.RESTMeta.user_owner_property, model)) def __init__(self, request, response): self.initialize(request, response) blobstore_handlers.BlobstoreUploadHandler.__init__(self, request, response) blobstore_handlers.BlobstoreDownloadHandler.__init__(self, request, response) self.after_get_callback = self.after_get_callback[0] self.before_post_callback = self.before_post_callback[0] self.after_post_callback = self.after_post_callback[0] self.before_put_callback = self.before_put_callback[0] self.after_put_callback = self.after_put_callback[0] self.before_delete_callback = self.before_delete_callback[0] self.after_delete_callback = self.after_delete_callback[0] def rest_method_wrapper(func): """Wraps GET/POST/PUT/DELETE methods and adds standard functionality""" def inner_f(self, model_id, property_name=None): # See if method type is supported method_name = func.func_name.upper() if method_name not in self.permissions: return self.method_not_allowed() # Verify permissions permission = self.permissions[method_name] if (permission in [PERMISSION_LOGGED_IN_USER, PERMISSION_OWNER_USER, PERMISSION_ADMIN]) and (not self.user): # User not logged-in as required return self.unauthorized() elif permission == PERMISSION_ADMIN and not self.is_user_admin: # User is not an admin return self.permission_denied() try: # Call original method if model_id: model = self._model_id_to_model(model_id.lstrip('/')) # Get rid of '/' at the beginning if (permission == PERMISSION_OWNER_USER) and (self.get_model_owner(model) != self.user.key): # The currently logged-in user is not the owner of the model return self.permission_denied() if property_name and model: # Get the original name of the property property_name = translate_property_names({ property_name: True }, model, 'input').keys()[0] result = func(self, model, property_name) else: result = func(self, None, None) if isinstance(result, webapp2.Response): # webapp2.Response instance - no need for further manipulation (return as-is) return result elif not isinstance(result, NoResponseResult): # Only return a result (i.e. write to the response object) if it's not a NoResponseResult (used when serving blobs - BlobKeyProperty) return self.success(result) except RESTException, exc: return self.error(exc) return inner_f # # REST endpoint methods # @rest_method_wrapper def options(self, model, property_name=None): """OPTIONS endpoint - doesn't return anything (only returns options in the HTTP response headers)""" return '' @rest_method_wrapper def get(self, model, property_name=None): """GET endpoint - retrieves a single model instance (by ID) or a list of model instances by query""" if not model: # Return a query with multiple results query = self._filter_query() # Filter the results if self.permissions['GET'] == PERMISSION_OWNER_USER: # Return only models owned by currently logged-in user query = query.filter(getattr(self.model, self.user_owner_property) == self.user.key) query = self._order_query(query) # Order the results (results, cursor) = self._fetch_query(query) # Fetch them (with a limit / specific page, if provided) if self.after_get_callback: # Additional processing required results = self.after_get_callback(results) return { 'results': results, 'next_results_url': self._build_next_query_url(cursor) } else: if property_name: # Return a specific property value - currently supported only for BlobKeyProperty if not hasattr(model, property_name): raise RESTException('Invalid property name "%s"' % property_name) blob_key = getattr(model, property_name) if not blob_key: raise RESTException('"%s" is not set' % property_name) if not isinstance(blob_key, blobstore.BlobKey): raise RESTException('"%s" is not a BlobKeyProperty' % property_name) # Send the blob contents self.send_blob(blob_key) # Make sure we don't return a value (i.e. not write to self.response) - so self.send_blob will work properly return NoResponseResult() # Return a single item (query by ID) if self.after_get_callback: # Additional processing required model = self.after_get_callback(model) return model @rest_method_wrapper def post(self, model, property_name=None): """POST endpoint - adds a new model instance""" if model and not property_name: # Invalid usage of the endpoint raise RESTException('Cannot POST to a specific model ID') if model and property_name: # POST to a BlobKeyProperty if not hasattr(model, property_name): raise RESTException('Invalid property name "%s"' % property_name) if not isinstance(model._properties[property_name], ndb.BlobKeyProperty): raise RESTException('"%s" is not a BlobKeyProperty' % property_name) # Next, get the created blob upload_files = self.get_uploads() if not upload_files: # No upload data - this happens when the user POSTS for the first time - we need to create an upload URL and redirect # the user to it (the BlobstoreUploadHandler will handle self.get_uploads() for us and we'll get to the same point). # We do it this way and not simply refer the user directly to create_upload_url, so we won't call create_upload_url # every time the user GETs to /my_model - since each create_upload_url call creates more DB garbage. upload_url = blobstore.create_upload_url(self.request.url) return self.redirect(upload_url, code=307) # We use a 307 redirect in order to tell the client (e.g. browser) to use the same method type (POST) and keep its POST data blob_info = upload_files[0] if getattr(model, property_name): # The property already has a previous value - delete the older blob blobstore.delete(getattr(model, property_name)) # Set the blob reference setattr(model, property_name, blob_info.key()) model.put() # Everything was OK return { 'status': True } try: # Parse POST data as JSON json_data = json.loads(self.request.body) except ValueError as exc: raise RESTException('Invalid JSON POST data') if not isinstance(json_data, list): json_data = [json_data] models = [] for model_to_create in json_data: try: # Any exceptions raised due to invalid/missing input will be caught model = self._build_model_from_data(model_to_create, self.model) models.append(model) except Exception as exc: raise RESTException('Invalid JSON POST data - %s' % exc) if self.before_post_callback: models = self.before_post_callback(models, json_data) # Commit all models in a transaction created_keys = ndb.put_multi(models) if self.after_post_callback: models = self.after_post_callback(created_keys, models) # Return the newly-created model instance(s) return models @rest_method_wrapper def put(self, model, property_name=None): """PUT endpoint - updates an existing model instance""" models = [] try: # Parse PUT data as JSON json_data = json.loads(self.request.body) except ValueError as exc: raise RESTException('Invalid JSON PUT data') if model: # Update just one model model = self._build_model_from_data(json_data, self.model, model) json_data = [json_data] models.append(model) else: # Update several models at once if not isinstance(json_data, list): raise RESTException('Invalid JSON PUT data') for model_to_update in json_data: model_id = model_to_update.pop('id', None) if model_id is None: raise RESTException('Missing "id" argument for model') model = self._model_id_to_model(model_id) model = self._build_model_from_data(model_to_update, self.model, model) models.append(model) if self.before_put_callback: models = self.before_put_callback(models, json_data) # Commit all models in a transaction updated_keys = ndb.put_multi(models) if self.after_put_callback: models = self.after_put_callback(updated_keys, models) return models def _delete_model_blobs(self, model): """Deletes all blobs associated with the model (finds all BlobKeyProperty)""" for (name, prop) in model._properties.iteritems(): if isinstance(prop, ndb.BlobKeyProperty): if getattr(model, name): blobstore.delete(getattr(model, name)) @rest_method_wrapper def delete(self, model, property_name=None): """DELETE endpoint - deletes an existing model instance""" models = [] if model: models.append(model) else: # Delete multiple model instances if self.permissions['DELETE'] == PERMISSION_OWNER_USER: # Delete all models owned by the currently logged-in user query = self.model.query().filter(getattr(self.model, self.user_owner_property) == self.user.key) else: # Delete all models query = self.model.query() # Delete the models (we might need to fetch several pages in case of many results) cursor = None more_available = True while more_available: results, cursor, more_available = query.fetch_page(BaseRESTHandler.DEFAULT_MAX_QUERY_RESULTS, start_cursor=cursor) if results: models.extend(results) if self.before_delete_callback: models = self.before_delete_callback(models) for m in models: self._delete_model_blobs(m) # No easy way to delete blobstore entries in a transaction deleted_keys = ndb.delete_multi(m.key for m in models) if self.after_delete_callback: self.after_delete_callback(deleted_keys, models) # Return the deleted models return models # # Utility methods/properties # @webapp2.cached_property def is_user_admin(self): """Determines if the currently logged-in user is an admin or not (relies on the user class RESTMeta.admin_property)""" if not hasattr(self.user, 'RESTMeta') or not hasattr(self.user.RESTMeta, 'admin_property'): # This is caused due to a misconfiguration by the coder (didn't define a proper RESTMeta.admin_property) - we raise an exception so # it'll trigger a 500 internal server error. This specific argument validation is done here instead of the class definition (where the # rest of the arguments are being validated) since at that stage we can't see the webapp2 auth configuration to determine the User model. raise ValueError('The user model class %s must include a RESTMeta class with `admin_property` defined' % (self.user.__class__)) admin_property = self.user.RESTMeta.admin_property if not hasattr(self.user, admin_property): raise ValueError('The user model class %s does not have the property %s as defined in its RESTMeta.admin_property' % (self.user.__class__, admin_property)) return getattr(self.user, admin_property) @webapp2.cached_property def user_owner_property(self): """Returns the name of the user_owner_property""" return self.model.RESTMeta.user_owner_property def get_model_owner(self, model): """Returns the user owner of the given `model` (relies on RESTMeta.user_owner_property)""" return getattr(model, self.user_owner_property) # Return the class statically initialized with given input arguments return RESTHandlerClass class RESTHandler(NamePrefixRoute): # We inherit from NamePrefixRoute so the same router can actually return several routes simultaneously (used for BlobKeyProperty) """Returns our RequestHandler with the appropriate permissions and model. Should be used as part of the WSGIApplication routing: app = webapp2.WSGIApplication([('/mymodel', RESTHandler( MyModel, permissions={ 'GET': PERMISSION_ANYONE, 'POST': PERMISSION_LOGGED_IN_USER, 'PUT': PERMISSION_OWNER_USER, 'DELETE': PERMISSION_ADMIN } )]) """ def __init__(self, url, model, **kwd): url = url.rstrip(' /') model = import_class(model) if not url.startswith('/'): raise ValueError('RESHandler url should start with "/": %s' % url) routes = [ # Make sure we catch both URLs: to '/mymodel' and to '/mymodel/123' webapp2.Route(url + '<model_id:(/.+)?|/>', get_rest_class(model, url, **kwd), 'main') ] included_properties = get_included_properties(model, 'input') translation_table = get_translation_table(model, 'input') # Build extra routes for each BlobKeyProperty for (name, prop) in model._properties.iteritems(): if isinstance(prop, ndb.BlobKeyProperty) and name in included_properties: # Register a route for the current BlobKeyProperty property_name = translation_table.get(name, name) blob_property_url = '%s/<model_id:.+?>/<property_name:%s>' % (url, property_name) # e.g. /api/my_model/<SOME_KEY>/blob_prop # Upload/Download blob route and handler routes.insert(0, webapp2.Route(blob_property_url, get_rest_class(model, url, **kwd), 'upload-download-blob')) super(RESTHandler, self).__init__('rest-handler-', routes)