Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Tree: 4fd1d03fc5
Fetching contributors…

Cannot retrieve contributors at this time

411 lines (318 sloc) 15.455 kB
# Copyright (c) 2009 Six Apart Ltd.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
#
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
#
# * Neither the name of Six Apart Ltd. nor the names of its contributors may
# be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
import simplejson as json
from remoteobjects.json import ForgivingDecoder
import httplib2
import httplib
import logging
from remoteobjects.dataobject import DataObject, DataObjectMetaclass
from remoteobjects import fields
userAgent = httplib2.Http()
log = logging.getLogger('remoteobjects.http')
def omit_nulls(data):
"""Strips `None` values from a dictionary or `RemoteObject` instance."""
if not isinstance(data, dict):
if not hasattr(data, '__dict__'):
return str(data)
data = dict(data.__dict__)
for key in data.keys():
if data[key] is None:
del data[key]
return data
class HttpObject(DataObject):
"""A `DataObject` that can be fetched and put over HTTP through a RESTful
JSON API."""
response_has_content = {
httplib.CREATED: True,
httplib.MOVED_PERMANENTLY: True,
httplib.FOUND: True,
httplib.OK: True,
httplib.NOT_MODIFIED: True,
httplib.NO_CONTENT: False,
}
location_headers = {
httplib.CREATED: 'Location',
httplib.MOVED_PERMANENTLY: 'Location',
httplib.FOUND: 'Location',
}
content_types = ('application/json',)
class NotFound(httplib.HTTPException):
"""An HTTPException thrown when the server reports that the requested
resource was not found."""
pass
class Unauthorized(httplib.HTTPException):
"""An HTTPException thrown when the server reports that the requested
resource is not available through an unauthenticated request.
This exception corresponds to the HTTP status code 401. Thus when this
exception is received, the caller may need to try again using the
available authentication credentials.
"""
pass
class Forbidden(httplib.HTTPException):
"""An HTTPException thrown when the server reports that the client, as
authenticated, is not authorized to request the requested resource.
This exception corresponds to the HTTP status code 403. Thus when this
exception is received, nothing the caller (as currently authenticated) can
do will make the requested resource available.
"""
pass
class PreconditionFailed(httplib.HTTPException):
"""An HTTPException thrown when the server reports that some of the
conditions in a conditional request were not true.
This exception corresponds to the HTTP status code 412. The most
common cause of this status is an attempt to ``PUT`` a resource that
has already changed on the server.
"""
pass
class RequestError(httplib.HTTPException):
"""An HTTPException thrown when the server reports an error in the
client's request.
This exception corresponds to the HTTP status code 400.
"""
pass
class ServerError(httplib.HTTPException):
"""An HTTPException thrown when the server reports an unexpected error.
This exception corresponds to the HTTP status code 500.
"""
pass
class BadResponse(httplib.HTTPException):
"""An HTTPException thrown when the client receives some other
non-success HTTP response."""
pass
def __init__(self, **kwargs):
self._location = None
super(HttpObject, self).__init__(**kwargs)
@classmethod
def statefields(cls):
return super(HttpObject, cls).statefields() + ['_location', '_etag']
def get_request(self, url=None, headers=None, **kwargs):
"""Returns the parameters for requesting this `RemoteObject` instance
as a dictionary of keyword arguments suitable for passing to
`httplib2.Http.request()`.
Optional parameter `headers` are also included in the request as HTTP
headers. Other optional keyword parameters are also included as
specified.
"""
if url is None:
url = self._location
if headers is None:
headers = {}
if 'accept' not in headers:
headers['accept'] = ', '.join(self.content_types)
# Use 'uri' because httplib2.request does.
request = dict(uri=url, headers=headers)
request.update(kwargs)
return request
@classmethod
def raise_for_response(cls, url, response, content):
"""Raises exceptions corresponding to invalid HTTP responses that
instances of this class can't be updated from.
Override this method to customize the error handling behavior of
`RemoteObject` for your target API. For example, if your API illegally
omits ``Location`` headers from 201 Created responses, override this
method to check for and allow them.
"""
# Turn exceptional httplib2 responses into exceptions.
classname = cls.__name__
if response.status == httplib.NOT_FOUND:
raise cls.NotFound('No such %s %s' % (classname, url))
if response.status == httplib.UNAUTHORIZED:
raise cls.Unauthorized('Not authorized to fetch %s %s' % (classname, url))
if response.status == httplib.FORBIDDEN:
raise cls.Forbidden('Forbidden from fetching %s %s' % (classname, url))
if response.status == httplib.PRECONDITION_FAILED:
raise cls.PreconditionFailed('Precondition failed for %s request to %s' % (classname, url))
if response.status in (httplib.INTERNAL_SERVER_ERROR, httplib.BAD_REQUEST):
if response.status == httplib.BAD_REQUEST:
err_cls = cls.RequestError
else:
err_cls = cls.ServerError
# Pull out an error if we can.
content_type = response.get('content-type', '').split(';', 1)[0].strip()
if content_type == 'text/plain':
error = content.split('\n', 2)[0]
exc = err_cls('%d %s requesting %s %s: %s'
% (response.status, response.reason, classname, url,
error))
exc.response_error = error
raise exc
raise err_cls('%d %s requesting %s %s'
% (response.status, response.reason, classname, url))
try:
response_has_content = cls.response_has_content[response.status]
except KeyError:
# we only expect the statuses that we know do or don't have content
raise cls.BadResponse('Unexpected response requesting %s %s: %d %s'
% (classname, url, response.status, response.reason))
try:
location_header = cls.location_headers[response.status]
except KeyError:
pass
else:
if location_header.lower() not in response:
raise cls.BadResponse(
"%r header missing from %d %s response requesting %s %s"
% (location_header, response.status, response.reason,
classname, url))
if not response_has_content:
# then there's no content-type either, so we're done
return
# check that the response body was json
content_type = response.get('content-type', '').split(';', 1)[0].strip()
if content_type not in cls.content_types:
raise cls.BadResponse(
'Bad response fetching %s %s: content-type %s is not an expected type'
% (classname, url, response.get('content-type')))
def update_from_response(self, url, response, content):
"""Adds the content of this HTTP response and message body to this
`RemoteObject` instance.
Use `update_from_response()` only when you would use
`DataObject.update_from_dict()`: when decoding outside content (in
this case an HTTP response) into an existing `RemoteObject` instance.
If the response is not a successful response from which the
`RemoteObject` instance can be updated, an appropriate exception will
be raised (as determined by the instance's `raise_from_response()`
method).
If the response includes a new location URL in the appropriate header
(depending on the response status), the location of the `RemoteObject`
instance is updated as well.
"""
self.raise_for_response(url, response, content)
try:
data = json.loads(content)
except UnicodeDecodeError:
data = json.loads(content, cls=ForgivingDecoder)
self.update_from_dict(data)
location_header = self.location_headers.get(response.status)
if location_header is None:
self._location = url
else:
self._location = response[location_header.lower()]
if 'etag' in response:
self._etag = response['etag']
@classmethod
def get(cls, url, http=None, **kwargs):
"""Fetches a new `RemoteObject` instance from a URL.
Parameter `url` is the URL from which the object should be requested.
Optional parameter `http` is the user agent object to use for
fetching. `http` should be compatible with `httplib2.Http` instances.
"""
self = cls()
request = self.get_request(url=url, **kwargs)
if http is None:
http = userAgent
response, content = http.request(**request)
self.update_from_response(url, response, content)
return self
def post(self, obj, http=None):
"""Add another `RemoteObject` to this remote resource through an HTTP
``POST`` request.
Parameter `obj` is a `RemoteObject` instance to save to this
instance's resource. For example, this (`self`) may be a collection to
which you want to post an asset (`obj`).
Optional parameter `http` is the user agent object to use for posting.
`http` should be compatible with `httplib2.Http` objects.
"""
if getattr(self, '_location', None) is None:
raise ValueError('Cannot add %r to %r with no URL to POST to'
% (obj, self))
body = json.dumps(obj.to_dict(), default=omit_nulls)
headers = {'content-type': self.content_types[0]}
request = obj.get_request(url=self._location, method='POST',
body=body, headers=headers)
if http is None:
http = userAgent
response, content = http.request(**request)
obj.update_from_response(self._location, response, content)
def put(self, http=None):
"""Save a previously requested `RemoteObject` back to its remote
resource through an HTTP ``PUT`` request.
Optional `http` parameter is the user agent object to use. `http`
objects should be compatible with `httplib2.Http` objects.
"""
if getattr(self, '_location', None) is None:
raise ValueError('Cannot save %r with no URL to PUT to' % self)
body = json.dumps(self.to_dict(), default=omit_nulls)
headers = {}
if hasattr(self, '_etag') and self._etag is not None:
headers['if-match'] = self._etag
headers['content-type'] = self.content_types[0]
request = self.get_request(method='PUT', body=body, headers=headers)
if http is None:
http = userAgent
response, content = http.request(**request)
log.debug('Yay saved my obj, now turning %r into new content', content)
self.update_from_response(self._location, response, content)
def delete(self, http=None):
"""Delete the remote resource represented by the `RemoteObject`
instance through an HTTP ``DELETE`` request.
Optional parameter `http` is the user agent object to use. `http`
objects should be compatible with `httplib2.Http` objects.
"""
if getattr(self, '_location', None) is None:
raise ValueError('Cannot delete %r with no URL to DELETE' % self)
headers = {}
if hasattr(self, '_etag') and self._etag is not None:
headers['if-match'] = self._etag
request = self.get_request(method='DELETE', headers=headers)
if http is None:
http = userAgent
response, content = http.request(**request)
self.raise_for_response(self._location, response, content)
log.debug('Yay deleted the remote resource, now disconnecting %r from it', self)
# No more resource, no more URL.
self._location = None
try:
del self._etag
except AttributeError:
# Don't mind if there's no etag.
pass
def head(self, http=None):
"""Issues a HTTP ``HEAD`` request for the object.
Optional parameter `http` is the user agent object to use. `http`
objects should be compatible with `httplib2.Http` objects.
"""
if getattr(self, '_location', None) is None:
raise ValueError('Cannot issue HEAD for %r with no URL' % self)
if http is None:
http = userAgent
response, content = http.request(uri=self._location, method='HEAD')
return response
def options(self, http=None):
"""Issues a HTTP ``OPTIONS`` request for the object.
Optional parameter `http` is the user agent object to use. `http`
objects should be compatible with `httplib2.Http` objects.
This method returns both the `HttpResponse` and content data.
"""
if getattr(self, '_location', None) is None:
raise ValueError('Cannot issue OPTIONS for %r with no URL' % self)
if http is None:
http = userAgent
response, content = http.request(uri=self._location, method='OPTIONS')
return response
Jump to Line
Something went wrong with that request. Please try again.