def fxa_oauth_token(request): """Return OAuth token from authorization code. """ state = request.validated['state'] code = request.validated['code'] # Require on-going session stored_redirect = request.cache.get(state) # Make sure we cannot try twice with the same code request.registry.cache.delete(state) if not stored_redirect: return authorization_required(request) # Trade the OAuth code for a longer-lived token auth_client = OAuthClient(server_url=fxa_conf(request, 'oauth_uri'), client_id=fxa_conf(request, 'client_id'), client_secret=fxa_conf(request, 'client_secret')) try: token = auth_client.trade_code(code) except fxa_errors.OutOfProtocolError: raise httpexceptions.HTTPServiceUnavailable() except fxa_errors.InProtocolError as error: logger.error(error) error_details = { 'name': 'code', 'location': 'querystring', 'description': 'Firefox Account code validation failed.' } errors.raise_invalid(request, **error_details) return httpexceptions.HTTPFound(location='%s%s' % (stored_redirect, token))
def collection_get(request): collection_name = request.matchdict['collection_name'] sync_client = build_sync_client(request) headers = import_headers(request) params = {} if '_since' in request.GET: try: params['newer'] = '%.2f' % (int(request.GET['_since']) / 1000.0) except ValueError: error_msg = ("_since should be a number.") raise_invalid(request, location="querystring", name="_since", description=error_msg) if '_limit' in request.GET: params['limit'] = request.GET['_limit'] if '_token' in request.GET: params['offset'] = request.GET['_token'] if '_sort' in request.GET: if request.GET['_sort'] in ('-last_modified', 'newest'): params['sort'] = 'newest' elif request.GET['_sort'] in ('-sortindex', 'index'): params['sort'] = 'index' elif request.GET['_sort'] in ('last_modified', 'oldest'): params['sort'] = 'oldest' else: error_msg = ("_sort should be one of ('-last_modified', 'newest', " "'-sortindex', 'index', 'last_modified', 'oldest')") raise_invalid(request, location="querystring", name="_sort", description=error_msg) if 'in_ids' in request.GET: params['ids'] = [record_id.strip() for record_id in request.GET['in_ids'].split(',') if record_id] records = sync_client.get_records(collection_name, full=True, headers=headers, **params) statsd_count(request, "syncclient.status_code.200") for r in records: r['last_modified'] = int(r.pop('modified') * 1000) # Configure headers export_headers(sync_client.raw_resp, request) if '_limit' in request.GET and 'Total-Records' in request.response.headers: del request.response.headers['Total-Records'] return {'data': records or []}
def _raise_304_if_not_modified(self, record=None): """Raise 304 if current timestamp is inferior to the one specified in headers. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotModified` """ if_none_match = self.request.headers.get('If-None-Match') if not if_none_match: return if_none_match = decode_header(if_none_match) try: if not (if_none_match[0] == if_none_match[-1] == '"'): raise ValueError() modified_since = int(if_none_match[1:-1]) except (IndexError, ValueError): if if_none_match == '*': return error_details = { 'location': 'headers', 'description': "Invalid value for If-None-Match" } raise_invalid(self.request, **error_details) if record: current_timestamp = record[self.model.modified_field] else: current_timestamp = self.model.timestamp() if current_timestamp <= modified_since: response = HTTPNotModified() self._add_timestamp_header(response, timestamp=current_timestamp) raise response
def _extract_pagination_rules_from_token(self, sorting): """Get pagination params.""" queryparams = self.request.GET paginate_by = self.request.registry.settings['cliquet.paginate_by'] limit = queryparams.get('_limit', paginate_by) if limit: try: limit = int(limit) except ValueError: error_details = { 'location': 'querystring', 'description': "_limit should be an integer" } raise_invalid(self.request, **error_details) # If limit is higher than paginate_by setting, ignore it. if limit and paginate_by: limit = min(limit, paginate_by) token = queryparams.get('_token', None) filters = [] if token: try: last_record = decode_token(token) assert isinstance(last_record, dict) except (ValueError, TypeError, AssertionError): error_msg = '_token has invalid content' error_details = { 'location': 'querystring', 'description': error_msg } raise_invalid(self.request, **error_details) filters = self._build_pagination_rules(sorting, last_record) return filters, limit
def delete(self): """Record ``DELETE`` endpoint: delete a record and return it. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotFound` if the record is not found. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if ``If-Match`` header is provided and record modified in the iterim. """ self._raise_400_if_invalid_id(self.record_id) record = self._get_record_or_404(self.record_id) self._raise_412_if_modified(record) # Retreive the last_modified information from a querystring if present. last_modified = self.request.GET.get('last_modified') if last_modified: last_modified = native_value(last_modified.strip('"')) if not isinstance(last_modified, six.integer_types): error_details = { 'name': 'last_modified', 'location': 'querystring', 'description': 'Invalid value for %s' % last_modified } raise_invalid(self.request, **error_details) # If less or equal than current record. Ignore it. if last_modified <= record[self.model.modified_field]: last_modified = None deleted = self.model.delete_record(record, last_modified=last_modified) return self.postprocess(deleted, action=ACTIONS.DELETE)
def resource_create_object(request, resource_cls, uri, resource_name, obj_id): """In the default bucket, the bucket and collection are implicitly created. This helper instantiate the resource and simulate a request with its RootFactory on the instantiated resource. :returns: the created object :rtype: dict """ # Fake context to instantiate a resource. context = RouteFactory(request) context.get_permission_object_id = lambda r, i: uri resource = resource_cls(request, context) # Check that provided id is valid for this resource. if not resource.model.id_generator.match(obj_id): error_details = { 'location': 'path', 'description': "Invalid %s id" % resource_name } raise_invalid(resource.request, **error_details) data = {'id': obj_id} try: obj = resource.model.create_record(data) # Since the current request is not a resource (but a straight Service), # we simulate a request on a resource. # This will be used in the resource event payload. resource.request.current_resource_name = resource_name resource.postprocess(data, action=ACTIONS.CREATE) except storage_exceptions.UnicityError as e: obj = e.record return obj
def _extract_sorting(self, limit): """Extracts filters from QueryString parameters.""" specified = self.request.GET.get('_sort', '').split(',') sorting = [] modified_field_used = self.model.modified_field in specified for field in specified: field = field.strip() m = re.match(r'^([\-+]?)(\w+)$', field) if m: order, field = m.groups() if not self.is_known_field(field): error_details = { 'location': 'querystring', 'description': "Unknown sort field '{0}'".format(field) } raise_invalid(self.request, **error_details) direction = -1 if order == '-' else 1 sorting.append(Sort(field, direction)) if not modified_field_used: # Add a sort by the ``modified_field`` in descending order # useful for pagination sorting.append(Sort(self.model.modified_field, -1)) return sorting
def process_record(self, new, old=None): """Validate records against collection schema, if any.""" new = super(Record, self).process_record(new, old) schema = self._collection.get('schema') settings = self.request.registry.settings schema_validation = 'experimental_collection_schema_validation' if not schema or not asbool(settings.get(schema_validation)): return new collection_timestamp = self._collection[self.model.modified_field] try: stripped = copy.deepcopy(new) stripped.pop(self.model.id_field, None) stripped.pop(self.model.modified_field, None) stripped.pop(self.model.permissions_field, None) stripped.pop(self.schema_field, None) jsonschema.validate(stripped, schema) except jsonschema_exceptions.ValidationError as e: try: field = e.path.pop() if e.path else e.validator_value.pop() except AttributeError: field = None raise_invalid(self.request, name=field, description=e.message) new[self.schema_field] = collection_timestamp return new
def _extract_filters(self, queryparams=None): """Extracts filters from QueryString parameters.""" if not queryparams: queryparams = self.request.GET filters = [] for param, value in queryparams.items(): param = param.strip() value = native_value(value) # Ignore specific fields if param.startswith('_') and param not in ('_since', '_to', '_before'): continue # Handle the _since specific filter. if param in ('_since', '_to', '_before'): if not isinstance(value, six.integer_types): error_details = { 'name': param, 'location': 'querystring', 'description': 'Invalid value for %s' % param } raise_invalid(self.request, **error_details) if param == '_since': operator = COMPARISON.GT else: if param == '_to': message = ('_to is now deprecated, ' 'you should use _before instead') url = ('http://cliquet.rtfd.org/en/2.4.0/api/resource' '.html#list-of-available-url-parameters') send_alert(self.request, message, url) operator = COMPARISON.LT filters.append( Filter(self.collection.modified_field, value, operator) ) continue m = re.match(r'^(min|max|not|lt|gt)_(\w+)$', param) if m: keyword, field = m.groups() operator = getattr(COMPARISON, keyword.upper()) else: operator, field = COMPARISON.EQ, param if not self.is_known_field(field): error_details = { 'location': 'querystring', 'description': "Unknown filter field '{0}'".format(param) } raise_invalid(self.request, **error_details) filters.append(Filter(field, value, operator)) return filters
def collection_get(request): collection_name = request.matchdict["collection_name"] sync_client = build_sync_client(request) headers = import_headers(request) params = {} if "_since" in request.GET: try: params["newer"] = "%.2f" % (int(request.GET["_since"]) / 1000.0) except ValueError: error_msg = "_since should be a number." raise_invalid(request, location="querystring", name="_since", description=error_msg) if "_limit" in request.GET: params["limit"] = request.GET["_limit"] if "_token" in request.GET: params["offset"] = request.GET["_token"] if "_sort" in request.GET: if request.GET["_sort"] in ("-last_modified", "newest"): params["sort"] = "newest" elif request.GET["_sort"] in ("-sortindex", "index"): params["sort"] = "index" elif request.GET["_sort"] in ("last_modified", "oldest"): params["sort"] = "oldest" else: error_msg = ( "_sort should be one of ('-last_modified', 'newest', " "'-sortindex', 'index', 'last_modified', 'oldest')" ) raise_invalid(request, location="querystring", name="_sort", description=error_msg) if "in_ids" in request.GET: params["ids"] = [record_id.strip() for record_id in request.GET["in_ids"].split(",") if record_id] records = sync_client.get_records(collection_name, full=True, headers=headers, **params) status_code(request, str="syncclient.status_code.200") for r in records: r["last_modified"] = int(r.pop("modified") * 1000) # Configure headers export_headers(sync_client.raw_resp, request) if "_limit" in request.GET and "Total-Records" in request.response.headers: del request.response.headers["Total-Records"] if records: return {"data": records}
def _raise_400_if_invalid_id(self, record_id): """Raise 400 if specified record id does not match the format excepted by storage backends. :raises: :class:`pyramid.httpexceptions.HTTPBadRequest` """ if not self.model.id_generator.match(six.text_type(record_id)): error_details = { 'location': 'path', 'description': "Invalid record id" } raise_invalid(self.request, **error_details)
def _raise_400_if_id_mismatch(self, new_id, record_id): """Raise 400 if the `new_id`, within the request body, does not match the `record_id`, obtained from request path. :raises: :class:`pyramid.httpexceptions.HTTPBadRequest` """ if new_id != record_id: error_msg = 'Record id does not match existing record' error_details = { 'name': self.model.id_field, 'description': error_msg } raise_invalid(self.request, **error_details)
def process_record(self, new, old=None): """Operate changes on submitted record. This implementation represents the specifities of the *Reading List* article resource. In a future version, URL resolution (*redirects*) and article title obtention (*HTML content*) will be performed here. Contrary to article content fetching, this fields resolution has to be performed synchronously (i.e. withing request/response cycle) during article creation, otherwise unicity of ``resolved_url`` cannot be guaranteed. :note: This could moved to a specific end-point, in order to keep the article API aligned with behaviour generic resources. """ if old: # Read position should be superior if old['read_position'] > new['read_position']: new['read_position'] = old['read_position'] # Marking as read requires device info if old['unread'] and not new['unread']: if not any((new['marked_read_on'], new['marked_read_by'])): error = 'Missing marked_read_by or marked_read_on fields' errors.raise_invalid(self.request, name='unread', description=error) # Device info is ignored if already read if not old['unread']: new['marked_read_on'] = old['marked_read_on'] new['marked_read_by'] = old['marked_read_by'] else: # Date of creation is set new['stored_on'] = TimeStamp().deserialize() # In this first version, do not resolve url and title. if new['resolved_title'] is None: new['resolved_title'] = new['title'] if new['resolved_url'] is None: new['resolved_url'] = new['url'] # Reset info when article is marked as unread if new['unread'] and (old and not old['unread']): new['marked_read_on'] = None new['marked_read_by'] = None new['read_position'] = 0 return new
def _raise_412_if_modified(self, record=None): """Raise 412 if current timestamp is superior to the one specified in headers. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` """ if_match = self.request.headers.get('If-Match') if_none_match = self.request.headers.get('If-None-Match') if not if_match and not if_none_match: return if_match = decode_header(if_match) if if_match else None if record and if_none_match and decode_header(if_none_match) == '*': if record.get(self.model.deleted_field, False): # Tombstones should not prevent creation. return modified_since = -1 # Always raise. elif if_match: try: if not (if_match[0] == if_match[-1] == '"'): raise ValueError() modified_since = int(if_match[1:-1]) except (IndexError, ValueError): message = ("Invalid value for If-Match. The value should " "be integer between double quotes.") error_details = { 'location': 'headers', 'description': message } raise_invalid(self.request, **error_details) else: # In case _raise_304_if_not_modified() did not raise. return if record: current_timestamp = record[self.model.modified_field] else: current_timestamp = self.model.timestamp() if current_timestamp > modified_since: error_msg = 'Resource was modified meanwhile' details = {'existing': record} if record else {} response = http_error(HTTPPreconditionFailed(), errno=ERRORS.MODIFIED_MEANWHILE, message=error_msg, details=details) self._add_timestamp_header(response, timestamp=current_timestamp) raise response
def _extract_filters(self, queryparams=None): """Extracts filters from QueryString parameters.""" if not queryparams: queryparams = self.request.GET filters = [] for param, value in queryparams.items(): param = param.strip() value = native_value(value) # Ignore specific fields if param.startswith('_') and param not in ('_since', '_to'): continue # Handle the _since specific filter. if param in ('_since', '_to'): if not isinstance(value, six.integer_types): error_details = { 'name': param, 'location': 'querystring', 'description': 'Invalid value for _since' } raise_invalid(self.request, **error_details) if param == '_since': operator = COMPARISON.GT else: operator = COMPARISON.LT filters.append( Filter(self.collection.modified_field, value, operator) ) continue m = re.match(r'^(min|max|not|lt|gt)_(\w+)$', param) if m: keyword, field = m.groups() operator = getattr(COMPARISON, keyword.upper()) else: operator, field = COMPARISON.EQ, param if not self.is_known_field(field): error_details = { 'location': 'querystring', 'description': "Unknown filter field '{0}'".format(param) } raise_invalid(self.request, **error_details) filters.append(Filter(field, value, operator)) return filters
def collection_get(request): collection_name = request.matchdict['collection_name'] sync_client = build_sync_client(request) params = {} if '_since' in request.GET: params['newer'] = request.GET['_since'] if '_limit' in request.GET: params['limit'] = request.GET['_limit'] if '_token' in request.GET: params['offset'] = request.GET['_token'] if '_sort' in request.GET: if request.GET['_sort'] in ('-last_modified', 'newest'): params['sort'] = 'newest' elif request.GET['_sort'] in ('-sortindex', 'index'): params['sort'] = 'index' else: error_msg = ("_sort should be one of ('-last_modified', 'newest', " "'-sortindex', 'index')") raise_invalid(request, location="querystring", name="_sort", description=error_msg) if 'ids' in request.GET: try: params['ids'] = [uuid4_to_base64(record_id.strip()) for record_id in request.GET['ids'].split(',') if record_id] except ValueError: raise_invalid(request, location="querystring", name="ids", description="Invalid id in ids list.") records = sync_client.get_records(collection_name, full=True, **params) for r in records: r['last_modified'] = int(r.pop('modified') * 1000) r['id'] = base64_to_uuid4(r.pop('id')) # Configure headers convert_headers(sync_client.raw_resp, request.response) return {'data': records}
def attachment_post(request): # Remove potential existing attachment. delete_attachment(request) # Store file locally. folder_pattern = request.registry.settings.get('attachment.folder', '') folder = folder_pattern.format(**request.matchdict) or None content = request.POST[FILE_FIELD] try: location = request.attachment.save(content, randomize=True, folder=folder) except FileNotAllowed: error_msg = 'File extension is not allowed.' raise_invalid(request, location='body', description=error_msg) # Read file to compute hash. content.file.seek(0) filecontent = content.file.read() # File metadata. fullurl = request.attachment.url(location) size = len(filecontent) filehash = sha256(filecontent) attachment = { 'filename': content.filename, 'location': fullurl, 'hash': filehash, 'mimetype': content.type, 'size': size } # Store link between record and attachment (for later deletion). request.registry.storage.create("", FILE_LINKS, { 'location': location, # store relative location. 'bucket_uri': bucket_uri(request), 'collection_uri': collection_uri(request), 'record_uri': record_uri(request) }) # Update related record. record = {k: v for k, v in request.POST.items() if k != FILE_FIELD} for k, v in record.items(): record[k] = json.loads(v) record.setdefault('data', {})[FILE_FIELD] = attachment save_record(record, request) # Return attachment data (with location header) request.response.headers['Location'] = record_uri(request, prefix=True) return attachment
def _raise_412_if_modified(self, record=None): """Raise 412 if current timestamp is superior to the one specified in headers. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` """ if_match = self.request.headers.get('If-Match') if_none_match = self.request.headers.get('If-None-Match') if not if_match and not if_none_match: return if_match = decode_header(if_match) if if_match else None if record and if_none_match and decode_header(if_none_match) == '*': if record.get(self.model.deleted_field, False): # Tombstones should not prevent creation. return modified_since = -1 # Always raise. elif if_match: try: if not (if_match[0] == if_match[-1] == '"'): raise ValueError() modified_since = int(if_match[1:-1]) except (IndexError, ValueError): message = ("Invalid value for If-Match. The value should " "be integer between double quotes.") error_details = {'location': 'headers', 'description': message} raise_invalid(self.request, **error_details) else: # In case _raise_304_if_not_modified() did not raise. return if record: current_timestamp = record[self.model.modified_field] else: current_timestamp = self.model.timestamp() if current_timestamp > modified_since: error_msg = 'Resource was modified meanwhile' details = {'existing': record} if record else {} response = http_error(HTTPPreconditionFailed(), errno=ERRORS.MODIFIED_MEANWHILE, message=error_msg, details=details) self._add_timestamp_header(response, timestamp=current_timestamp) raise response
def import_headers(syncto_request, sync_request_headers=None): """Convert incoming Kinto headers into Sync headers.""" request_headers = syncto_request.headers headers = sync_request_headers or {} # For server-side analytics, keep original User-Agent. ua = "Syncto/%s" % __version__ original_ua = request_headers.get('User-Agent') if original_ua: ua += " (on behalf of %s)" % original_ua headers['User-Agent'] = ua # Handle concurrency control. if 'If-Match' in request_headers: if_match = request_headers['If-Match'] try: assert if_match[0] == if_match[-1] == '"' unmodified_since = int(if_match[1:-1]) except (IndexError, AssertionError, ValueError): error_details = { 'location': 'headers', 'description': "Invalid value for If-Match" } raise_invalid(syncto_request, **error_details) else: headers['X-If-Unmodified-Since'] = '%.2f' % ( int(unmodified_since) / 1000.0) if 'If-None-Match' in request_headers: if_none_match = request_headers['If-None-Match'] if if_none_match == '*': headers['X-If-Unmodified-Since'] = 0 else: try: assert if_none_match[0] == if_none_match[-1] == '"' modified_since = int(if_none_match[1:-1]) except (IndexError, AssertionError, ValueError): error_details = { 'location': 'headers', 'description': "Invalid value for If-None-Match" } raise_invalid(syncto_request, **error_details) headers['X-If-Modified-Since'] = '%.2f' % (int(modified_since) / 1000.0) return headers
def patch(self): """Record `PATCH` endpoint.""" record = self.get_record(self.record_id) self._raise_412_if_modified(record) if not self.request.body: error_details = { 'description': 'Empty body' } raise_invalid(self.request, **error_details) changes = self.request.json updated = self.apply_changes(record, changes=changes) updated = self.process_record(updated, old=record) record = self.update_record(record, updated, changes) return record
def apply_changes(self, record, changes): """Merge `changes` into `record` fields. .. note:: This is used in the context of PATCH only. Override this to control field changes at record level, for example: .. code-block:: python def apply_changes(self, record, changes): # Ignore value change if inferior if record['position'] > changes.get('position', -1): changes.pop('position', None) return super(MyResource, self).apply_changes(record, changes) :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPBadRequest` if result does not comply with resource schema. :returns: the new record with `changes` applied. :rtype: dict """ for field, value in changes.items(): has_changed = record.get(field, value) != value if self.mapping.is_readonly(field) and has_changed: error_details = { 'name': field, 'description': 'Cannot modify {0}'.format(field) } raise_invalid(self.request, **error_details) updated = record.copy() updated.update(**changes) try: return self.mapping.deserialize(updated) except colander.Invalid as e: # Transform the errors we got from colander into Cornice errors. # We could not rely on Service schema because the record should be # validated only once the changes are applied for field, error in e.asdict().items(): raise_invalid(self.request, name=field, description=error)
def _extract_limit(self): """Extract limit value from QueryString parameters.""" paginate_by = self.request.registry.settings['paginate_by'] limit = self.request.GET.get('_limit', paginate_by) if limit: try: limit = int(limit) except ValueError: error_details = { 'location': 'querystring', 'description': "_limit should be an integer" } raise_invalid(self.request, **error_details) # If limit is higher than paginate_by setting, ignore it. if limit and paginate_by: limit = min(limit, paginate_by) return limit
def _extract_pagination_rules_from_token(self, limit, sorting): """Get pagination params.""" queryparams = self.request.GET token = queryparams.get('_token', None) filters = [] if token: try: last_record = json.loads(decode64(token)) assert isinstance(last_record, dict) except (ValueError, TypeError, AssertionError): error_msg = '_token has invalid content' error_details = { 'location': 'querystring', 'description': error_msg } raise_invalid(self.request, **error_details) filters = self._build_pagination_rules(sorting, last_record) return filters
def _raise_412_if_modified(self, record=None): """Raise 412 if current timestamp is superior to the one specified in headers. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` """ if_match = self.request.headers.get('If-Match') if_none_match = self.request.headers.get('If-None-Match') if not if_match and not if_none_match: return if_match = if_match.decode('utf-8') if if_match else None if if_none_match and if_none_match.decode('utf-8') == '*': modified_since = -1 # Always raise. elif if_match: try: assert if_match[0] == if_match[-1] == '"' modified_since = int(if_match[1:-1]) except (IndexError, AssertionError, ValueError): error_details = { 'location': 'headers', 'description': "Invalid value for If-Match" } raise_invalid(self.request, **error_details) else: # In case _raise_304_if_not_modified() did not raise. return if record: current_timestamp = record[self.collection.modified_field] else: current_timestamp = self.collection.timestamp() if current_timestamp > modified_since: error_msg = 'Resource was modified meanwhile' response = http_error(HTTPPreconditionFailed(), errno=ERRORS.MODIFIED_MEANWHILE, message=error_msg) self._add_timestamp_header(response, timestamp=current_timestamp) raise response
def process_record(self, new, old=None): """Validate records against collection schema, if any.""" new = super(Record, self).process_record(new, old) schema = self._collection.get('schema') settings = self.request.registry.settings schema_validation = 'kinto.experimental_collection_schema_validation' if not schema or not asbool(settings.get(schema_validation)): return new collection_timestamp = self._collection[self.collection.modified_field] try: jsonschema.validate(new, schema) new[self.schema_field] = collection_timestamp except jsonschema_exceptions.ValidationError as e: field = e.path.pop() if e.path else e.validator_value.pop() raise_invalid(self.request, name=field, description=e.message) return new
def process_record(self, new, old=None): """Validate records against collection schema, if any.""" new = super(Record, self).process_record(new, old) schema = self._collection.get('schema') settings = self.request.registry.settings schema_validation = 'experimental_collection_schema_validation' if not schema or not asbool(settings.get(schema_validation)): return new collection_timestamp = self._collection[self.model.modified_field] try: jsonschema.validate(new, schema) new[self.schema_field] = collection_timestamp except jsonschema_exceptions.ValidationError as e: field = e.path.pop() if e.path else e.validator_value.pop() raise_invalid(self.request, name=field, description=e.message) return new
def create_collection(request, bucket_id): # Do nothing if current request does not involve a collection. subpath = request.matchdict.get('subpath') if not (subpath and subpath.startswith('collections/')): return collection_id = subpath.split('/')[1] collection_uri = '/buckets/%s/collections/%s' % (bucket_id, collection_id) # Do not intent to create multiple times per request (e.g. in batch). already_created = request.bound_data.setdefault('collections', {}) if collection_uri in already_created: return # Do nothing if current request will already create the collection. collection_put = (request.method.lower() == 'put' and request.path.endswith(collection_id)) if collection_put: return # Fake context to instantiate a Collection resource. context = RouteFactory(request) context.get_permission_object_id = lambda r, i: collection_uri backup = request.matchdict request.matchdict = dict(bucket_id=bucket_id, id=collection_id, **request.matchdict) resource = Collection(request, context) if not resource.model.id_generator.match(collection_id): error_details = { 'location': 'path', 'description': "Invalid collection_id id" } raise_invalid(request, **error_details) try: collection = resource.model.create_record({'id': collection_id}) except storage_exceptions.UnicityError as e: collection = e.record already_created[collection_uri] = collection request.matchdict = backup
def import_headers(syncto_request, sync_request_headers=None): """Convert incoming Kinto headers into Sync headers.""" request_headers = syncto_request.headers headers = sync_request_headers or {} if 'If-Match' in request_headers: if_match = request_headers['If-Match'] try: assert if_match[0] == if_match[-1] == '"' unmodified_since = int(if_match[1:-1]) except (IndexError, AssertionError, ValueError): error_details = { 'location': 'headers', 'description': "Invalid value for If-Match" } raise_invalid(syncto_request, **error_details) else: headers['X-If-Unmodified-Since'] = '%.2f' % ( int(unmodified_since) / 1000.0) if 'If-None-Match' in request_headers: if_none_match = request_headers['If-None-Match'] if if_none_match == '"*"': headers['X-If-Unmodified-Since'] = 0 else: try: assert if_none_match[0] == if_none_match[-1] == '"' modified_since = int(if_none_match[1:-1]) except (IndexError, AssertionError, ValueError): error_details = { 'location': 'headers', 'description': "Invalid value for If-None-Match" } raise_invalid(syncto_request, **error_details) headers['X-If-Modified-Since'] = '%.2f' % ( int(modified_since) / 1000.0) return headers
def put(self): """Record `PUT` endpoint.""" try: existing = self.get_record(self.record_id) self._raise_412_if_modified(existing) except HTTPNotFound: existing = None new_record = self.request.validated new_id = new_record.setdefault(self.id_field, self.record_id) if new_id != self.record_id: error_msg = 'Record id does not match existing record' error_details = { 'name': self.id_field, 'location': 'querystring', 'description': error_msg } raise_invalid(self.request, **error_details) new_record = self.process_record(new_record, old=existing) record = self.update_record(existing, new_record) return record
def _extract_partial_fields(self): """Extract the fields to do the projection from QueryString parameters. """ fields = self.request.GET.get('_fields', None) if fields: fields = fields.split(',') root_fields = [f.split('.')[0] for f in fields] known_fields = self._get_known_fields() invalid_fields = set(root_fields) - set(known_fields) preserve_unknown = self.mapping.get_option('preserve_unknown') if not preserve_unknown and invalid_fields: error_msg = "Fields %s do not exist" % ','.join(invalid_fields) error_details = { 'name': "Invalid _fields parameter", 'description': error_msg } raise_invalid(self.request, **error_details) # Since id and last_modified are part of the synchronisation # protocol, force their presence in payloads. fields = fields + [self.model.id_field, self.model.modified_field] return fields
def _extract_pagination_rules_from_token(self, limit, sorting): """Get pagination params.""" queryparams = self.request.GET token = queryparams.get('_token', None) filters = [] offset = 0 if token: try: tokeninfo = json.loads(decode64(token)) if not isinstance(tokeninfo, dict): raise ValueError() last_record = tokeninfo['last_record'] offset = tokeninfo['offset'] except (ValueError, KeyError, TypeError): error_msg = '_token has invalid content' error_details = { 'location': 'querystring', 'description': error_msg } raise_invalid(self.request, **error_details) filters = self._build_pagination_rules(sorting, last_record) return filters, offset
def patch(self): """Record ``PATCH`` endpoint: modify a record and return its new version. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if ``If-Unmodified-Since`` header is provided and record modified in the iterim. .. seealso:: Add custom behaviour by overriding :meth:`cliquet.resource.BaseResource.get_record`, :meth:`cliquet.resource.BaseResource.apply_changes`, :meth:`cliquet.resource.BaseResource.process_record` or :meth:`cliquet.resource.BaseResource.update_record`. """ self._raise_400_if_invalid_id(self.record_id) record = self.get_record(self.record_id) self._raise_412_if_modified(record) if not self.request.body: error_details = { 'description': 'Empty body' } raise_invalid(self.request, **error_details) changes = self.request.json updated = self.apply_changes(record, changes=changes) record_id = updated.setdefault(self.id_field, self.record_id) self._raise_400_if_id_mismatch(record_id, self.record_id) updated = self.process_record(updated, old=record) record = self.update_record(record, updated, changes) return record
def save_file(content, request, randomize=True): folder_pattern = request.registry.settings.get('attachment.folder', '') folder = folder_pattern.format(**request.matchdict) or None try: location = request.attachment.save(content, folder=folder, randomize=randomize) except FileNotAllowed: error_msg = 'File extension is not allowed.' raise_invalid(request, location='body', description=error_msg) # Read file to compute hash. content.file.seek(0) filecontent = content.file.read() # File metadata. fullurl = request.attachment.url(location) size = len(filecontent) filehash = sha256(filecontent) attachment = { 'filename': content.filename, 'location': fullurl, 'hash': filehash, 'mimetype': content.type, 'size': size } # Store link between record and attachment (for later deletion). request.registry.storage.create("", FILE_LINKS, { 'location': location, # store relative location. 'bucket_uri': bucket_uri(request), 'collection_uri': collection_uri(request), 'record_uri': record_uri(request) }) return attachment
def collection_get(request): collection_name = request.matchdict['collection_name'] sync_client = build_sync_client(request) headers = import_headers(request) params = {} if '_since' in request.GET: try: params['newer'] = '%.2f' % (int(request.GET['_since']) / 1000.0) except ValueError: error_msg = ("_since should be a number.") raise_invalid(request, location="querystring", name="_since", description=error_msg) if '_limit' in request.GET: params['limit'] = request.GET['_limit'] if '_token' in request.GET: params['offset'] = request.GET['_token'] if '_sort' in request.GET: if request.GET['_sort'] in ('-last_modified', 'newest'): params['sort'] = 'newest' elif request.GET['_sort'] in ('-sortindex', 'index'): params['sort'] = 'index' elif request.GET['_sort'] in ('last_modified', 'oldest'): params['sort'] = 'oldest' else: error_msg = ("_sort should be one of ('-last_modified', 'newest', " "'-sortindex', 'index', 'last_modified', 'oldest')") raise_invalid(request, location="querystring", name="_sort", description=error_msg) if 'in_ids' in request.GET: params['ids'] = [ record_id.strip() for record_id in request.GET['in_ids'].split(',') if record_id ] records = sync_client.get_records(collection_name, full=True, headers=headers, **params) statsd_count(request, "syncclient.status_code.200") for r in records: r['last_modified'] = int(r.pop('modified') * 1000) # Configure headers export_headers(sync_client.raw_resp, request) if '_limit' in request.GET and 'Total-Records' in request.response.headers: del request.response.headers['Total-Records'] return {'data': records or []}
def _extract_filters(self, queryparams=None): """Extracts filters from QueryString parameters.""" if not queryparams: queryparams = self.request.GET filters = [] for param, paramvalue in queryparams.items(): param = param.strip() error_details = { 'name': param, 'location': 'querystring', 'description': 'Invalid value for %s' % param } # Ignore specific fields if param.startswith('_') and param not in ('_since', '_to', '_before'): continue # Handle the _since specific filter. if param in ('_since', '_to', '_before'): value = native_value(paramvalue.strip('"')) if not isinstance(value, six.integer_types): raise_invalid(self.request, **error_details) if param == '_since': operator = COMPARISON.GT else: if param == '_to': message = ('_to is now deprecated, ' 'you should use _before instead') url = ('http://cliquet.rtfd.org/en/2.4.0/api/resource' '.html#list-of-available-url-parameters') send_alert(self.request, message, url) operator = COMPARISON.LT filters.append( Filter(self.model.modified_field, value, operator) ) continue m = re.match(r'^(min|max|not|lt|gt|in|exclude)_(\w+)$', param) if m: keyword, field = m.groups() operator = getattr(COMPARISON, keyword.upper()) else: operator, field = COMPARISON.EQ, param if not self.is_known_field(field): error_msg = "Unknown filter field '{0}'".format(param) error_details['description'] = error_msg raise_invalid(self.request, **error_details) value = native_value(paramvalue) if operator in (COMPARISON.IN, COMPARISON.EXCLUDE): value = set([native_value(v) for v in paramvalue.split(',')]) all_integers = all([isinstance(v, six.integer_types) for v in value]) all_strings = all([isinstance(v, six.text_type) for v in value]) has_invalid_value = ( (field == self.model.id_field and not all_strings) or (field == self.model.modified_field and not all_integers) ) if has_invalid_value: raise_invalid(self.request, **error_details) filters.append(Filter(field, value, operator)) return filters
def patch(self): """Record ``PATCH`` endpoint: modify a record and return its new version. If a request header ``Response-Behavior`` is set to ``light``, only the fields whose value was changed are returned. If set to ``diff``, only the fields whose value became different than the one provided are returned. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPNotFound` if the record is not found. :raises: :exc:`~pyramid:pyramid.httpexceptions.HTTPPreconditionFailed` if ``If-Match`` header is provided and record modified in the iterim. .. seealso:: Add custom behaviour by overriding :meth:`cliquet.resource.UserResource.apply_changes` or :meth:`cliquet.resource.UserResource.process_record`. """ self._raise_400_if_invalid_id(self.record_id) existing = self._get_record_or_404(self.record_id) self._raise_412_if_modified(existing) try: # `data` attribute may not be present if only perms are patched. changes = self.request.json.get('data', {}) except ValueError: # If no `data` nor `permissions` is provided in patch, reject! # XXX: This should happen in schema instead (c.f. ShareableViewSet) error_details = { 'name': 'data', 'description': 'Provide at least one of data or permissions', } raise_invalid(self.request, **error_details) updated = self.apply_changes(existing, changes=changes) record_id = updated.setdefault(self.model.id_field, self.record_id) self._raise_400_if_id_mismatch(record_id, self.record_id) new_record = self.process_record(updated, old=existing) changed_fields = [ k for k in changes.keys() if existing.get(k) != new_record.get(k) ] # Save in storage if necessary. if changed_fields or self.force_patch_update: try: unique_fields = self.mapping.get_option('unique_fields') new_record = self.model.update_record( new_record, unique_fields=unique_fields) except storage_exceptions.UnicityError as e: self._raise_conflict(e) else: # Behave as if storage would have added `id` and `last_modified`. for extra_field in [ self.model.modified_field, self.model.id_field ]: new_record[extra_field] = existing[extra_field] # Adjust response according to ``Response-Behavior`` header body_behavior = self.request.headers.get('Response-Behavior', 'full') if body_behavior.lower() == 'light': # Only fields that were changed. data = {k: new_record[k] for k in changed_fields} elif body_behavior.lower() == 'diff': # Only fields that are different from those provided. data = { k: new_record[k] for k in changed_fields if changes.get(k) != new_record.get(k) } else: data = new_record timestamp = new_record.get(self.model.modified_field, existing[self.model.modified_field]) self._add_timestamp_header(self.request.response, timestamp=timestamp) return self.postprocess(data, action=ACTIONS.UPDATE, old=existing)
def _extract_filters(self, queryparams=None): """Extracts filters from QueryString parameters.""" if not queryparams: queryparams = self.request.GET filters = [] for param, paramvalue in queryparams.items(): param = param.strip() error_details = { 'name': param, 'location': 'querystring', 'description': 'Invalid value for %s' % param } # Ignore specific fields if param.startswith('_') and param not in ('_since', '_to', '_before'): continue # Handle the _since specific filter. if param in ('_since', '_to', '_before'): value = native_value(paramvalue.strip('"')) if not isinstance(value, six.integer_types): raise_invalid(self.request, **error_details) if param == '_since': operator = COMPARISON.GT else: if param == '_to': message = ('_to is now deprecated, ' 'you should use _before instead') url = ('http://cliquet.rtfd.org/en/2.4.0/api/resource' '.html#list-of-available-url-parameters') send_alert(self.request, message, url) operator = COMPARISON.LT filters.append( Filter(self.model.modified_field, value, operator)) continue m = re.match(r'^(min|max|not|lt|gt|in|exclude)_(\w+)$', param) if m: keyword, field = m.groups() operator = getattr(COMPARISON, keyword.upper()) else: operator, field = COMPARISON.EQ, param if not self.is_known_field(field): error_msg = "Unknown filter field '{0}'".format(param) error_details['description'] = error_msg raise_invalid(self.request, **error_details) value = native_value(paramvalue) if operator in (COMPARISON.IN, COMPARISON.EXCLUDE): value = set([native_value(v) for v in paramvalue.split(',')]) all_integers = all( [isinstance(v, six.integer_types) for v in value]) all_strings = all( [isinstance(v, six.text_type) for v in value]) has_invalid_value = ( (field == self.model.id_field and not all_strings) or (field == self.model.modified_field and not all_integers)) if has_invalid_value: raise_invalid(self.request, **error_details) filters.append(Filter(field, value, operator)) return filters