Merge pull request #29 from sneakypete81/api_versioning

API versioning support
This commit is contained in:
sneakypete81 2013-04-28 10:40:48 -07:00
commit e3bc3ea9f7
12 changed files with 143 additions and 16 deletions

View file

@ -41,6 +41,17 @@ The OpenPhoto Python class hierarchy mirrors the [OpenPhoto API](http://theopenp
* client.photos.list() -> /photos/list.json
* photos[0].update() -> /photo/<id>/update.json
<a name="api_versioning"></a>
### API Versioning
It may be useful to lock your application to a particular version of the OpenPhoto API.
This ensures that future API updates won't cause unexpected breakages.
To do this, add the optional ```api_version``` parameter when creating the client object:
from openphoto import OpenPhoto
client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret, api_version=2)
----------------------------------------
<a name="cli"></a>

View file

@ -4,15 +4,25 @@ import api_photo
import api_tag
import api_album
LATEST_API_VERSION = 2
class OpenPhoto(OpenPhotoHttp):
""" Client library for OpenPhoto """
def __init__(self, host,
"""
Python client library for the specified OpenPhoto host.
OAuth tokens (consumer*, token*) can optionally be specified.
All requests will include the api_version path, if specified.
This should be used to ensure that your application will continue to work
even if the OpenPhoto API is updated to a new revision.
"""
def __init__(self, host,
consumer_key='', consumer_secret='',
token='', token_secret=''):
OpenPhotoHttp.__init__(self, host,
token='', token_secret='',
api_version=None):
OpenPhotoHttp.__init__(self, host,
consumer_key, consumer_secret,
token, token_secret)
token, token_secret, api_version)
self.photos = api_photo.ApiPhotos(self)
self.photo = api_photo.ApiPhoto(self)
self.tags = api_tag.ApiTags(self)

View file

@ -88,13 +88,23 @@ class Photo(OpenPhotoObject):
**kwds)["result"]
value = {}
if "next" in result:
# Workaround for APIv1
if not isinstance(result["next"], list):
result["next"] = [result["next"]]
value["next"] = []
for photo in result["next"]:
value["next"].append(Photo(self._openphoto, photo))
if "previous" in result:
# Workaround for APIv1
if not isinstance(result["previous"], list):
result["previous"] = [result["previous"]]
value["previous"] = []
for photo in result["previous"]:
value["previous"].append(Photo(self._openphoto, photo))
return value
def transform(self, **kwds):

View file

@ -18,12 +18,13 @@ DUPLICATE_RESPONSE = {"code": 409,
class OpenPhotoHttp:
""" Base class to handle HTTP requests to an OpenPhoto server """
def __init__(self, host, consumer_key='', consumer_secret='',
token='', token_secret=''):
token='', token_secret='', api_version=None):
self._host = host
self._consumer_key = consumer_key
self._consumer_secret = consumer_secret
self._token = token
self._token_secret = token_secret
self._api_version = api_version
self._logger = logging.getLogger("openphoto")
@ -36,11 +37,18 @@ class OpenPhotoHttp:
"""
Performs an HTTP GET from the specified endpoint (API path),
passing parameters if given.
Returns the decoded JSON dictionary, and raises exceptions if an
The api_version is prepended to the endpoint,
if it was specified when the OpenPhoto object was created.
Returns the decoded JSON dictionary, and raises exceptions if an
error code is received.
Returns the raw response if process_response=False
"""
params = self._process_params(params)
if not endpoint.startswith("/"):
endpoint = "/" + endpoint
if self._api_version is not None:
endpoint = "/v%d%s" % (self._api_version, endpoint)
url = urlparse.urlunparse(('http', self._host, endpoint, '',
urllib.urlencode(params), ''))
if self._consumer_key:
@ -70,13 +78,20 @@ class OpenPhotoHttp:
"""
Performs an HTTP POST to the specified endpoint (API path),
passing parameters if given.
Returns the decoded JSON dictionary, and raises exceptions if an
The api_version is prepended to the endpoint,
if it was specified when the OpenPhoto object was created.
Returns the decoded JSON dictionary, and raises exceptions if an
error code is received.
Returns the raw response if process_response=False
"""
params = self._process_params(params)
if not endpoint.startswith("/"):
endpoint = "/" + endpoint
if self._api_version is not None:
endpoint = "/v%d%s" % (self._api_version, endpoint)
url = urlparse.urlunparse(('http', self._host, endpoint, '', '', ''))
if not self._consumer_key:
raise OpenPhotoError("Cannot issue POST without OAuth tokens")

View file

View file

@ -0,0 +1,10 @@
from tests import test_albums, test_photos, test_tags
class TestAlbumsV1(test_albums.TestAlbums):
api_version = 1
class TestPhotosV1(test_photos.TestPhotos):
api_version = 1
class TestTagsV1(test_tags.TestTags):
api_version = 1

View file

@ -0,0 +1,10 @@
from tests import test_albums, test_photos, test_tags
class TestAlbumsV2(test_albums.TestAlbums):
api_version = 2
class TestPhotosV2(test_photos.TestPhotos):
api_version = 2
class TestTagsV2(test_tags.TestTags):
api_version = 2

View file

@ -3,6 +3,7 @@ import openphoto
import test_base
class TestAlbums(test_base.TestBase):
testcase_name = "album API"
def test_create_delete(self):
""" Create an album then delete it """

View file

@ -21,6 +21,8 @@ class TestBase(unittest.TestCase):
TEST_TAG = "test_tag"
TEST_ALBUM = "test_album"
MAXIMUM_TEST_PHOTOS = 4 # Never have more the 4 photos on the test server
testcase_name = "(unknown testcase)"
api_version = None
def __init__(self, *args, **kwds):
unittest.TestCase.__init__(self, *args, **kwds)
@ -34,9 +36,15 @@ class TestBase(unittest.TestCase):
@classmethod
def setUpClass(cls):
""" Ensure there is nothing on the server before running any tests """
if cls.api_version is None:
print "\nTesting Latest %s" % cls.testcase_name
else:
print "\nTesting %s v%d" % (cls.testcase_name, cls.api_version)
cls.client = openphoto.OpenPhoto(tokens.host,
tokens.consumer_key, tokens.consumer_secret,
tokens.token, tokens.token_secret)
tokens.consumer_key, tokens.consumer_secret,
tokens.token, tokens.token_secret,
cls.api_version)
if cls.client.photos.list() != []:
raise ValueError("The test server (%s) contains photos. "
@ -67,7 +75,7 @@ class TestBase(unittest.TestCase):
self.photos = self.client.photos.list()
if len(self.photos) != 3:
# print self.photos
print "\n[Regenerating Photos]"
print "[Regenerating Photos]"
if len(self.photos) > 0:
self._delete_all()
self._create_test_photos()
@ -76,8 +84,8 @@ class TestBase(unittest.TestCase):
self.tags = self.client.tags.list()
if (len(self.tags) != 1 or
self.tags[0].id != self.TEST_TAG or
self.tags[0].count != 3):
print "\n[Regenerating Tags]"
str(self.tags[0].count) != "3"):
print "[Regenerating Tags]"
self._delete_all()
self._create_test_photos()
self.photos = self.client.photos.list()
@ -90,7 +98,7 @@ class TestBase(unittest.TestCase):
if (len(self.albums) != 1 or
self.albums[0].name != self.TEST_ALBUM or
self.albums[0].count != "3"):
print "\n[Regenerating Albums]"
print "[Regenerating Albums]"
self._delete_all()
self._create_test_photos()
self.photos = self.client.photos.list()

48
tests/test_framework.py Normal file
View file

@ -0,0 +1,48 @@
import unittest
import logging
import openphoto
import test_base
class TestFramework(test_base.TestBase):
testcase_name = "framework"
def setUp(self):
"""Override the default setUp, since we don't need a populated database"""
logging.info("\nRunning %s..." % self.id())
def create_client_from_base(self, api_version):
return openphoto.OpenPhoto(self.client._host,
self.client._consumer_key,
self.client._consumer_secret,
self.client._token,
self.client._token_secret,
api_version=api_version)
def test_api_version_zero(self):
# API v0 has a special hello world message
client = self.create_client_from_base(api_version=0)
result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world! This is version zero of the API!")
self.assertEqual(result['result']['__route__'], "/v0/hello.json")
def test_specified_api_version(self):
# For all API versions >0, we get a generic hello world message
for api_version in range(1, openphoto.LATEST_API_VERSION + 1):
client = self.create_client_from_base(api_version=api_version)
result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world!")
self.assertEqual(result['result']['__route__'], "/v%d/hello.json" % api_version)
def test_unspecified_api_version(self):
# If the API version is unspecified, we get a generic hello world message
client = self.create_client_from_base(api_version=None)
result = client.get("hello.json")
self.assertEqual(result['message'], "Hello, world!")
self.assertEqual(result['result']['__route__'], "/hello.json")
def test_future_api_version(self):
# If the API version is unsupported, we should get an error
# (it's a ValueError, since the returned 404 HTML page is not valid JSON)
client = self.create_client_from_base(api_version=openphoto.LATEST_API_VERSION + 1)
with self.assertRaises(openphoto.OpenPhoto404Error):
client.get("hello.json")

View file

@ -3,6 +3,8 @@ import openphoto
import test_base
class TestPhotos(test_base.TestBase):
testcase_name = "photo API"
def test_delete_upload(self):
""" Test photo deletion and upload """
# Delete one photo using the OpenPhoto class, passing in the id

View file

@ -3,6 +3,8 @@ import openphoto
import test_base
class TestTags(test_base.TestBase):
testcase_name = "tag API"
def test_create_delete(self, tag_id="create_tag"):
""" Create a tag then delete it """
# Create a tag