blob: 6bf1de426d9e956d51d6a087d731dab59af728b5 [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
Joe Gregoriod0bd3882011-11-22 09:49:47 -050029import copy
Joe Gregorio66f57522011-11-30 11:00:00 -050030import gzip
Joe Gregorioc6722462010-12-20 14:29:28 -050031import httplib2
Joe Gregoriod0bd3882011-11-22 09:49:47 -050032import mimeparse
33import mimetypes
Joe Gregorio66f57522011-11-30 11:00:00 -050034import os
35import urllib
36import urlparse
37import uuid
Joe Gregoriocb8103d2011-02-11 23:20:52 -050038
Joe Gregorio66f57522011-11-30 11:00:00 -050039from anyjson import simplejson
40from email.mime.multipart import MIMEMultipart
41from email.mime.nonmultipart import MIMENonMultipart
42from email.parser import FeedParser
43from errors import BatchError
Joe Gregorio49396552011-03-08 10:39:00 -050044from errors import HttpError
Joe Gregoriod0bd3882011-11-22 09:49:47 -050045from errors import ResumableUploadError
Joe Gregorioa388ce32011-09-09 17:19:13 -040046from errors import UnexpectedBodyError
47from errors import UnexpectedMethodError
Joe Gregorio66f57522011-11-30 11:00:00 -050048from model import JsonModel
Joe Gregorioc5c5a372010-09-22 11:42:32 -040049
50
Joe Gregoriod0bd3882011-11-22 09:49:47 -050051class MediaUploadProgress(object):
52 """Status of a resumable upload."""
53
54 def __init__(self, resumable_progress, total_size):
55 """Constructor.
56
57 Args:
58 resumable_progress: int, bytes sent so far.
59 total_size: int, total bytes in complete upload.
60 """
61 self.resumable_progress = resumable_progress
62 self.total_size = total_size
63
64 def progress(self):
65 """Percent of upload completed, as a float."""
Joe Gregorio66f57522011-11-30 11:00:00 -050066 return float(self.resumable_progress) / float(self.total_size)
Joe Gregoriod0bd3882011-11-22 09:49:47 -050067
68
69class MediaUpload(object):
70 """Describes a media object to upload.
71
72 Base class that defines the interface of MediaUpload subclasses.
73 """
74
75 def getbytes(self, begin, end):
76 raise NotImplementedError()
77
78 def size(self):
79 raise NotImplementedError()
80
81 def chunksize(self):
82 raise NotImplementedError()
83
84 def mimetype(self):
85 return 'application/octet-stream'
86
87 def resumable(self):
88 return False
89
90 def _to_json(self, strip=None):
91 """Utility function for creating a JSON representation of a MediaUpload.
92
93 Args:
94 strip: array, An array of names of members to not include in the JSON.
95
96 Returns:
97 string, a JSON representation of this instance, suitable to pass to
98 from_json().
99 """
100 t = type(self)
101 d = copy.copy(self.__dict__)
102 if strip is not None:
103 for member in strip:
104 del d[member]
105 d['_class'] = t.__name__
106 d['_module'] = t.__module__
107 return simplejson.dumps(d)
108
109 def to_json(self):
110 """Create a JSON representation of an instance of MediaUpload.
111
112 Returns:
113 string, a JSON representation of this instance, suitable to pass to
114 from_json().
115 """
116 return self._to_json()
117
118 @classmethod
119 def new_from_json(cls, s):
120 """Utility class method to instantiate a MediaUpload subclass from a JSON
121 representation produced by to_json().
122
123 Args:
124 s: string, JSON from to_json().
125
126 Returns:
127 An instance of the subclass of MediaUpload that was serialized with
128 to_json().
129 """
130 data = simplejson.loads(s)
131 # Find and call the right classmethod from_json() to restore the object.
132 module = data['_module']
133 m = __import__(module, fromlist=module.split('.')[:-1])
134 kls = getattr(m, data['_class'])
135 from_json = getattr(kls, 'from_json')
136 return from_json(s)
137
Joe Gregorio66f57522011-11-30 11:00:00 -0500138
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500139class MediaFileUpload(MediaUpload):
140 """A MediaUpload for a file.
141
142 Construct a MediaFileUpload and pass as the media_body parameter of the
143 method. For example, if we had a service that allowed uploading images:
144
145
146 media = MediaFileUpload('smiley.png', mimetype='image/png', chunksize=1000,
147 resumable=True)
148 service.objects().insert(
149 bucket=buckets['items'][0]['id'],
150 name='smiley.png',
151 media_body=media).execute()
152 """
153
Joe Gregorio2b843142012-01-06 15:53:14 -0500154 def __init__(self, filename, mimetype=None, chunksize=150000, resumable=False):
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500155 """Constructor.
156
157 Args:
158 filename: string, Name of the file.
159 mimetype: string, Mime-type of the file. If None then a mime-type will be
160 guessed from the file extension.
161 chunksize: int, File will be uploaded in chunks of this many bytes. Only
162 used if resumable=True.
Joe Gregorio66f57522011-11-30 11:00:00 -0500163 resumable: bool, True if this is a resumable upload. False means upload
164 in a single request.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500165 """
166 self._filename = filename
167 self._size = os.path.getsize(filename)
168 self._fd = None
169 if mimetype is None:
170 (mimetype, encoding) = mimetypes.guess_type(filename)
171 self._mimetype = mimetype
172 self._chunksize = chunksize
173 self._resumable = resumable
174
175 def mimetype(self):
176 return self._mimetype
177
178 def size(self):
179 return self._size
180
181 def chunksize(self):
182 return self._chunksize
183
184 def resumable(self):
185 return self._resumable
186
187 def getbytes(self, begin, length):
188 """Get bytes from the media.
189
190 Args:
191 begin: int, offset from beginning of file.
192 length: int, number of bytes to read, starting at begin.
193
194 Returns:
195 A string of bytes read. May be shorted than length if EOF was reached
196 first.
197 """
198 if self._fd is None:
199 self._fd = open(self._filename, 'rb')
200 self._fd.seek(begin)
201 return self._fd.read(length)
202
203 def to_json(self):
204 """Creating a JSON representation of an instance of Credentials.
205
206 Returns:
207 string, a JSON representation of this instance, suitable to pass to
208 from_json().
209 """
210 return self._to_json(['_fd'])
211
212 @staticmethod
213 def from_json(s):
214 d = simplejson.loads(s)
215 return MediaFileUpload(
216 d['_filename'], d['_mimetype'], d['_chunksize'], d['_resumable'])
217
218
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400219class HttpRequest(object):
Joe Gregorio66f57522011-11-30 11:00:00 -0500220 """Encapsulates a single HTTP request."""
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400221
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500222 def __init__(self, http, postproc, uri,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500223 method='GET',
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500224 body=None,
225 headers=None,
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500226 methodId=None,
227 resumable=None):
Joe Gregorioaf276d22010-12-09 14:26:58 -0500228 """Constructor for an HttpRequest.
229
Joe Gregorioaf276d22010-12-09 14:26:58 -0500230 Args:
231 http: httplib2.Http, the transport object to use to make a request
Joe Gregorioabda96f2011-02-11 20:19:33 -0500232 postproc: callable, called on the HTTP response and content to transform
233 it into a data object before returning, or raising an exception
234 on an error.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500235 uri: string, the absolute URI to send the request to
236 method: string, the HTTP method to use
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500237 body: string, the request body of the HTTP request,
Joe Gregorioaf276d22010-12-09 14:26:58 -0500238 headers: dict, the HTTP request headers
Joe Gregorioaf276d22010-12-09 14:26:58 -0500239 methodId: string, a unique identifier for the API method being called.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500240 resumable: MediaUpload, None if this is not a resumbale request.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500241 """
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400242 self.uri = uri
243 self.method = method
244 self.body = body
245 self.headers = headers or {}
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500246 self.methodId = methodId
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400247 self.http = http
248 self.postproc = postproc
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500249 self.resumable = resumable
250
Joe Gregorio66f57522011-11-30 11:00:00 -0500251 # Pull the multipart boundary out of the content-type header.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500252 major, minor, params = mimeparse.parse_mime_type(
253 headers.get('content-type', 'application/json'))
Joe Gregoriobd512b52011-12-06 15:39:26 -0500254
255 # Terminating multipart boundary get a trailing '--' appended.
256 self.multipart_boundary = params.get('boundary', '').strip('"') + '--'
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500257
258 # If this was a multipart resumable, the size of the non-media part.
259 self.multipart_size = 0
260
261 # The resumable URI to send chunks to.
262 self.resumable_uri = None
263
264 # The bytes that have been uploaded.
265 self.resumable_progress = 0
266
Joe Gregorio66f57522011-11-30 11:00:00 -0500267 self.total_size = 0
268
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500269 if resumable is not None:
270 if self.body is not None:
271 self.multipart_size = len(self.body)
272 else:
273 self.multipart_size = 0
Joe Gregorio66f57522011-11-30 11:00:00 -0500274 self.total_size = (
275 self.resumable.size() +
276 self.multipart_size +
277 len(self.multipart_boundary))
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400278
279 def execute(self, http=None):
280 """Execute the request.
281
Joe Gregorioaf276d22010-12-09 14:26:58 -0500282 Args:
283 http: httplib2.Http, an http object to be used in place of the
284 one the HttpRequest request object was constructed with.
285
286 Returns:
287 A deserialized object model of the response body as determined
288 by the postproc.
289
290 Raises:
291 apiclient.errors.HttpError if the response was not a 2xx.
292 httplib2.Error if a transport error has occured.
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400293 """
294 if http is None:
295 http = self.http
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500296 if self.resumable:
297 body = None
298 while body is None:
299 _, body = self.next_chunk(http)
300 return body
301 else:
302 resp, content = http.request(self.uri, self.method,
303 body=self.body,
304 headers=self.headers)
Joe Gregorio49396552011-03-08 10:39:00 -0500305
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500306 if resp.status >= 300:
307 raise HttpError(resp, content, self.uri)
Joe Gregorioc5c5a372010-09-22 11:42:32 -0400308 return self.postproc(resp, content)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500309
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500310 def next_chunk(self, http=None):
311 """Execute the next step of a resumable upload.
312
Joe Gregorio66f57522011-11-30 11:00:00 -0500313 Can only be used if the method being executed supports media uploads and
314 the MediaUpload object passed in was flagged as using resumable upload.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500315
316 Example:
317
Joe Gregorio66f57522011-11-30 11:00:00 -0500318 media = MediaFileUpload('smiley.png', mimetype='image/png',
319 chunksize=1000, resumable=True)
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500320 request = service.objects().insert(
321 bucket=buckets['items'][0]['id'],
322 name='smiley.png',
323 media_body=media)
324
325 response = None
326 while response is None:
327 status, response = request.next_chunk()
328 if status:
329 print "Upload %d%% complete." % int(status.progress() * 100)
330
331
332 Returns:
333 (status, body): (ResumableMediaStatus, object)
334 The body will be None until the resumable media is fully uploaded.
335 """
336 if http is None:
337 http = self.http
338
339 if self.resumable_uri is None:
340 start_headers = copy.copy(self.headers)
341 start_headers['X-Upload-Content-Type'] = self.resumable.mimetype()
342 start_headers['X-Upload-Content-Length'] = str(self.resumable.size())
343 start_headers['Content-Length'] = '0'
344 resp, content = http.request(self.uri, self.method,
345 body="",
346 headers=start_headers)
347 if resp.status == 200 and 'location' in resp:
348 self.resumable_uri = resp['location']
349 else:
350 raise ResumableUploadError("Failed to retrieve starting URI.")
351 if self.body:
352 begin = 0
353 data = self.body
354 else:
355 begin = self.resumable_progress - self.multipart_size
356 data = self.resumable.getbytes(begin, self.resumable.chunksize())
357
358 # Tack on the multipart/related boundary if we are at the end of the file.
359 if begin + self.resumable.chunksize() >= self.resumable.size():
360 data += self.multipart_boundary
361 headers = {
362 'Content-Range': 'bytes %d-%d/%d' % (
363 self.resumable_progress, self.resumable_progress + len(data) - 1,
364 self.total_size),
365 }
366 resp, content = http.request(self.resumable_uri, 'PUT',
367 body=data,
368 headers=headers)
369 if resp.status in [200, 201]:
370 return None, self.postproc(resp, content)
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500371 elif resp.status == 308:
Joe Gregorio66f57522011-11-30 11:00:00 -0500372 # A "308 Resume Incomplete" indicates we are not done.
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500373 self.resumable_progress = int(resp['range'].split('-')[1]) + 1
374 if self.resumable_progress >= self.multipart_size:
375 self.body = None
376 if 'location' in resp:
377 self.resumable_uri = resp['location']
378 else:
379 raise HttpError(resp, content, self.uri)
380
381 return MediaUploadProgress(self.resumable_progress, self.total_size), None
382
383 def to_json(self):
384 """Returns a JSON representation of the HttpRequest."""
385 d = copy.copy(self.__dict__)
386 if d['resumable'] is not None:
387 d['resumable'] = self.resumable.to_json()
388 del d['http']
389 del d['postproc']
390 return simplejson.dumps(d)
391
392 @staticmethod
393 def from_json(s, http, postproc):
394 """Returns an HttpRequest populated with info from a JSON object."""
395 d = simplejson.loads(s)
396 if d['resumable'] is not None:
397 d['resumable'] = MediaUpload.new_from_json(d['resumable'])
398 return HttpRequest(
399 http,
400 postproc,
Joe Gregorio66f57522011-11-30 11:00:00 -0500401 uri=d['uri'],
402 method=d['method'],
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500403 body=d['body'],
404 headers=d['headers'],
405 methodId=d['methodId'],
406 resumable=d['resumable'])
407
Joe Gregorioaf276d22010-12-09 14:26:58 -0500408
Joe Gregorio66f57522011-11-30 11:00:00 -0500409class BatchHttpRequest(object):
410 """Batches multiple HttpRequest objects into a single HTTP request."""
411
412 def __init__(self, callback=None, batch_uri=None):
413 """Constructor for a BatchHttpRequest.
414
415 Args:
416 callback: callable, A callback to be called for each response, of the
417 form callback(id, response). The first parameter is the request id, and
418 the second is the deserialized response object.
419 batch_uri: string, URI to send batch requests to.
420 """
421 if batch_uri is None:
422 batch_uri = 'https://www.googleapis.com/batch'
423 self._batch_uri = batch_uri
424
425 # Global callback to be called for each individual response in the batch.
426 self._callback = callback
427
428 # A map from id to (request, callback) pairs.
429 self._requests = {}
430
431 # List of request ids, in the order in which they were added.
432 self._order = []
433
434 # The last auto generated id.
435 self._last_auto_id = 0
436
437 # Unique ID on which to base the Content-ID headers.
438 self._base_id = None
439
440 def _id_to_header(self, id_):
441 """Convert an id to a Content-ID header value.
442
443 Args:
444 id_: string, identifier of individual request.
445
446 Returns:
447 A Content-ID header with the id_ encoded into it. A UUID is prepended to
448 the value because Content-ID headers are supposed to be universally
449 unique.
450 """
451 if self._base_id is None:
452 self._base_id = uuid.uuid4()
453
454 return '<%s+%s>' % (self._base_id, urllib.quote(id_))
455
456 def _header_to_id(self, header):
457 """Convert a Content-ID header value to an id.
458
459 Presumes the Content-ID header conforms to the format that _id_to_header()
460 returns.
461
462 Args:
463 header: string, Content-ID header value.
464
465 Returns:
466 The extracted id value.
467
468 Raises:
469 BatchError if the header is not in the expected format.
470 """
471 if header[0] != '<' or header[-1] != '>':
472 raise BatchError("Invalid value for Content-ID: %s" % header)
473 if '+' not in header:
474 raise BatchError("Invalid value for Content-ID: %s" % header)
475 base, id_ = header[1:-1].rsplit('+', 1)
476
477 return urllib.unquote(id_)
478
479 def _serialize_request(self, request):
480 """Convert an HttpRequest object into a string.
481
482 Args:
483 request: HttpRequest, the request to serialize.
484
485 Returns:
486 The request as a string in application/http format.
487 """
488 # Construct status line
489 parsed = urlparse.urlparse(request.uri)
490 request_line = urlparse.urlunparse(
491 (None, None, parsed.path, parsed.params, parsed.query, None)
492 )
493 status_line = request.method + ' ' + request_line + ' HTTP/1.1\n'
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500494 major, minor = request.headers.get('content-type', 'application/json').split('/')
Joe Gregorio66f57522011-11-30 11:00:00 -0500495 msg = MIMENonMultipart(major, minor)
496 headers = request.headers.copy()
497
498 # MIMENonMultipart adds its own Content-Type header.
499 if 'content-type' in headers:
500 del headers['content-type']
501
502 for key, value in headers.iteritems():
503 msg[key] = value
504 msg['Host'] = parsed.netloc
505 msg.set_unixfrom(None)
506
507 if request.body is not None:
508 msg.set_payload(request.body)
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500509 msg['content-length'] = str(len(request.body))
Joe Gregorio66f57522011-11-30 11:00:00 -0500510
511 body = msg.as_string(False)
512 # Strip off the \n\n that the MIME lib tacks onto the end of the payload.
513 if request.body is None:
514 body = body[:-2]
515
516 return status_line + body
517
518 def _deserialize_response(self, payload):
519 """Convert string into httplib2 response and content.
520
521 Args:
522 payload: string, headers and body as a string.
523
524 Returns:
525 A pair (resp, content) like would be returned from httplib2.request.
526 """
527 # Strip off the status line
528 status_line, payload = payload.split('\n', 1)
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500529 protocol, status, reason = status_line.split(' ', 2)
Joe Gregorio66f57522011-11-30 11:00:00 -0500530
531 # Parse the rest of the response
532 parser = FeedParser()
533 parser.feed(payload)
534 msg = parser.close()
535 msg['status'] = status
536
537 # Create httplib2.Response from the parsed headers.
538 resp = httplib2.Response(msg)
539 resp.reason = reason
540 resp.version = int(protocol.split('/', 1)[1].replace('.', ''))
541
542 content = payload.split('\r\n\r\n', 1)[1]
543
544 return resp, content
545
546 def _new_id(self):
547 """Create a new id.
548
549 Auto incrementing number that avoids conflicts with ids already used.
550
551 Returns:
552 string, a new unique id.
553 """
554 self._last_auto_id += 1
555 while str(self._last_auto_id) in self._requests:
556 self._last_auto_id += 1
557 return str(self._last_auto_id)
558
559 def add(self, request, callback=None, request_id=None):
560 """Add a new request.
561
562 Every callback added will be paired with a unique id, the request_id. That
563 unique id will be passed back to the callback when the response comes back
564 from the server. The default behavior is to have the library generate it's
565 own unique id. If the caller passes in a request_id then they must ensure
566 uniqueness for each request_id, and if they are not an exception is
567 raised. Callers should either supply all request_ids or nevery supply a
568 request id, to avoid such an error.
569
570 Args:
571 request: HttpRequest, Request to add to the batch.
572 callback: callable, A callback to be called for this response, of the
573 form callback(id, response). The first parameter is the request id, and
574 the second is the deserialized response object.
575 request_id: string, A unique id for the request. The id will be passed to
576 the callback with the response.
577
578 Returns:
579 None
580
581 Raises:
582 BatchError if a resumable request is added to a batch.
583 KeyError is the request_id is not unique.
584 """
585 if request_id is None:
586 request_id = self._new_id()
587 if request.resumable is not None:
588 raise BatchError("Resumable requests cannot be used in a batch request.")
589 if request_id in self._requests:
590 raise KeyError("A request with this ID already exists: %s" % request_id)
591 self._requests[request_id] = (request, callback)
592 self._order.append(request_id)
593
594 def execute(self, http=None):
595 """Execute all the requests as a single batched HTTP request.
596
597 Args:
598 http: httplib2.Http, an http object to be used in place of the one the
599 HttpRequest request object was constructed with. If one isn't supplied
600 then use a http object from the requests in this batch.
601
602 Returns:
603 None
604
605 Raises:
606 apiclient.errors.HttpError if the response was not a 2xx.
607 httplib2.Error if a transport error has occured.
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500608 apiclient.errors.BatchError if the response is the wrong format.
Joe Gregorio66f57522011-11-30 11:00:00 -0500609 """
610 if http is None:
611 for request_id in self._order:
612 request, callback = self._requests[request_id]
613 if request is not None:
614 http = request.http
615 break
616 if http is None:
617 raise ValueError("Missing a valid http object.")
618
619
620 msgRoot = MIMEMultipart('mixed')
621 # msgRoot should not write out it's own headers
622 setattr(msgRoot, '_write_headers', lambda self: None)
623
624 # Add all the individual requests.
625 for request_id in self._order:
626 request, callback = self._requests[request_id]
627
628 msg = MIMENonMultipart('application', 'http')
629 msg['Content-Transfer-Encoding'] = 'binary'
630 msg['Content-ID'] = self._id_to_header(request_id)
631
632 body = self._serialize_request(request)
633 msg.set_payload(body)
634 msgRoot.attach(msg)
635
636 body = msgRoot.as_string()
637
638 headers = {}
639 headers['content-type'] = ('multipart/mixed; '
640 'boundary="%s"') % msgRoot.get_boundary()
641
642 resp, content = http.request(self._batch_uri, 'POST', body=body,
643 headers=headers)
644
645 if resp.status >= 300:
646 raise HttpError(resp, content, self._batch_uri)
647
648 # Now break up the response and process each one with the correct postproc
649 # and trigger the right callbacks.
650 boundary, _ = content.split(None, 1)
651
652 # Prepend with a content-type header so FeedParser can handle it.
653 header = 'Content-Type: %s\r\n\r\n' % resp['content-type']
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500654 for_parser = header + content
Joe Gregorio66f57522011-11-30 11:00:00 -0500655
656 parser = FeedParser()
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500657 parser.feed(for_parser)
Joe Gregorio66f57522011-11-30 11:00:00 -0500658 respRoot = parser.close()
659
660 if not respRoot.is_multipart():
Joe Gregorio5d1171b2012-01-05 10:48:24 -0500661 raise BatchError("Response not in multipart/mixed format.", resp,
662 content)
Joe Gregorio66f57522011-11-30 11:00:00 -0500663
664 parts = respRoot.get_payload()
665 for part in parts:
666 request_id = self._header_to_id(part['Content-ID'])
667
668 headers, content = self._deserialize_response(part.get_payload())
669
670 # TODO(jcgregorio) Remove this temporary hack once the server stops
671 # gzipping individual response bodies.
672 if content[0] != '{':
673 gzipped_content = content
674 content = gzip.GzipFile(
675 fileobj=StringIO.StringIO(gzipped_content)).read()
676
677 request, cb = self._requests[request_id]
678 postproc = request.postproc
679 response = postproc(resp, content)
680 if cb is not None:
681 cb(request_id, response)
682 if self._callback is not None:
683 self._callback(request_id, response)
684
685
Joe Gregorioaf276d22010-12-09 14:26:58 -0500686class HttpRequestMock(object):
687 """Mock of HttpRequest.
688
689 Do not construct directly, instead use RequestMockBuilder.
690 """
691
692 def __init__(self, resp, content, postproc):
693 """Constructor for HttpRequestMock
694
695 Args:
696 resp: httplib2.Response, the response to emulate coming from the request
697 content: string, the response body
698 postproc: callable, the post processing function usually supplied by
699 the model class. See model.JsonModel.response() as an example.
700 """
701 self.resp = resp
702 self.content = content
703 self.postproc = postproc
704 if resp is None:
Joe Gregorioc6722462010-12-20 14:29:28 -0500705 self.resp = httplib2.Response({'status': 200, 'reason': 'OK'})
Joe Gregorioaf276d22010-12-09 14:26:58 -0500706 if 'reason' in self.resp:
707 self.resp.reason = self.resp['reason']
708
709 def execute(self, http=None):
710 """Execute the request.
711
712 Same behavior as HttpRequest.execute(), but the response is
713 mocked and not really from an HTTP request/response.
714 """
715 return self.postproc(self.resp, self.content)
716
717
718class RequestMockBuilder(object):
719 """A simple mock of HttpRequest
720
721 Pass in a dictionary to the constructor that maps request methodIds to
Joe Gregorioa388ce32011-09-09 17:19:13 -0400722 tuples of (httplib2.Response, content, opt_expected_body) that should be
723 returned when that method is called. None may also be passed in for the
724 httplib2.Response, in which case a 200 OK response will be generated.
725 If an opt_expected_body (str or dict) is provided, it will be compared to
726 the body and UnexpectedBodyError will be raised on inequality.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500727
728 Example:
729 response = '{"data": {"id": "tag:google.c...'
730 requestBuilder = RequestMockBuilder(
731 {
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500732 'plus.activities.get': (None, response),
Joe Gregorioaf276d22010-12-09 14:26:58 -0500733 }
734 )
Joe Gregorioc4fc0952011-11-09 12:21:11 -0500735 apiclient.discovery.build("plus", "v1", requestBuilder=requestBuilder)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500736
737 Methods that you do not supply a response for will return a
Joe Gregorio66f57522011-11-30 11:00:00 -0500738 200 OK with an empty string as the response content or raise an excpetion
739 if check_unexpected is set to True. The methodId is taken from the rpcName
Joe Gregorioa388ce32011-09-09 17:19:13 -0400740 in the discovery document.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500741
742 For more details see the project wiki.
743 """
744
Joe Gregorioa388ce32011-09-09 17:19:13 -0400745 def __init__(self, responses, check_unexpected=False):
Joe Gregorioaf276d22010-12-09 14:26:58 -0500746 """Constructor for RequestMockBuilder
747
748 The constructed object should be a callable object
749 that can replace the class HttpResponse.
750
751 responses - A dictionary that maps methodIds into tuples
752 of (httplib2.Response, content). The methodId
753 comes from the 'rpcName' field in the discovery
754 document.
Joe Gregorioa388ce32011-09-09 17:19:13 -0400755 check_unexpected - A boolean setting whether or not UnexpectedMethodError
756 should be raised on unsupplied method.
Joe Gregorioaf276d22010-12-09 14:26:58 -0500757 """
758 self.responses = responses
Joe Gregorioa388ce32011-09-09 17:19:13 -0400759 self.check_unexpected = check_unexpected
Joe Gregorioaf276d22010-12-09 14:26:58 -0500760
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500761 def __call__(self, http, postproc, uri, method='GET', body=None,
Joe Gregoriod0bd3882011-11-22 09:49:47 -0500762 headers=None, methodId=None, resumable=None):
Joe Gregorioaf276d22010-12-09 14:26:58 -0500763 """Implements the callable interface that discovery.build() expects
764 of requestBuilder, which is to build an object compatible with
765 HttpRequest.execute(). See that method for the description of the
766 parameters and the expected response.
767 """
768 if methodId in self.responses:
Joe Gregorioa388ce32011-09-09 17:19:13 -0400769 response = self.responses[methodId]
770 resp, content = response[:2]
771 if len(response) > 2:
772 # Test the body against the supplied expected_body.
773 expected_body = response[2]
774 if bool(expected_body) != bool(body):
775 # Not expecting a body and provided one
776 # or expecting a body and not provided one.
777 raise UnexpectedBodyError(expected_body, body)
778 if isinstance(expected_body, str):
779 expected_body = simplejson.loads(expected_body)
780 body = simplejson.loads(body)
781 if body != expected_body:
782 raise UnexpectedBodyError(expected_body, body)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500783 return HttpRequestMock(resp, content, postproc)
Joe Gregorioa388ce32011-09-09 17:19:13 -0400784 elif self.check_unexpected:
785 raise UnexpectedMethodError(methodId)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500786 else:
Joe Gregoriod433b2a2011-02-22 10:51:51 -0500787 model = JsonModel(False)
Joe Gregorioaf276d22010-12-09 14:26:58 -0500788 return HttpRequestMock(None, '{}', model.response)
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500789
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500790
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500791class HttpMock(object):
792 """Mock of httplib2.Http"""
793
Joe Gregorioec343652011-02-16 16:52:51 -0500794 def __init__(self, filename, headers=None):
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500795 """
796 Args:
797 filename: string, absolute filename to read response from
798 headers: dict, header to return with response
799 """
Joe Gregorioec343652011-02-16 16:52:51 -0500800 if headers is None:
801 headers = {'status': '200 OK'}
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500802 f = file(filename, 'r')
803 self.data = f.read()
804 f.close()
805 self.headers = headers
806
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500807 def request(self, uri,
Joe Gregorio7c22ab22011-02-16 15:32:39 -0500808 method='GET',
Joe Gregoriodeeb0202011-02-15 14:49:57 -0500809 body=None,
810 headers=None,
811 redirections=1,
812 connection_type=None):
Joe Gregoriocb8103d2011-02-11 23:20:52 -0500813 return httplib2.Response(self.headers), self.data
Joe Gregorioccc79542011-02-19 00:05:26 -0500814
815
816class HttpMockSequence(object):
817 """Mock of httplib2.Http
818
819 Mocks a sequence of calls to request returning different responses for each
820 call. Create an instance initialized with the desired response headers
821 and content and then use as if an httplib2.Http instance.
822
823 http = HttpMockSequence([
824 ({'status': '401'}, ''),
825 ({'status': '200'}, '{"access_token":"1/3w","expires_in":3600}'),
826 ({'status': '200'}, 'echo_request_headers'),
827 ])
828 resp, content = http.request("http://examples.com")
829
830 There are special values you can pass in for content to trigger
831 behavours that are helpful in testing.
832
833 'echo_request_headers' means return the request headers in the response body
Joe Gregorioe9e236f2011-03-21 22:23:14 -0400834 'echo_request_headers_as_json' means return the request headers in
835 the response body
Joe Gregorioccc79542011-02-19 00:05:26 -0500836 'echo_request_body' means return the request body in the response body
Joe Gregorio0bc70912011-05-24 15:30:49 -0400837 'echo_request_uri' means return the request uri in the response body
Joe Gregorioccc79542011-02-19 00:05:26 -0500838 """
839
840 def __init__(self, iterable):
841 """
842 Args:
843 iterable: iterable, a sequence of pairs of (headers, body)
844 """
845 self._iterable = iterable
846
847 def request(self, uri,
848 method='GET',
849 body=None,
850 headers=None,
851 redirections=1,
852 connection_type=None):
853 resp, content = self._iterable.pop(0)
854 if content == 'echo_request_headers':
855 content = headers
Joe Gregoriof4153422011-03-18 22:45:18 -0400856 elif content == 'echo_request_headers_as_json':
857 content = simplejson.dumps(headers)
Joe Gregorioccc79542011-02-19 00:05:26 -0500858 elif content == 'echo_request_body':
859 content = body
Joe Gregorio0bc70912011-05-24 15:30:49 -0400860 elif content == 'echo_request_uri':
861 content = uri
Joe Gregorioccc79542011-02-19 00:05:26 -0500862 return httplib2.Response(resp), content
Joe Gregorio6bcbcea2011-03-10 15:26:05 -0500863
864
865def set_user_agent(http, user_agent):
Joe Gregoriof4153422011-03-18 22:45:18 -0400866 """Set the user-agent on every request.
867
Joe Gregorio6bcbcea2011-03-10 15:26:05 -0500868 Args:
869 http - An instance of httplib2.Http
870 or something that acts like it.
871 user_agent: string, the value for the user-agent header.
872
873 Returns:
874 A modified instance of http that was passed in.
875
876 Example:
877
878 h = httplib2.Http()
879 h = set_user_agent(h, "my-app-name/6.0")
880
881 Most of the time the user-agent will be set doing auth, this is for the rare
882 cases where you are accessing an unauthenticated endpoint.
883 """
884 request_orig = http.request
885
886 # The closure that will replace 'httplib2.Http.request'.
887 def new_request(uri, method='GET', body=None, headers=None,
888 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
889 connection_type=None):
890 """Modify the request headers to add the user-agent."""
891 if headers is None:
892 headers = {}
893 if 'user-agent' in headers:
894 headers['user-agent'] = user_agent + ' ' + headers['user-agent']
895 else:
896 headers['user-agent'] = user_agent
897 resp, content = request_orig(uri, method, body, headers,
898 redirections, connection_type)
899 return resp, content
900
901 http.request = new_request
902 return http
Joe Gregoriof4153422011-03-18 22:45:18 -0400903
904
905def tunnel_patch(http):
906 """Tunnel PATCH requests over POST.
907 Args:
908 http - An instance of httplib2.Http
909 or something that acts like it.
910
911 Returns:
912 A modified instance of http that was passed in.
913
914 Example:
915
916 h = httplib2.Http()
917 h = tunnel_patch(h, "my-app-name/6.0")
918
919 Useful if you are running on a platform that doesn't support PATCH.
920 Apply this last if you are using OAuth 1.0, as changing the method
921 will result in a different signature.
922 """
923 request_orig = http.request
924
925 # The closure that will replace 'httplib2.Http.request'.
926 def new_request(uri, method='GET', body=None, headers=None,
927 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
928 connection_type=None):
929 """Modify the request headers to add the user-agent."""
930 if headers is None:
931 headers = {}
932 if method == 'PATCH':
Joe Gregorio06d852b2011-03-25 15:03:10 -0400933 if 'oauth_token' in headers.get('authorization', ''):
Joe Gregorioe9e236f2011-03-21 22:23:14 -0400934 logging.warning(
Joe Gregorio06d852b2011-03-25 15:03:10 -0400935 'OAuth 1.0 request made with Credentials after tunnel_patch.')
Joe Gregoriof4153422011-03-18 22:45:18 -0400936 headers['x-http-method-override'] = "PATCH"
937 method = 'POST'
938 resp, content = request_orig(uri, method, body, headers,
939 redirections, connection_type)
940 return resp, content
941
942 http.request = new_request
943 return http