From 9eb9a461c1850412f7305d4ac9f5c1831de4e007 Mon Sep 17 00:00:00 2001 From: Tres Seaver Date: Thu, 21 Sep 2017 15:15:52 -0400 Subject: [PATCH] Add 'Bucket.list_notifications' API wrapper. (#3990) Toward #3956. --- storage/google/cloud/storage/bucket.py | 49 ++++++++++++- storage/google/cloud/storage/notification.py | 49 ++++++++++++- storage/tests/unit/test_bucket.py | 48 +++++++++++++ storage/tests/unit/test_notification.py | 73 ++++++++++++++++++++ 4 files changed, 214 insertions(+), 5 deletions(-) diff --git a/storage/google/cloud/storage/bucket.py b/storage/google/cloud/storage/bucket.py index 5379a33aa216..2df5db43ec6e 100644 --- a/storage/google/cloud/storage/bucket.py +++ b/storage/google/cloud/storage/bucket.py @@ -77,6 +77,26 @@ def _item_to_blob(iterator, item): return blob +def _item_to_notification(iterator, item): + """Convert a JSON blob to the native object. + + .. note:: + + This assumes that the ``bucket`` attribute has been + added to the iterator after being created. + + :type iterator: :class:`~google.api.core.page_iterator.Iterator` + :param iterator: The iterator that has retrieved the item. + + :type item: dict + :param item: An item to be converted to a blob. + + :rtype: :class:`.BucketNotification` + :returns: The next notification being iterated. + """ + return BucketNotification.from_api_repr(item, bucket=iterator.bucket) + + class Bucket(_PropertyMixin): """A class representing a Bucket on Cloud Storage. @@ -168,10 +188,9 @@ def notification(self, topic_name, payload_format=None): """Factory: create a notification resource for the bucket. - See: :class:`google.cloud.storage.notification.BucketNotification` - for parameters. + See: :class:`.BucketNotification` for parameters. - :rtype: :class:`google.cloud.storage.notification.BucketNotification` + :rtype: :class:`.BucketNotification` """ return BucketNotification( self, topic_name, @@ -405,6 +424,30 @@ def list_blobs(self, max_results=None, page_token=None, prefix=None, iterator.prefixes = set() return iterator + def list_notifications(self, client=None): + """List Pub / Sub notifications for this bucket. + + See: + https://cloud.google.com/storage/docs/json_api/v1/notifications/list + + :type client: :class:`~google.cloud.storage.client.Client` or + ``NoneType`` + :param client: Optional. The client to use. If not passed, falls back + to the ``client`` stored on the current bucket. + + :rtype: list of :class:`.BucketNotification` + :returns: notification instances + """ + client = self._require_client(client) + path = self.path + '/notificationConfigs' + iterator = page_iterator.HTTPIterator( + client=client, + api_request=client._connection.api_request, + path=path, + item_to_value=_item_to_notification) + iterator.bucket = self + return iterator + def delete(self, force=False, client=None): """Delete this bucket. diff --git a/storage/google/cloud/storage/notification.py b/storage/google/cloud/storage/notification.py index 4ca302a035be..d1230234fc3a 100644 --- a/storage/google/cloud/storage/notification.py +++ b/storage/google/cloud/storage/notification.py @@ -14,6 +14,8 @@ """Support for bucket notification resources.""" +import re + from google.api.core.exceptions import NotFound @@ -25,7 +27,12 @@ JSON_API_V1_PAYLOAD_FORMAT = 'JSON_API_V1' NONE_PAYLOAD_FORMAT = 'NONE' -_TOPIC_REF = '//pubsub.googleapis.com/projects/{}/topics/{}' +_TOPIC_REF_FMT = '//pubsub.googleapis.com/projects/{}/topics/{}' +_PROJECT_PATTERN = r'(?P[a-z]+-[a-z]+-\d+)' +_TOPIC_NAME_PATTERN = r'(?P[A-Za-z](\w|[-_.~+%])+)' +_TOPIC_REF_PATTERN = _TOPIC_REF_FMT.format( + _PROJECT_PATTERN, _TOPIC_NAME_PATTERN) +_TOPIC_REF_RE = re.compile(_TOPIC_REF_PATTERN) class BucketNotification(object): @@ -85,6 +92,44 @@ def __init__(self, bucket, topic_name, if payload_format is not None: self._properties['payload_format'] = payload_format + @classmethod + def from_api_repr(cls, resource, bucket): + """Construct an instance from the JSON repr returned by the server. + + See: https://cloud.google.com/storage/docs/json_api/v1/notifications + + :type resource: dict + :param resource: JSON repr of the notification + + :type bucket: :class:`google.cloud.storage.bucket.Bucket` + :param bucket: Bucket to which the notification is bound. + + :rtype: :class:`BucketNotification` + :returns: the new notification instance + :raises ValueError: + if resource is missing 'topic' key, or if it is not formatted + per the spec documented in + https://cloud.google.com/storage/docs/json_api/v1/notifications/insert#topic + """ + topic_path = resource.get('topic') + if topic_path is None: + raise ValueError('Resource has no topic') + + match = _TOPIC_REF_RE.match(topic_path) + if match is None: + raise ValueError( + 'Resource has invalid topic: {}; see {}'.format( + topic_path, + 'https://cloud.google.com/storage/docs/json_api/v1/' + 'notifications/insert#topic')) + + name = match.group('name') + project = match.group('project') + instance = cls(bucket, name, topic_project=project) + instance._properties = resource + + return instance + @property def bucket(self): """Bucket to which the notification is bound.""" @@ -191,7 +236,7 @@ def create(self, client=None): path = '/b/{}/notificationConfigs'.format(self.bucket.name) properties = self._properties.copy() - properties['topic'] = _TOPIC_REF.format( + properties['topic'] = _TOPIC_REF_FMT.format( self.topic_project, self.topic_name) self._properties = client._connection.api_request( method='POST', diff --git a/storage/tests/unit/test_bucket.py b/storage/tests/unit/test_bucket.py index 342a8f5e4ee4..a7ae550eb5e5 100644 --- a/storage/tests/unit/test_bucket.py +++ b/storage/tests/unit/test_bucket.py @@ -389,6 +389,54 @@ def test_list_blobs(self): self.assertEqual(kw['path'], '/b/%s/o' % NAME) self.assertEqual(kw['query_params'], {'projection': 'noAcl'}) + def test_list_notifications(self): + from google.cloud.storage.notification import BucketNotification + from google.cloud.storage.notification import _TOPIC_REF_FMT + + NAME = 'name' + + topic_refs = [ + ('my-project-123', 'topic-1'), + ('other-project-456', 'topic-2'), + ] + + resources = [{ + 'topic': _TOPIC_REF_FMT.format(*topic_refs[0]), + 'id': '1', + 'etag': 'DEADBEEF', + 'selfLink': 'https://example.com/notification/1', + }, { + 'topic': _TOPIC_REF_FMT.format(*topic_refs[1]), + 'id': '2', + 'etag': 'FACECABB', + 'selfLink': 'https://example.com/notification/2', + }] + connection = _Connection({'items': resources}) + client = _Client(connection) + bucket = self._make_one(client=client, name=NAME) + + notifications = list(bucket.list_notifications()) + + self.assertEqual(len(notifications), len(resources)) + for notification, resource, topic_ref in zip( + notifications, resources, topic_refs): + self.assertIsInstance(notification, BucketNotification) + self.assertEqual(notification.topic_project, topic_ref[0]) + self.assertEqual(notification.topic_name, topic_ref[1]) + self.assertEqual(notification.notification_id, resource['id']) + self.assertEqual(notification.etag, resource['etag']) + self.assertEqual(notification.self_link, resource['selfLink']) + self.assertEqual( + notification.custom_attributes, + resource.get('custom_attributes')) + self.assertEqual( + notification.event_types, resource.get('event_types')) + self.assertEqual( + notification.blob_name_prefix, + resource.get('blob_name_prefix')) + self.assertEqual( + notification.payload_format, resource.get('payload_format')) + def test_delete_miss(self): from google.cloud.exceptions import NotFound diff --git a/storage/tests/unit/test_notification.py b/storage/tests/unit/test_notification.py index af83c6e1533b..0a755e866969 100644 --- a/storage/tests/unit/test_notification.py +++ b/storage/tests/unit/test_notification.py @@ -111,6 +111,79 @@ def test_ctor_explicit(self): self.assertEqual( notification.payload_format, self.payload_format()) + def test_from_api_repr_no_topic(self): + klass = self._get_target_class() + client = self._make_client() + bucket = self._make_bucket(client) + resource = {} + + with self.assertRaises(ValueError): + klass.from_api_repr(resource, bucket=bucket) + + def test_from_api_repr_invalid_topic(self): + klass = self._get_target_class() + client = self._make_client() + bucket = self._make_bucket(client) + resource = { + 'topic': '@#$%', + } + + with self.assertRaises(ValueError): + klass.from_api_repr(resource, bucket=bucket) + + def test_from_api_repr_minimal(self): + klass = self._get_target_class() + client = self._make_client() + bucket = self._make_bucket(client) + resource = { + 'topic': self.TOPIC_REF, + 'id': self.NOTIFICATION_ID, + 'etag': self.ETAG, + 'selfLink': self.SELF_LINK, + } + + notification = klass.from_api_repr(resource, bucket=bucket) + + self.assertIs(notification.bucket, bucket) + self.assertEqual(notification.topic_name, self.TOPIC_NAME) + self.assertEqual(notification.topic_project, self.BUCKET_PROJECT) + self.assertIsNone(notification.custom_attributes) + self.assertIsNone(notification.event_types) + self.assertIsNone(notification.blob_name_prefix) + self.assertIsNone(notification.payload_format) + self.assertEqual(notification.etag, self.ETAG) + self.assertEqual(notification.self_link, self.SELF_LINK) + + def test_from_api_repr_explicit(self): + klass = self._get_target_class() + client = self._make_client() + bucket = self._make_bucket(client) + resource = { + 'topic': self.TOPIC_ALT_REF, + 'custom_attributes': self.CUSTOM_ATTRIBUTES, + 'event_types': self.event_types(), + 'blob_name_prefix': self.BLOB_NAME_PREFIX, + 'payload_format': self.payload_format(), + 'id': self.NOTIFICATION_ID, + 'etag': self.ETAG, + 'selfLink': self.SELF_LINK, + } + + notification = klass.from_api_repr(resource, bucket=bucket) + + self.assertIs(notification.bucket, bucket) + self.assertEqual(notification.topic_name, self.TOPIC_NAME) + self.assertEqual(notification.topic_project, self.TOPIC_ALT_PROJECT) + self.assertEqual( + notification.custom_attributes, self.CUSTOM_ATTRIBUTES) + self.assertEqual(notification.event_types, self.event_types()) + self.assertEqual(notification.blob_name_prefix, self.BLOB_NAME_PREFIX) + self.assertEqual( + notification.payload_format, self.payload_format()) + self.assertEqual(notification.notification_id, self.NOTIFICATION_ID) + self.assertEqual(notification.etag, self.ETAG) + self.assertEqual(notification.self_link, self.SELF_LINK) + def test_notification_id(self): client = self._make_client() bucket = self._make_bucket(client)