blob: 9fdd39c3a02592eadb10f16d27820c04fbb89d17 [file] [log] [blame]
Joe Gregorio20a5aa92011-04-01 17:44:25 -04001# Copyright (C) 2010 Google Inc.
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.
Joe Gregorioc5c5a372010-09-22 11:42:32 -040014
Joe Gregorioaf276d22010-12-09 14:26:58 -050015"""Classes to encapsulate a single HTTP request.
Joe Gregorioc5c5a372010-09-22 11:42:32 -040016
Joe Gregorioaf276d22010-12-09 14:26:58 -050017The classes implement a command pattern, with every
18object supporting an execute() method that does the
19actuall HTTP request.
Joe Gregorioc5c5a372010-09-22 11:42:32 -040020"""
21
22__author__ = 'jcgregorio@google.com (Joe Gregorio)'
Joe Gregorioaf276d22010-12-09 14:26:58 -050023__all__ = [
Joe Gregoriocb8103d2011-02-11 23:20:52 -050024 'HttpRequest', 'RequestMockBuilder', 'HttpMock'
Joe Gregoriof4153422011-03-18 22:45:18 -040025 'set_user_agent', 'tunnel_patch'
Joe Gregorioaf276d22010-12-09 14:26:58 -050026 ]
27
Joe Gregorio66f57522011-11-30 11:00:00 -050028import StringIO
Ali Afshar6f11ea12012-02-07 10:32:14 -050029import base64
Joe Gregoriod0bd3882011-11-22 09:49:47 -050030import copy
Joe Gregorio66f57522011-11-30 11:00:00 -050031import gzip
Joe Gregorioc6722462010-12-20 14:29:28 -050032import httplib2
Joe Gregoriod0bd3882011-11-22 09:49:47 -050033import mimeparse
34import mimetypes
Joe Gregorio66f57522011-11-30 11:00:00 -050035import os
36import urllib
37import urlparse
38import uuid
Joe Gregoriocb8103d2011-02-11 23:20:52 -050039
Joe Gregorio654f4a22012-02-09 14:15:44 -050040from email.generator import Generator
Joe Gregorio66f57522011-11-30 11:00:00 -050041from email.mime.multipart import MIMEMultipart
42from email.mime.nonmultipart import MIMENonMultipart
43from email.parser import FeedParser
44from errors import BatchError
Joe Gregorio49396552011-03-08 10:39:00 -050045from errors import HttpError
Joe Gregoriod0bd3882011-11-22 09:49:47 -050046from errors import ResumableUploadError
Joe Gregorioa388ce32011-09-09 17:19:13 -040047from errors import UnexpectedBodyError
48from errors import UnexpectedMethodError
Joe Gregorio66f57522011-11-30 11:00:00 -050049from model import JsonModel
Joe Gregorio549230c2012-01-11 10:38:05 -050050from oauth2client.anyjson import simplejson
Joe Gregorioc5c5a372010-09-22 11:42:32 -040051
52
Joe Gregoriod0bd3882011-11-22 09:49:47 -050053class MediaUploadProgress(object):
54 """Status of a resumable upload."""
55
56 def __init__(self, resumable_progress, total_size):
57 """Constructor.
58
59 Args:
60 resumable_progress: int, bytes sent so far.
61 total_size: int, total bytes in complete upload.
62 """
63 self.resumable_progress = resumable_progress
64 self.total_size = total_size
65
66 def progress(self):
67 """Percent of upload completed, as a float."""
Joe Gregorio66f57522011-11-30 11:00:00 -050068 return float(self.resumable_progress) / float(self.total_size)
Joe Gregoriod0bd3882011-11-22 09:49:47 -050069
70
71class MediaUpload(object):
72 """Describes a media object to upload.
73
74 Base class that defines the interface of MediaUpload subclasses.
75 """
76
77 def getbytes(self, begin, end):
78 raise NotImplementedError()
79
80 def size(self):
81 raise NotImplementedError()
82
83 def chunksize(self):
84 raise NotImplementedError()
85
86 def mimetype(self):
87 return 'application/octet-stream'
88
89 def resumable(self):
90 return False
91
92 def _to_json(self, strip=None):
93 """Utility function for creating a JSON representation of a MediaUpload.
94
95 Args:
96 strip: array, An array of names of members to not include in the JSON.
97
98 Returns:
99 string, a JSON representation of this instance, suitable to pass to
100 from_json().
101 """
102 t = type(self)
103 d = copy.copy(self.__dict__)
104 if strip is not None:
105 for member in strip:
106 del d[member]
107 d['_class'] = t.__name__
108 d['_module'] = t.__module__
109 return simplejson.dumps(d)
110
111 def to_json(self):
112 """Create a JSON representation of an instance of MediaUpload.
113
114 Returns:
115 string, a JSON representation of this instance, suitable to pass to
116 from_json().
117 """
118 return self._to_json()
119
120 @classmethod
121 def new_from_json(cls, s):
122 """Utility class method to instantiate a MediaUpload subclass from a JSON
123 representation produced by to_json().
124
125 Args:
126 s: string, JSON from to_json().
127
128 Returns:
129 An instance of the subclass of MediaUpload that was serialized with
130 to_json().
131 """
132 data = simplejson.loads(s)
133 # Find and call the right classmethod from_json() to restore the object.
134 module = data['_module']
135 m = __import__(module, fromlist=module.split('.')[:-1])
136 kls = getattr(m, data['_class'])
137 from_json = getattr(kls, 'from_json')
138 return from_json(s)
139
Joe Gregorio66f57522011-11-30 11:00:00 -0500140
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500141class MediaFileUpload(MediaUpload):
142 """A MediaUpload for a file.
143
144 Construct a MediaFileUpload and pass as the media_body parameter of the
145 method. For example, if we had a service that allowed uploading images:
146
147
148 media = MediaFileUpload('smiley.png', mimetype='image/png', chunksize=1000,
149 resumable=True)
150 service.objects().insert(
151 bucket=buckets['items'][0]['id'],
152 name='smiley.png',
153 media_body=media).execute()
154 """
155
Joe Gregorio945be3e2012-01-27 17:01:06 -0500156 def __init__(self, filename, mimetype=None, chunksize=256*1024, resumable=False):
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500157 """Constructor.
158
159 Args:
160 filename: string, Name of the file.
161 mimetype: string, Mime-type of the file. If None then a mime-type will be
162 guessed from the file extension.
163 chunksize: int, File will be uploaded in chunks of this many bytes. Only
164 used if resumable=True.
Joe Gregorio66f57522011-11-30 11:00:00 -0500165 resumable: bool, True if this is a resumable upload. False means upload
166 in a single request.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500167 """
168 self._filename = filename
169 self._size = os.path.getsize(filename)
170 self._fd = None
171 if mimetype is None:
172 (mimetype, encoding) = mimetypes.guess_type(filename)
173 self._mimetype = mimetype
174 self._chunksize = chunksize
175 self._resumable = resumable
176
177 def mimetype(self):
178 return self._mimetype
179
180 def size(self):
181 return self._size
182
183 def chunksize(self):
184 return self._chunksize
185
186 def resumable(self):
187 return self._resumable
188
189 def getbytes(self, begin, length):
190 """Get bytes from the media.
191
192 Args:
193 begin: int, offset from beginning of file.
194 length: int, number of bytes to read, starting at begin.
195
196 Returns:
197 A string of bytes read. May be shorted than length if EOF was reached
198 first.
199 """
200 if self._fd is None:
201 self._fd = open(self._filename, 'rb')
202 self._fd.seek(begin)
203 return self._fd.read(length)
204
205 def to_json(self):
206 """Creating a JSON representation of an instance of Credentials.
207
208 Returns:
209 string, a JSON representation of this instance, suitable to pass to
210 from_json().
211 """
212 return self._to_json(['_fd'])
213
214 @staticmethod
215 def from_json(s):
216 d = simplejson.loads(s)
217 return MediaFileUpload(
218 d['_filename'], d['_mimetype'], d['_chunksize'], d['_resumable'])
219
220
Ali Afshar6f11ea12012-02-07 10:32:14 -0500221class MediaInMemoryUpload(MediaUpload):
222 """MediaUpload for a chunk of bytes.
223
224 Construct a MediaFileUpload and pass as the media_body parameter of the
225 method. For example, if we had a service that allowed plain text:
226 """
227
228 def __init__(self, body, mimetype='application/octet-stream',
229 chunksize=256*1024, resumable=False):
230 """Create a new MediaBytesUpload.
231
232 Args:
233 body: string, Bytes of body content.
234 mimetype: string, Mime-type of the file or default of
235 'application/octet-stream'.
236 chunksize: int, File will be uploaded in chunks of this many bytes. Only
237 used if resumable=True.
238 resumable: bool, True if this is a resumable upload. False means upload
239 in a single request.
240 """
241 self._body = body
242 self._mimetype = mimetype
243 self._resumable = resumable
244 self._chunksize = chunksize
245
246 def chunksize(self):
247 """Chunk size for resumable uploads.
248
249 Returns:
250 Chunk size in bytes.
251 """
252 return self._chunksize
253
254 def mimetype(self):
255 """Mime type of the body.
256
257 Returns:
258 Mime type.
259 """
260 return self._mimetype
261
262 def size(self):
263 """Size of upload.
264
265 Returns:
266 Size of the body.
267 """
268 return len(self.body)
269
270 def resumable(self):
271 """Whether this upload is resumable.
272
273 Returns:
274 True if resumable upload or False.
275 """
276 return self._resumable
277
278 def getbytes(self, begin, length):
279 """Get bytes from the media.
280
281 Args:
282 begin: int, offset from beginning of file.
283 length: int, number of bytes to read, starting at begin.
284
285 Returns:
286 A string of bytes read. May be shorter than length if EOF was reached
287 first.
288 """
289 return self._body[begin:begin + length]
290
291 def to_json(self):
292 """Create a JSON representation of a MediaInMemoryUpload.
293
294 Returns:
295 string, a JSON representation of this instance, suitable to pass to
296 from_json().
297 """
298 t = type(self)
299 d = copy.copy(self.__dict__)
300 del d['_body']
301 d['_class'] = t.__name__
302 d['_module'] = t.__module__
303 d['_b64body'] = base64.b64encode(self._body)
304 return simplejson.dumps(d)
305
306 @staticmethod
307 def from_json(s):
308 d = simplejson.loads(s)
309 return MediaInMemoryUpload(base64.b64decode(d['_b64body']),
310 d['_mimetype'], d['_chunksize'],
311 d['_resumable'])
312
313
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400314class HttpRequest(object):
Joe Gregorio66f57522011-11-30 11:00:00 -0500315 """Encapsulates a single HTTP request."""
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400316
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500317 def __init__(self, http, postproc, uri,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500318 method='GET',
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500319 body=None,
320 headers=None,
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500321 methodId=None,
322 resumable=None):
Joe Gregorioaf276d22010-12-09 14:26:58 -0500323 """Constructor for an HttpRequest.
324
Joe Gregorioaf276d22010-12-09 14:26:58 -0500325 Args:
326 http: httplib2.Http, the transport object to use to make a request
Joe Gregorioabda96f2011-02-11 20:19:33 -0500327 postproc: callable, called on the HTTP response and content to transform
328 it into a data object before returning, or raising an exception
329 on an error.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500330 uri: string, the absolute URI to send the request to
331 method: string, the HTTP method to use
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500332 body: string, the request body of the HTTP request,
Joe Gregorioaf276d22010-12-09 14:26:58 -0500333 headers: dict, the HTTP request headers
Joe Gregorioaf276d22010-12-09 14:26:58 -0500334 methodId: string, a unique identifier for the API method being called.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500335 resumable: MediaUpload, None if this is not a resumbale request.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500336 """
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400337 self.uri = uri
338 self.method = method
339 self.body = body
340 self.headers = headers or {}
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500341 self.methodId = methodId
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400342 self.http = http
343 self.postproc = postproc
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500344 self.resumable = resumable
345
Joe Gregorio66f57522011-11-30 11:00:00 -0500346 # Pull the multipart boundary out of the content-type header.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500347 major, minor, params = mimeparse.parse_mime_type(
348 headers.get('content-type', 'application/json'))
Joe Gregoriobd512b52011-12-06 15:39:26 -0500349
Joe Gregorio945be3e2012-01-27 17:01:06 -0500350 # The size of the non-media part of the request.
351 self.body_size = len(self.body or '')
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500352
353 # The resumable URI to send chunks to.
354 self.resumable_uri = None
355
356 # The bytes that have been uploaded.
357 self.resumable_progress = 0
358
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400359 def execute(self, http=None):
360 """Execute the request.
361
Joe Gregorioaf276d22010-12-09 14:26:58 -0500362 Args:
363 http: httplib2.Http, an http object to be used in place of the
364 one the HttpRequest request object was constructed with.
365
366 Returns:
367 A deserialized object model of the response body as determined
368 by the postproc.
369
370 Raises:
371 apiclient.errors.HttpError if the response was not a 2xx.
372 httplib2.Error if a transport error has occured.
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400373 """
374 if http is None:
375 http = self.http
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500376 if self.resumable:
377 body = None
378 while body is None:
379 _, body = self.next_chunk(http)
380 return body
381 else:
Joe Gregorio884e2b22012-02-24 09:37:00 -0500382 if 'content-length' not in self.headers:
383 self.headers['content-length'] = str(self.body_size)
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500384 resp, content = http.request(self.uri, self.method,
385 body=self.body,
386 headers=self.headers)
Joe Gregorio49396552011-03-08 10:39:00 -0500387
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500388 if resp.status >= 300:
389 raise HttpError(resp, content, self.uri)
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400390 return self.postproc(resp, content)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500391
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500392 def next_chunk(self, http=None):
393 """Execute the next step of a resumable upload.
394
Joe Gregorio66f57522011-11-30 11:00:00 -0500395 Can only be used if the method being executed supports media uploads and
396 the MediaUpload object passed in was flagged as using resumable upload.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500397
398 Example:
399
Joe Gregorio66f57522011-11-30 11:00:00 -0500400 media = MediaFileUpload('smiley.png', mimetype='image/png',
401 chunksize=1000, resumable=True)
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500402 request = service.objects().insert(
403 bucket=buckets['items'][0]['id'],
404 name='smiley.png',
405 media_body=media)
406
407 response = None
408 while response is None:
409 status, response = request.next_chunk()
410 if status:
411 print "Upload %d%% complete." % int(status.progress() * 100)
412
413
414 Returns:
415 (status, body): (ResumableMediaStatus, object)
416 The body will be None until the resumable media is fully uploaded.
417 """
418 if http is None:
419 http = self.http
420
421 if self.resumable_uri is None:
422 start_headers = copy.copy(self.headers)
423 start_headers['X-Upload-Content-Type'] = self.resumable.mimetype()
424 start_headers['X-Upload-Content-Length'] = str(self.resumable.size())
Joe Gregorio945be3e2012-01-27 17:01:06 -0500425 start_headers['content-length'] = str(self.body_size)
426
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500427 resp, content = http.request(self.uri, self.method,
Joe Gregorio945be3e2012-01-27 17:01:06 -0500428 body=self.body,
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500429 headers=start_headers)
430 if resp.status == 200 and 'location' in resp:
431 self.resumable_uri = resp['location']
432 else:
433 raise ResumableUploadError("Failed to retrieve starting URI.")
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500434
Joe Gregorio945be3e2012-01-27 17:01:06 -0500435 data = self.resumable.getbytes(self.resumable_progress,
436 self.resumable.chunksize())
437
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500438 headers = {
439 'Content-Range': 'bytes %d-%d/%d' % (
440 self.resumable_progress, self.resumable_progress + len(data) - 1,
Joe Gregorio945be3e2012-01-27 17:01:06 -0500441 self.resumable.size()),
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500442 }
443 resp, content = http.request(self.resumable_uri, 'PUT',
444 body=data,
445 headers=headers)
446 if resp.status in [200, 201]:
447 return None, self.postproc(resp, content)
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500448 elif resp.status == 308:
Joe Gregorio66f57522011-11-30 11:00:00 -0500449 # A "308 Resume Incomplete" indicates we are not done.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500450 self.resumable_progress = int(resp['range'].split('-')[1]) + 1
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500451 if 'location' in resp:
452 self.resumable_uri = resp['location']
453 else:
454 raise HttpError(resp, content, self.uri)
455
Joe Gregorio945be3e2012-01-27 17:01:06 -0500456 return (MediaUploadProgress(self.resumable_progress, self.resumable.size()),
457 None)
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500458
459 def to_json(self):
460 """Returns a JSON representation of the HttpRequest."""
461 d = copy.copy(self.__dict__)
462 if d['resumable'] is not None:
463 d['resumable'] = self.resumable.to_json()
464 del d['http']
465 del d['postproc']
466 return simplejson.dumps(d)
467
468 @staticmethod
469 def from_json(s, http, postproc):
470 """Returns an HttpRequest populated with info from a JSON object."""
471 d = simplejson.loads(s)
472 if d['resumable'] is not None:
473 d['resumable'] = MediaUpload.new_from_json(d['resumable'])
474 return HttpRequest(
475 http,
476 postproc,
Joe Gregorio66f57522011-11-30 11:00:00 -0500477 uri=d['uri'],
478 method=d['method'],
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500479 body=d['body'],
480 headers=d['headers'],
481 methodId=d['methodId'],
482 resumable=d['resumable'])
483
Joe Gregorioaf276d22010-12-09 14:26:58 -0500484
Joe Gregorio66f57522011-11-30 11:00:00 -0500485class BatchHttpRequest(object):
486 """Batches multiple HttpRequest objects into a single HTTP request."""
487
488 def __init__(self, callback=None, batch_uri=None):
489 """Constructor for a BatchHttpRequest.
490
491 Args:
492 callback: callable, A callback to be called for each response, of the
493 form callback(id, response). The first parameter is the request id, and
494 the second is the deserialized response object.
495 batch_uri: string, URI to send batch requests to.
496 """
497 if batch_uri is None:
498 batch_uri = 'https://www.googleapis.com/batch'
499 self._batch_uri = batch_uri
500
501 # Global callback to be called for each individual response in the batch.
502 self._callback = callback
503
Joe Gregorio654f4a22012-02-09 14:15:44 -0500504 # A map from id to request.
Joe Gregorio66f57522011-11-30 11:00:00 -0500505 self._requests = {}
506
Joe Gregorio654f4a22012-02-09 14:15:44 -0500507 # A map from id to callback.
508 self._callbacks = {}
509
Joe Gregorio66f57522011-11-30 11:00:00 -0500510 # List of request ids, in the order in which they were added.
511 self._order = []
512
513 # The last auto generated id.
514 self._last_auto_id = 0
515
516 # Unique ID on which to base the Content-ID headers.
517 self._base_id = None
518
Joe Gregorio654f4a22012-02-09 14:15:44 -0500519 # A map from request id to (headers, content) response pairs
520 self._responses = {}
521
522 # A map of id(Credentials) that have been refreshed.
523 self._refreshed_credentials = {}
524
525 def _refresh_and_apply_credentials(self, request, http):
526 """Refresh the credentials and apply to the request.
527
528 Args:
529 request: HttpRequest, the request.
530 http: httplib2.Http, the global http object for the batch.
531 """
532 # For the credentials to refresh, but only once per refresh_token
533 # If there is no http per the request then refresh the http passed in
534 # via execute()
535 creds = None
536 if request.http is not None and hasattr(request.http.request,
537 'credentials'):
538 creds = request.http.request.credentials
539 elif http is not None and hasattr(http.request, 'credentials'):
540 creds = http.request.credentials
541 if creds is not None:
542 if id(creds) not in self._refreshed_credentials:
543 creds.refresh(http)
544 self._refreshed_credentials[id(creds)] = 1
545
546 # Only apply the credentials if we are using the http object passed in,
547 # otherwise apply() will get called during _serialize_request().
548 if request.http is None or not hasattr(request.http.request,
549 'credentials'):
550 creds.apply(request.headers)
551
Joe Gregorio66f57522011-11-30 11:00:00 -0500552 def _id_to_header(self, id_):
553 """Convert an id to a Content-ID header value.
554
555 Args:
556 id_: string, identifier of individual request.
557
558 Returns:
559 A Content-ID header with the id_ encoded into it. A UUID is prepended to
560 the value because Content-ID headers are supposed to be universally
561 unique.
562 """
563 if self._base_id is None:
564 self._base_id = uuid.uuid4()
565
566 return '<%s+%s>' % (self._base_id, urllib.quote(id_))
567
568 def _header_to_id(self, header):
569 """Convert a Content-ID header value to an id.
570
571 Presumes the Content-ID header conforms to the format that _id_to_header()
572 returns.
573
574 Args:
575 header: string, Content-ID header value.
576
577 Returns:
578 The extracted id value.
579
580 Raises:
581 BatchError if the header is not in the expected format.
582 """
583 if header[0] != '<' or header[-1] != '>':
584 raise BatchError("Invalid value for Content-ID: %s" % header)
585 if '+' not in header:
586 raise BatchError("Invalid value for Content-ID: %s" % header)
587 base, id_ = header[1:-1].rsplit('+', 1)
588
589 return urllib.unquote(id_)
590
591 def _serialize_request(self, request):
592 """Convert an HttpRequest object into a string.
593
594 Args:
595 request: HttpRequest, the request to serialize.
596
597 Returns:
598 The request as a string in application/http format.
599 """
600 # Construct status line
601 parsed = urlparse.urlparse(request.uri)
602 request_line = urlparse.urlunparse(
603 (None, None, parsed.path, parsed.params, parsed.query, None)
604 )
605 status_line = request.method + ' ' + request_line + ' HTTP/1.1\n'
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500606 major, minor = request.headers.get('content-type', 'application/json').split('/')
Joe Gregorio66f57522011-11-30 11:00:00 -0500607 msg = MIMENonMultipart(major, minor)
608 headers = request.headers.copy()
609
Joe Gregorio654f4a22012-02-09 14:15:44 -0500610 if request.http is not None and hasattr(request.http.request,
611 'credentials'):
612 request.http.request.credentials.apply(headers)
613
Joe Gregorio66f57522011-11-30 11:00:00 -0500614 # MIMENonMultipart adds its own Content-Type header.
615 if 'content-type' in headers:
616 del headers['content-type']
617
618 for key, value in headers.iteritems():
619 msg[key] = value
620 msg['Host'] = parsed.netloc
621 msg.set_unixfrom(None)
622
623 if request.body is not None:
624 msg.set_payload(request.body)
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500625 msg['content-length'] = str(len(request.body))
Joe Gregorio66f57522011-11-30 11:00:00 -0500626
Joe Gregorio654f4a22012-02-09 14:15:44 -0500627 # Serialize the mime message.
628 fp = StringIO.StringIO()
629 # maxheaderlen=0 means don't line wrap headers.
630 g = Generator(fp, maxheaderlen=0)
631 g.flatten(msg, unixfrom=False)
632 body = fp.getvalue()
633
Joe Gregorio66f57522011-11-30 11:00:00 -0500634 # Strip off the \n\n that the MIME lib tacks onto the end of the payload.
635 if request.body is None:
636 body = body[:-2]
637
Joe Gregoriodd813822012-01-25 10:32:47 -0500638 return status_line.encode('utf-8') + body
Joe Gregorio66f57522011-11-30 11:00:00 -0500639
640 def _deserialize_response(self, payload):
641 """Convert string into httplib2 response and content.
642
643 Args:
644 payload: string, headers and body as a string.
645
646 Returns:
647 A pair (resp, content) like would be returned from httplib2.request.
648 """
649 # Strip off the status line
650 status_line, payload = payload.split('\n', 1)
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500651 protocol, status, reason = status_line.split(' ', 2)
Joe Gregorio66f57522011-11-30 11:00:00 -0500652
653 # Parse the rest of the response
654 parser = FeedParser()
655 parser.feed(payload)
656 msg = parser.close()
657 msg['status'] = status
658
659 # Create httplib2.Response from the parsed headers.
660 resp = httplib2.Response(msg)
661 resp.reason = reason
662 resp.version = int(protocol.split('/', 1)[1].replace('.', ''))
663
664 content = payload.split('\r\n\r\n', 1)[1]
665
666 return resp, content
667
668 def _new_id(self):
669 """Create a new id.
670
671 Auto incrementing number that avoids conflicts with ids already used.
672
673 Returns:
674 string, a new unique id.
675 """
676 self._last_auto_id += 1
677 while str(self._last_auto_id) in self._requests:
678 self._last_auto_id += 1
679 return str(self._last_auto_id)
680
681 def add(self, request, callback=None, request_id=None):
682 """Add a new request.
683
684 Every callback added will be paired with a unique id, the request_id. That
685 unique id will be passed back to the callback when the response comes back
686 from the server. The default behavior is to have the library generate it's
687 own unique id. If the caller passes in a request_id then they must ensure
688 uniqueness for each request_id, and if they are not an exception is
689 raised. Callers should either supply all request_ids or nevery supply a
690 request id, to avoid such an error.
691
692 Args:
693 request: HttpRequest, Request to add to the batch.
694 callback: callable, A callback to be called for this response, of the
695 form callback(id, response). The first parameter is the request id, and
696 the second is the deserialized response object.
697 request_id: string, A unique id for the request. The id will be passed to
698 the callback with the response.
699
700 Returns:
701 None
702
703 Raises:
704 BatchError if a resumable request is added to a batch.
705 KeyError is the request_id is not unique.
706 """
707 if request_id is None:
708 request_id = self._new_id()
709 if request.resumable is not None:
710 raise BatchError("Resumable requests cannot be used in a batch request.")
711 if request_id in self._requests:
712 raise KeyError("A request with this ID already exists: %s" % request_id)
Joe Gregorio654f4a22012-02-09 14:15:44 -0500713 self._requests[request_id] = request
714 self._callbacks[request_id] = callback
Joe Gregorio66f57522011-11-30 11:00:00 -0500715 self._order.append(request_id)
716
Joe Gregorio654f4a22012-02-09 14:15:44 -0500717 def _execute(self, http, order, requests):
718 """Serialize batch request, send to server, process response.
719
720 Args:
721 http: httplib2.Http, an http object to be used to make the request with.
722 order: list, list of request ids in the order they were added to the
723 batch.
724 request: list, list of request objects to send.
725
726 Raises:
727 httplib2.Error if a transport error has occured.
728 apiclient.errors.BatchError if the response is the wrong format.
729 """
730 message = MIMEMultipart('mixed')
731 # Message should not write out it's own headers.
732 setattr(message, '_write_headers', lambda self: None)
733
734 # Add all the individual requests.
735 for request_id in order:
736 request = requests[request_id]
737
738 msg = MIMENonMultipart('application', 'http')
739 msg['Content-Transfer-Encoding'] = 'binary'
740 msg['Content-ID'] = self._id_to_header(request_id)
741
742 body = self._serialize_request(request)
743 msg.set_payload(body)
744 message.attach(msg)
745
746 body = message.as_string()
747
748 headers = {}
749 headers['content-type'] = ('multipart/mixed; '
750 'boundary="%s"') % message.get_boundary()
751
752 resp, content = http.request(self._batch_uri, 'POST', body=body,
753 headers=headers)
754
755 if resp.status >= 300:
756 raise HttpError(resp, content, self._batch_uri)
757
758 # Now break out the individual responses and store each one.
759 boundary, _ = content.split(None, 1)
760
761 # Prepend with a content-type header so FeedParser can handle it.
762 header = 'content-type: %s\r\n\r\n' % resp['content-type']
763 for_parser = header + content
764
765 parser = FeedParser()
766 parser.feed(for_parser)
767 mime_response = parser.close()
768
769 if not mime_response.is_multipart():
770 raise BatchError("Response not in multipart/mixed format.", resp,
771 content)
772
773 for part in mime_response.get_payload():
774 request_id = self._header_to_id(part['Content-ID'])
775 headers, content = self._deserialize_response(part.get_payload())
776 self._responses[request_id] = (headers, content)
777
Joe Gregorio66f57522011-11-30 11:00:00 -0500778 def execute(self, http=None):
779 """Execute all the requests as a single batched HTTP request.
780
781 Args:
782 http: httplib2.Http, an http object to be used in place of the one the
783 HttpRequest request object was constructed with. If one isn't supplied
784 then use a http object from the requests in this batch.
785
786 Returns:
787 None
788
789 Raises:
Joe Gregorio66f57522011-11-30 11:00:00 -0500790 httplib2.Error if a transport error has occured.
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500791 apiclient.errors.BatchError if the response is the wrong format.
Joe Gregorio66f57522011-11-30 11:00:00 -0500792 """
Joe Gregorio654f4a22012-02-09 14:15:44 -0500793
794 # If http is not supplied use the first valid one given in the requests.
Joe Gregorio66f57522011-11-30 11:00:00 -0500795 if http is None:
796 for request_id in self._order:
Joe Gregorio654f4a22012-02-09 14:15:44 -0500797 request = self._requests[request_id]
Joe Gregorio66f57522011-11-30 11:00:00 -0500798 if request is not None:
799 http = request.http
800 break
Joe Gregorio654f4a22012-02-09 14:15:44 -0500801
Joe Gregorio66f57522011-11-30 11:00:00 -0500802 if http is None:
803 raise ValueError("Missing a valid http object.")
804
Joe Gregorio654f4a22012-02-09 14:15:44 -0500805 self._execute(http, self._order, self._requests)
Joe Gregorio66f57522011-11-30 11:00:00 -0500806
Joe Gregorio654f4a22012-02-09 14:15:44 -0500807 # Loop over all the requests and check for 401s. For each 401 request the
808 # credentials should be refreshed and then sent again in a separate batch.
809 redo_requests = {}
810 redo_order = []
Joe Gregorio66f57522011-11-30 11:00:00 -0500811
Joe Gregorio66f57522011-11-30 11:00:00 -0500812 for request_id in self._order:
Joe Gregorio654f4a22012-02-09 14:15:44 -0500813 headers, content = self._responses[request_id]
814 if headers['status'] == '401':
815 redo_order.append(request_id)
816 request = self._requests[request_id]
817 self._refresh_and_apply_credentials(request, http)
818 redo_requests[request_id] = request
Joe Gregorio66f57522011-11-30 11:00:00 -0500819
Joe Gregorio654f4a22012-02-09 14:15:44 -0500820 if redo_requests:
821 self._execute(http, redo_order, redo_requests)
Joe Gregorio66f57522011-11-30 11:00:00 -0500822
Joe Gregorio654f4a22012-02-09 14:15:44 -0500823 # Now process all callbacks that are erroring, and raise an exception for
824 # ones that return a non-2xx response? Or add extra parameter to callback
825 # that contains an HttpError?
Joe Gregorio66f57522011-11-30 11:00:00 -0500826
Joe Gregorio654f4a22012-02-09 14:15:44 -0500827 for request_id in self._order:
828 headers, content = self._responses[request_id]
Joe Gregorio66f57522011-11-30 11:00:00 -0500829
Joe Gregorio654f4a22012-02-09 14:15:44 -0500830 request = self._requests[request_id]
831 callback = self._callbacks[request_id]
Joe Gregorio66f57522011-11-30 11:00:00 -0500832
Joe Gregorio654f4a22012-02-09 14:15:44 -0500833 response = None
834 exception = None
835 try:
836 r = httplib2.Response(headers)
837 response = request.postproc(r, content)
838 except HttpError, e:
839 exception = e
Joe Gregorio66f57522011-11-30 11:00:00 -0500840
Joe Gregorio654f4a22012-02-09 14:15:44 -0500841 if callback is not None:
842 callback(request_id, response, exception)
Joe Gregorio66f57522011-11-30 11:00:00 -0500843 if self._callback is not None:
Joe Gregorio654f4a22012-02-09 14:15:44 -0500844 self._callback(request_id, response, exception)
Joe Gregorio66f57522011-11-30 11:00:00 -0500845
846
Joe Gregorioaf276d22010-12-09 14:26:58 -0500847class HttpRequestMock(object):
848 """Mock of HttpRequest.
849
850 Do not construct directly, instead use RequestMockBuilder.
851 """
852
853 def __init__(self, resp, content, postproc):
854 """Constructor for HttpRequestMock
855
856 Args:
857 resp: httplib2.Response, the response to emulate coming from the request
858 content: string, the response body
859 postproc: callable, the post processing function usually supplied by
860 the model class. See model.JsonModel.response() as an example.
861 """
862 self.resp = resp
863 self.content = content
864 self.postproc = postproc
865 if resp is None:
Joe Gregorioc6722462010-12-20 14:29:28 -0500866 self.resp = httplib2.Response({'status': 200, 'reason': 'OK'})
Joe Gregorioaf276d22010-12-09 14:26:58 -0500867 if 'reason' in self.resp:
868 self.resp.reason = self.resp['reason']
869
870 def execute(self, http=None):
871 """Execute the request.
872
873 Same behavior as HttpRequest.execute(), but the response is
874 mocked and not really from an HTTP request/response.
875 """
876 return self.postproc(self.resp, self.content)
877
878
879class RequestMockBuilder(object):
880 """A simple mock of HttpRequest
881
882 Pass in a dictionary to the constructor that maps request methodIds to
Joe Gregorioa388ce32011-09-09 17:19:13 -0400883 tuples of (httplib2.Response, content, opt_expected_body) that should be
884 returned when that method is called. None may also be passed in for the
885 httplib2.Response, in which case a 200 OK response will be generated.
886 If an opt_expected_body (str or dict) is provided, it will be compared to
887 the body and UnexpectedBodyError will be raised on inequality.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500888
889 Example:
890 response = '{"data": {"id": "tag:google.c...'
891 requestBuilder = RequestMockBuilder(
892 {
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500893 'plus.activities.get': (None, response),
Joe Gregorioaf276d22010-12-09 14:26:58 -0500894 }
895 )
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500896 apiclient.discovery.build("plus", "v1", requestBuilder=requestBuilder)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500897
898 Methods that you do not supply a response for will return a
Joe Gregorio66f57522011-11-30 11:00:00 -0500899 200 OK with an empty string as the response content or raise an excpetion
900 if check_unexpected is set to True. The methodId is taken from the rpcName
Joe Gregorioa388ce32011-09-09 17:19:13 -0400901 in the discovery document.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500902
903 For more details see the project wiki.
904 """
905
Joe Gregorioa388ce32011-09-09 17:19:13 -0400906 def __init__(self, responses, check_unexpected=False):
Joe Gregorioaf276d22010-12-09 14:26:58 -0500907 """Constructor for RequestMockBuilder
908
909 The constructed object should be a callable object
910 that can replace the class HttpResponse.
911
912 responses - A dictionary that maps methodIds into tuples
913 of (httplib2.Response, content). The methodId
914 comes from the 'rpcName' field in the discovery
915 document.
Joe Gregorioa388ce32011-09-09 17:19:13 -0400916 check_unexpected - A boolean setting whether or not UnexpectedMethodError
917 should be raised on unsupplied method.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500918 """
919 self.responses = responses
Joe Gregorioa388ce32011-09-09 17:19:13 -0400920 self.check_unexpected = check_unexpected
Joe Gregorioaf276d22010-12-09 14:26:58 -0500921
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500922 def __call__(self, http, postproc, uri, method='GET', body=None,
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500923 headers=None, methodId=None, resumable=None):
Joe Gregorioaf276d22010-12-09 14:26:58 -0500924 """Implements the callable interface that discovery.build() expects
925 of requestBuilder, which is to build an object compatible with
926 HttpRequest.execute(). See that method for the description of the
927 parameters and the expected response.
928 """
929 if methodId in self.responses:
Joe Gregorioa388ce32011-09-09 17:19:13 -0400930 response = self.responses[methodId]
931 resp, content = response[:2]
932 if len(response) > 2:
933 # Test the body against the supplied expected_body.
934 expected_body = response[2]
935 if bool(expected_body) != bool(body):
936 # Not expecting a body and provided one
937 # or expecting a body and not provided one.
938 raise UnexpectedBodyError(expected_body, body)
939 if isinstance(expected_body, str):
940 expected_body = simplejson.loads(expected_body)
941 body = simplejson.loads(body)
942 if body != expected_body:
943 raise UnexpectedBodyError(expected_body, body)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500944 return HttpRequestMock(resp, content, postproc)
Joe Gregorioa388ce32011-09-09 17:19:13 -0400945 elif self.check_unexpected:
946 raise UnexpectedMethodError(methodId)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500947 else:
Joe Gregoriod433b2a2011-02-22 10:51:51 -0500948 model = JsonModel(False)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500949 return HttpRequestMock(None, '{}', model.response)
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500950
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500951
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500952class HttpMock(object):
953 """Mock of httplib2.Http"""
954
Joe Gregorioec343652011-02-16 16:52:51 -0500955 def __init__(self, filename, headers=None):
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500956 """
957 Args:
958 filename: string, absolute filename to read response from
959 headers: dict, header to return with response
960 """
Joe Gregorioec343652011-02-16 16:52:51 -0500961 if headers is None:
962 headers = {'status': '200 OK'}
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500963 f = file(filename, 'r')
964 self.data = f.read()
965 f.close()
966 self.headers = headers
967
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500968 def request(self, uri,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500969 method='GET',
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500970 body=None,
971 headers=None,
972 redirections=1,
973 connection_type=None):
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500974 return httplib2.Response(self.headers), self.data
Joe Gregorioccc79542011-02-19 00:05:26 -0500975
976
977class HttpMockSequence(object):
978 """Mock of httplib2.Http
979
980 Mocks a sequence of calls to request returning different responses for each
981 call. Create an instance initialized with the desired response headers
982 and content and then use as if an httplib2.Http instance.
983
984 http = HttpMockSequence([
985 ({'status': '401'}, ''),
986 ({'status': '200'}, '{"access_token":"1/3w","expires_in":3600}'),
987 ({'status': '200'}, 'echo_request_headers'),
988 ])
989 resp, content = http.request("http://examples.com")
990
991 There are special values you can pass in for content to trigger
992 behavours that are helpful in testing.
993
994 'echo_request_headers' means return the request headers in the response body
Joe Gregorioe9e236f2011-03-21 22:23:14 -0400995 'echo_request_headers_as_json' means return the request headers in
996 the response body
Joe Gregorioccc79542011-02-19 00:05:26 -0500997 'echo_request_body' means return the request body in the response body
Joe Gregorio0bc70912011-05-24 15:30:49 -0400998 'echo_request_uri' means return the request uri in the response body
Joe Gregorioccc79542011-02-19 00:05:26 -0500999 """
1000
1001 def __init__(self, iterable):
1002 """
1003 Args:
1004 iterable: iterable, a sequence of pairs of (headers, body)
1005 """
1006 self._iterable = iterable
1007
1008 def request(self, uri,
1009 method='GET',
1010 body=None,
1011 headers=None,
1012 redirections=1,
1013 connection_type=None):
1014 resp, content = self._iterable.pop(0)
1015 if content == 'echo_request_headers':
1016 content = headers
Joe Gregoriof4153422011-03-18 22:45:18 -04001017 elif content == 'echo_request_headers_as_json':
1018 content = simplejson.dumps(headers)
Joe Gregorioccc79542011-02-19 00:05:26 -05001019 elif content == 'echo_request_body':
1020 content = body
Joe Gregorio0bc70912011-05-24 15:30:49 -04001021 elif content == 'echo_request_uri':
1022 content = uri
Joe Gregorioccc79542011-02-19 00:05:26 -05001023 return httplib2.Response(resp), content
Joe Gregorio6bcbcea2011-03-10 15:26:05 -05001024
1025
1026def set_user_agent(http, user_agent):
Joe Gregoriof4153422011-03-18 22:45:18 -04001027 """Set the user-agent on every request.
1028
Joe Gregorio6bcbcea2011-03-10 15:26:05 -05001029 Args:
1030 http - An instance of httplib2.Http
1031 or something that acts like it.
1032 user_agent: string, the value for the user-agent header.
1033
1034 Returns:
1035 A modified instance of http that was passed in.
1036
1037 Example:
1038
1039 h = httplib2.Http()
1040 h = set_user_agent(h, "my-app-name/6.0")
1041
1042 Most of the time the user-agent will be set doing auth, this is for the rare
1043 cases where you are accessing an unauthenticated endpoint.
1044 """
1045 request_orig = http.request
1046
1047 # The closure that will replace 'httplib2.Http.request'.
1048 def new_request(uri, method='GET', body=None, headers=None,
1049 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
1050 connection_type=None):
1051 """Modify the request headers to add the user-agent."""
1052 if headers is None:
1053 headers = {}
1054 if 'user-agent' in headers:
1055 headers['user-agent'] = user_agent + ' ' + headers['user-agent']
1056 else:
1057 headers['user-agent'] = user_agent
1058 resp, content = request_orig(uri, method, body, headers,
1059 redirections, connection_type)
1060 return resp, content
1061
1062 http.request = new_request
1063 return http
Joe Gregoriof4153422011-03-18 22:45:18 -04001064
1065
1066def tunnel_patch(http):
1067 """Tunnel PATCH requests over POST.
1068 Args:
1069 http - An instance of httplib2.Http
1070 or something that acts like it.
1071
1072 Returns:
1073 A modified instance of http that was passed in.
1074
1075 Example:
1076
1077 h = httplib2.Http()
1078 h = tunnel_patch(h, "my-app-name/6.0")
1079
1080 Useful if you are running on a platform that doesn't support PATCH.
1081 Apply this last if you are using OAuth 1.0, as changing the method
1082 will result in a different signature.
1083 """
1084 request_orig = http.request
1085
1086 # The closure that will replace 'httplib2.Http.request'.
1087 def new_request(uri, method='GET', body=None, headers=None,
1088 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
1089 connection_type=None):
1090 """Modify the request headers to add the user-agent."""
1091 if headers is None:
1092 headers = {}
1093 if method == 'PATCH':
Joe Gregorio06d852b2011-03-25 15:03:10 -04001094 if 'oauth_token' in headers.get('authorization', ''):
Joe Gregorioe9e236f2011-03-21 22:23:14 -04001095 logging.warning(
Joe Gregorio06d852b2011-03-25 15:03:10 -04001096 'OAuth 1.0 request made with Credentials after tunnel_patch.')
Joe Gregoriof4153422011-03-18 22:45:18 -04001097 headers['x-http-method-override'] = "PATCH"
1098 method = 'POST'
1099 resp, content = request_orig(uri, method, body, headers,
1100 redirections, connection_type)
1101 return resp, content
1102
1103 http.request = new_request
1104 return http