1# Copyright 2017 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Support for bucket notification resources."""
16
17import re
18
19from google.api_core.exceptions import NotFound
20
21from google.cloud.storage.constants import _DEFAULT_TIMEOUT
22from google.cloud.storage.retry import DEFAULT_RETRY
23
24
25OBJECT_FINALIZE_EVENT_TYPE = "OBJECT_FINALIZE"
26OBJECT_METADATA_UPDATE_EVENT_TYPE = "OBJECT_METADATA_UPDATE"
27OBJECT_DELETE_EVENT_TYPE = "OBJECT_DELETE"
28OBJECT_ARCHIVE_EVENT_TYPE = "OBJECT_ARCHIVE"
29
30JSON_API_V1_PAYLOAD_FORMAT = "JSON_API_V1"
31NONE_PAYLOAD_FORMAT = "NONE"
32
33_TOPIC_REF_FMT = "//pubsub.googleapis.com/projects/{}/topics/{}"
34_PROJECT_PATTERN = r"(?P<project>[a-z][a-z0-9-]{4,28}[a-z0-9])"
35_TOPIC_NAME_PATTERN = r"(?P<name>[A-Za-z](\w|[-_.~+%])+)"
36_TOPIC_REF_PATTERN = _TOPIC_REF_FMT.format(_PROJECT_PATTERN, _TOPIC_NAME_PATTERN)
37_TOPIC_REF_RE = re.compile(_TOPIC_REF_PATTERN)
38_BAD_TOPIC = (
39    "Resource has invalid topic: {}; see "
40    "https://cloud.google.com/storage/docs/json_api/v1/"
41    "notifications/insert#topic"
42)
43
44
45class BucketNotification(object):
46    """Represent a single notification resource for a bucket.
47
48    See: https://cloud.google.com/storage/docs/json_api/v1/notifications
49
50    :type bucket: :class:`google.cloud.storage.bucket.Bucket`
51    :param bucket: Bucket to which the notification is bound.
52
53    :type topic_name: str
54    :param topic_name:
55        (Optional) Topic name to which notifications are published.
56
57    :type topic_project: str
58    :param topic_project:
59        (Optional) Project ID of topic to which notifications are published.
60        If not passed, uses the project ID of the bucket's client.
61
62    :type custom_attributes: dict
63    :param custom_attributes:
64        (Optional) Additional attributes passed with notification events.
65
66    :type event_types: list(str)
67    :param event_types:
68        (Optional) Event types for which notification events are published.
69
70    :type blob_name_prefix: str
71    :param blob_name_prefix:
72        (Optional) Prefix of blob names for which notification events are
73        published.
74
75    :type payload_format: str
76    :param payload_format:
77        (Optional) Format of payload for notification events.
78
79    :type notification_id: str
80    :param notification_id:
81        (Optional) The ID of the notification.
82    """
83
84    def __init__(
85        self,
86        bucket,
87        topic_name=None,
88        topic_project=None,
89        custom_attributes=None,
90        event_types=None,
91        blob_name_prefix=None,
92        payload_format=NONE_PAYLOAD_FORMAT,
93        notification_id=None,
94    ):
95        self._bucket = bucket
96        self._topic_name = topic_name
97
98        if topic_project is None:
99            topic_project = bucket.client.project
100
101        if topic_project is None:
102            raise ValueError("Client project not set:  pass an explicit topic_project.")
103
104        self._topic_project = topic_project
105
106        self._properties = {}
107
108        if custom_attributes is not None:
109            self._properties["custom_attributes"] = custom_attributes
110
111        if event_types is not None:
112            self._properties["event_types"] = event_types
113
114        if blob_name_prefix is not None:
115            self._properties["object_name_prefix"] = blob_name_prefix
116
117        if notification_id is not None:
118            self._properties["id"] = notification_id
119
120        self._properties["payload_format"] = payload_format
121
122    @classmethod
123    def from_api_repr(cls, resource, bucket):
124        """Construct an instance from the JSON repr returned by the server.
125
126        See: https://cloud.google.com/storage/docs/json_api/v1/notifications
127
128        :type resource: dict
129        :param resource: JSON repr of the notification
130
131        :type bucket: :class:`google.cloud.storage.bucket.Bucket`
132        :param bucket: Bucket to which the notification is bound.
133
134        :rtype: :class:`BucketNotification`
135        :returns: the new notification instance
136        """
137        topic_path = resource.get("topic")
138        if topic_path is None:
139            raise ValueError("Resource has no topic")
140
141        name, project = _parse_topic_path(topic_path)
142        instance = cls(bucket, name, topic_project=project)
143        instance._properties = resource
144
145        return instance
146
147    @property
148    def bucket(self):
149        """Bucket to which the notification is bound."""
150        return self._bucket
151
152    @property
153    def topic_name(self):
154        """Topic name to which notifications are published."""
155        return self._topic_name
156
157    @property
158    def topic_project(self):
159        """Project ID of topic to which notifications are published.
160        """
161        return self._topic_project
162
163    @property
164    def custom_attributes(self):
165        """Custom attributes passed with notification events.
166        """
167        return self._properties.get("custom_attributes")
168
169    @property
170    def event_types(self):
171        """Event types for which notification events are published.
172        """
173        return self._properties.get("event_types")
174
175    @property
176    def blob_name_prefix(self):
177        """Prefix of blob names for which notification events are published.
178        """
179        return self._properties.get("object_name_prefix")
180
181    @property
182    def payload_format(self):
183        """Format of payload of notification events."""
184        return self._properties.get("payload_format")
185
186    @property
187    def notification_id(self):
188        """Server-set ID of notification resource."""
189        return self._properties.get("id")
190
191    @property
192    def etag(self):
193        """Server-set ETag of notification resource."""
194        return self._properties.get("etag")
195
196    @property
197    def self_link(self):
198        """Server-set ETag of notification resource."""
199        return self._properties.get("selfLink")
200
201    @property
202    def client(self):
203        """The client bound to this notfication."""
204        return self.bucket.client
205
206    @property
207    def path(self):
208        """The URL path for this notification."""
209        return "/b/{}/notificationConfigs/{}".format(
210            self.bucket.name, self.notification_id
211        )
212
213    def _require_client(self, client):
214        """Check client or verify over-ride.
215
216        :type client: :class:`~google.cloud.storage.client.Client` or
217                      ``NoneType``
218        :param client: the client to use.
219
220        :rtype: :class:`google.cloud.storage.client.Client`
221        :returns: The client passed in or the bucket's client.
222        """
223        if client is None:
224            client = self.client
225        return client
226
227    def _set_properties(self, response):
228        """Helper for :meth:`reload`.
229
230        :type response: dict
231        :param response: resource mapping from server
232        """
233        self._properties.clear()
234        self._properties.update(response)
235
236    def create(self, client=None, timeout=_DEFAULT_TIMEOUT, retry=None):
237        """API wrapper: create the notification.
238
239        See:
240        https://cloud.google.com/storage/docs/json_api/v1/notifications/insert
241
242        If :attr:`user_project` is set on the bucket, bills the API request
243        to that project.
244
245        :type client: :class:`~google.cloud.storage.client.Client`
246        :param client: (Optional) The client to use.  If not passed, falls back
247                       to the ``client`` stored on the notification's bucket.
248        :type timeout: float or tuple
249        :param timeout:
250            (Optional) The amount of time, in seconds, to wait
251            for the server response.  See: :ref:`configuring_timeouts`
252
253        :type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
254        :param retry:
255            (Optional) How to retry the RPC. See: :ref:`configuring_retries`
256
257        :raises ValueError: if the notification already exists.
258        """
259        if self.notification_id is not None:
260            raise ValueError(
261                "Notification already exists w/ id: {}".format(self.notification_id)
262            )
263
264        client = self._require_client(client)
265
266        query_params = {}
267        if self.bucket.user_project is not None:
268            query_params["userProject"] = self.bucket.user_project
269
270        path = "/b/{}/notificationConfigs".format(self.bucket.name)
271        properties = self._properties.copy()
272
273        if self.topic_name is None:
274            properties["topic"] = _TOPIC_REF_FMT.format(self.topic_project, "")
275        else:
276            properties["topic"] = _TOPIC_REF_FMT.format(
277                self.topic_project, self.topic_name
278            )
279
280        self._properties = client._post_resource(
281            path, properties, query_params=query_params, timeout=timeout, retry=retry,
282        )
283
284    def exists(self, client=None, timeout=_DEFAULT_TIMEOUT, retry=DEFAULT_RETRY):
285        """Test whether this notification exists.
286
287        See:
288        https://cloud.google.com/storage/docs/json_api/v1/notifications/get
289
290        If :attr:`user_project` is set on the bucket, bills the API request
291        to that project.
292
293        :type client: :class:`~google.cloud.storage.client.Client` or
294                      ``NoneType``
295        :param client: (Optional) The client to use.  If not passed, falls back
296                       to the ``client`` stored on the current bucket.
297        :type timeout: float or tuple
298        :param timeout:
299            (Optional) The amount of time, in seconds, to wait
300            for the server response.  See: :ref:`configuring_timeouts`
301
302        :type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
303        :param retry:
304            (Optional) How to retry the RPC. See: :ref:`configuring_retries`
305
306        :rtype: bool
307        :returns: True, if the notification exists, else False.
308        :raises ValueError: if the notification has no ID.
309        """
310        if self.notification_id is None:
311            raise ValueError("Notification not intialized by server")
312
313        client = self._require_client(client)
314
315        query_params = {}
316        if self.bucket.user_project is not None:
317            query_params["userProject"] = self.bucket.user_project
318
319        try:
320            client._get_resource(
321                self.path, query_params=query_params, timeout=timeout, retry=retry,
322            )
323        except NotFound:
324            return False
325        else:
326            return True
327
328    def reload(self, client=None, timeout=_DEFAULT_TIMEOUT, retry=DEFAULT_RETRY):
329        """Update this notification from the server configuration.
330
331        See:
332        https://cloud.google.com/storage/docs/json_api/v1/notifications/get
333
334        If :attr:`user_project` is set on the bucket, bills the API request
335        to that project.
336
337        :type client: :class:`~google.cloud.storage.client.Client` or
338                      ``NoneType``
339        :param client: (Optional) The client to use.  If not passed, falls back
340                       to the ``client`` stored on the current bucket.
341        :type timeout: float or tuple
342        :param timeout:
343            (Optional) The amount of time, in seconds, to wait
344            for the server response.  See: :ref:`configuring_timeouts`
345
346        :type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
347        :param retry:
348            (Optional) How to retry the RPC. See: :ref:`configuring_retries`
349
350
351        :raises ValueError: if the notification has no ID.
352        """
353        if self.notification_id is None:
354            raise ValueError("Notification not intialized by server")
355
356        client = self._require_client(client)
357
358        query_params = {}
359        if self.bucket.user_project is not None:
360            query_params["userProject"] = self.bucket.user_project
361
362        response = client._get_resource(
363            self.path, query_params=query_params, timeout=timeout, retry=retry,
364        )
365        self._set_properties(response)
366
367    def delete(self, client=None, timeout=_DEFAULT_TIMEOUT, retry=DEFAULT_RETRY):
368        """Delete this notification.
369
370        See:
371        https://cloud.google.com/storage/docs/json_api/v1/notifications/delete
372
373        If :attr:`user_project` is set on the bucket, bills the API request
374        to that project.
375
376        :type client: :class:`~google.cloud.storage.client.Client` or
377                      ``NoneType``
378        :param client: (Optional) The client to use.  If not passed, falls back
379                       to the ``client`` stored on the current bucket.
380        :type timeout: float or tuple
381        :param timeout:
382            (Optional) The amount of time, in seconds, to wait
383            for the server response.  See: :ref:`configuring_timeouts`
384
385        :type retry: google.api_core.retry.Retry or google.cloud.storage.retry.ConditionalRetryPolicy
386        :param retry:
387            (Optional) How to retry the RPC. See: :ref:`configuring_retries`
388
389        :raises: :class:`google.api_core.exceptions.NotFound`:
390            if the notification does not exist.
391        :raises ValueError: if the notification has no ID.
392        """
393        if self.notification_id is None:
394            raise ValueError("Notification not intialized by server")
395
396        client = self._require_client(client)
397
398        query_params = {}
399        if self.bucket.user_project is not None:
400            query_params["userProject"] = self.bucket.user_project
401
402        client._delete_resource(
403            self.path, query_params=query_params, timeout=timeout, retry=retry,
404        )
405
406
407def _parse_topic_path(topic_path):
408    """Verify that a topic path is in the correct format.
409
410    .. _resource manager docs: https://cloud.google.com/resource-manager/\
411                               reference/rest/v1beta1/projects#\
412                               Project.FIELDS.project_id
413    .. _topic spec: https://cloud.google.com/storage/docs/json_api/v1/\
414                    notifications/insert#topic
415
416    Expected to be of the form:
417
418        //pubsub.googleapis.com/projects/{project}/topics/{topic}
419
420    where the ``project`` value must be "6 to 30 lowercase letters, digits,
421    or hyphens. It must start with a letter. Trailing hyphens are prohibited."
422    (see `resource manager docs`_) and ``topic`` must have length at least two,
423    must start with a letter and may only contain alphanumeric characters or
424    ``-``, ``_``, ``.``, ``~``, ``+`` or ``%`` (i.e characters used for URL
425    encoding, see `topic spec`_).
426
427    Args:
428        topic_path (str): The topic path to be verified.
429
430    Returns:
431        Tuple[str, str]: The ``project`` and ``topic`` parsed from the
432        ``topic_path``.
433
434    Raises:
435        ValueError: If the topic path is invalid.
436    """
437    match = _TOPIC_REF_RE.match(topic_path)
438    if match is None:
439        raise ValueError(_BAD_TOPIC.format(topic_path))
440
441    return match.group("name"), match.group("project")
442