diff options
author | Lars Wirzenius <liw@liw.fi> | 2017-10-12 16:01:48 +0300 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2017-10-12 16:01:48 +0300 |
commit | 2afe31ec08117274e989c2de927ea9aab2187179 (patch) | |
tree | 6769a80a0e5d7469fc7cccdb03c43e338db8f5ad | |
parent | d585b8a513769f445fbc2e8f1b3ee31c9a6f7cd3 (diff) | |
parent | 9255a00983e4b270c1901a8399ce58adcd2906ff (diff) | |
download | qvisqve-2afe31ec08117274e989c2de927ea9aab2187179.tar.gz |
Merge: refactor api.py for cleanliness
-rw-r--r-- | qvarn/__init__.py | 29 | ||||
-rw-r--r-- | qvarn/api.py | 773 | ||||
-rw-r--r-- | qvarn/api_errors.py | 45 | ||||
-rw-r--r-- | qvarn/api_tests.py | 19 | ||||
-rw-r--r-- | qvarn/file_router.py | 98 | ||||
-rw-r--r-- | qvarn/notification_router.py | 236 | ||||
-rw-r--r-- | qvarn/resource_router.py | 165 | ||||
-rw-r--r-- | qvarn/responses.py | 97 | ||||
-rw-r--r-- | qvarn/router.py | 23 | ||||
-rw-r--r-- | qvarn/subresource_router.py | 82 | ||||
-rw-r--r-- | qvarn/timestamp.py | 25 | ||||
-rw-r--r-- | qvarn/version_router.py | 42 | ||||
-rw-r--r-- | resource_type/listeners.yaml | 11 | ||||
-rw-r--r-- | resource_type/notifications.yaml | 13 | ||||
-rw-r--r-- | without-tests | 9 |
15 files changed, 931 insertions, 736 deletions
diff --git a/qvarn/__init__.py b/qvarn/__init__.py index b2d6bb1..7d35b1a 100644 --- a/qvarn/__init__.py +++ b/qvarn/__init__.py @@ -84,4 +84,31 @@ from .collection import ( WrongRevision, ) -from .api import QvarnAPI, NoSuchResourceType +from .responses import ( + bad_request_response, + conflict_response, + created_response, + need_sort_response, + no_such_resource_response, + ok_response, + search_parser_error_response, + unknown_search_field_response, +) + +from .api_errors import ( + IdMismatch, + NoSuchResourceType, + NotJson, + TooManyResources, + TooManyResourceTypes, +) + +from .router import Router +from .file_router import FileRouter +from .notification_router import NotificationRouter +from .resource_router import ResourceRouter +from .subresource_router import SubresourceRouter +from .version_router import VersionRouter +from .timestamp import get_current_timestamp + +from .api import QvarnAPI diff --git a/qvarn/api.py b/qvarn/api.py index b7b8cc7..a2f3e64 100644 --- a/qvarn/api.py +++ b/qvarn/api.py @@ -14,71 +14,9 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. -import io -import os -import time - -import yaml - -import apifw import qvarn -resource_type_spec_yaml = ''' -type: resource_type -path: /resource_types -versions: - -- version: v0 - prototype: - type: "" - id: "" - revision: "" - name: "" - yaml: "" -''' - -listener_spec = { - 'type': 'listener', - 'path': '/listeners', - 'versions': [ - { - 'version': 'v0', - 'prototype': { - 'id': '', - 'type': '', - 'revision': '', - 'notify_of_new': False, - 'listen_on_all': False, - 'listen_on': [''], - }, - 'subpaths': {}, - }, - ], -} - -notification_spec = { - 'type': 'notification', - 'path': '/notifications', - 'versions': [ - { - 'version': 'v0', - 'prototype': { - 'id': '', - 'type': '', - 'revision': '', - 'listener_id': '', - 'resource_id': '', - 'resource_revision': '', - 'resource_change': '', - 'timestamp': '', - }, - 'subpaths': {}, - }, - ], -} - - class QvarnAPI: def __init__(self): @@ -93,29 +31,10 @@ class QvarnAPI: def set_object_store(self, store): self._store = store self._store.create_store(obj_id=str, subpath=str) - self.set_up_resource_types() - - def set_up_resource_types(self): - f = io.StringIO(resource_type_spec_yaml) - spec = yaml.safe_load(f) - rt = qvarn.ResourceType() - rt.from_spec(spec) - self._rt_coll = qvarn.CollectionAPI() - self._rt_coll.set_object_store(self._store) - self._rt_coll.set_resource_type(rt) - - for spec in [listener_spec, notification_spec]: - rt2 = qvarn.ResourceType() - rt2.from_spec(spec) - self.add_resource_type(rt2) def add_resource_type(self, rt): path = rt.get_path() - cond1 = qvarn.Equal('path', path) - cond2 = qvarn.Equal('type', 'resource_type') - cond = qvarn.All(cond1, cond2) - results = self._store.find_objects(cond) - objs = [obj for _, obj in results] + objs = self._get_resource_type_given_path(path) if not objs: obj = { 'id': rt.get_type(), @@ -127,55 +46,51 @@ class QvarnAPI: obj, obj_id=obj['id'], subpath='', auxtable=True) def get_resource_type(self, path): - cond1 = qvarn.Equal('path', path) - cond2 = qvarn.Equal('type', 'resource_type') - cond = qvarn.All(cond1, cond2) - results = self._store.find_objects(cond) - objs = [obj for _, obj in results] - qvarn.log.log('debug', objs=objs) + objs = self._get_resource_type_given_path(path) if len(objs) == 0: qvarn.log.log( 'error', msg_text='There is no resource type for path', path=path) - raise NoSuchResourceType(path) + raise qvarn.NoSuchResourceType(path) elif len(objs) > 1: # pragma: no cover qvarn.log.log( 'error', msg_text='There are more than one resource types for path', path=path, objs=objs) - raise TooManyResourceTypes(path) + raise qvarn.TooManyResourceTypes(path) rt = qvarn.ResourceType() rt.from_spec(objs[0]['spec']) return rt - def get_listener_resource_type(self): - cond1 = qvarn.Equal('id', 'listener') - cond2 = qvarn.Equal('type', 'resource_type') - cond = qvarn.All(cond1, cond2) + def _get_resource_type_given_path(self, path): + cond = qvarn.All( + qvarn.Equal('path', path), + qvarn.Equal('type', 'resource_type'), + ) results = self._store.find_objects(cond) - objs = [obj for _, obj in results] - qvarn.log.log('debug', objs=objs) - if len(objs) == 0: # pragma: no cover - raise NoSuchResourceType('listener') - elif len(objs) > 1: # pragma: no cover - raise TooManyResourceTypes('listener') - rt = qvarn.ResourceType() - rt.from_spec(objs[0]['spec']) - return rt + return [obj for _, obj in results] + + def get_listener_resource_type(self): + return self._get_resource_type_given_type('listener') def get_notification_resource_type(self): # pragma: no cover - cond1 = qvarn.Equal('id', 'notification') - cond2 = qvarn.Equal('type', 'resource_type') - cond = qvarn.All(cond1, cond2) + return self._get_resource_type_given_type('notification') + + def _get_resource_type_given_type(self, type_name): + cond = qvarn.All( + qvarn.Equal('id', type_name), + qvarn.Equal('type', 'resource_type'), + ) results = self._store.find_objects(cond) objs = [obj for _, obj in results] - qvarn.log.log('debug', objs=objs) + if len(objs) == 0: # pragma: no cover - raise NoSuchResourceType('listener') + raise qvarn.NoSuchResourceType(type_name) elif len(objs) > 1: # pragma: no cover - raise TooManyResourceTypes('listener') + raise qvarn.TooManyResourceTypes(type_name) + rt = qvarn.ResourceType() rt.from_spec(objs[0]['spec']) return rt @@ -185,336 +100,52 @@ class QvarnAPI: if path == '/version': qvarn.log.log('info', msg_text='Add /version route') - return self.version_route() + v = qvarn.VersionRouter() + return v.get_routes() try: rt = self.get_resource_type(path) - except NoSuchResourceType: + except qvarn.NoSuchResourceType: qvarn.log.log('warning', msg_text='No such route', path=path) return [] routes = self.resource_routes(path, rt) - loggable_routes = [ - { - key: repr(r[key]) - for key in r - } - for r in routes - ] - qvarn.log.log('info', msg_text='Add routes', routes=loggable_routes) + qvarn.log.log('info', msg_text='Found missing routes', routes=routes) return routes - def version_route(self): - return [ - { - 'method': 'GET', - 'path': '/version', - 'callback': self.version, - 'needs-authorization': False, - }, - ] - - def version(self, content_type, body, **kwargs): - version = { - 'api': { - 'version': qvarn.__version__, - }, - 'implementation': { - 'name': 'Qvarn', - 'version': qvarn.__version__, - }, - } - return ok_response(version) - def resource_routes(self, path, rt): # pragma: no cover coll = qvarn.CollectionAPI() coll.set_object_store(self._store) coll.set_resource_type(rt) - id_path = os.path.join(path, '<id>') - routes = [ - { - 'method': 'POST', - 'path': path, - 'callback': self.get_post_callback(coll), - }, - { - 'method': 'PUT', - 'path': id_path, - 'callback': self.get_put_callback(coll), - }, - { - 'method': 'GET', - 'path': path, - 'callback': self.get_resource_list_callback(coll), - }, - { - 'method': 'GET', - 'path': id_path, - 'callback': self.get_resource_callback(coll), - }, - { - 'method': 'GET', - 'path': path + '/search/<search_criteria:path>', - 'callback': self.get_search_callback(coll), - }, - { - 'method': 'DELETE', - 'path': id_path, - 'callback': self.delete_resource_callback(coll), - }, - ] + router = qvarn.ResourceRouter() + router.set_collection(coll) + router.set_notifier(self.notify) + routes = router.get_routes() files = rt.get_files() for subpath in rt.get_subpaths(): if subpath not in files: - more = self.get_subresource_routes(id_path, coll, subpath) + sub_router = qvarn.SubresourceRouter() + sub_router.set_subpath(subpath) + sub_router.set_parent_collection(coll) + more = sub_router.get_routes() else: - more = self.get_file_routes(id_path, coll, subpath) + file_router = qvarn.FileRouter() + file_router.set_subpath(subpath) + file_router.set_object_store(self._store) + file_router.set_parent_collection(coll) + more = file_router.get_routes() routes.extend(more) - return routes + self._get_notification_routes(coll, path, id_path) - - def get_subresource_routes( - self, id_path, coll, subpath): # pragma: no cover - return [ - { - 'method': 'GET', - 'path': '{}/{}'.format(id_path, subpath), - 'callback': self.get_subpath_callback(coll, subpath), - }, - { - 'method': 'PUT', - 'path': '{}/{}'.format(id_path, subpath), - 'callback': self.put_subpath_callback(coll, subpath), - }, - ] - - def get_file_routes(self, id_path, objcoll, subpath): # pragma: no cover - return [ - { - 'method': 'GET', - 'path': '{}/{}'.format(id_path, subpath), - 'callback': self.get_file_callback(objcoll, subpath), - }, - { - 'method': 'PUT', - 'path': '{}/{}'.format(id_path, subpath), - 'callback': self.put_file_callback(objcoll, subpath), - }, - ] - - def _get_notification_routes(self, coll, path, id_path): - rt = self.get_listener_resource_type() - listeners = qvarn.CollectionAPI() - listeners.set_object_store(self._store) - listeners.set_resource_type(rt) - - return [ - { - 'method': 'POST', - 'path': path + '/listeners', - 'callback': self.get_post_listener_callback(coll, listeners), - }, - { - 'method': 'GET', - 'path': path + '/listeners', - 'callback': self.get_listener_list_callback(listeners), - }, - { - 'method': 'GET', - 'path': path + '/listeners/<id>', - 'callback': self.get_listener_callback(coll, listeners), - }, - { - 'method': 'PUT', - 'path': path + '/listeners/<id>', - 'callback': self.put_listener_callback(listeners), - }, - { - 'method': 'DELETE', - 'path': path + '/listeners/<id>', - 'callback': self.delete_listener_callback(listeners), - }, - { - 'method': 'GET', - 'path': path + '/listeners/<id>/notifications', - 'callback': self.get_notifications_list_callback(), - }, - { - 'method': 'GET', - 'path': path + '/listeners/<listener_id>/notifications/<id>', - 'callback': self.get_notification_callback(), - }, - { - 'method': 'DELETE', - 'path': path + '/listeners/<listener_id>/notifications/<id>', - 'callback': self.delete_notification_callback(), - }, - ] - - def get_post_listener_callback(self, coll, listeners): # pragma: no cover - def wrapper(content_type, body, **kwargs): - if content_type != 'application/json': - raise NotJson(content_type) - - rt = listeners.get_type() - try: - self._validator.validate_against_prototype( - rt.get_type(), body, rt.get_latest_prototype()) - except qvarn.ValidationError as e: - qvarn.log.log('error', msg_text=str(e), body=body) - return bad_request_response(str(e)) - - if 'type' not in body: - body['type'] = 'listener' - - result_body = listeners.post(body) - qvarn.log.log( - 'debug', msg_text='POST a new listener, result', - body=result_body) - location = '{}{}/listeners/{}'.format( - self._baseurl, coll.get_type().get_path(), - result_body['id']) - return created_response(result_body, location) - return wrapper - - def get_listener_list_callback(self, listeners): # pragma: no cover - def wrapper(content_type, body, **kwargs): - body = listeners.list() - return ok_response(body) - return wrapper - - def get_listener_callback(self, coll, listeners): # pragma: no cover - def wrapper(content_type, body, **kwargs): - try: - obj = listeners.get(kwargs['id']) - except qvarn.NoSuchResource as e: - return no_such_resource_response(str(e)) - return ok_response(obj) - return wrapper - - def put_listener_callback(self, listeners): # pragma: no cover - def wrapper(content_type, body, **kwargs): - if content_type != 'application/json': - raise NotJson(content_type) - - if 'type' not in body: - body['type'] = 'listener' + listener_rt = self.get_listener_resource_type() + notif_router = qvarn.NotificationRouter() + notif_router.set_baseurl(self._baseurl) + notif_router.set_parent_collection(coll) + notif_router.set_object_store(self._store, listener_rt) + routes.extend(notif_router.get_routes()) - listener_id = kwargs['id'] - - if 'id' not in body: - body['id'] = listener_id - - try: - self._validator.validate_resource_update( - body, listeners.get_type()) - except qvarn.ValidationError as e: - qvarn.log.log('error', msg_text=str(e), body=body) - return bad_request_response(str(e)) - - try: - result_body = listeners.put(body) - except qvarn.WrongRevision as e: - return conflict_response(str(e)) - except qvarn.NoSuchResource as e: - # We intentionally say bad request, instead of not found. - # This is to be compatible with old Qvarn. This may get - # changed later. - return bad_request_response(str(e)) - - return ok_response(result_body) - return wrapper - - def get_notifications_list_callback(self): # pragma: no cover - def timestamp(pair): - _, obj = pair - return obj['timestamp'] - - def wrapper(content_type, body, **kwargs): - rid = kwargs['id'] - cond = qvarn.All( - qvarn.Equal('type', 'notification'), - qvarn.Equal('listener_id', rid) - ) - pairs = self._store.find_objects(cond) - ordered = sorted(pairs, key=timestamp) - qvarn.log.log( - 'trace', msg_text='Found notifications', - notifications=ordered) - body = { - 'resources': [ - { - 'id': keys['obj_id'] - } - for keys, _ in ordered - ] - } - return ok_response(body) - return wrapper - - def get_notification_callback(self): # pragma: no cover - def wrapper(content_type, body, **kwargs): - listener_id = kwargs['listener_id'] - notification_id = kwargs['id'] - cond = qvarn.All( - qvarn.Equal('type', 'notification'), - qvarn.Equal('listener_id', listener_id), - qvarn.Equal('id', notification_id), - ) - pairs = self._store.find_objects(cond) - qvarn.log.log( - 'trace', msg_text='Found notifications', - notifications=pairs) - if len(pairs) == 0: - return no_such_resource_response(notification_id) - if len(pairs) > 1: - raise TooManyResources(notification_id) - return ok_response(pairs[0][1]) - return wrapper - - def delete_listener_callback(self, listeners): # pragma: no cover - def wrapper(content_type, body, **kwargs): - listener_id = kwargs['id'] - listeners.delete(listener_id) - for obj_id in self.find_notifications(listener_id): - self._store.remove_objects(obj_id=obj_id) - return ok_response({}) - return wrapper - - def delete_notification_callback(self): # pragma: no cover - def wrapper(content_type, body, **kwargs): - listener_id = kwargs['listener_id'] - notification_id = kwargs['id'] - cond = qvarn.All( - qvarn.Equal('type', 'notification'), - qvarn.Equal('listener_id', listener_id), - qvarn.Equal('id', notification_id), - ) - for keys, _ in self._store.find_objects(cond): - values = { - key: keys[key] - for key in keys - if isinstance(keys[key], str) - } - self._store.remove_objects(**values) - return ok_response({}) - return wrapper - - def find_notifications(self, listener_id): # pragma: no cover - cond = qvarn.All( - qvarn.Equal('type', 'notification'), - qvarn.Equal('listener_id', listener_id), - ) - obj_ids = [ - keys['obj_id'] - for keys, _ in self._store.find_objects(cond) - ] - qvarn.log.log( - 'trace', msg_text='Found notifications', - notifications=obj_ids) - return obj_ids + return routes def notify(self, rid, rrev, change): # pragma: no cover rt = self.get_notification_resource_type() @@ -526,7 +157,7 @@ class QvarnAPI: 'resource_id': rid, 'resource_revision': rrev, 'resource_change': change, - 'timestamp': self.get_current_timestamp(), + 'timestamp': qvarn.get_current_timestamp(), } for listener in self.find_listeners(rid, change): obj['listener_id'] = listener['id'] @@ -550,309 +181,3 @@ class QvarnAPI: if rid in obj.get('listen_on', []): return True return False - - def get_current_timestamp(self): # pragma: no cover - t = time.time() - tm = time.gmtime(t) - ss = t - int(t) - secs = '%f' % ss - return time.strftime('%Y-%m-%dT%H:%M:%S', tm) + secs[1:] - - def get_post_callback(self, coll): # pragma: no cover - def wrapper(content_type, body, **kwargs): - if content_type != 'application/json': - raise NotJson(content_type) - if 'type' not in body: - body['type'] = coll.get_type_name() - try: - self._validator.validate_new_resource(body, coll.get_type()) - except qvarn.ValidationError as e: - qvarn.log.log('error', msg_text=str(e), body=body) - return bad_request_response(str(e)) - result_body = coll.post(body) - qvarn.log.log( - 'debug', msg_text='POST a new resource, result', - body=result_body) - location = '{}{}/{}'.format( - self._baseurl, coll.get_type().get_path(), result_body['id']) - self.notify(result_body['id'], result_body['revision'], 'created') - return created_response(result_body, location) - return wrapper - - def get_put_callback(self, coll): # pragma: no cover - def wrapper(content_type, body, **kwargs): - if content_type != 'application/json': - raise NotJson(content_type) - - if 'type' not in body: - body['type'] = coll.get_type_name() - - if 'id' not in body: - body['id'] = kwargs['id'] - - try: - self._validator.validate_resource_update( - body, coll.get_type()) - except qvarn.ValidationError as e: - qvarn.log.log('error', msg_text=str(e), body=body) - return bad_request_response(str(e)) - - obj_id = kwargs['id'] - # FIXME: the following test should be enabled once we - # no longer need test-api. - if False and body['id'] != obj_id: - raise IdMismatch(body['id'], obj_id) - - try: - result_body = coll.put(body) - except qvarn.WrongRevision as e: - return conflict_response(str(e)) - except qvarn.NoSuchResource as e: - # We intentionally say bad request, instead of not found. - # This is to be compatible with old Qvarn. This may get - # changed later. - return bad_request_response(str(e)) - - self.notify( - result_body['id'], result_body['revision'], 'updated') - return ok_response(result_body) - return wrapper - - def put_subpath_callback(self, coll, subpath): # pragma: no cover - def wrapper(content_type, body, **kwargs): - if content_type != 'application/json': - raise NotJson(content_type) - - obj_id = kwargs['id'] - if 'revision' not in body: - return bad_request_response('must have revision') - revision = body.pop('revision') - - rt = coll.get_type() - try: - self._validator.validate_subresource(subpath, rt, body) - except qvarn.ValidationError as e: - qvarn.log.log('error', msg_text=str(e), body=body) - return bad_request_response(str(e)) - - try: - result_body = coll.put_subresource( - body, subpath=subpath, obj_id=obj_id, revision=revision) - except qvarn.WrongRevision as e: - return conflict_response(str(e)) - except qvarn.NoSuchResource as e: - return no_such_resource_response(str(e)) - - return ok_response(result_body) - return wrapper - - def put_file_callback(self, objcoll, subpath): # pragma: no cover - def wrapper(content_type, body, **kwargs): - obj_id = kwargs['id'] - - # FIXME: add header getting to apifw - import bottle - revision = bottle.request.get_header('Revision') - - obj = objcoll.get(obj_id) - if obj['revision'] != revision: - qvarn.log.log( - 'error', - msg_text='Client gave wrong revision', - revision_from_client=revision, - current_revision=obj['revision']) - return conflict_response('Bad revision {}'.format(revision)) - - sub_obj = objcoll.get_subresource(obj_id, subpath) - sub_obj['content_type'] = content_type - new_sub = objcoll.put_subresource( - sub_obj, subpath=subpath, obj_id=obj_id, revision=revision) - - try: - self._store.remove_blob(subpath=subpath, obj_id=obj_id) - self._store.create_blob(body, subpath=subpath, obj_id=obj_id) - except qvarn.NoSuchObject as e: - return no_such_resource_response(str(e)) - - headers = { - 'Revision': new_sub['revision'], - } - return ok_response('', headers) - return wrapper - - def get_resource_callback(self, coll): # pragma: no cover - def wrapper(content_type, body, **kwargs): - try: - obj = coll.get(kwargs['id']) - except qvarn.NoSuchResource as e: - return no_such_resource_response(str(e)) - return ok_response(obj) - return wrapper - - def get_subpath_callback(self, coll, subpath): # pragma: no cover - def wrapper(content_type, body, **kwargs): - try: - obj = coll.get_subresource(kwargs['id'], subpath) - except qvarn.NoSuchResource as e: - return no_such_resource_response(str(e)) - return ok_response(obj) - return wrapper - - def get_file_callback(self, coll, subpath): # pragma: no cover - def wrapper(content_type, body, **kwargs): - obj_id = kwargs['id'] - try: - obj = coll.get(obj_id) - sub_obj = coll.get_subresource(obj_id, subpath) - blob = self._store.get_blob(obj_id=obj_id, subpath=subpath) - except (qvarn.NoSuchResource, qvarn.NoSuchObject) as e: - return no_such_resource_response(str(e)) - headers = { - 'Content-Type': sub_obj['content_type'], - 'Revision': obj['revision'], - } - return ok_response(blob, headers) - return wrapper - - def get_resource_list_callback(self, coll): # pragma: no cover - def wrapper(content_type, body, **kwargs): - body = coll.list() - return ok_response(body) - return wrapper - - def get_search_callback(self, coll): # pragma: no cover - def wrapper(content_type, body, **kwargs): - path = kwargs['raw_uri_path'] - search_criteria = path.split('/search/', 1)[1] - try: - result = coll.search(search_criteria) - except qvarn.UnknownSearchField as e: - return unknown_search_field_response(e) - except qvarn.NeedSortOperator: - return need_sort_response() - except qvarn.SearchParserError as e: - return search_parser_error_response(e) - body = { - 'resources': result, - } - return ok_response(body) - return wrapper - - def delete_resource_callback(self, coll): # pragma: no cover - def wrapper(content_type, body, **kwargs): - obj_id = kwargs['id'] - coll.delete(obj_id) - self.notify(obj_id, None, 'deleted') - return ok_response({}) - return wrapper - - -def response(status, body, headers): # pragma: no cover - return apifw.Response( - { - 'status': status, - 'body': body, - 'headers': headers, - } - ) - - -def ok_response(body, headers=None): # pragma: no cover - if headers is None: - headers = {} - if 'Content-Type' not in headers: - headers.update({ - 'Content-Type': 'application/json', - }) - return response(apifw.HTTP_OK, body, headers) - - -def no_such_resource_response(msg): # pragma: no cover - return response(apifw.HTTP_NOT_FOUND, msg, {}) - - -def created_response(body, location): # pragma: no cover - headers = { - 'Content-Type': 'application/json', - 'Location': location, - } - return response(apifw.HTTP_CREATED, body, headers) - - -def bad_request_response(body): # pragma: no cover - headers = { - 'Content-Type': 'text/plain', - } - return response(apifw.HTTP_BAD_REQUEST, body, headers) - - -def need_sort_response(): # pragma: no cover - headers = { - 'Content-Type': 'application/json', - } - body = { - 'message': 'LIMIT and OFFSET can only be used with together SORT.', - 'error_code': 'LimitWithoutSortError', - } - return response(apifw.HTTP_BAD_REQUEST, body, headers) - - -def search_parser_error_response(e): # pragma: no cover - headers = { - 'Content-Type': 'application/json', - } - body = { - 'message': 'Could not parse search condition', - 'error_code': 'BadSearchCondition', - } - return response(apifw.HTTP_BAD_REQUEST, body, headers) - - -def unknown_search_field_response(e): # pragma: no cover - headers = { - 'Content-Type': 'application/json', - } - body = { - 'field': e.field, - 'message': 'Resource does not contain given field', - 'error_code': 'FieldNotInResource', - } - return response(apifw.HTTP_BAD_REQUEST, body, headers) - - -def conflict_response(body): # pragma: no cover - headers = { - 'Content-Type': 'text/plain', - } - return response(apifw.HTTP_CONFLICT, body, headers) - - -class NoSuchResourceType(Exception): # pragma: no cover - - def __init__(self, path): - super().__init__('No resource type for path {}'.format(path)) - - -class TooManyResourceTypes(Exception): # pragma: no cover - - def __init__(self, path): - super().__init__('Too many resource types for path {}'.format(path)) - - -class TooManyResources(Exception): # pragma: no cover - - def __init__(self, resource_id): - super().__init__('Too many resources with id {}'.format(resource_id)) - - -class NotJson(Exception): # pragma: no cover - - def __init__(self, ct): - super().__init__('Was expecting application/json, not {}'.format(ct)) - - -class IdMismatch(Exception): # pragma: no cover - - def __init__(self, obj_id, id_from_path): - super().__init__( - 'Resource has id {} but path says {}'.format(obj_id, id_from_path)) diff --git a/qvarn/api_errors.py b/qvarn/api_errors.py new file mode 100644 index 0000000..368ddc5 --- /dev/null +++ b/qvarn/api_errors.py @@ -0,0 +1,45 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +class NoSuchResourceType(Exception): # pragma: no cover + + def __init__(self, path): + super().__init__('No resource type for path {}'.format(path)) + + +class TooManyResourceTypes(Exception): # pragma: no cover + + def __init__(self, path): + super().__init__('Too many resource types for path {}'.format(path)) + + +class TooManyResources(Exception): # pragma: no cover + + def __init__(self, resource_id): + super().__init__('Too many resources with id {}'.format(resource_id)) + + +class NotJson(Exception): # pragma: no cover + + def __init__(self, ct): + super().__init__('Was expecting application/json, not {}'.format(ct)) + + +class IdMismatch(Exception): # pragma: no cover + + def __init__(self, obj_id, id_from_path): + super().__init__( + 'Resource has id {} but path says {}'.format(obj_id, id_from_path)) diff --git a/qvarn/api_tests.py b/qvarn/api_tests.py index 29c0e6c..23e6143 100644 --- a/qvarn/api_tests.py +++ b/qvarn/api_tests.py @@ -14,6 +14,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. +import os import unittest @@ -26,17 +27,6 @@ class QvarnAPITests(unittest.TestCase): api = qvarn.QvarnAPI() self.assertNotEqual(api.find_missing_route('/version'), []) - def test_version_returns_sensible_data(self): - api = qvarn.QvarnAPI() - r = api.version(None, None) - v = r['body'] - self.assertTrue(isinstance(v, dict)) - self.assertTrue('api' in v) - self.assertTrue('version' in v['api']) - self.assertTrue('implementation' in v) - self.assertTrue('name' in v['implementation']) - self.assertTrue('version' in v['implementation']) - def test_returns_no_routes_for_unknown_resource_type(self): store = qvarn.MemoryObjectStore() api = qvarn.QvarnAPI() @@ -73,6 +63,13 @@ class QvarnAPITests(unittest.TestCase): api = qvarn.QvarnAPI() api.set_object_store(store) api.add_resource_type(rt) + + dirname = os.path.dirname(qvarn.__file__) + dirname = os.path.join(dirname, '../resource_type') + resource_types = qvarn.load_resource_types(dirname) + for rt in resource_types: + api.add_resource_type(rt) + self.assertNotEqual(api.find_missing_route('/subjects'), []) def test_get_resource_type_raises_error_for_unknown_path(self): diff --git a/qvarn/file_router.py b/qvarn/file_router.py new file mode 100644 index 0000000..b7e4507 --- /dev/null +++ b/qvarn/file_router.py @@ -0,0 +1,98 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import qvarn + + +class FileRouter(qvarn.Router): + + def __init__(self): + super().__init__() + self._store = None + self._parent_coll = None + self._subpath = None + + def set_subpath(self, subpath): + self._subpath = subpath + + def set_parent_collection(self, parent_coll): + self._parent_coll = parent_coll + + def set_object_store(self, store): + self._store = store + + def get_routes(self): + rt = self._parent_coll.get_type() + file_path = '{}/<id>/{}'.format(rt.get_path(), self._subpath) + return [ + { + 'method': 'GET', + 'path': file_path, + 'callback': self._get_file, + }, + { + 'method': 'PUT', + 'path': file_path, + 'callback': self._put_file, + }, + ] + + def _get_file(self, *args, **kwargs): + obj_id = kwargs['id'] + try: + obj = self._parent_coll.get(obj_id) + sub_obj = self._parent_coll.get_subresource(obj_id, self._subpath) + blob = self._store.get_blob(obj_id=obj_id, subpath=self._subpath) + except (qvarn.NoSuchResource, qvarn.NoSuchObject) as e: + return qvarn.no_such_resource_response(str(e)) + headers = { + 'Content-Type': sub_obj['content_type'], + 'Revision': obj['revision'], + } + return qvarn.ok_response(blob, headers) + + def _put_file(self, content_type, body, *args, **kwargs): + obj_id = kwargs['id'] + + # FIXME: add header getting to apifw + import bottle + revision = bottle.request.get_header('Revision') + + obj = self._parent_coll.get(obj_id) + if obj['revision'] != revision: + qvarn.log.log( + 'error', + msg_text='Client gave wrong revision', + revision_from_client=revision, + current_revision=obj['revision']) + return qvarn.conflict_response( + 'Bad revision {}'.format(revision)) + + sub_obj = self._parent_coll.get_subresource(obj_id, self._subpath) + sub_obj['content_type'] = content_type + new_sub = self._parent_coll.put_subresource( + sub_obj, subpath=self._subpath, obj_id=obj_id, revision=revision) + + try: + self._store.remove_blob(obj_id=obj_id, subpath=self._subpath) + self._store.create_blob(body, obj_id=obj_id, subpath=self._subpath) + except qvarn.NoSuchObject as e: + return qvarn.no_such_resource_response(str(e)) + + headers = { + 'Revision': new_sub['revision'], + } + return qvarn.ok_response('', headers) diff --git a/qvarn/notification_router.py b/qvarn/notification_router.py new file mode 100644 index 0000000..000c445 --- /dev/null +++ b/qvarn/notification_router.py @@ -0,0 +1,236 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import qvarn + + +class NotificationRouter(qvarn.Router): + + def __init__(self): + super().__init__() + self._baseurl = None + self._store = None + self._parent_coll = None + self._listener_coll = None + + def set_baseurl(self, baseurl): + self._baseurl = baseurl + + def set_parent_collection(self, parent_coll): + self._parent_coll = parent_coll + + def set_object_store(self, store, listener_rt): + self._store = store + listeners = qvarn.CollectionAPI() + listeners.set_object_store(self._store) + listeners.set_resource_type(listener_rt) + self._listener_coll = listeners + + def get_routes(self): + rt = self._parent_coll.get_type() + listeners_path = '{}/listeners'.format(rt.get_path()) + listener_id_path = '{}/<listener_id>'.format(listeners_path) + notifications_path = '{}/notifications'.format(listener_id_path) + notification_id_path = '{}/<notification_id>'.format( + notifications_path) + + return [ + { + 'method': 'POST', + 'path': listeners_path, + 'callback': self._create_listener, + }, + { + 'method': 'GET', + 'path': listeners_path, + 'callback': self._get_listener_list, + }, + { + 'method': 'GET', + 'path': listener_id_path, + 'callback': self._get_a_listener, + }, + { + 'method': 'PUT', + 'path': listener_id_path, + 'callback': self._update_listener, + }, + { + 'method': 'DELETE', + 'path': listener_id_path, + 'callback': self._delete_listener, + }, + { + 'method': 'GET', + 'path': notifications_path, + 'callback': self._get_notifications_list, + }, + { + 'method': 'GET', + 'path': notification_id_path, + 'callback': self._get_a_notification, + }, + { + 'method': 'DELETE', + 'path': notification_id_path, + 'callback': self._delete_notification, + }, + ] + + def _create_listener(self, content_type, body, *args, **kwargs): + if content_type != 'application/json': + raise qvarn.NotJson(content_type) + + rt = self._listener_coll.get_type() + validator = qvarn.Validator() + try: + validator.validate_against_prototype( + rt.get_type(), body, rt.get_latest_prototype()) + except qvarn.ValidationError as e: + qvarn.log.log('error', msg_text=str(e), body=body) + return qvarn.bad_request_response(str(e)) + + if 'type' not in body: + body['type'] = 'listener' + + result_body = self._listener_coll.post(body) + location = self._get_new_resource_location(result_body) + qvarn.log.log( + 'debug', msg_text='POST a new listener, result', + body=result_body, location=location) + return qvarn.created_response(result_body, location) + + def _get_new_resource_location(self, resource): + return '{}{}/listeners/{}'.format( + self._baseurl, self._parent_coll.get_type().get_path(), + resource['id']) + + def _get_listener_list(self, content_type, body, *args, **kwargs): + body = self._listener_coll.list() + return qvarn.ok_response(body) + + def _get_a_listener(self, *args, **kwargs): + try: + obj = self._listener_coll.get(kwargs['listener_id']) + except qvarn.NoSuchResource as e: + return qvarn.no_such_resource_response(str(e)) + return qvarn.ok_response(obj) + + def _update_listener(self, content_type, body, *args, **kwargs): + if content_type != 'application/json': + raise qvarn.NotJson(content_type) + + if 'type' not in body: + body['type'] = 'listener' + + listener_id = kwargs['listener_id'] + if 'id' not in body: + body['id'] = listener_id + + validator = qvarn.Validator() + try: + validator.validate_resource_update( + body, self._listener_coll.get_type()) + except qvarn.ValidationError as e: + qvarn.log.log('error', msg_text=str(e), body=body) + return qvarn.bad_request_response(str(e)) + + try: + result_body = self._listener_coll.put(body) + except qvarn.WrongRevision as e: + return qvarn.conflict_response(str(e)) + except qvarn.NoSuchResource as e: + # We intentionally say bad request, instead of not found. + # This is to be compatible with old Qvarn. This may get + # changed later. + return qvarn.bad_request_response(str(e)) + + return qvarn.ok_response(result_body) + + def _delete_listener(self, *args, **kwargs): + listener_id = kwargs['listener_id'] + self._listener_coll.delete(listener_id) + for obj_id in self._find_notifications(listener_id): + self._store.remove_objects(obj_id=obj_id) + return qvarn.ok_response({}) + + def _find_notifications(self, listener_id): + cond = qvarn.All( + qvarn.Equal('type', 'notification'), + qvarn.Equal('listener_id', listener_id), + ) + obj_ids = [ + keys['obj_id'] + for keys, _ in self._store.find_objects(cond) + ] + qvarn.log.log( + 'trace', msg_text='Found notifications', + notifications=obj_ids) + return obj_ids + + def _get_notifications_list(self, *args, **kwargs): + def timestamp(pair): + _, obj = pair + return obj['timestamp'] + + listener_id = kwargs['listener_id'] + cond = qvarn.All( + qvarn.Equal('type', 'notification'), + qvarn.Equal('listener_id', listener_id) + ) + pairs = self._store.find_objects(cond) + ordered = sorted(pairs, key=timestamp) + body = { + 'resources': [ + { + 'id': keys['obj_id'] + } + for keys, _ in ordered + ] + } + return qvarn.ok_response(body) + + def _get_a_notification(self, *args, **kwargs): + listener_id = kwargs['listener_id'] + notification_id = kwargs['notification_id'] + cond = qvarn.All( + qvarn.Equal('type', 'notification'), + qvarn.Equal('listener_id', listener_id), + qvarn.Equal('id', notification_id), + ) + pairs = self._store.find_objects(cond) + if len(pairs) == 0: + return qvarn.no_such_resource_response(notification_id) + if len(pairs) > 1: + raise qvarn.TooManyResources(notification_id) + return qvarn.ok_response(pairs[0][1]) + + def _delete_notification(self, *args, **kwargs): + listener_id = kwargs['listener_id'] + notification_id = kwargs['notification_id'] + cond = qvarn.All( + qvarn.Equal('type', 'notification'), + qvarn.Equal('listener_id', listener_id), + qvarn.Equal('id', notification_id), + ) + for keys, _ in self._store.find_objects(cond): + values = { + key: keys[key] + for key in keys + if isinstance(keys[key], str) + } + self._store.remove_objects(**values) + return qvarn.ok_response({}) diff --git a/qvarn/resource_router.py b/qvarn/resource_router.py new file mode 100644 index 0000000..ddfabe7 --- /dev/null +++ b/qvarn/resource_router.py @@ -0,0 +1,165 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import qvarn + + +class ResourceRouter(qvarn.Router): + + def __init__(self): + super().__init__() + self._coll = None + self._baseurl = None + self._notify = None + + def set_baseurl(self, baseurl): + self._baseurl = baseurl + + def set_collection(self, coll): + self._coll = coll + + def set_notifier(self, notify): + self._notify = notify + + def get_routes(self): + rt = self._coll.get_type() + path = rt.get_path() + id_path = '{}/<id>'.format(path) + + return [ + { + 'method': 'POST', + 'path': path, + 'callback': self._create, + }, + { + 'method': 'PUT', + 'path': id_path, + 'callback': self._update, + }, + { + 'method': 'GET', + 'path': path, + 'callback': self._list, + }, + { + 'method': 'GET', + 'path': id_path, + 'callback': self._get, + }, + { + 'method': 'GET', + 'path': path + '/search/<search_criteria:path>', + 'callback': self._search, + }, + { + 'method': 'DELETE', + 'path': id_path, + 'callback': self._delete, + }, + ] + + def _create(self, content_type, body, *args, **kwargs): + if content_type != 'application/json': + raise qvarn.NotJson(content_type) + + if 'type' not in body: + body['type'] = self._coll.get_type_name() + + validator = qvarn.Validator() + try: + validator.validate_new_resource(body, self._coll.get_type()) + except qvarn.ValidationError as e: + qvarn.log.log('error', msg_text=str(e), body=body) + return qvarn.bad_request_response(str(e)) + + result_body = self._coll.post(body) + qvarn.log.log( + 'debug', msg_text='POST a new resource, result', + body=result_body) + location = '{}{}/{}'.format( + self._baseurl, self._coll.get_type().get_path(), result_body['id']) + self._notify(result_body['id'], result_body['revision'], 'created') + return qvarn.created_response(result_body, location) + + def _update(self, content_type, body, *args, **kwargs): + if content_type != 'application/json': + raise qvarn.NotJson(content_type) + + if 'type' not in body: + body['type'] = self._coll.get_type_name() + + if 'id' not in body: + body['id'] = kwargs['id'] + + validator = qvarn.Validator() + try: + validator.validate_resource_update(body, self._coll.get_type()) + except qvarn.ValidationError as e: + qvarn.log.log('error', msg_text=str(e), body=body) + return qvarn.bad_request_response(str(e)) + + obj_id = kwargs['id'] + # FIXME: the following test should be enabled once we + # no longer need test-api. + if False and body['id'] != obj_id: + raise qvarn.IdMismatch(body['id'], obj_id) + + try: + result_body = self._coll.put(body) + except qvarn.WrongRevision as e: + return qvarn.conflict_response(str(e)) + except qvarn.NoSuchResource as e: + # We intentionally say bad request, instead of not found. + # This is to be compatible with old Qvarn. This may get + # changed later. + return qvarn.bad_request_response(str(e)) + + self._notify(result_body['id'], result_body['revision'], 'updated') + return qvarn.ok_response(result_body) + + def _list(self, *args, **kwargs): + body = self._coll.list() + return qvarn.ok_response(body) + + def _get(self, *args, **kwargs): + try: + obj = self._coll.get(kwargs['id']) + except qvarn.NoSuchResource as e: + return qvarn.no_such_resource_response(str(e)) + return qvarn.ok_response(obj) + + def _search(self, *args, **kwargs): + path = kwargs['raw_uri_path'] + search_criteria = path.split('/search/', 1)[1] + try: + result = self._coll.search(search_criteria) + except qvarn.UnknownSearchField as e: + return qvarn.unknown_search_field_response(e) + except qvarn.NeedSortOperator: + return qvarn.need_sort_response() + except qvarn.SearchParserError as e: + return qvarn.search_parser_error_response(e) + body = { + 'resources': result, + } + return qvarn.ok_response(body) + + def _delete(self, *args, **kwargs): + obj_id = kwargs['id'] + self._coll.delete(obj_id) + self._notify(obj_id, None, 'deleted') + return qvarn.ok_response({}) diff --git a/qvarn/responses.py b/qvarn/responses.py new file mode 100644 index 0000000..eea78d7 --- /dev/null +++ b/qvarn/responses.py @@ -0,0 +1,97 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import apifw + + +def response(status, body, headers): + return apifw.Response( + { + 'status': status, + 'body': body, + 'headers': headers, + } + ) + + +def ok_response(body, headers=None): + if headers is None: + headers = {} + if 'Content-Type' not in headers: + headers.update({ + 'Content-Type': 'application/json', + }) + return response(apifw.HTTP_OK, body, headers) + + +def no_such_resource_response(msg): + return response(apifw.HTTP_NOT_FOUND, msg, {}) + + +def created_response(body, location): + headers = { + 'Content-Type': 'application/json', + 'Location': location, + } + return response(apifw.HTTP_CREATED, body, headers) + + +def bad_request_response(body): + headers = { + 'Content-Type': 'text/plain', + } + return response(apifw.HTTP_BAD_REQUEST, body, headers) + + +def need_sort_response(): + headers = { + 'Content-Type': 'application/json', + } + body = { + 'message': 'LIMIT and OFFSET can only be used with together SORT.', + 'error_code': 'LimitWithoutSortError', + } + return response(apifw.HTTP_BAD_REQUEST, body, headers) + + +def search_parser_error_response(e): + headers = { + 'Content-Type': 'application/json', + } + body = { + 'message': 'Could not parse search condition', + 'error_code': 'BadSearchCondition', + } + return response(apifw.HTTP_BAD_REQUEST, body, headers) + + +def unknown_search_field_response(e): + headers = { + 'Content-Type': 'application/json', + } + body = { + 'field': e.field, + 'message': 'Resource does not contain given field', + 'error_code': 'FieldNotInResource', + } + return response(apifw.HTTP_BAD_REQUEST, body, headers) + + +def conflict_response(body): + headers = { + 'Content-Type': 'text/plain', + } + return response(apifw.HTTP_CONFLICT, body, headers) diff --git a/qvarn/router.py b/qvarn/router.py new file mode 100644 index 0000000..9f171b0 --- /dev/null +++ b/qvarn/router.py @@ -0,0 +1,23 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +class Router: + + def __init__(self): + pass + + def get_routes(self): + raise NotImplementedError() diff --git a/qvarn/subresource_router.py b/qvarn/subresource_router.py new file mode 100644 index 0000000..343ee4a --- /dev/null +++ b/qvarn/subresource_router.py @@ -0,0 +1,82 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import qvarn + + +class SubresourceRouter(qvarn.Router): + + def __init__(self): + super().__init__() + self._parent_coll = None + self._subpath = None + + def set_subpath(self, subpath): + self._subpath = subpath + + def set_parent_collection(self, parent_coll): + self._parent_coll = parent_coll + + def get_routes(self): + rt = self._parent_coll.get_type() + path = '{}/<id>/{}'.format(rt.get_path(), self._subpath) + return [ + { + 'method': 'GET', + 'path': path, + 'callback': self._get_subresource, + }, + { + 'method': 'PUT', + 'path': path, + 'callback': self._put_subresource, + }, + ] + + def _get_subresource(self, *args, **kwargs): + obj_id = kwargs['id'] + try: + obj = self._parent_coll.get_subresource(obj_id, self._subpath) + except qvarn.NoSuchResource as e: + return qvarn.no_such_resource_response(str(e)) + return qvarn.ok_response(obj) + + def _put_subresource(self, content_type, body, *args, **kwargs): + if content_type != 'application/json': + raise qvarn.NotJson(content_type) + + obj_id = kwargs['id'] + if 'revision' not in body: + return qvarn.bad_request_response('must have revision') + revision = body.pop('revision') + + rt = self._parent_coll.get_type() + validator = qvarn.Validator() + try: + validator.validate_subresource(self._subpath, rt, body) + except qvarn.ValidationError as e: + qvarn.log.log('error', msg_text=str(e), body=body) + return qvarn.bad_request_response(str(e)) + + try: + result_body = self._parent_coll.put_subresource( + body, subpath=self._subpath, obj_id=obj_id, revision=revision) + except qvarn.WrongRevision as e: + return qvarn.conflict_response(str(e)) + except qvarn.NoSuchResource as e: + return qvarn.no_such_resource_response(str(e)) + + return qvarn.ok_response(result_body) diff --git a/qvarn/timestamp.py b/qvarn/timestamp.py new file mode 100644 index 0000000..355bee0 --- /dev/null +++ b/qvarn/timestamp.py @@ -0,0 +1,25 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import time + + +def get_current_timestamp(): + t = time.time() + tm = time.gmtime(t) + ss = t - int(t) + secs = '%f' % ss + return time.strftime('%Y-%m-%dT%H:%M:%S', tm) + secs[1:] diff --git a/qvarn/version_router.py b/qvarn/version_router.py new file mode 100644 index 0000000..b9f1056 --- /dev/null +++ b/qvarn/version_router.py @@ -0,0 +1,42 @@ +# Copyright (C) 2017 Lars Wirzenius +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +import qvarn + + +class VersionRouter(qvarn.Router): + + def get_routes(self): + return [ + { + 'method': 'GET', + 'path': '/version', + 'callback': self._version, + 'needs-authorization': False, + }, + ] + + def _version(self, *args, **kwargs): + version = { + 'api': { + 'version': qvarn.__version__, + }, + 'implementation': { + 'name': 'Qvarn', + 'version': qvarn.__version__, + }, + } + return qvarn.ok_response(version) diff --git a/resource_type/listeners.yaml b/resource_type/listeners.yaml new file mode 100644 index 0000000..07c2e53 --- /dev/null +++ b/resource_type/listeners.yaml @@ -0,0 +1,11 @@ +type: listener +path: /listeners +versions: + - version: v0 + prototype: + id: "" + type: "" + revision: "" + notify_of_new: false + listen_on_all: false + listen_on: [""] diff --git a/resource_type/notifications.yaml b/resource_type/notifications.yaml new file mode 100644 index 0000000..9660282 --- /dev/null +++ b/resource_type/notifications.yaml @@ -0,0 +1,13 @@ +type: notification +path: /notifications +versions: + - version: v0 + prototype: + id: "" + type: "" + revision: "" + listener_id: "" + resource_id: "" + resource_revision: "" + resource_change: "" + timestamp: "" diff --git a/without-tests b/without-tests index 1cdc82c..a752d9d 100644 --- a/without-tests +++ b/without-tests @@ -1,7 +1,16 @@ setup.py qvarn/__init__.py +qvarn/api_errors.py qvarn/backend.py +qvarn/file_router.py qvarn/logging.py +qvarn/notification_router.py +qvarn/resource_router.py +qvarn/responses.py +qvarn/router.py qvarn/sql.py +qvarn/subresource_router.py +qvarn/timestamp.py qvarn/version.py +qvarn/version_router.py yarns/lib.py |