Repository: googleapis/google-cloud-python

Issue:
Add support for storage->pubsub notifications
See the [concepts doc](https://cloud.google.com/storage/docs/pubsub-notifications) and the [registering object changes doc](https://cloud.google.com/storage/docs/reporting-changes).

Pull requests target the [`3956-storage-notifications` branch](https://github.com/GoogleCloudPlatform/google-cloud-python/tree/3956-storage-notifications)

Steps:

- [x] Add a `BucketNotification` class, mapping the [notification resource](https://cloud.google.com/storage/docs/json_api/v1/notifications#resource).  Note that a `BucketNotification` may point to a topic which belongs to a different `project` than the one which owns its bucket. (#3957)
- [x] Add `Bucket.notification` factory. returning new `BucketNotification`. (#3958)
- [x] Add `BucketNotification.create` method, mapping [notifications/insert](https://cloud.google.com/storage/docs/json_api/v1/notifications/insert).  Update the bucket's properties from the response (e.g., to get its server-generated ID).  Should we barf if the ID is already set? (#3966)
- [x] Add `BucketNotification.delete` method, mapping [notifications/delete](https://cloud.google.com/storage/docs/json_api/v1/notifications/delete).  Presumably barf if the ID is not set. (#3983)
- [x] Add `BucketNotification.exists` method, mapping [notifications/get](https://cloud.google.com/storage/docs/json_api/v1/notifications/get) onto boolean (False for `NotFound`).  Presumably barf if the ID is not set. (#3984)
- [x] Add `BucketNotification.reload` method, mapping [notifications/get](https://cloud.google.com/storage/docs/json_api/v1/notifications/get),  Presumably barf if the ID is not set. (#3986)
- [x] Add `Bucket.list_notifications` method, mapping [notifications/list](https://cloud.google.com/storage/docs/json_api/v1/notifications/list).  Return an iterator of `BucketNotification` instances. (#3990)
- [x] Add a system test which exercises the new API wrapper methods. (#4021)

Questions:

- Do we need to support *de novo* creation of `BucketNotfication` instances w/ a user-supplied ID?  The spec says that the ID is generated during `notifications/insert`, which suggests not.
- I assume that access to the new features does not depend on being able to import `google.cloud.pubsub`.  ~~The counter-example would be that `BucketNotification.create` must verify the existence of its topic, and maybe create a new one.  Or does the back-end do that?~~ The [docs say](https://cloud.google.com/storage/docs/reporting-changes#enabling) that a topic is created if it does not exist.
- There is no documented support for update / patch of existing notifications.  Is that intentional?
- What are the intended semantics of passing {'payload_format`: 'NONE'}?  I don't think subscribers will find message with an empty payload useful, so I must be misunderstanding something.  Do the non-payload attributes provide enough information for the subscriber to do something useful?

/cc @lukesneeringer, perhaps to loop in the appropriate GCS back-end contact.


Discussion:
Another question:  do we need a "round trip" system test?  E.g., one which verifies that notifications arrive at the subscriber?  Or can we get away with just doing `BucketNotification` CRUD in the system tests?

Resolving patch:
diff --git a/storage/google/cloud/storage/bucket.py b/storage/google/cloud/storage/bucket.py
--- a/storage/google/cloud/storage/bucket.py
+++ b/storage/google/cloud/storage/bucket.py
@@ -35,6 +35,8 @@
 from google.cloud.storage.acl import DefaultObjectACL
 from google.cloud.storage.blob import Blob
 from google.cloud.storage.blob import _get_encryption_headers
+from google.cloud.storage.notification import BucketNotification
+from google.cloud.storage.notification import NONE_PAYLOAD_FORMAT
 
 
 def _blobs_page_start(iterator, page, response):
@@ -76,6 +78,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.
 
@@ -159,6 +181,27 @@ def blob(self, blob_name, chunk_size=None, encryption_key=None):
         return Blob(name=blob_name, bucket=self, chunk_size=chunk_size,
                     encryption_key=encryption_key)
 
+    def notification(self, topic_name,
+                     topic_project=None,
+                     custom_attributes=None,
+                     event_types=None,
+                     blob_name_prefix=None,
+                     payload_format=NONE_PAYLOAD_FORMAT):
+        """Factory:  create a notification resource for the bucket.
+
+        See: :class:`.BucketNotification` for parameters.
+
+        :rtype: :class:`.BucketNotification`
+        """
+        return BucketNotification(
+            self, topic_name,
+            topic_project=topic_project,
+            custom_attributes=custom_attributes,
+            event_types=event_types,
+            blob_name_prefix=blob_name_prefix,
+            payload_format=payload_format,
+        )
+
     def exists(self, client=None):
         """Determines whether or not this bucket exists.
 
@@ -382,6 +425,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
new file mode 100644
--- /dev/null
+++ b/storage/google/cloud/storage/notification.py
@@ -0,0 +1,313 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Support for bucket notification resources."""
+
+import re
+
+from google.api.core.exceptions import NotFound
+
+
+OBJECT_FINALIZE_EVENT_TYPE = 'OBJECT_FINALIZE'
+OBJECT_METADATA_UPDATE_EVENT_TYPE = 'OBJECT_METADATA_UPDATE'
+OBJECT_DELETE_EVENT_TYPE = 'OBJECT_DELETE'
+OBJECT_ARCHIVE_EVENT_TYPE = 'OBJECT_ARCHIVE'
+
+JSON_API_V1_PAYLOAD_FORMAT = 'JSON_API_V1'
+NONE_PAYLOAD_FORMAT = 'NONE'
+
+_TOPIC_REF_FMT = '//pubsub.googleapis.com/projects/{}/topics/{}'
+_PROJECT_PATTERN = r'(?P<project>[a-z]+-[a-z]+-\d+)'
+_TOPIC_NAME_PATTERN = r'(?P<name>[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):
+    """Represent a single notification resource for a bucket.
+
+    See: https://cloud.google.com/storage/docs/json_api/v1/notifications
+
+    :type bucket: :class:`google.cloud.storage.bucket.Bucket`
+    :param bucket: Bucket to which the notification is bound.
+
+    :type topic_name: str
+    :param topic_name: Topic name to which notifications are published.
+
+    :type topic_project: str
+    :param topic_project:
+        (Optional) project ID of topic to which notifications are published.
+        If not passed, uses the project ID of the bucket's client.
+
+    :type custom_attributes: dict
+    :param custom_attributes:
+        (Optional) additional attributes passed with notification events.
+
+    :type event_types: list(str)
+    :param event_types:
+        (Optional) event types for which notificatin events are published.
+
+    :type blob_name_prefix: str
+    :param blob_name_prefix:
+        (Optional) prefix of blob names for which notification events are
+        published..
+
+    :type payload_format: str
+    :param payload_format:
+        (Optional) format of payload for notification events.
+    """
+    def __init__(self, bucket, topic_name,
+                 topic_project=None, custom_attributes=None, event_types=None,
+                 blob_name_prefix=None, payload_format=NONE_PAYLOAD_FORMAT):
+        self._bucket = bucket
+        self._topic_name = topic_name
+
+        if topic_project is None:
+            topic_project = bucket.client.project
+        self._topic_project = topic_project
+
+        self._properties = {}
+
+        if custom_attributes is not None:
+            self._properties['custom_attributes'] = custom_attributes
+
+        if event_types is not None:
+            self._properties['event_types'] = event_types
+
+        if blob_name_prefix is not None:
+            self._properties['object_name_prefix'] = blob_name_prefix
+
+        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."""
+        return self._bucket
+
+    @property
+    def topic_name(self):
+        """Topic name to which notifications are published."""
+        return self._topic_name
+
+    @property
+    def topic_project(self):
+        """Project ID of topic to which notifications are published.
+        """
+        return self._topic_project
+
+    @property
+    def custom_attributes(self):
+        """Custom attributes passed with notification events.
+        """
+        return self._properties.get('custom_attributes')
+
+    @property
+    def event_types(self):
+        """Event types for which notification events are published.
+        """
+        return self._properties.get('event_types')
+
+    @property
+    def blob_name_prefix(self):
+        """Prefix of blob names for which notification events are published.
+        """
+        return self._properties.get('object_name_prefix')
+
+    @property
+    def payload_format(self):
+        """Format of payload of notification events."""
+        return self._properties.get('payload_format')
+
+    @property
+    def notification_id(self):
+        """Server-set ID of notification resource."""
+        return self._properties.get('id')
+
+    @property
+    def etag(self):
+        """Server-set ETag of notification resource."""
+        return self._properties.get('etag')
+
+    @property
+    def self_link(self):
+        """Server-set ETag of notification resource."""
+        return self._properties.get('selfLink')
+
+    @property
+    def client(self):
+        """The client bound to this notfication."""
+        return self.bucket.client
+
+    @property
+    def path(self):
+        """The URL path for this notification."""
+        return '/b/{}/notificationConfigs/{}'.format(
+            self.bucket.name, self.notification_id)
+
+    def _require_client(self, client):
+        """Check client or verify over-ride.
+
+        :type client: :class:`~google.cloud.storage.client.Client` or
+                      ``NoneType``
+        :param client: the client to use.
+
+        :rtype: :class:`google.cloud.storage.client.Client`
+        :returns: The client passed in or the bucket's client.
+        """
+        if client is None:
+            client = self.client
+        return client
+
+    def _set_properties(self, response):
+        """Helper for :meth:`reload`.
+
+        :type response: dict
+        :param response: resource mapping from server
+        """
+        self._properties.clear()
+        self._properties.update(response)
+
+    def create(self, client=None):
+        """API wrapper: create the notification.
+
+        See:
+        https://cloud.google.com/storage/docs/json_api/v1/notifications/insert
+
+        :type client: :class:`~google.cloud.storage.client.Client`
+        :param client: (Optional) the client to use.  If not passed, falls back
+                       to the ``client`` stored on the notification's bucket.
+        """
+        if self.notification_id is not None:
+            raise ValueError("Notification already exists w/ id: {}".format(
+                self.notification_id))
+
+        client = self._require_client(client)
+
+        path = '/b/{}/notificationConfigs'.format(self.bucket.name)
+        properties = self._properties.copy()
+        properties['topic'] = _TOPIC_REF_FMT.format(
+            self.topic_project, self.topic_name)
+        self._properties = client._connection.api_request(
+            method='POST',
+            path=path,
+            data=properties,
+        )
+
+    def exists(self, client=None):
+        """Test whether this notification exists.
+
+        See:
+        https://cloud.google.com/storage/docs/json_api/v1/notifications/get
+
+        :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: bool
+        :returns: True, if the notification exists, else False.
+        :raises ValueError: if the notification has no ID.
+        """
+        if self.notification_id is None:
+            raise ValueError("Notification not intialized by server")
+
+        client = self._require_client(client)
+        try:
+            client._connection.api_request(method='GET', path=self.path)
+        except NotFound:
+            return False
+        else:
+            return True
+
+    def reload(self, client=None):
+        """Update this notification from the server configuration.
+
+        See:
+        https://cloud.google.com/storage/docs/json_api/v1/notifications/get
+
+        :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: bool
+        :returns: True, if the notification exists, else False.
+        :raises ValueError: if the notification has no ID.
+        """
+        if self.notification_id is None:
+            raise ValueError("Notification not intialized by server")
+
+        client = self._require_client(client)
+        response = client._connection.api_request(method='GET', path=self.path)
+        self._set_properties(response)
+
+    def delete(self, client=None):
+        """Delete this notification.
+
+        See:
+        https://cloud.google.com/storage/docs/json_api/v1/notifications/delete
+
+        :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.
+
+        :raises: :class:`google.api.core.exceptions.NotFound`:
+            if the notification does not exist.
+        :raises ValueError: if the notification has no ID.
+        """
+        if self.notification_id is None:
+            raise ValueError("Notification not intialized by server")
+
+        client = self._require_client(client)
+        client._connection.api_request(method='DELETE', path=self.path)
diff --git a/storage/nox.py b/storage/nox.py
--- a/storage/nox.py
+++ b/storage/nox.py
@@ -71,10 +71,11 @@ def system_tests(session, python_version):
     # virutalenv's dist-packages.
     session.install('mock', 'pytest', *LOCAL_DEPS)
     session.install('../test_utils/')
-    session.install('.')
+    session.install('../pubsub')
+    session.install('-e', '.')
 
     # Run py.test against the system tests.
-    session.run('py.test', '--quiet', 'tests/system.py')
+    session.run('py.test', '--quiet', 'tests/system.py', *session.posargs)
 
 
 @nox.session


Test patch:
diff --git a/storage/tests/system.py b/storage/tests/system.py
--- a/storage/tests/system.py
+++ b/storage/tests/system.py
@@ -550,3 +550,103 @@ def test_rewrite_rotate_encryption_key(self):
         self.assertEqual(total, len(source_data))
 
         self.assertEqual(dest.download_as_string(), source_data)
+
+
+class TestStorageNotificationCRUD(unittest.TestCase):
+
+    topic = None
+    TOPIC_NAME = 'notification' + unique_resource_id('-')
+    CUSTOM_ATTRIBUTES = {
+        'attr1': 'value1',
+        'attr2': 'value2',
+    }
+    BLOB_NAME_PREFIX = 'blob-name-prefix/'
+
+    @property
+    def topic_path(self):
+        return 'projects/{}/topics/{}'.format(
+            Config.CLIENT.project, self.TOPIC_NAME)
+
+    def _intialize_topic(self):
+        try:
+            from google.cloud.pubsub_v1 import PublisherClient
+        except ImportError:
+            raise unittest.SkipTest("Cannot import pubsub")
+        self.publisher_client = PublisherClient()
+        retry_429(self.publisher_client.create_topic)(self.topic_path)
+        policy = self.publisher_client.get_iam_policy(self.topic_path)
+        binding = policy.bindings.add()
+        binding.role = 'roles/pubsub.publisher'
+        binding.members.append(
+            'serviceAccount:{}'
+            '@gs-project-accounts.iam.gserviceaccount.com'.format(
+                Config.CLIENT.project))
+        self.publisher_client.set_iam_policy(self.topic_path, policy)
+
+
+    def setUp(self):
+        self.case_buckets_to_delete = []
+        self._intialize_topic()
+
+    def tearDown(self):
+        retry_429(self.publisher_client.delete_topic)(self.topic_path)
+        with Config.CLIENT.batch():
+            for bucket_name in self.case_buckets_to_delete:
+                bucket = Config.CLIENT.bucket(bucket_name)
+                retry_429(bucket.delete)()
+
+    @staticmethod
+    def event_types():
+        from google.cloud.storage.notification import (
+            OBJECT_FINALIZE_EVENT_TYPE,
+            OBJECT_DELETE_EVENT_TYPE)
+
+        return [OBJECT_FINALIZE_EVENT_TYPE, OBJECT_DELETE_EVENT_TYPE]
+
+    @staticmethod
+    def payload_format():
+        from google.cloud.storage.notification import (
+            JSON_API_V1_PAYLOAD_FORMAT)
+
+        return JSON_API_V1_PAYLOAD_FORMAT
+
+    def test_notification_minimal(self):
+        new_bucket_name = 'notification-minimal' + unique_resource_id('-')
+        bucket = retry_429(Config.CLIENT.create_bucket)(new_bucket_name)
+        self.case_buckets_to_delete.append(new_bucket_name)
+        self.assertEqual(list(bucket.list_notifications()), [])
+        notification = bucket.notification(self.TOPIC_NAME)
+        retry_429(notification.create)()
+        try:
+            self.assertTrue(notification.exists())
+            self.assertIsNotNone(notification.notification_id)
+            notifications = list(bucket.list_notifications())
+            self.assertEqual(len(notifications), 1)
+            self.assertEqual(notifications[0].topic_name, self.TOPIC_NAME)
+        finally:
+            notification.delete()
+
+    def test_notification_explicit(self):
+        new_bucket_name = 'notification-explicit' + unique_resource_id('-')
+        bucket = retry_429(Config.CLIENT.create_bucket)(new_bucket_name)
+        self.case_buckets_to_delete.append(new_bucket_name)
+        notification = bucket.notification(
+            self.TOPIC_NAME,
+            custom_attributes=self.CUSTOM_ATTRIBUTES,
+            event_types=self.event_types(),
+            blob_name_prefix=self.BLOB_NAME_PREFIX,
+            payload_format=self.payload_format(),
+        )
+        retry_429(notification.create)()
+        try:
+            self.assertTrue(notification.exists())
+            self.assertIsNotNone(notification.notification_id)
+            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())
+        finally:
+            notification.delete()
diff --git a/storage/tests/unit/test_bucket.py b/storage/tests/unit/test_bucket.py
--- a/storage/tests/unit/test_bucket.py
+++ b/storage/tests/unit/test_bucket.py
@@ -72,6 +72,64 @@ def test_blob(self):
         self.assertEqual(blob.chunk_size, CHUNK_SIZE)
         self.assertEqual(blob._encryption_key, KEY)
 
+    def test_notification_defaults(self):
+        from google.cloud.storage.notification import BucketNotification
+        from google.cloud.storage.notification import NONE_PAYLOAD_FORMAT
+
+        PROJECT = 'PROJECT'
+        BUCKET_NAME = 'BUCKET_NAME'
+        TOPIC_NAME = 'TOPIC_NAME'
+        client = _Client(_Connection(), project=PROJECT)
+        bucket = self._make_one(client, name=BUCKET_NAME)
+
+        notification = bucket.notification(TOPIC_NAME)
+
+        self.assertIsInstance(notification, BucketNotification)
+        self.assertIs(notification.bucket, bucket)
+        self.assertEqual(notification.topic_project, PROJECT)
+        self.assertIsNone(notification.custom_attributes)
+        self.assertIsNone(notification.event_types)
+        self.assertIsNone(notification.blob_name_prefix)
+        self.assertEqual(notification.payload_format, NONE_PAYLOAD_FORMAT)
+
+    def test_notification_explicit(self):
+        from google.cloud.storage.notification import (
+            BucketNotification,
+            OBJECT_FINALIZE_EVENT_TYPE,
+            OBJECT_DELETE_EVENT_TYPE,
+            JSON_API_V1_PAYLOAD_FORMAT)
+
+        PROJECT = 'PROJECT'
+        BUCKET_NAME = 'BUCKET_NAME'
+        TOPIC_NAME = 'TOPIC_NAME'
+        TOPIC_ALT_PROJECT = 'topic-project-456'
+        CUSTOM_ATTRIBUTES = {
+            'attr1': 'value1',
+            'attr2': 'value2',
+        }
+        EVENT_TYPES = [OBJECT_FINALIZE_EVENT_TYPE, OBJECT_DELETE_EVENT_TYPE]
+        BLOB_NAME_PREFIX = 'blob-name-prefix/'
+        client = _Client(_Connection(), project=PROJECT)
+        bucket = self._make_one(client, name=BUCKET_NAME)
+
+        notification = bucket.notification(
+            TOPIC_NAME,
+            topic_project=TOPIC_ALT_PROJECT,
+            custom_attributes=CUSTOM_ATTRIBUTES,
+            event_types=EVENT_TYPES,
+            blob_name_prefix=BLOB_NAME_PREFIX,
+            payload_format=JSON_API_V1_PAYLOAD_FORMAT,
+        )
+
+        self.assertIsInstance(notification, BucketNotification)
+        self.assertIs(notification.bucket, bucket)
+        self.assertEqual(notification.topic_project, TOPIC_ALT_PROJECT)
+        self.assertEqual(notification.custom_attributes, CUSTOM_ATTRIBUTES)
+        self.assertEqual(notification.event_types, EVENT_TYPES)
+        self.assertEqual(notification.blob_name_prefix, BLOB_NAME_PREFIX)
+        self.assertEqual(
+            notification.payload_format, JSON_API_V1_PAYLOAD_FORMAT)
+
     def test_bucket_name_value(self):
         bucket_name = 'testing123'
         mixin = self._make_one(name=bucket_name)
@@ -332,6 +390,58 @@ 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
+        from google.cloud.storage.notification import (
+            JSON_API_V1_PAYLOAD_FORMAT, NONE_PAYLOAD_FORMAT)
+
+        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',
+            'payload_format': NONE_PAYLOAD_FORMAT,
+        }, {
+            'topic': _TOPIC_REF_FMT.format(*topic_refs[1]),
+            'id': '2',
+            'etag': 'FACECABB',
+            'selfLink': 'https://example.com/notification/2',
+            'payload_format': JSON_API_V1_PAYLOAD_FORMAT,
+        }]
+        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
new file mode 100644
--- /dev/null
+++ b/storage/tests/unit/test_notification.py
@@ -0,0 +1,469 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import mock
+
+
+class TestBucketNotification(unittest.TestCase):
+
+    BUCKET_NAME = 'test-bucket'
+    BUCKET_PROJECT = 'bucket-project-123'
+    TOPIC_NAME = 'test-topic'
+    TOPIC_ALT_PROJECT = 'topic-project-456'
+    TOPIC_REF_FMT = '//pubsub.googleapis.com/projects/{}/topics/{}'
+    TOPIC_REF = TOPIC_REF_FMT.format(BUCKET_PROJECT, TOPIC_NAME)
+    TOPIC_ALT_REF = TOPIC_REF_FMT.format(TOPIC_ALT_PROJECT, TOPIC_NAME)
+    CUSTOM_ATTRIBUTES = {
+        'attr1': 'value1',
+        'attr2': 'value2',
+    }
+    BLOB_NAME_PREFIX = 'blob-name-prefix/'
+    NOTIFICATION_ID = '123'
+    SELF_LINK = 'https://example.com/notification/123'
+    ETAG = 'DEADBEEF'
+    CREATE_PATH = '/b/{}/notificationConfigs'.format(BUCKET_NAME)
+    NOTIFICATION_PATH = '/b/{}/notificationConfigs/{}'.format(
+            BUCKET_NAME, NOTIFICATION_ID)
+
+    @staticmethod
+    def event_types():
+        from google.cloud.storage.notification import (
+            OBJECT_FINALIZE_EVENT_TYPE,
+            OBJECT_DELETE_EVENT_TYPE)
+
+        return [OBJECT_FINALIZE_EVENT_TYPE, OBJECT_DELETE_EVENT_TYPE]
+
+    @staticmethod
+    def payload_format():
+        from google.cloud.storage.notification import (
+            JSON_API_V1_PAYLOAD_FORMAT)
+
+        return JSON_API_V1_PAYLOAD_FORMAT
+
+    @staticmethod
+    def _get_target_class():
+        from google.cloud.storage.notification import BucketNotification
+
+        return BucketNotification
+
+    def _make_one(self, *args, **kw):
+        return self._get_target_class()(*args, **kw)
+
+    def _make_client(self, project=BUCKET_PROJECT):
+        from google.cloud.storage.client import Client
+
+        return mock.Mock(project=project, spec=Client)
+
+    def _make_bucket(self, client, name=BUCKET_NAME):
+         bucket = mock.Mock(spec=['client', 'name'])
+         bucket.client= client
+         bucket.name = name
+         return bucket
+
+    def test_ctor_defaults(self):
+        from google.cloud.storage.notification import NONE_PAYLOAD_FORMAT
+
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+
+        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.assertEqual(notification.payload_format, NONE_PAYLOAD_FORMAT)
+
+    def test_ctor_explicit(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME,
+            topic_project=self.TOPIC_ALT_PROJECT,
+            custom_attributes=self.CUSTOM_ATTRIBUTES,
+            event_types=self.event_types(),
+            blob_name_prefix=self.BLOB_NAME_PREFIX,
+            payload_format=self.payload_format(),
+        )
+
+        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())
+
+    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):
+        from google.cloud.storage.notification import NONE_PAYLOAD_FORMAT
+
+        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,
+            'payload_format': NONE_PAYLOAD_FORMAT,
+        }
+
+        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.assertEqual(notification.payload_format, NONE_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(),
+            'object_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)
+
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+
+        self.assertIsNone(notification.notification_id)
+
+        notification._properties['id'] = self.NOTIFICATION_ID
+        self.assertEqual(notification.notification_id, self.NOTIFICATION_ID)
+
+    def test_etag(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+
+        self.assertIsNone(notification.etag)
+
+        notification._properties['etag'] = self.ETAG
+        self.assertEqual(notification.etag, self.ETAG)
+
+    def test_self_link(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+
+        self.assertIsNone(notification.self_link)
+
+        notification._properties['selfLink'] = self.SELF_LINK
+        self.assertEqual(notification.self_link, self.SELF_LINK)
+
+    def test_create_w_existing_notification_id(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+        notification._properties['id'] = self.NOTIFICATION_ID
+
+        with self.assertRaises(ValueError):
+            notification.create()
+
+    def test_create_w_defaults(self):
+        from google.cloud.storage.notification import NONE_PAYLOAD_FORMAT
+
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+        api_request = client._connection.api_request
+        api_request.return_value = {
+            'topic': self.TOPIC_REF,
+            'id': self.NOTIFICATION_ID,
+            'etag': self.ETAG,
+            'selfLink': self.SELF_LINK,
+            'payload_format': NONE_PAYLOAD_FORMAT,
+        }
+
+        notification.create()
+
+        self.assertEqual(notification.notification_id, self.NOTIFICATION_ID)
+        self.assertEqual(notification.etag, self.ETAG)
+        self.assertEqual(notification.self_link, self.SELF_LINK)
+        self.assertIsNone(notification.custom_attributes)
+        self.assertIsNone(notification.event_types)
+        self.assertIsNone(notification.blob_name_prefix)
+        self.assertEqual(notification.payload_format, NONE_PAYLOAD_FORMAT)
+
+        data = {
+            'topic': self.TOPIC_REF,
+            'payload_format': NONE_PAYLOAD_FORMAT,
+        }
+        api_request.assert_called_once_with(
+            method='POST',
+            path=self.CREATE_PATH,
+            data=data,
+        )
+
+    def test_create_w_explicit_client(self):
+        client = self._make_client()
+        alt_client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME,
+            topic_project=self.TOPIC_ALT_PROJECT,
+            custom_attributes=self.CUSTOM_ATTRIBUTES,
+            event_types=self.event_types(),
+            blob_name_prefix=self.BLOB_NAME_PREFIX,
+            payload_format=self.payload_format(),
+        )
+        api_request = alt_client._connection.api_request
+        api_request.return_value = {
+            'topic': self.TOPIC_ALT_REF,
+            'custom_attributes': self.CUSTOM_ATTRIBUTES,
+            'event_types': self.event_types(),
+            'object_name_prefix': self.BLOB_NAME_PREFIX,
+            'payload_format': self.payload_format(),
+            'id': self.NOTIFICATION_ID,
+            'etag': self.ETAG,
+            'selfLink': self.SELF_LINK,
+        }
+
+        notification.create(client=alt_client)
+
+        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)
+
+        data = {
+            'topic': self.TOPIC_ALT_REF,
+            'custom_attributes': self.CUSTOM_ATTRIBUTES,
+            'event_types': self.event_types(),
+            'object_name_prefix': self.BLOB_NAME_PREFIX,
+            'payload_format': self.payload_format(),
+        }
+        api_request.assert_called_once_with(
+            method='POST',
+            path=self.CREATE_PATH,
+            data=data,
+        )
+
+    def test_exists_wo_notification_id(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+
+        with self.assertRaises(ValueError):
+            notification.exists()
+
+    def test_exists_miss(self):
+        from google.cloud.exceptions import NotFound
+
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(bucket, self.TOPIC_NAME)
+        notification._properties['id'] = self.NOTIFICATION_ID
+        api_request = client._connection.api_request
+        api_request.side_effect = NotFound('testing')
+
+        self.assertFalse(notification.exists())
+
+        api_request.assert_called_once_with(
+            method='GET',
+            path=self.NOTIFICATION_PATH,
+        )
+
+    def test_exists_hit(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        alt_client = self._make_client()
+        notification = self._make_one(bucket, self.TOPIC_NAME)
+        notification._properties['id'] = self.NOTIFICATION_ID
+        api_request = client._connection.api_request
+        api_request.return_value = {
+            'topic': self.TOPIC_REF,
+            'id': self.NOTIFICATION_ID,
+            'etag': self.ETAG,
+            'selfLink': self.SELF_LINK,
+        }
+
+        self.assertTrue(notification.exists(client=client))
+
+        api_request.assert_called_once_with(
+            method='GET',
+            path=self.NOTIFICATION_PATH,
+        )
+
+    def test_reload_wo_notification_id(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+
+        with self.assertRaises(ValueError):
+            notification.reload()
+
+    def test_reload_miss(self):
+        from google.cloud.exceptions import NotFound
+
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(bucket, self.TOPIC_NAME)
+        notification._properties['id'] = self.NOTIFICATION_ID
+        api_request = client._connection.api_request
+        api_request.side_effect = NotFound('testing')
+
+        with self.assertRaises(NotFound):
+            notification.reload()
+
+        api_request.assert_called_once_with(
+            method='GET',
+            path=self.NOTIFICATION_PATH,
+        )
+
+    def test_reload_hit(self):
+        from google.cloud.storage.notification import NONE_PAYLOAD_FORMAT
+
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        alt_client = self._make_client()
+        notification = self._make_one(bucket, self.TOPIC_NAME)
+        notification._properties['id'] = self.NOTIFICATION_ID
+        api_request = client._connection.api_request
+        api_request.return_value = {
+            'topic': self.TOPIC_REF,
+            'id': self.NOTIFICATION_ID,
+            'etag': self.ETAG,
+            'selfLink': self.SELF_LINK,
+            'payload_format': NONE_PAYLOAD_FORMAT,
+        }
+
+        notification.reload(client=client)
+
+        self.assertEqual(notification.etag, self.ETAG)
+        self.assertEqual(notification.self_link, self.SELF_LINK)
+        self.assertIsNone(notification.custom_attributes)
+        self.assertIsNone(notification.event_types)
+        self.assertIsNone(notification.blob_name_prefix)
+        self.assertEqual(notification.payload_format, NONE_PAYLOAD_FORMAT)
+
+        api_request.assert_called_once_with(
+            method='GET',
+            path=self.NOTIFICATION_PATH,
+        )
+
+    def test_delete_wo_notification_id(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(
+            bucket, self.TOPIC_NAME)
+
+        with self.assertRaises(ValueError):
+            notification.delete()
+
+    def test_delete_miss(self):
+        from google.cloud.exceptions import NotFound
+
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        notification = self._make_one(bucket, self.TOPIC_NAME)
+        notification._properties['id'] = self.NOTIFICATION_ID
+        api_request = client._connection.api_request
+        api_request.side_effect = NotFound('testing')
+
+        with self.assertRaises(NotFound):
+            notification.delete()
+
+        api_request.assert_called_once_with(
+            method='DELETE',
+            path=self.NOTIFICATION_PATH,
+        )
+
+    def test_delete_hit(self):
+        client = self._make_client()
+        bucket = self._make_bucket(client)
+        alt_client = self._make_client()
+        notification = self._make_one(bucket, self.TOPIC_NAME)
+        notification._properties['id'] = self.NOTIFICATION_ID
+        api_request = client._connection.api_request
+        api_request.return_value = None
+
+        notification.delete(client=client)
+
+        api_request.assert_called_once_with(
+            method='DELETE',
+            path=self.NOTIFICATION_PATH,
+        )