From 5ad07a05227c5ac772a87f3d0b8b7aaffee6fc8b Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Mon, 8 Feb 2016 11:09:00 +0100 Subject: [PATCH 1/3] Add endpoints to delete buckets and collections --- kinto/tests/test_views_buckets.py | 12 ++++++++++++ kinto/tests/test_views_collections.py | 21 +++++++++++++++++++-- kinto/views/buckets.py | 2 +- kinto/views/collections.py | 2 +- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/kinto/tests/test_views_buckets.py b/kinto/tests/test_views_buckets.py index 19ff7a585..ebc701a21 100644 --- a/kinto/tests/test_views_buckets.py +++ b/kinto/tests/test_views_buckets.py @@ -158,6 +158,18 @@ def get_app_settings(self, extra=None): settings['kinto.bucket_read_principals'] = self.principal return settings + def test_buckets_can_be_deleted_in_bulk(self): + self.app.put_json('/buckets/1', MINIMALIST_BUCKET, + headers=get_user_headers('alice')) + self.app.put_json('/buckets/2', MINIMALIST_BUCKET, + headers=self.headers) + self.app.put_json('/buckets/3', MINIMALIST_BUCKET, + headers=self.headers) + self.app.delete('/buckets', headers=self.headers) + self.app.get('/buckets/1', headers=self.headers, status=200) + self.app.get('/buckets/2', headers=self.headers, status=404) + self.app.get('/buckets/3', headers=self.headers, status=404) + def test_buckets_can_be_deleted(self): self.app.get(self.bucket_url, headers=self.headers, status=404) diff --git a/kinto/tests/test_views_collections.py b/kinto/tests/test_views_collections.py index 8b5f6f87e..acd6b29dc 100644 --- a/kinto/tests/test_views_collections.py +++ b/kinto/tests/test_views_collections.py @@ -1,5 +1,6 @@ from .support import (BaseWebTest, unittest, MINIMALIST_BUCKET, - MINIMALIST_COLLECTION, MINIMALIST_RECORD) + MINIMALIST_COLLECTION, MINIMALIST_RECORD, + get_user_headers) class CollectionViewTest(BaseWebTest, unittest.TestCase): @@ -79,7 +80,10 @@ class CollectionDeletionTest(BaseWebTest, unittest.TestCase): def setUp(self): super(CollectionDeletionTest, self).setUp() - self.app.put_json('/buckets/beers', MINIMALIST_BUCKET, + bucket = MINIMALIST_BUCKET.copy() + bucket['permissions'] = {'collection:create': ['system.Everyone'], + 'read': ['system.Everyone']} + self.app.put_json('/buckets/beers', bucket, headers=self.headers) self.app.put_json(self.collection_url, MINIMALIST_COLLECTION, headers=self.headers) @@ -94,6 +98,19 @@ def test_collections_can_be_deleted(self): self.app.get(self.collection_url, headers=self.headers, status=404) + def test_collections_can_be_deleted_in_bulk(self): + alice_headers = get_user_headers('alice') + self.app.put_json('/buckets/beers/collections/1', + MINIMALIST_COLLECTION, headers=self.headers) + self.app.put_json('/buckets/beers/collections/2', + MINIMALIST_COLLECTION, headers=alice_headers) + self.app.put_json('/buckets/beers/collections/3', + MINIMALIST_COLLECTION, headers=alice_headers) + self.app.delete('/buckets/beers/collections', + headers=alice_headers) + resp = self.app.get('/buckets/beers/collections', headers=self.headers) + self.assertEqual(len(resp.json['data']), 1) + def test_records_of_collection_are_deleted_too(self): self.app.put_json(self.collection_url, MINIMALIST_COLLECTION, headers=self.headers) diff --git a/kinto/views/buckets.py b/kinto/views/buckets.py index b14d5e280..edcc3ceb6 100644 --- a/kinto/views/buckets.py +++ b/kinto/views/buckets.py @@ -8,7 +8,7 @@ class Options: @resource.register(name='bucket', - collection_methods=('GET', 'POST'), + collection_methods=('GET', 'POST', 'DELETE'), collection_path='/buckets', record_path='/buckets/{{id}}') class Bucket(resource.ProtectedResource): diff --git a/kinto/views/collections.py b/kinto/views/collections.py index c30d6cbdf..942f22989 100644 --- a/kinto/views/collections.py +++ b/kinto/views/collections.py @@ -34,7 +34,7 @@ class Options: @resource.register(name='collection', - collection_methods=('GET', 'POST'), + collection_methods=('GET', 'POST', 'DELETE'), collection_path='/buckets/{{bucket_id}}/collections', record_path='/buckets/{{bucket_id}}/collections/{{id}}') class Collection(resource.ProtectedResource): From a1f13c6e31a53332761578601000f6a442c4ed53 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Mon, 8 Feb 2016 11:16:44 +0100 Subject: [PATCH 2/3] Update docs --- CHANGELOG.rst | 4 ++ docs/api/1.x/buckets.rst | 50 +++++++++++++++ docs/api/1.x/collections.rst | 121 ++++++++++++++++++++++++++++++++++- docs/api/1.x/index.rst | 8 ++- docs/api/index.rst | 2 + 5 files changed, 183 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 208a1f2ab..63e51c178 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,8 @@ This document describes changes between each past release. **Protocol** - Allow buckets to store arbitrary properties. (#462) +- Delete every (writable) buckets using ``DELETE /v1/buckets`` +- Delete every (writable) collections using ``DELETE /v1/buckets/<>/collections`` - Clients are redirected to URLs without trailing slash only if the current URL does not exist (#656) - Partial responses can now be specified for nested objects (#445) @@ -18,6 +20,8 @@ This document describes changes between each past release. - Server now returns 415 error response if client cannot accept JSON response (#461, mozilla-services/cliquet#667) - Server now returns 415 error response if client does not send JSON request (#461, mozilla-services/cliquet#667) +Protocol is now version 1.4. See `API changelog `_. + **Breaking changes** - ``kinto start`` must be explicitly run with ``--reload`` in order to diff --git a/docs/api/1.x/buckets.rst b/docs/api/1.x/buckets.rst index cc1bbe302..f19af9e85 100644 --- a/docs/api/1.x/buckets.rst +++ b/docs/api/1.x/buckets.rst @@ -305,6 +305,56 @@ Retrieving all buckets } +.. _buckets-delete: + +Delete all buckets +======================= + +.. http:delete:: /buckets + + :synopsis: Delete every writable buckets for this user + + **Requires authentication** + + **Example Request** + + .. sourcecode:: bash + + $ http delete http://localhost:8888/v1/buckets --auth="token:bob-token" --verbose + + .. sourcecode:: http + + DELETE /v1/buckets HTTP/1.1 + Accept: */* + Accept-Encoding: gzip, deflate + Authorization: Basic YWxpY2U6 + Connection: keep-alive + Content-Length: 0 + Host: localhost:8888 + User-Agent: HTTPie/0.9.2 + + **Example Response** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff + Content-Length: 101 + Content-Type: application/json; charset=UTF-8 + Date: Fri, 26 Feb 2016 14:12:22 GMT + Server: waitress + + { + "data": [ + { + "deleted": true, + "id": "e64db3f9-6a60-1acf-fc3a-7d1ba7e823aa", + "last_modified": 1456495942515 + } + ] + } + + .. _buckets-default-id: Personal bucket «default» diff --git a/docs/api/1.x/collections.rst b/docs/api/1.x/collections.rst index 74ea5a948..b030e9c7c 100644 --- a/docs/api/1.x/collections.rst +++ b/docs/api/1.x/collections.rst @@ -26,7 +26,126 @@ A collection is a mapping with the following attribute: data from their personnal bucket, by sharing :ref:`its URL using the full ID `. -.. _collection-post: + +.. _collections-get: + +List bucket collections +======================= + +.. http:post:: /buckets/(bucket_id)/collections + + :synopsis: List bucket's readable collections + + **Requires authentication** + + **Example Request** + + .. sourcecode:: bash + + $ http GET http://localhost:8888/v1/buckets/blog/collections --auth="token:bob-token" --verbose + + .. sourcecode:: http + + GET /v1/buckets/blog/collections HTTP/1.1 + Accept: */* + Accept-Encoding: gzip, deflate + Authorization: Basic YWxpY2U6 + Connection: keep-alive + Host: localhost:8888 + User-Agent: HTTPie/0.9.2 + + .. sourcecode:: http + + HTTP/1.1 200 OK + Access-Control-Expose-Headers: Content-Length, Expires, Alert, Retry-After, Last-Modified, Total-Records, ETag, Pragma, Cache-Control, Backoff, Next-Page + Cache-Control: no-cache + Content-Length: 144 + Content-Type: application/json; charset=UTF-8 + Date: Fri, 26 Feb 2016 14:14:40 GMT + Etag: "1456496072475" + Last-Modified: Fri, 26 Feb 2016 14:14:32 GMT + Server: waitress + Total-Records: 3 + + { + "data": [ + { + "id": "scores", + "last_modified": 1456496072475 + }, + { + "id": "game", + "last_modified": 1456496060675 + }, + { + "id": "articles", + "last_modified": 1456496056908 + } + ] + } + + +.. _collections-delete: + +Delete bucket collections +======================= + +.. http:delete:: /buckets/(bucket_id)/collections + + :synopsis: Delete every writable collections in this bucket + + **Requires authentication** + + **Example Request** + + .. sourcecode:: bash + + $ http delete http://localhost:8888/v1/buckets/blog/collections --auth="token:bob-token" --verbose + + .. sourcecode:: http + + DELETE /v1/buckets/blog/collections HTTP/1.1 + Accept: */* + Accept-Encoding: gzip, deflate + Authorization: Basic YWxpY2U6 + Connection: keep-alive + Content-Length: 0 + Host: localhost:8888 + User-Agent: HTTPie/0.9.2 + + **Example Response** + + .. sourcecode:: http + + HTTP/1.1 200 OK + Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff + Content-Length: 189 + Content-Type: application/json; charset=UTF-8 + Date: Fri, 26 Feb 2016 14:19:21 GMT + Server: waitress + + { + "data": [ + { + "deleted": true, + "id": "articles", + "last_modified": 1456496361303 + }, + { + "deleted": true, + "id": "game", + "last_modified": 1456496361304 + }, + { + "deleted": true, + "id": "scores", + "last_modified": 1456496361305 + } + ] + } + + +.. _collections-post: Creating a collection ===================== diff --git a/docs/api/1.x/index.rst b/docs/api/1.x/index.rst index 375e09cf1..745878d29 100644 --- a/docs/api/1.x/index.rst +++ b/docs/api/1.x/index.rst @@ -23,6 +23,8 @@ Cheatsheet +----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ | `GET` | :ref:`/buckets ` | :ref:`List buckets ` | +----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ +| `DELETE` | :ref:`/buckets ` | :ref:`Delete every writable buckets ` | ++----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ | `PUT` | :ref:`/buckets/(bucket_id) ` | :ref:`Create or replace a bucket ` | +----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ | `GET` | :ref:`/buckets/(bucket_id) ` | :ref:`Retrieve an existing bucket ` | @@ -43,7 +45,11 @@ Cheatsheet +----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ | **Collections** | +----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ -| `POST` | :ref:`/buckets/(bucket_id)/collections ` | :ref:`Create a collection ` | +| `GET` | :ref:`/buckets/(bucket_id)/collections ` | :ref:`List bucket's collections ` | ++----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ +| `DELETE` | :ref:`/buckets/(bucket_id)/collections ` | :ref:`Delete writable collections ` | ++----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ +| `POST` | :ref:`/buckets/(bucket_id)/collections ` | :ref:`Create a collection ` | +----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ | `PUT` | :ref:`/buckets/(bucket_id)/collections/(collection_id) ` | :ref:`Create or replace a collection ` | +----------+----------------------------------------------------------------------------------------------+---------------------------------------------------------+ diff --git a/docs/api/index.rst b/docs/api/index.rst index 66649a997..d85e3e7d6 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -15,6 +15,8 @@ Changelog '''''''''''''''' - Allow bucket to get arbitrary attributes. +- Delete every (writable) buckets using ``DELETE /v1/buckets`` +- Delete every (writable) collections using ``DELETE /v1/buckets/<>/collections`` - URLs with trailing slash are redirected only if the current URL does not exist - Partial responses can now be specified for nested objects. For example, ``/records?_fields=address.street``. From 9cdedbf0063239a5e3f5ae34a790da75bdb99b52 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Tue, 9 Feb 2016 13:20:12 +0100 Subject: [PATCH 3/3] Move inheritance to resource events * Use recent permission.remove_principal() method * Use ACTIONS constants --- CHANGELOG.rst | 2 +- docs/api/index.rst | 2 +- kinto/views/buckets.py | 37 +++++++++++-------- kinto/views/collections.py | 25 ++++++++----- kinto/views/groups.py | 74 +++++++++++++++++++------------------- 5 files changed, 78 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 63e51c178..56058fe66 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,7 +10,7 @@ This document describes changes between each past release. - Allow buckets to store arbitrary properties. (#462) - Delete every (writable) buckets using ``DELETE /v1/buckets`` -- Delete every (writable) collections using ``DELETE /v1/buckets/<>/collections`` +- Delete every (writable) collections using ``DELETE /v1/buckets//collections`` - Clients are redirected to URLs without trailing slash only if the current URL does not exist (#656) - Partial responses can now be specified for nested objects (#445) diff --git a/docs/api/index.rst b/docs/api/index.rst index d85e3e7d6..228aab13e 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -16,7 +16,7 @@ Changelog - Allow bucket to get arbitrary attributes. - Delete every (writable) buckets using ``DELETE /v1/buckets`` -- Delete every (writable) collections using ``DELETE /v1/buckets/<>/collections`` +- Delete every (writable) collections using ``DELETE /v1/buckets//collections`` - URLs with trailing slash are redirected only if the current URL does not exist - Partial responses can now be specified for nested objects. For example, ``/records?_fields=address.street``. diff --git a/kinto/views/buckets.py b/kinto/views/buckets.py index edcc3ceb6..38851f795 100644 --- a/kinto/views/buckets.py +++ b/kinto/views/buckets.py @@ -1,4 +1,7 @@ from cliquet import resource +from cliquet.events import ResourceChanged, ACTIONS +from pyramid.events import subscriber + from kinto.views import NameGenerator @@ -23,12 +26,20 @@ def get_parent_id(self, request): # Buckets are not isolated by user, unlike Cliquet resources. return '' - def delete(self): - result = super(Bucket, self).delete() + +@subscriber(ResourceChanged, + for_resources=('bucket',), + for_actions=(ACTIONS.DELETE,)) +def on_buckets_deleted(event): + """Some buckets were deleted, delete sub-resources. + """ + storage = event.request.registry.storage + + for change in event.impacted_records: + bucket = change['old'] + parent_id = '/buckets/%s' % bucket['id'] # Delete groups. - storage = self.model.storage - parent_id = '/buckets/%s' % self.record_id storage.delete_all(collection_id='group', parent_id=parent_id, with_deleted=False) @@ -36,20 +47,18 @@ def delete(self): parent_id=parent_id) # Delete collections. - deleted = storage.delete_all(collection_id='collection', - parent_id=parent_id, - with_deleted=False) + deleted_collections = storage.delete_all(collection_id='collection', + parent_id=parent_id, + with_deleted=False) storage.purge_deleted(collection_id='collection', parent_id=parent_id) # Delete records. - id_field = self.model.id_field - for collection in deleted: - parent_id = '/buckets/%s/collections/%s' % (self.record_id, - collection[id_field]) + for collection in deleted_collections: + parent_id = '/buckets/%s/collections/%s' % (bucket['id'], + collection['id']) storage.delete_all(collection_id='record', parent_id=parent_id, with_deleted=False) - storage.purge_deleted(collection_id='record', parent_id=parent_id) - - return result + storage.purge_deleted(collection_id='record', + parent_id=parent_id) diff --git a/kinto/views/collections.py b/kinto/views/collections.py index 942f22989..2ea2a7797 100644 --- a/kinto/views/collections.py +++ b/kinto/views/collections.py @@ -1,7 +1,9 @@ import colander import jsonschema from cliquet import resource +from cliquet.events import ResourceChanged, ACTIONS from jsonschema import exceptions as jsonschema_exceptions +from pyramid.events import subscriber from kinto.views import NameGenerator @@ -50,16 +52,21 @@ def get_parent_id(self, request): parent_id = '/buckets/%s' % bucket_id return parent_id - def delete(self): - result = super(Collection, self).delete() - # Delete records. - storage = self.model.storage - parent_id = '%s/collections/%s' % (self.model.parent_id, - self.record_id) +@subscriber(ResourceChanged, + for_resources=('collection',), + for_actions=(ACTIONS.DELETE,)) +def on_collections_deleted(event): + """Some collections were deleted, delete records. + """ + storage = event.request.registry.storage + + for change in event.impacted_records: + collection = change['old'] + parent_id = '/buckets/%s/collections/%s' % (event.payload['bucket_id'], + collection['id']) storage.delete_all(collection_id='record', parent_id=parent_id, with_deleted=False) - storage.purge_deleted(collection_id='record', parent_id=parent_id) - - return result + storage.purge_deleted(collection_id='record', + parent_id=parent_id) diff --git a/kinto/views/groups.py b/kinto/views/groups.py index 13d5c7451..a9e5e2cc5 100644 --- a/kinto/views/groups.py +++ b/kinto/views/groups.py @@ -1,6 +1,8 @@ import colander from cliquet import resource +from cliquet.events import ResourceChanged, ACTIONS +from pyramid.events import subscriber from kinto.views import NameGenerator @@ -26,49 +28,47 @@ def get_parent_id(self, request): parent_id = '/buckets/%s' % bucket_id return parent_id - def collection_delete(self): - filters = self._extract_filters() - groups, _ = self.model.get_records(filters=filters) - body = super(Group, self).collection_delete() - permission = self.request.registry.permission - for group in groups: - group_id = self.context.get_permission_object_id( - self.request, group[self.model.id_field]) - # Remove the group's principal from all members of the group. - for member in group['members']: - permission.remove_user_principal( - member, - group_id) - return body - - def delete(self): - group = self._get_record_or_404(self.record_id) - permission = self.request.registry.permission - body = super(Group, self).delete() - group_id = self.context.permission_object_id - for member in group['members']: - # Remove the group's principal from all members of the group. - permission.remove_user_principal(member, group_id) - return body - - def process_record(self, new, old=None): - if old is None: - existing_record_members = set() + +@subscriber(ResourceChanged, + for_resources=('group',), + for_actions=(ACTIONS.DELETE,)) +def on_groups_deleted(event): + """Some groups were deleted, remove them from users principals. + """ + permission_backend = event.request.registry.permission + + for change in event.impacted_records: + group = change['old'] + group_uri = '/buckets/{bucket_id}/groups/{id}'.format(id=group['id'], + **event.payload) + permission_backend.remove_principal(group_uri) + + +@subscriber(ResourceChanged, + for_resources=('group',), + for_actions=(ACTIONS.CREATE, ACTIONS.UPDATE)) +def on_groups_changed(event): + """Some groups were changed, update users principals. + """ + permission_backend = event.request.registry.permission + + for change in event.impacted_records: + if 'old' in change: + existing_record_members = set(change['old'].get('members', [])) else: - existing_record_members = set(old.get('members', [])) - new_record_members = set(new['members']) + existing_record_members = set() + + group = change['new'] + group_uri = '/buckets/{bucket_id}/groups/{id}'.format(id=group['id'], + **event.payload) + new_record_members = set(group.get('members', [])) new_members = new_record_members - existing_record_members removed_members = existing_record_members - new_record_members - group_principal = self.context.get_permission_object_id( - self.request, self.record_id) - permission = self.request.registry.permission for member in new_members: # Add the group to the member principal. - permission.add_user_principal(member, group_principal) + permission_backend.add_user_principal(member, group_uri) for member in removed_members: # Remove the group from the member principal. - permission.remove_user_principal(member, group_principal) - - return new + permission_backend.remove_user_principal(member, group_uri)