OLD | NEW |
(Empty) | |
| 1 #!/usr/bin/env python |
| 2 # |
| 3 # Copyright 2015 Google Inc. |
| 4 # |
| 5 # Licensed under the Apache License, Version 2.0 (the "License"); |
| 6 # you may not use this file except in compliance with the License. |
| 7 # You may obtain a copy of the License at |
| 8 # |
| 9 # http://www.apache.org/licenses/LICENSE-2.0 |
| 10 # |
| 11 # Unless required by applicable law or agreed to in writing, software |
| 12 # distributed under the License is distributed on an "AS IS" BASIS, |
| 13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 14 # See the License for the specific language governing permissions and |
| 15 # limitations under the License. |
| 16 |
| 17 """Library for handling batch HTTP requests for apitools.""" |
| 18 |
| 19 import collections |
| 20 import email.generator as generator |
| 21 import email.mime.multipart as mime_multipart |
| 22 import email.mime.nonmultipart as mime_nonmultipart |
| 23 import email.parser as email_parser |
| 24 import itertools |
| 25 import time |
| 26 import uuid |
| 27 |
| 28 import six |
| 29 from six.moves import http_client |
| 30 from six.moves import urllib_parse |
| 31 |
| 32 from apitools.base.py import exceptions |
| 33 from apitools.base.py import http_wrapper |
| 34 |
| 35 __all__ = [ |
| 36 'BatchApiRequest', |
| 37 ] |
| 38 |
| 39 |
| 40 class RequestResponseAndHandler(collections.namedtuple( |
| 41 'RequestResponseAndHandler', ['request', 'response', 'handler'])): |
| 42 |
| 43 """Container for data related to completing an HTTP request. |
| 44 |
| 45 This contains an HTTP request, its response, and a callback for handling |
| 46 the response from the server. |
| 47 |
| 48 Attributes: |
| 49 request: An http_wrapper.Request object representing the HTTP request. |
| 50 response: The http_wrapper.Response object returned from the server. |
| 51 handler: A callback function accepting two arguments, response |
| 52 and exception. Response is an http_wrapper.Response object, and |
| 53 exception is an apiclient.errors.HttpError object if an error |
| 54 occurred, or otherwise None. |
| 55 """ |
| 56 |
| 57 |
| 58 class BatchApiRequest(object): |
| 59 |
| 60 class ApiCall(object): |
| 61 |
| 62 """Holds request and response information for each request. |
| 63 |
| 64 ApiCalls are ultimately exposed to the client once the HTTP |
| 65 batch request has been completed. |
| 66 |
| 67 Attributes: |
| 68 http_request: A client-supplied http_wrapper.Request to be |
| 69 submitted to the server. |
| 70 response: A http_wrapper.Response object given by the server as a |
| 71 response to the user request, or None if an error occurred. |
| 72 exception: An apiclient.errors.HttpError object if an error |
| 73 occurred, or None. |
| 74 |
| 75 """ |
| 76 |
| 77 def __init__(self, request, retryable_codes, service, method_config): |
| 78 """Initialize an individual API request. |
| 79 |
| 80 Args: |
| 81 request: An http_wrapper.Request object. |
| 82 retryable_codes: A list of integer HTTP codes that can |
| 83 be retried. |
| 84 service: A service inheriting from base_api.BaseApiService. |
| 85 method_config: Method config for the desired API request. |
| 86 |
| 87 """ |
| 88 self.__retryable_codes = list( |
| 89 set(retryable_codes + [http_client.UNAUTHORIZED])) |
| 90 self.__http_response = None |
| 91 self.__service = service |
| 92 self.__method_config = method_config |
| 93 |
| 94 self.http_request = request |
| 95 # TODO(user): Add some validation to these fields. |
| 96 self.__response = None |
| 97 self.__exception = None |
| 98 |
| 99 @property |
| 100 def is_error(self): |
| 101 return self.exception is not None |
| 102 |
| 103 @property |
| 104 def response(self): |
| 105 return self.__response |
| 106 |
| 107 @property |
| 108 def exception(self): |
| 109 return self.__exception |
| 110 |
| 111 @property |
| 112 def authorization_failed(self): |
| 113 return (self.__http_response and ( |
| 114 self.__http_response.status_code == http_client.UNAUTHORIZED)) |
| 115 |
| 116 @property |
| 117 def terminal_state(self): |
| 118 if self.__http_response is None: |
| 119 return False |
| 120 response_code = self.__http_response.status_code |
| 121 return response_code not in self.__retryable_codes |
| 122 |
| 123 def HandleResponse(self, http_response, exception): |
| 124 """Handles an incoming http response to the request in http_request. |
| 125 |
| 126 This is intended to be used as a callback function for |
| 127 BatchHttpRequest.Add. |
| 128 |
| 129 Args: |
| 130 http_response: Deserialized http_wrapper.Response object. |
| 131 exception: apiclient.errors.HttpError object if an error |
| 132 occurred. |
| 133 |
| 134 """ |
| 135 self.__http_response = http_response |
| 136 self.__exception = exception |
| 137 if self.terminal_state and not self.__exception: |
| 138 self.__response = self.__service.ProcessHttpResponse( |
| 139 self.__method_config, self.__http_response) |
| 140 |
| 141 def __init__(self, batch_url=None, retryable_codes=None): |
| 142 """Initialize a batch API request object. |
| 143 |
| 144 Args: |
| 145 batch_url: Base URL for batch API calls. |
| 146 retryable_codes: A list of integer HTTP codes that can be retried. |
| 147 """ |
| 148 self.api_requests = [] |
| 149 self.retryable_codes = retryable_codes or [] |
| 150 self.batch_url = batch_url or 'https://www.googleapis.com/batch' |
| 151 |
| 152 def Add(self, service, method, request, global_params=None): |
| 153 """Add a request to the batch. |
| 154 |
| 155 Args: |
| 156 service: A class inheriting base_api.BaseApiService. |
| 157 method: A string indicated desired method from the service. See |
| 158 the example in the class docstring. |
| 159 request: An input message appropriate for the specified |
| 160 service.method. |
| 161 global_params: Optional additional parameters to pass into |
| 162 method.PrepareHttpRequest. |
| 163 |
| 164 Returns: |
| 165 None |
| 166 |
| 167 """ |
| 168 # Retrieve the configs for the desired method and service. |
| 169 method_config = service.GetMethodConfig(method) |
| 170 upload_config = service.GetUploadConfig(method) |
| 171 |
| 172 # Prepare the HTTP Request. |
| 173 http_request = service.PrepareHttpRequest( |
| 174 method_config, request, global_params=global_params, |
| 175 upload_config=upload_config) |
| 176 |
| 177 # Create the request and add it to our master list. |
| 178 api_request = self.ApiCall( |
| 179 http_request, self.retryable_codes, service, method_config) |
| 180 self.api_requests.append(api_request) |
| 181 |
| 182 def Execute(self, http, sleep_between_polls=5, max_retries=5): |
| 183 """Execute all of the requests in the batch. |
| 184 |
| 185 Args: |
| 186 http: httplib2.Http object for use in the request. |
| 187 sleep_between_polls: Integer number of seconds to sleep between |
| 188 polls. |
| 189 max_retries: Max retries. Any requests that have not succeeded by |
| 190 this number of retries simply report the last response or |
| 191 exception, whatever it happened to be. |
| 192 |
| 193 Returns: |
| 194 List of ApiCalls. |
| 195 """ |
| 196 requests = [request for request in self.api_requests |
| 197 if not request.terminal_state] |
| 198 |
| 199 for attempt in range(max_retries): |
| 200 if attempt: |
| 201 time.sleep(sleep_between_polls) |
| 202 |
| 203 # Create a batch_http_request object and populate it with |
| 204 # incomplete requests. |
| 205 batch_http_request = BatchHttpRequest(batch_url=self.batch_url) |
| 206 for request in requests: |
| 207 batch_http_request.Add( |
| 208 request.http_request, request.HandleResponse) |
| 209 batch_http_request.Execute(http) |
| 210 |
| 211 # Collect retryable requests. |
| 212 requests = [request for request in self.api_requests if not |
| 213 request.terminal_state] |
| 214 |
| 215 if hasattr(http.request, 'credentials'): |
| 216 if any(request.authorization_failed for request in requests): |
| 217 http.request.credentials.refresh(http) |
| 218 |
| 219 if not requests: |
| 220 break |
| 221 |
| 222 return self.api_requests |
| 223 |
| 224 |
| 225 class BatchHttpRequest(object): |
| 226 |
| 227 """Batches multiple http_wrapper.Request objects into a single request.""" |
| 228 |
| 229 def __init__(self, batch_url, callback=None): |
| 230 """Constructor for a BatchHttpRequest. |
| 231 |
| 232 Args: |
| 233 batch_url: URL to send batch requests to. |
| 234 callback: A callback to be called for each response, of the |
| 235 form callback(response, exception). The first parameter is |
| 236 the deserialized Response object. The second is an |
| 237 apiclient.errors.HttpError exception object if an HTTP error |
| 238 occurred while processing the request, or None if no error |
| 239 occurred. |
| 240 """ |
| 241 # Endpoint to which these requests are sent. |
| 242 self.__batch_url = batch_url |
| 243 |
| 244 # Global callback to be called for each individual response in the |
| 245 # batch. |
| 246 self.__callback = callback |
| 247 |
| 248 # List of requests, responses and handlers. |
| 249 self.__request_response_handlers = {} |
| 250 |
| 251 # The last auto generated id. |
| 252 self.__last_auto_id = itertools.count() |
| 253 |
| 254 # Unique ID on which to base the Content-ID headers. |
| 255 self.__base_id = uuid.uuid4() |
| 256 |
| 257 def _ConvertIdToHeader(self, request_id): |
| 258 """Convert an id to a Content-ID header value. |
| 259 |
| 260 Args: |
| 261 request_id: String identifier for a individual request. |
| 262 |
| 263 Returns: |
| 264 A Content-ID header with the id_ encoded into it. A UUID is |
| 265 prepended to the value because Content-ID headers are |
| 266 supposed to be universally unique. |
| 267 |
| 268 """ |
| 269 return '<%s+%s>' % (self.__base_id, urllib_parse.quote(request_id)) |
| 270 |
| 271 @staticmethod |
| 272 def _ConvertHeaderToId(header): |
| 273 """Convert a Content-ID header value to an id. |
| 274 |
| 275 Presumes the Content-ID header conforms to the format that |
| 276 _ConvertIdToHeader() returns. |
| 277 |
| 278 Args: |
| 279 header: A string indicating the Content-ID header value. |
| 280 |
| 281 Returns: |
| 282 The extracted id value. |
| 283 |
| 284 Raises: |
| 285 BatchError if the header is not in the expected format. |
| 286 """ |
| 287 if not (header.startswith('<') or header.endswith('>')): |
| 288 raise exceptions.BatchError( |
| 289 'Invalid value for Content-ID: %s' % header) |
| 290 if '+' not in header: |
| 291 raise exceptions.BatchError( |
| 292 'Invalid value for Content-ID: %s' % header) |
| 293 _, request_id = header[1:-1].rsplit('+', 1) |
| 294 |
| 295 return urllib_parse.unquote(request_id) |
| 296 |
| 297 def _SerializeRequest(self, request): |
| 298 """Convert a http_wrapper.Request object into a string. |
| 299 |
| 300 Args: |
| 301 request: A http_wrapper.Request to serialize. |
| 302 |
| 303 Returns: |
| 304 The request as a string in application/http format. |
| 305 """ |
| 306 # Construct status line |
| 307 parsed = urllib_parse.urlsplit(request.url) |
| 308 request_line = urllib_parse.urlunsplit( |
| 309 (None, None, parsed.path, parsed.query, None)) |
| 310 status_line = u' '.join(( |
| 311 request.http_method, |
| 312 request_line.decode('utf-8'), |
| 313 u'HTTP/1.1\n' |
| 314 )) |
| 315 major, minor = request.headers.get( |
| 316 'content-type', 'application/json').split('/') |
| 317 msg = mime_nonmultipart.MIMENonMultipart(major, minor) |
| 318 |
| 319 # MIMENonMultipart adds its own Content-Type header. |
| 320 # Keep all of the other headers in `request.headers`. |
| 321 for key, value in request.headers.items(): |
| 322 if key == 'content-type': |
| 323 continue |
| 324 msg[key] = value |
| 325 |
| 326 msg['Host'] = parsed.netloc |
| 327 msg.set_unixfrom(None) |
| 328 |
| 329 if request.body is not None: |
| 330 msg.set_payload(request.body) |
| 331 |
| 332 # Serialize the mime message. |
| 333 str_io = six.StringIO() |
| 334 # maxheaderlen=0 means don't line wrap headers. |
| 335 gen = generator.Generator(str_io, maxheaderlen=0) |
| 336 gen.flatten(msg, unixfrom=False) |
| 337 body = str_io.getvalue() |
| 338 |
| 339 return status_line + body |
| 340 |
| 341 def _DeserializeResponse(self, payload): |
| 342 """Convert string into Response and content. |
| 343 |
| 344 Args: |
| 345 payload: Header and body string to be deserialized. |
| 346 |
| 347 Returns: |
| 348 A Response object |
| 349 """ |
| 350 # Strip off the status line. |
| 351 status_line, payload = payload.split('\n', 1) |
| 352 _, status, _ = status_line.split(' ', 2) |
| 353 |
| 354 # Parse the rest of the response. |
| 355 parser = email_parser.Parser() |
| 356 msg = parser.parsestr(payload) |
| 357 |
| 358 # Get the headers. |
| 359 info = dict(msg) |
| 360 info['status'] = status |
| 361 |
| 362 # Create Response from the parsed headers. |
| 363 content = msg.get_payload() |
| 364 |
| 365 return http_wrapper.Response(info, content, self.__batch_url) |
| 366 |
| 367 def _NewId(self): |
| 368 """Create a new id. |
| 369 |
| 370 Auto incrementing number that avoids conflicts with ids already used. |
| 371 |
| 372 Returns: |
| 373 A new unique id string. |
| 374 """ |
| 375 return str(next(self.__last_auto_id)) |
| 376 |
| 377 def Add(self, request, callback=None): |
| 378 """Add a new request. |
| 379 |
| 380 Args: |
| 381 request: A http_wrapper.Request to add to the batch. |
| 382 callback: A callback to be called for this response, of the |
| 383 form callback(response, exception). The first parameter is the |
| 384 deserialized response object. The second is an |
| 385 apiclient.errors.HttpError exception object if an HTTP error |
| 386 occurred while processing the request, or None if no errors |
| 387 occurred. |
| 388 |
| 389 Returns: |
| 390 None |
| 391 """ |
| 392 handler = RequestResponseAndHandler(request, None, callback) |
| 393 self.__request_response_handlers[self._NewId()] = handler |
| 394 |
| 395 def _Execute(self, http): |
| 396 """Serialize batch request, send to server, process response. |
| 397 |
| 398 Args: |
| 399 http: A httplib2.Http object to be used to make the request with. |
| 400 |
| 401 Raises: |
| 402 httplib2.HttpLib2Error if a transport error has occured. |
| 403 apiclient.errors.BatchError if the response is the wrong format. |
| 404 """ |
| 405 message = mime_multipart.MIMEMultipart('mixed') |
| 406 # Message should not write out its own headers. |
| 407 setattr(message, '_write_headers', lambda self: None) |
| 408 |
| 409 # Add all the individual requests. |
| 410 for key in self.__request_response_handlers: |
| 411 msg = mime_nonmultipart.MIMENonMultipart('application', 'http') |
| 412 msg['Content-Transfer-Encoding'] = 'binary' |
| 413 msg['Content-ID'] = self._ConvertIdToHeader(key) |
| 414 |
| 415 body = self._SerializeRequest( |
| 416 self.__request_response_handlers[key].request) |
| 417 msg.set_payload(body) |
| 418 message.attach(msg) |
| 419 |
| 420 request = http_wrapper.Request(self.__batch_url, 'POST') |
| 421 request.body = message.as_string() |
| 422 request.headers['content-type'] = ( |
| 423 'multipart/mixed; boundary="%s"') % message.get_boundary() |
| 424 |
| 425 response = http_wrapper.MakeRequest(http, request) |
| 426 |
| 427 if response.status_code >= 300: |
| 428 raise exceptions.HttpError.FromResponse(response) |
| 429 |
| 430 # Prepend with a content-type header so Parser can handle it. |
| 431 header = 'content-type: %s\r\n\r\n' % response.info['content-type'] |
| 432 |
| 433 parser = email_parser.Parser() |
| 434 mime_response = parser.parsestr(header + response.content) |
| 435 |
| 436 if not mime_response.is_multipart(): |
| 437 raise exceptions.BatchError( |
| 438 'Response not in multipart/mixed format.') |
| 439 |
| 440 for part in mime_response.get_payload(): |
| 441 request_id = self._ConvertHeaderToId(part['Content-ID']) |
| 442 response = self._DeserializeResponse(part.get_payload()) |
| 443 |
| 444 # Disable protected access because namedtuple._replace(...) |
| 445 # is not actually meant to be protected. |
| 446 # pylint: disable=protected-access |
| 447 self.__request_response_handlers[request_id] = ( |
| 448 self.__request_response_handlers[request_id]._replace( |
| 449 response=response)) |
| 450 |
| 451 def Execute(self, http): |
| 452 """Execute all the requests as a single batched HTTP request. |
| 453 |
| 454 Args: |
| 455 http: A httplib2.Http object to be used with the request. |
| 456 |
| 457 Returns: |
| 458 None |
| 459 |
| 460 Raises: |
| 461 BatchError if the response is the wrong format. |
| 462 """ |
| 463 |
| 464 self._Execute(http) |
| 465 |
| 466 for key in self.__request_response_handlers: |
| 467 response = self.__request_response_handlers[key].response |
| 468 callback = self.__request_response_handlers[key].handler |
| 469 |
| 470 exception = None |
| 471 |
| 472 if response.status_code >= 300: |
| 473 exception = exceptions.HttpError.FromResponse(response) |
| 474 |
| 475 if callback is not None: |
| 476 callback(response, exception) |
| 477 if self.__callback is not None: |
| 478 self.__callback(response, exception) |
OLD | NEW |