Ali Afshar | 164f37e | 2013-01-07 14:05:45 -0800 | [diff] [blame] | 1 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 2 | # you may not use this file except in compliance with the License. |
| 3 | # You may obtain a copy of the License at |
| 4 | # |
| 5 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 6 | # |
| 7 | # Unless required by applicable law or agreed to in writing, software |
| 8 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 10 | # See the License for the specific language governing permissions and |
| 11 | # limitations under the License. |
| 12 | |
| 13 | """Push notifications support. |
| 14 | |
| 15 | This code is based on experimental APIs and is subject to change. |
| 16 | """ |
| 17 | |
| 18 | __author__ = 'afshar@google.com (Ali Afshar)' |
| 19 | |
| 20 | import binascii |
| 21 | import collections |
| 22 | import os |
| 23 | import urllib |
| 24 | |
| 25 | SUBSCRIBE = 'X-GOOG-SUBSCRIBE' |
| 26 | SUBSCRIPTION_ID = 'X-GOOG-SUBSCRIPTION-ID' |
| 27 | TOPIC_ID = 'X-GOOG-TOPIC-ID' |
| 28 | TOPIC_URI = 'X-GOOG-TOPIC-URI' |
| 29 | CLIENT_TOKEN = 'X-GOOG-CLIENT-TOKEN' |
| 30 | EVENT_TYPE = 'X-GOOG-EVENT-TYPE' |
| 31 | UNSUBSCRIBE = 'X-GOOG-UNSUBSCRIBE' |
| 32 | |
| 33 | |
| 34 | class InvalidSubscriptionRequestError(ValueError): |
| 35 | """The request cannot be subscribed.""" |
| 36 | |
| 37 | |
| 38 | def new_token(): |
| 39 | """Gets a random token for use as a client_token in push notifications. |
| 40 | |
| 41 | Returns: |
| 42 | str, a new random token. |
| 43 | """ |
| 44 | return binascii.hexlify(os.urandom(32)) |
| 45 | |
| 46 | |
| 47 | class Channel(object): |
| 48 | """Base class for channel types.""" |
| 49 | |
| 50 | def __init__(self, channel_type, channel_args): |
| 51 | """Create a new Channel. |
| 52 | |
| 53 | You probably won't need to create this channel manually, since there are |
| 54 | subclassed Channel for each specific type with a more customized set of |
| 55 | arguments to pass. However, you may wish to just create it manually here. |
| 56 | |
| 57 | Args: |
| 58 | channel_type: str, the type of channel. |
| 59 | channel_args: dict, arguments to pass to the channel. |
| 60 | """ |
| 61 | self.channel_type = channel_type |
| 62 | self.channel_args = channel_args |
| 63 | |
| 64 | def as_header_value(self): |
| 65 | """Create the appropriate header for this channel. |
| 66 | |
| 67 | Returns: |
| 68 | str encoded channel description suitable for use as a header. |
| 69 | """ |
| 70 | return '%s?%s' % (self.channel_type, urllib.urlencode(self.channel_args)) |
| 71 | |
| 72 | def write_header(self, headers): |
| 73 | """Write the appropriate subscribe header to a headers dict. |
| 74 | |
| 75 | Args: |
| 76 | headers: dict, headers to add subscribe header to. |
| 77 | """ |
| 78 | headers[SUBSCRIBE] = self.as_header_value() |
| 79 | |
| 80 | |
| 81 | class WebhookChannel(Channel): |
| 82 | """Channel for registering web hook notifications.""" |
| 83 | |
| 84 | def __init__(self, url, app_engine=False): |
| 85 | """Create a new WebhookChannel |
| 86 | |
| 87 | Args: |
| 88 | url: str, URL to post notifications to. |
| 89 | app_engine: bool, default=False, whether the destination for the |
| 90 | notifications is an App Engine application. |
| 91 | """ |
| 92 | super(WebhookChannel, self).__init__( |
| 93 | channel_type='web_hook', |
| 94 | channel_args={ |
| 95 | 'url': url, |
| 96 | 'app_engine': app_engine and 'true' or 'false', |
| 97 | } |
| 98 | ) |
| 99 | |
| 100 | |
| 101 | class Headers(collections.defaultdict): |
| 102 | """Headers for managing subscriptions.""" |
| 103 | |
| 104 | |
| 105 | ALL_HEADERS = set([SUBSCRIBE, SUBSCRIPTION_ID, TOPIC_ID, TOPIC_URI, |
| 106 | CLIENT_TOKEN, EVENT_TYPE, UNSUBSCRIBE]) |
| 107 | |
| 108 | def __init__(self): |
| 109 | """Create a new subscription configuration instance.""" |
| 110 | collections.defaultdict.__init__(self, str) |
| 111 | |
| 112 | def __setitem__(self, key, value): |
| 113 | """Set a header value, ensuring the key is an allowed value. |
| 114 | |
| 115 | Args: |
| 116 | key: str, the header key. |
| 117 | value: str, the header value. |
| 118 | Raises: |
| 119 | ValueError if key is not one of the accepted headers. |
| 120 | """ |
| 121 | normal_key = self._normalize_key(key) |
| 122 | if normal_key not in self.ALL_HEADERS: |
| 123 | raise ValueError('Header name must be one of %s.' % self.ALL_HEADERS) |
| 124 | else: |
| 125 | return collections.defaultdict.__setitem__(self, normal_key, value) |
| 126 | |
| 127 | def __getitem__(self, key): |
| 128 | """Get a header value, normalizing the key case. |
| 129 | |
| 130 | Args: |
| 131 | key: str, the header key. |
| 132 | Returns: |
| 133 | String header value. |
| 134 | Raises: |
| 135 | KeyError if the key is not one of the accepted headers. |
| 136 | """ |
| 137 | normal_key = self._normalize_key(key) |
| 138 | if normal_key not in self.ALL_HEADERS: |
| 139 | raise ValueError('Header name must be one of %s.' % self.ALL_HEADERS) |
| 140 | else: |
| 141 | return collections.defaultdict.__getitem__(self, normal_key) |
| 142 | |
| 143 | def _normalize_key(self, key): |
| 144 | """Normalize a header name for use as a key.""" |
| 145 | return key.upper() |
| 146 | |
| 147 | def items(self): |
| 148 | """Generator for each header.""" |
| 149 | for header in self.ALL_HEADERS: |
| 150 | value = self[header] |
| 151 | if value: |
| 152 | yield header, value |
| 153 | |
| 154 | def write(self, headers): |
| 155 | """Applies the subscription headers. |
| 156 | |
| 157 | Args: |
| 158 | headers: dict of headers to insert values into. |
| 159 | """ |
| 160 | for header, value in self.items(): |
| 161 | headers[header.lower()] = value |
| 162 | |
| 163 | def read(self, headers): |
| 164 | """Read from headers. |
| 165 | |
| 166 | Args: |
| 167 | headers: dict of headers to read from. |
| 168 | """ |
| 169 | for header in self.ALL_HEADERS: |
| 170 | if header.lower() in headers: |
| 171 | self[header] = headers[header.lower()] |
| 172 | |
| 173 | |
| 174 | class Subscription(object): |
| 175 | """Information about a subscription.""" |
| 176 | |
| 177 | def __init__(self): |
| 178 | """Create a new Subscription.""" |
| 179 | self.headers = Headers() |
| 180 | |
| 181 | @classmethod |
| 182 | def for_request(cls, request, channel, client_token=None): |
| 183 | """Creates a subscription and attaches it to a request. |
| 184 | |
| 185 | Args: |
| 186 | request: An http.HttpRequest to modify for making a subscription. |
| 187 | channel: A apiclient.push.Channel describing the subscription to |
| 188 | create. |
| 189 | client_token: (optional) client token to verify the notification. |
| 190 | |
| 191 | Returns: |
| 192 | New subscription object. |
| 193 | """ |
| 194 | subscription = cls.for_channel(channel=channel, client_token=client_token) |
| 195 | subscription.headers.write(request.headers) |
| 196 | if request.method != 'GET': |
| 197 | raise InvalidSubscriptionRequestError( |
| 198 | 'Can only subscribe to requests which are GET.') |
| 199 | request.method = 'POST' |
| 200 | |
| 201 | def _on_response(response, subscription=subscription): |
| 202 | """Called with the response headers. Reads the subscription headers.""" |
| 203 | subscription.headers.read(response) |
| 204 | |
| 205 | request.add_response_callback(_on_response) |
| 206 | return subscription |
| 207 | |
| 208 | @classmethod |
| 209 | def for_channel(cls, channel, client_token=None): |
| 210 | """Alternate constructor to create a subscription from a channel. |
| 211 | |
| 212 | Args: |
| 213 | channel: A apiclient.push.Channel describing the subscription to |
| 214 | create. |
| 215 | client_token: (optional) client token to verify the notification. |
| 216 | |
| 217 | Returns: |
| 218 | New subscription object. |
| 219 | """ |
| 220 | subscription = cls() |
| 221 | channel.write_header(subscription.headers) |
| 222 | if client_token is None: |
| 223 | client_token = new_token() |
| 224 | subscription.headers[SUBSCRIPTION_ID] = new_token() |
| 225 | subscription.headers[CLIENT_TOKEN] = client_token |
| 226 | return subscription |
| 227 | |
| 228 | def verify(self, headers): |
| 229 | """Verifies that a webhook notification has the correct client_token. |
| 230 | |
| 231 | Args: |
| 232 | headers: dict of request headers for a push notification. |
| 233 | |
| 234 | Returns: |
| 235 | Boolean value indicating whether the notification is verified. |
| 236 | """ |
| 237 | new_subscription = Subscription() |
| 238 | new_subscription.headers.read(headers) |
| 239 | return new_subscription.client_token == self.client_token |
| 240 | |
| 241 | @property |
| 242 | def subscribe(self): |
| 243 | """Subscribe header value.""" |
| 244 | return self.headers[SUBSCRIBE] |
| 245 | |
| 246 | @property |
| 247 | def subscription_id(self): |
| 248 | """Subscription ID header value.""" |
| 249 | return self.headers[SUBSCRIPTION_ID] |
| 250 | |
| 251 | @property |
| 252 | def topic_id(self): |
| 253 | """Topic ID header value.""" |
| 254 | return self.headers[TOPIC_ID] |
| 255 | |
| 256 | @property |
| 257 | def topic_uri(self): |
| 258 | """Topic URI header value.""" |
| 259 | return self.headers[TOPIC_URI] |
| 260 | |
| 261 | @property |
| 262 | def client_token(self): |
| 263 | """Client Token header value.""" |
| 264 | return self.headers[CLIENT_TOKEN] |
| 265 | |
| 266 | @property |
| 267 | def event_type(self): |
| 268 | """Event Type header value.""" |
| 269 | return self.headers[EVENT_TYPE] |
| 270 | |
| 271 | @property |
| 272 | def unsubscribe(self): |
| 273 | """Unsuscribe header value.""" |
| 274 | return self.headers[UNSUBSCRIBE] |