Skip to content

Commit

Permalink
Multiple API endpoints can now target the same database collection
Browse files Browse the repository at this point in the history
It is now possibile to set predefined db filters for each resource
  • Loading branch information
nicolaiarocci committed Jan 30, 2013
1 parent 98162f7 commit b15bbc9
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 22 deletions.
42 changes: 37 additions & 5 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,46 @@ Version 0.0.4

Not released yet.

- JSON-Datetime dependency removed.
- Multiple API endpoints can now target the same database collection. For
example now you can set both '/admins/' and '/users/' to read and write from
the same collection on the db, 'people'.

The new 'datasource' setting allows to explicitly link API resources to
database collections. It is a dictionary with two allowed keys: 'source' and
'filter'. 'source' dictates the database collection consumed by the resource.
'filter' is the underlying query, applied by the API when retrieving and
validating data for the resource.

'datasource': {
'source': 'people',
'filter': {'userlevel': 1}
}

Previously, the resource name would dictate the linked datasource (and of
course you could not have two resources with the same name). This remains
the default behaviour: if you omit the 'datasource' setting for a resource,
its name will be used to determine the database collection.

- It is now possibile to set predefined db filters for each resource.

'datasource': {
'filter': {'username': {'$exists': True}}
}

In the example above the API endpoint will only expose (and update) documents
with the 'username' field.

Please note that datasource filters are applied on GET, PATCH and DELETE
requests. If your resource allows for POST requests (document insertions),
then you will probably want to set the validation rules accordingly (in our
example, 'username' should probably be a required field).

- Support for Cerberus v0.0.3 and later.
- Support for Flask-PyMongo v0.2.0 and later.
- Repeated XML requests to the same endpoint could occasionally return
an Internal Server Error (Fixes #8).
- Repeated XML requests to the same endpoint could occasionally return an
Internal Server Error (Fixes #8).

Version 0.0.3
-------------
Version 0.0.3 -------------

Released on January 22th 2013.

Expand Down
14 changes: 14 additions & 0 deletions eve/flaskapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ def set_defaults(self):
""" When not provided, fills individual resource settings with default
or global configuration settings.
.. versionchanged:: 0.0.4
'datasource' keywords. Defaults to the resourcename.
.. versionchanged:: 0.0.3
`item_title` default value.
"""
Expand All @@ -247,6 +250,11 @@ def set_defaults(self):
item_methods = eve.ITEM_METHODS
settings.setdefault('item_methods', item_methods)

datasource = {}
settings.setdefault('datasource', datasource)
settings['datasource'].setdefault('source', resource)
settings['datasource'].setdefault('filter', None)

# empty schemas are allowed for read-only access to resources
schema = settings.setdefault('schema', {})

Expand All @@ -263,12 +271,16 @@ def _add_url_rules(self):
""" Builds the API url map. Methods are enabled for each mapped
endpoint, as configured in the settings.
.. versionchanged:: 0.0.4
config.SOURCES. Maps resources to their datasources.
.. versionchanged:: 0.0.3
Support for API_VERSION as an endpoint prefix.
"""
# helpers
resources = dict() # maps urls to resources (DOMAIN keys)
urls = dict() # maps resources to urls
datasources = dict() # maps resources to their datasources

prefix = api_prefix(self.config['URL_PREFIX'],
self.config['API_VERSION'])
Expand All @@ -279,6 +291,7 @@ def _add_url_rules(self):
for resource, settings in self.config['DOMAIN'].items():
resources[settings['url']] = resource
urls[resource] = settings['url']
datasources[resource] = settings['datasource']

# resource endpoint
url = '/<regex("%s"):url>/' % settings['url']
Expand Down Expand Up @@ -312,3 +325,4 @@ def _add_url_rules(self):
methods=['GET'])
self.config['RESOURCES'] = resources
self.config['URLS'] = urls
self.config['SOURCES'] = datasources
7 changes: 7 additions & 0 deletions eve/io/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ class DataLayer(object):
Admittedly, this interface is a Mongo rip-off. See the io.mongo
package for an implementation example.
.. versionchanged:: 0.0.4
the _datasource helper function has been added.
"""

def __init__(self, app):
Expand Down Expand Up @@ -66,3 +69,7 @@ def update(self, resource, id_, updates):

def remove(self, resource, id_):
raise NotImplementedError

def _datasource(self, resource):
return (config.SOURCES[resource]['source'],
config.SOURCES[resource]['filter'])
58 changes: 45 additions & 13 deletions eve/io/mongo/mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ def find(self, resource, req):
:param resource: resource name.
:param req: a :class:`ParsedRequest`instance.
.. versionchanged:: 0.0.4
retrieves the target collection via the new config.SOURCES helper.
"""
args = dict()

Expand All @@ -65,7 +68,8 @@ def find(self, resource, req):
if req.sort:
args['sort'] = ast.literal_eval(req.sort)

spec = dict()
spec = {}

if req.where:
try:
spec = self._jsondatetime(json.loads(req.where))
Expand All @@ -75,53 +79,66 @@ def find(self, resource, req):
except ParseError:
abort(400)

datasource, spec = self._datasource_ex(resource, spec)

if req.if_modified_since:
spec[config.LAST_UPDATED] = \
{'$gt': req.if_modified_since}

if len(spec) > 0:
args['spec'] = spec

return self.driver.db[resource].find(**args)
return self.driver.db[datasource].find(**args)

def find_one(self, resource, **lookup):
"""Retrieves a single document.
:param resource: resource name.
:param **lookup: lookup query.
.. versionchanged:: 0.0.4
retrieves the target collection via the new config.SOURCES helper.
"""
try:
if config.ID_FIELD in lookup:
lookup[ID_FIELD] = ObjectId(lookup[ID_FIELD])
except:
pass
document = self.driver.db[resource].find_one(lookup)
#if document:
# self.fix_last_updated(document)
datasource, filter_ = self._datasource_ex(resource, lookup)
document = self.driver.db[datasource].find_one(filter_)
return document

def insert(self, resource, document):
"""Inserts a document into a resource collection.
.. versionchanged:: 0.0.4
retrieves the target collection via the new config.SOURCES helper.
"""
return self.driver.db[resource].insert(document)
datasource, filter_ = self._datasource_ex(resource)
return self.driver.db[datasource].insert(document)

def update(self, resource, id_, updates):
"""Updates a collection document.
.. versionchanged:: 0.0.4
retrieves the target collection via the new config.SOURCES helper.
"""
return self.driver.db[resource].update({ID_FIELD: ObjectId(id_)},
{"$set": updates})
datasource, filter_ = self._datasource_ex(resource,
{ID_FIELD: ObjectId(id_)})
return self.driver.db[datasource].update(filter_, {"$set": updates})

def remove(self, resource, id_=None):
"""Removes a document or the entire set of documents from a collection.
.. versionchanged:: 0.0.4
retrieves the target collection via the new config.SOURCES helper.
.. versionadded:: 0.0.2
Support for deletion of entire documents collection.
"""
if id_:
return self.driver.db[resource].remove({ID_FIELD: ObjectId(id_)})
else:
# this will delete all documents in a collection!
return self.driver.db[resource].remove()
query = {ID_FIELD: ObjectId(id_)} if id_ else None
datasource, filter_ = self._datasource_ex(resource, query)
return self.driver.db[datasource].remove(filter_)

def _jsondatetime(self, source):
""" Recursively iterates a JSON dictionary, turning RFC-1123 strings
Expand All @@ -140,3 +157,18 @@ def _jsondatetime(self, source):
pass

return source

def _datasource_ex(self, resource, query=None):
""" Returns both db collection and exact query (base filter included)
to which an API resource refers to
.. versionadded:: 0.0.4
"""

datasource, filter_ = self._datasource(resource)
if filter_:
if query:
query.update(filter_)
else:
query = filter_
return datasource, query
22 changes: 22 additions & 0 deletions eve/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ def setUp(self):
self.readonly_resource_url = (
'/%s/' % self.domain[self.readonly_resource]['url'])

self.different_resource = 'users'
self.different_resource_url = ('/%s/' %
self.domain[
self.different_resource]['url'])

def assert200(self, status):
self.assertEqual(status, 200)

Expand Down Expand Up @@ -82,6 +87,16 @@ def setUp(self):
self.readonly_id_url = ('%s%s/' % (self.readonly_resource_url,
self.readonly_id))

response, status = self.get('users')
user = response['_items'][0]
self.user_id = user[self.app.config['ID_FIELD']]
self.user_username = user['username']
self.user_name = user['ref']
self.user_etag = user['etag']
self.user_id_url = ('/%s/%s/' %
(self.domain[self.different_resource]['url'],
self.user_id))

def tearDown(self):
self.dropDB()

Expand Down Expand Up @@ -232,6 +247,7 @@ def setupDB(self):
def bulk_insert(self):
_db = self.connection[MONGO_DBNAME]
_db.contacts.insert(self.random_contacts(100))
_db.contacts.insert(self.random_users(2))
_db.payments.insert(self.random_payments(10))
self.connection.close()

Expand Down Expand Up @@ -266,6 +282,12 @@ def random_contacts(self, num):
contacts.append(contact)
return contacts

def random_users(self, num):
users = self.random_contacts(num)
for user in users:
user['username'] = self.random_string(10)
return users

def random_payments(self, num):
payments = []
for i in range(num):
Expand Down
8 changes: 8 additions & 0 deletions eve/tests/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ def test_set_defaults(self):
self.assertNotEqual(settings['schema'], None)
self.assertEqual(type(settings['schema']), dict)
self.assertEqual(len(settings['schema']), 0)
self.assertEqual(settings['datasource'],
{'source': resource, 'filter': None})

def test_schema_dates(self):
self.domain.clear()
Expand Down Expand Up @@ -171,12 +173,18 @@ def test_url_helpers(self):
self.assertNotEqual(self.app.config.get('URLS'), None)
self.assertEqual(type(self.app.config['URLS']), dict)

self.assertNotEqual(self.app.config.get('SOURCES'), None)
self.assertEqual(type(self.app.config['SOURCES']), dict)

for resource, settings in self.domain.items():
self.assertEqual(settings['url'],
self.app.config['URLS'][resource])
self.assertEqual(resource,
self.app.config['RESOURCES'][settings['url']])

self.assertEqual(settings['datasource'],
self.app.config['SOURCES'][resource])

def test_url_rules(self):
map_adapter = self.app.url_map.bind(self.app.config['SERVER_NAME'])

Expand Down
24 changes: 23 additions & 1 deletion eve/tests/methods/delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,21 @@ def test_delete_from_resource_endpoint(self):
self.known_resource_url))
self.assert200(status)
self.assertEqual(len(r['_items']), 0)
self.bulk_insert()

def test_delete_from_resource_endpoint_different_resource(self):
r, status = self.delete(self.different_resource_url)
self.assert200(status)
r, status = self.parse_response(self.test_client.get(
self.different_resource_url))
self.assert200(status)
self.assertEqual(len(r['_items']), 0)

# deletion of 'users' will still lave 'contacts' untouched (same db
# collection)
r, status = self.parse_response(self.test_client.get(
self.known_resource_url))
self.assert200(status)
self.assertEqual(len(r['_items']), 25)

def test_delete_empty_resource(self):
url = '%s%s/' % (self.empty_resource_url, self.item_id)
Expand Down Expand Up @@ -47,6 +61,14 @@ def test_delete(self):
r = self.test_client.get(self.item_id_url)
self.assert404(r.status_code)

def test_delete_different_resource(self):
r, status = self.delete(self.user_id_url,
headers=[('If-Match', self.user_etag)])
self.assert200(status)

r = self.test_client.get(self.user_id_url)
self.assert404(r.status_code)

def delete(self, url, headers=None):
r = self.test_client.delete(url, headers=headers)
return self.parse_response(r)

0 comments on commit b15bbc9

Please sign in to comment.