Chris McDonough | 781de4c | 2018-07-26 12:33:30 -0400 | [diff] [blame] | 1 | # Copyright 2014 Google Inc. All Rights Reserved. |
| 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 | |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 15 | """Channel notifications support. |
| 16 | |
| 17 | Classes and functions to support channel subscriptions and notifications |
| 18 | on those channels. |
| 19 | |
| 20 | Notes: |
| 21 | - This code is based on experimental APIs and is subject to change. |
| 22 | - Notification does not do deduplication of notification ids, that's up to |
| 23 | the receiver. |
| 24 | - Storing the Channel between calls is up to the caller. |
| 25 | |
| 26 | |
| 27 | Example setting up a channel: |
| 28 | |
| 29 | # Create a new channel that gets notifications via webhook. |
| 30 | channel = new_webhook_channel("https://example.com/my_web_hook") |
| 31 | |
| 32 | # Store the channel, keyed by 'channel.id'. Store it before calling the |
| 33 | # watch method because notifications may start arriving before the watch |
| 34 | # method returns. |
| 35 | ... |
| 36 | |
| 37 | resp = service.objects().watchAll( |
| 38 | bucket="some_bucket_id", body=channel.body()).execute() |
| 39 | channel.update(resp) |
| 40 | |
| 41 | # Store the channel, keyed by 'channel.id'. Store it after being updated |
| 42 | # since the resource_id value will now be correct, and that's needed to |
| 43 | # stop a subscription. |
| 44 | ... |
| 45 | |
| 46 | |
| 47 | An example Webhook implementation using webapp2. Note that webapp2 puts |
| 48 | headers in a case insensitive dictionary, as headers aren't guaranteed to |
| 49 | always be upper case. |
| 50 | |
| 51 | id = self.request.headers[X_GOOG_CHANNEL_ID] |
| 52 | |
| 53 | # Retrieve the channel by id. |
| 54 | channel = ... |
| 55 | |
| 56 | # Parse notification from the headers, including validating the id. |
| 57 | n = notification_from_headers(channel, self.request.headers) |
| 58 | |
| 59 | # Do app specific stuff with the notification here. |
| 60 | if n.resource_state == 'sync': |
| 61 | # Code to handle sync state. |
| 62 | elif n.resource_state == 'exists': |
| 63 | # Code to handle the exists state. |
| 64 | elif n.resource_state == 'not_exists': |
| 65 | # Code to handle the not exists state. |
| 66 | |
| 67 | |
| 68 | Example of unsubscribing. |
| 69 | |
Corey Schaf | b1b16fd | 2018-12-12 14:13:23 -0500 | [diff] [blame] | 70 | service.channels().stop(channel.body()).execute() |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 71 | """ |
INADA Naoki | e4ea1a9 | 2015-03-04 03:45:42 +0900 | [diff] [blame] | 72 | from __future__ import absolute_import |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 73 | |
| 74 | import datetime |
| 75 | import uuid |
| 76 | |
| 77 | from googleapiclient import errors |
Helen Koike | de13e3b | 2018-04-26 16:05:16 -0300 | [diff] [blame] | 78 | from googleapiclient import _helpers as util |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 79 | |
| 80 | |
| 81 | # The unix time epoch starts at midnight 1970. |
| 82 | EPOCH = datetime.datetime.utcfromtimestamp(0) |
| 83 | |
| 84 | # Map the names of the parameters in the JSON channel description to |
| 85 | # the parameter names we use in the Channel class. |
| 86 | CHANNEL_PARAMS = { |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 87 | "address": "address", |
| 88 | "id": "id", |
| 89 | "expiration": "expiration", |
| 90 | "params": "params", |
| 91 | "resourceId": "resource_id", |
| 92 | "resourceUri": "resource_uri", |
| 93 | "type": "type", |
| 94 | "token": "token", |
| 95 | } |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 96 | |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 97 | X_GOOG_CHANNEL_ID = "X-GOOG-CHANNEL-ID" |
| 98 | X_GOOG_MESSAGE_NUMBER = "X-GOOG-MESSAGE-NUMBER" |
| 99 | X_GOOG_RESOURCE_STATE = "X-GOOG-RESOURCE-STATE" |
| 100 | X_GOOG_RESOURCE_URI = "X-GOOG-RESOURCE-URI" |
| 101 | X_GOOG_RESOURCE_ID = "X-GOOG-RESOURCE-ID" |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 102 | |
| 103 | |
| 104 | def _upper_header_keys(headers): |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 105 | new_headers = {} |
Anthonios Partheniou | 9f7b410 | 2021-07-23 12:18:25 -0400 | [diff] [blame] | 106 | for k, v in headers.items(): |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 107 | new_headers[k.upper()] = v |
| 108 | return new_headers |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 109 | |
| 110 | |
| 111 | class Notification(object): |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 112 | """A Notification from a Channel. |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 113 | |
| 114 | Notifications are not usually constructed directly, but are returned |
| 115 | from functions like notification_from_headers(). |
| 116 | |
| 117 | Attributes: |
| 118 | message_number: int, The unique id number of this notification. |
| 119 | state: str, The state of the resource being monitored. |
| 120 | uri: str, The address of the resource being monitored. |
| 121 | resource_id: str, The unique identifier of the version of the resource at |
| 122 | this event. |
| 123 | """ |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 124 | |
| 125 | @util.positional(5) |
| 126 | def __init__(self, message_number, state, resource_uri, resource_id): |
| 127 | """Notification constructor. |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 128 | |
| 129 | Args: |
| 130 | message_number: int, The unique id number of this notification. |
| 131 | state: str, The state of the resource being monitored. Can be one |
| 132 | of "exists", "not_exists", or "sync". |
| 133 | resource_uri: str, The address of the resource being monitored. |
| 134 | resource_id: str, The identifier of the watched resource. |
| 135 | """ |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 136 | self.message_number = message_number |
| 137 | self.state = state |
| 138 | self.resource_uri = resource_uri |
| 139 | self.resource_id = resource_id |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 140 | |
| 141 | |
| 142 | class Channel(object): |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 143 | """A Channel for notifications. |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 144 | |
| 145 | Usually not constructed directly, instead it is returned from helper |
| 146 | functions like new_webhook_channel(). |
| 147 | |
| 148 | Attributes: |
| 149 | type: str, The type of delivery mechanism used by this channel. For |
| 150 | example, 'web_hook'. |
| 151 | id: str, A UUID for the channel. |
| 152 | token: str, An arbitrary string associated with the channel that |
| 153 | is delivered to the target address with each event delivered |
| 154 | over this channel. |
| 155 | address: str, The address of the receiving entity where events are |
| 156 | delivered. Specific to the channel type. |
| 157 | expiration: int, The time, in milliseconds from the epoch, when this |
| 158 | channel will expire. |
| 159 | params: dict, A dictionary of string to string, with additional parameters |
| 160 | controlling delivery channel behavior. |
| 161 | resource_id: str, An opaque id that identifies the resource that is |
| 162 | being watched. Stable across different API versions. |
| 163 | resource_uri: str, The canonicalized ID of the watched resource. |
| 164 | """ |
| 165 | |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 166 | @util.positional(5) |
| 167 | def __init__( |
| 168 | self, |
| 169 | type, |
| 170 | id, |
| 171 | token, |
| 172 | address, |
| 173 | expiration=None, |
| 174 | params=None, |
| 175 | resource_id="", |
| 176 | resource_uri="", |
| 177 | ): |
| 178 | """Create a new Channel. |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 179 | |
| 180 | In user code, this Channel constructor will not typically be called |
| 181 | manually since there are functions for creating channels for each specific |
| 182 | type with a more customized set of arguments to pass. |
| 183 | |
| 184 | Args: |
| 185 | type: str, The type of delivery mechanism used by this channel. For |
| 186 | example, 'web_hook'. |
| 187 | id: str, A UUID for the channel. |
| 188 | token: str, An arbitrary string associated with the channel that |
| 189 | is delivered to the target address with each event delivered |
| 190 | over this channel. |
| 191 | address: str, The address of the receiving entity where events are |
| 192 | delivered. Specific to the channel type. |
| 193 | expiration: int, The time, in milliseconds from the epoch, when this |
| 194 | channel will expire. |
| 195 | params: dict, A dictionary of string to string, with additional parameters |
| 196 | controlling delivery channel behavior. |
| 197 | resource_id: str, An opaque id that identifies the resource that is |
| 198 | being watched. Stable across different API versions. |
| 199 | resource_uri: str, The canonicalized ID of the watched resource. |
| 200 | """ |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 201 | self.type = type |
| 202 | self.id = id |
| 203 | self.token = token |
| 204 | self.address = address |
| 205 | self.expiration = expiration |
| 206 | self.params = params |
| 207 | self.resource_id = resource_id |
| 208 | self.resource_uri = resource_uri |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 209 | |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 210 | def body(self): |
| 211 | """Build a body from the Channel. |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 212 | |
| 213 | Constructs a dictionary that's appropriate for passing into watch() |
| 214 | methods as the value of body argument. |
| 215 | |
| 216 | Returns: |
| 217 | A dictionary representation of the channel. |
| 218 | """ |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 219 | result = { |
| 220 | "id": self.id, |
| 221 | "token": self.token, |
| 222 | "type": self.type, |
| 223 | "address": self.address, |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 224 | } |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 225 | if self.params: |
| 226 | result["params"] = self.params |
| 227 | if self.resource_id: |
| 228 | result["resourceId"] = self.resource_id |
| 229 | if self.resource_uri: |
| 230 | result["resourceUri"] = self.resource_uri |
| 231 | if self.expiration: |
| 232 | result["expiration"] = self.expiration |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 233 | |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 234 | return result |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 235 | |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 236 | def update(self, resp): |
| 237 | """Update a channel with information from the response of watch(). |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 238 | |
| 239 | When a request is sent to watch() a resource, the response returned |
| 240 | from the watch() request is a dictionary with updated channel information, |
| 241 | such as the resource_id, which is needed when stopping a subscription. |
| 242 | |
| 243 | Args: |
| 244 | resp: dict, The response from a watch() method. |
| 245 | """ |
Anthonios Partheniou | 9f7b410 | 2021-07-23 12:18:25 -0400 | [diff] [blame] | 246 | for json_name, param_name in CHANNEL_PARAMS.items(): |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 247 | value = resp.get(json_name) |
| 248 | if value is not None: |
| 249 | setattr(self, param_name, value) |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 250 | |
| 251 | |
| 252 | def notification_from_headers(channel, headers): |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 253 | """Parse a notification from the webhook request headers, validate |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 254 | the notification, and return a Notification object. |
| 255 | |
| 256 | Args: |
| 257 | channel: Channel, The channel that the notification is associated with. |
| 258 | headers: dict, A dictionary like object that contains the request headers |
| 259 | from the webhook HTTP request. |
| 260 | |
| 261 | Returns: |
| 262 | A Notification object. |
| 263 | |
| 264 | Raises: |
| 265 | errors.InvalidNotificationError if the notification is invalid. |
| 266 | ValueError if the X-GOOG-MESSAGE-NUMBER can't be converted to an int. |
| 267 | """ |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 268 | headers = _upper_header_keys(headers) |
| 269 | channel_id = headers[X_GOOG_CHANNEL_ID] |
| 270 | if channel.id != channel_id: |
| 271 | raise errors.InvalidNotificationError( |
| 272 | "Channel id mismatch: %s != %s" % (channel.id, channel_id) |
| 273 | ) |
| 274 | else: |
| 275 | message_number = int(headers[X_GOOG_MESSAGE_NUMBER]) |
| 276 | state = headers[X_GOOG_RESOURCE_STATE] |
| 277 | resource_uri = headers[X_GOOG_RESOURCE_URI] |
| 278 | resource_id = headers[X_GOOG_RESOURCE_ID] |
| 279 | return Notification(message_number, state, resource_uri, resource_id) |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 280 | |
| 281 | |
| 282 | @util.positional(2) |
| 283 | def new_webhook_channel(url, token=None, expiration=None, params=None): |
| 284 | """Create a new webhook Channel. |
| 285 | |
| 286 | Args: |
| 287 | url: str, URL to post notifications to. |
| 288 | token: str, An arbitrary string associated with the channel that |
| 289 | is delivered to the target address with each notification delivered |
| 290 | over this channel. |
| 291 | expiration: datetime.datetime, A time in the future when the channel |
| 292 | should expire. Can also be None if the subscription should use the |
| 293 | default expiration. Note that different services may have different |
| 294 | limits on how long a subscription lasts. Check the response from the |
| 295 | watch() method to see the value the service has set for an expiration |
| 296 | time. |
| 297 | params: dict, Extra parameters to pass on channel creation. Currently |
| 298 | not used for webhook channels. |
| 299 | """ |
| 300 | expiration_ms = 0 |
| 301 | if expiration: |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 302 | delta = expiration - EPOCH |
| 303 | expiration_ms = ( |
| 304 | delta.microseconds / 1000 + (delta.seconds + delta.days * 24 * 3600) * 1000 |
| 305 | ) |
| 306 | if expiration_ms < 0: |
| 307 | expiration_ms = 0 |
John Asmuth | 864311d | 2014-04-24 15:46:08 -0400 | [diff] [blame] | 308 | |
Bu Sun Kim | 66bb32c | 2019-10-30 10:11:58 -0700 | [diff] [blame] | 309 | return Channel( |
| 310 | "web_hook", |
| 311 | str(uuid.uuid4()), |
| 312 | token, |
| 313 | url, |
| 314 | expiration=expiration_ms, |
| 315 | params=params, |
| 316 | ) |