diff options
author | Lars Wirzenius <liw@liw.fi> | 2017-10-09 21:19:37 +0300 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2017-10-09 21:19:37 +0300 |
commit | efa6ec4dd68865264073f1fa8ecb8bb4548be180 (patch) | |
tree | d090a8c8ccc7c106809828ab6fc69c7a8b15ff69 | |
parent | 52b398761f0550c5a637007ab2aa780e9e1c53fe (diff) | |
parent | fbb368b5752005535aebbe073824970b07bc395c (diff) | |
download | qvisqve-efa6ec4dd68865264073f1fa8ecb8bb4548be180.tar.gz |
Merge branch 'liw/blobs' to add blob support
-rw-r--r-- | NEWS | 2 | ||||
-rw-r--r-- | qvarn/__init__.py | 2 | ||||
-rw-r--r-- | qvarn/api.py | 110 | ||||
-rw-r--r-- | qvarn/backend.py | 9 | ||||
-rw-r--r-- | qvarn/objstore.py | 126 | ||||
-rw-r--r-- | qvarn/objstore_tests.py | 36 | ||||
-rw-r--r-- | qvarn/resource_type.py | 4 | ||||
-rw-r--r-- | qvarn/resource_type_tests.py | 25 | ||||
-rw-r--r-- | qvarn/sql.py | 5 | ||||
-rw-r--r-- | yarns/900-implements.yarn | 46 | ||||
-rw-r--r-- | yarns/lib.py | 20 | ||||
-rw-r--r-- | yarns/smoke.yarn | 101 |
12 files changed, 452 insertions, 34 deletions
@@ -6,7 +6,7 @@ This file has release notes for Qvarn (the JSONB version). Version 0.85+git, not yet released ---------------------------------- -* Lars Wirzenius added support for notifications to the API. +* Lars Wirzenius added support for notifications and blobs to the API. Version 0.85, released 2017-09-27 ---------------------------------- diff --git a/qvarn/__init__.py b/qvarn/__init__.py index 21eac23..b2d6bb1 100644 --- a/qvarn/__init__.py +++ b/qvarn/__init__.py @@ -50,6 +50,8 @@ from .objstore import ( UnknownKey, WrongKeyType, KeyValueError, + NoSuchObject, + BlobKeyCollision, flatten_object, ) diff --git a/qvarn/api.py b/qvarn/api.py index 909e202..b7b8cc7 100644 --- a/qvarn/api.py +++ b/qvarn/api.py @@ -226,7 +226,7 @@ class QvarnAPI: } return ok_response(version) - def resource_routes(self, path, rt): + def resource_routes(self, path, rt): # pragma: no cover coll = qvarn.CollectionAPI() coll.set_object_store(self._store) coll.set_resource_type(rt) @@ -265,22 +265,45 @@ class QvarnAPI: }, ] + files = rt.get_files() for subpath in rt.get_subpaths(): - routes.extend([ - { - '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), - }, - ]) + if subpath not in files: + more = self.get_subresource_routes(id_path, coll, subpath) + else: + more = self.get_file_routes(id_path, coll, subpath) + 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() @@ -623,6 +646,40 @@ class QvarnAPI: 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: @@ -641,6 +698,22 @@ class QvarnAPI: 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() @@ -684,10 +757,13 @@ def response(status, body, headers): # pragma: no cover ) -def ok_response(body): # pragma: no cover - headers = { - 'Content-Type': 'application/json', - } +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) diff --git a/qvarn/backend.py b/qvarn/backend.py index bf5f1c2..4a3eab8 100644 --- a/qvarn/backend.py +++ b/qvarn/backend.py @@ -111,7 +111,16 @@ subject.from_spec({ 'subfield': '', }, }, + 'blob': { + 'prototype': { + 'body': 'blob', + 'content-type': '', + }, + }, }, + 'files': [ + 'blob', + ], }, ], }) diff --git a/qvarn/objstore.py b/qvarn/objstore.py index 9449a74..90d4e84 100644 --- a/qvarn/objstore.py +++ b/qvarn/objstore.py @@ -68,6 +68,12 @@ class ObjectStoreInterface: # pragma: no cover if key not in known_keys: raise UnknownKey(key) + def check_value_types(self, **keys): + known_keys = self.get_known_keys() + for key in keys: + if type(keys[key]) is not known_keys[key]: + raise KeyValueError(key, keys[key]) + def create_object(self, obj, auxtable=True, **keys): raise NotImplementedError() @@ -80,11 +86,21 @@ class ObjectStoreInterface: # pragma: no cover def find_objects(self, cond): raise NotImplementedError() + def create_blob(self, blob, subpath=None, **keys): + raise NotImplementedError() + + def get_blob(self, subpath=None, **keys): + raise NotImplementedError() + + def remove_blob(self, blob, subpath=None, **keys): + raise NotImplementedError() + class MemoryObjectStore(ObjectStoreInterface): def __init__(self): self._objs = [] + self._blobs = [] self._known_keys = {} def get_known_keys(self): @@ -97,17 +113,56 @@ class MemoryObjectStore(ObjectStoreInterface): self._known_keys = keys def create_object(self, obj, auxtable=True, **keys): - self.check_all_keys_are_allowed(**keys) qvarn.log.log( 'trace', msg_text='Creating object', object=repr(obj), keys=keys) - for key in keys: - if type(keys[key]) is not self._known_keys[key]: - raise KeyValueError(key, keys[key]) + self.check_all_keys_are_allowed(**keys) + self.check_value_types(**keys) + self._check_unique_object(**keys) + self._objs.append((obj, keys)) + def _check_unique_object(self, **keys): for _, k in self._objs: if self._keys_match(k, keys): raise KeyCollision(k) - self._objs.append((obj, keys)) + + def create_blob(self, blob, **keys): + qvarn.log.log('trace', msg_text='Creating blob', keys=keys) + subpath = keys.pop('subpath') + self.check_all_keys_are_allowed(**keys) + self.check_value_types(**keys) + self._check_unique_blob(subpath, **keys) + if not self.get_objects(**keys): + raise NoSuchObject(keys) + self._blobs.append((blob, subpath, keys)) + + def _check_unique_blob(self, subpath, **keys): + for _, s, k in self._blobs: + if self._keys_match(k, keys) and s == subpath: + raise BlobKeyCollision(subpath, k) + + def get_blob(self, **keys): + subpath = keys.pop('subpath') + self.check_all_keys_are_allowed(**keys) + self.check_value_types(**keys) + blobs = [ + b + for b, s, k in self._blobs + if self._keys_match(k, keys) and s == subpath + ] + assert len(blobs) <= 1 + if not blobs: + raise NoSuchObject(keys) + return blobs[0] + + def remove_blob(self, **keys): + subpath = keys.pop('subpath') + self.check_all_keys_are_allowed(**keys) + self.check_value_types(**keys) + self._blobs = [ + b + for b, s, k in self._blobs + if not self._keys_match(k, keys) or s != subpath + ] def remove_objects(self, **keys): self.check_all_keys_are_allowed(**keys) @@ -132,9 +187,7 @@ class PostgresObjectStore(ObjectStoreInterface): # pragma: no cover _table = '_objects' _auxtable = '_aux' - _strtable = '_strings' - _inttable = '_ints' - _booltable = '_bools' + _blobtable = '_blobs' def __init__(self, sql): self._sql = sql @@ -153,9 +206,12 @@ class PostgresObjectStore(ObjectStoreInterface): # pragma: no cover # Create main table for objects. self._create_table(self._table, self._keys, '_obj', dict) - # Create helper tables for fields at all depths. + # Create helper table for fields at all depths. Needed by searches. self._create_table(self._auxtable, self._keys, '_field', dict) + # Create helper table for blobs. + self._create_table(self._blobtable, self._keys, '_blob', bytes) + def _create_table(self, name, col_dict, col_name, col_type): columns = dict(col_dict) columns[col_name] = col_type @@ -250,6 +306,45 @@ class PostgresObjectStore(ObjectStoreInterface): # pragma: no cover obj = row.pop('_obj') return keys, obj + def create_blob(self, blob, **keys): + qvarn.log.log('trace', msg_text='Creating blob', keys=keys) + + self.check_all_keys_are_allowed(**keys) + self.check_value_types(**keys) + if not self.get_objects(**keys): + raise NoSuchObject(keys) + + with self._sql.transaction() as t: + column_names = list(keys.keys()) + ['_blob'] + query = t.insert_object(self._blobtable, *column_names) + + values = dict(keys) + values['_blob'] = blob + + t.execute(query, values) + + def get_blob(self, **keys): + self.check_all_keys_are_allowed(**keys) + self.check_value_types(**keys) + + column_names = list(keys.keys()) + + with self._sql.transaction() as t: + query = t.select_objects(self._blobtable, '_blob', *column_names) + blobs = [bytes(row['_blob']) for row in t.execute(query, keys)] + if len(blobs) == 0: + raise NoSuchObject(keys) + return blobs + + def remove_blob(self, **keys): + self.check_all_keys_are_allowed(**keys) + self.check_value_types(**keys) + + column_names = list(keys.keys()) + with self._sql.transaction() as t: + query = t.remove_objects(self._blobtable, *column_names) + t.execute(query, keys) + class KeyCollision(Exception): @@ -257,6 +352,13 @@ class KeyCollision(Exception): super().__init__('Cannot add object with same keys: %r' % keys) +class BlobKeyCollision(Exception): + + def __init__(self, subpath, keys): + super().__init__( + 'Cannot add blob with same keys: subpath=%s %r' % (subpath, keys)) + + class UnknownKey(Exception): def __init__(self, key): @@ -277,6 +379,12 @@ class KeyValueError(Exception): super().__init__('Key %r value %r has the wrong type' % (key, value)) +class NoSuchObject(Exception): + + def __init__(self, keys): + super().__init__('No object/blob with keys {}'.format(keys)) + + def flatten_object(obj): return list(sorted(set(_flatten(obj)))) diff --git a/qvarn/objstore_tests.py b/qvarn/objstore_tests.py index 5103d47..f8d631a 100644 --- a/qvarn/objstore_tests.py +++ b/qvarn/objstore_tests.py @@ -28,6 +28,8 @@ class ObjectStoreTests(unittest.TestCase): self.obj2 = { 'name': 'this is my other object', } + self.blob1 = 'my first blob' + self.blob2 = 'my other blob' def create_store(self, **keys): store = qvarn.MemoryObjectStore() @@ -123,6 +125,40 @@ class ObjectStoreTests(unittest.TestCase): [({'key': '1st'}, self.obj1)] ) + def test_has_no_blob_initially(self): + store = self.create_store(key=str) + store.create_object(self.obj1, key='1st') + with self.assertRaises(qvarn.NoSuchObject): + store.get_blob(key='1st', subpath='blob') + + def test_add_blob_to_nonexistent_parent_fails(self): + store = self.create_store(key=str) + store.create_object(self.obj1, key='1st') + with self.assertRaises(qvarn.NoSuchObject): + store.create_blob(self.blob1, key='2nd', subpath='blob') + + def test_adds_blob(self): + store = self.create_store(key=str) + store.create_object(self.obj1, key='1st') + store.create_blob(self.blob1, key='1st', subpath='blob') + blob = store.get_blob(key='1st', subpath='blob') + self.assertEqual(blob, self.blob1) + + def test_add_blob_twice_fails(self): + store = self.create_store(key=str) + store.create_object(self.obj1, key='1st') + store.create_blob(self.blob1, key='1st', subpath='blob') + with self.assertRaises(qvarn.BlobKeyCollision): + store.create_blob(self.blob1, key='1st', subpath='blob') + + def test_removes_blob(self): + store = self.create_store(key=str) + store.create_object(self.obj1, key='1st') + store.create_blob(self.blob1, key='1st', subpath='blob') + store.remove_blob(key='1st', subpath='blob') + with self.assertRaises(qvarn.NoSuchObject): + store.get_blob(key='1st', subpath='blob') + class FlattenObjectsTests(unittest.TestCase): diff --git a/qvarn/resource_type.py b/qvarn/resource_type.py index 3740b84..40f8930 100644 --- a/qvarn/resource_type.py +++ b/qvarn/resource_type.py @@ -89,6 +89,10 @@ class ResourceType: subproto = subpaths.get(subpath, {}) return subproto.get('prototype') + def get_files(self): + v = self._versions[-1] + return v.get('files', []) + def load_resource_types(dirname): # pragma: no cover assert dirname is not None diff --git a/qvarn/resource_type_tests.py b/qvarn/resource_type_tests.py index 06c502c..a1fd09a 100644 --- a/qvarn/resource_type_tests.py +++ b/qvarn/resource_type_tests.py @@ -66,7 +66,16 @@ class ResourceTypeTests(unittest.TestCase): 'subbar': '', }, }, + 'blob': { + 'prototype': { + 'blob': 'blob', + 'content-type': '', + }, + }, }, + 'files': [ + 'blob', + ], }, ], } @@ -84,16 +93,26 @@ class ResourceTypeTests(unittest.TestCase): self.assertEqual( rt.get_latest_prototype(), spec['versions'][-1]['prototype']) self.assertEqual(rt.as_dict(), spec) + subpaths = spec['versions'][-1]['subpaths'] self.assertEqual( rt.get_subpaths(), { - 'subfoo': - spec['versions'][-1]['subpaths']['subfoo']['prototype'], - }) + 'subfoo': subpaths['subfoo']['prototype'], + 'blob': subpaths['blob']['prototype'], + }, + ) self.assertEqual( rt.get_subprototype('subfoo'), spec['versions'][-1]['subpaths']['subfoo']['prototype'] ) + self.assertEqual(rt.get_files(), ['blob']) + self.assertEqual( + rt.get_subprototype('blob'), + { + 'blob': 'blob', + 'content-type': '', + } + ) class AddMissingFieldsTests(unittest.TestCase): diff --git a/qvarn/sql.py b/qvarn/sql.py index 5f14876..604272b 100644 --- a/qvarn/sql.py +++ b/qvarn/sql.py @@ -66,9 +66,7 @@ class Transaction: self._conn = None def execute(self, query, values): - qvarn.log.log( - 'trace', msg_text='executing SQL query', query=query, - values=values) + qvarn.log.log('trace', msg_text='executing SQL query', query=query) c = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) c.execute(query, values) return c @@ -92,6 +90,7 @@ class Transaction: (int, 'BIGINT'), (bool, 'BOOL'), (dict, 'JSONB'), + (bytes, 'BYTEA'), ] for t, n in types: if col_type == t: diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn index a5c267d..2f7ee0f 100644 --- a/yarns/900-implements.yarn +++ b/yarns/900-implements.yarn @@ -98,6 +98,32 @@ Start a Qvarn running in the background. vars['status_code'], vars['headers'], vars['body'] = put( vars['url'] + path, headers=headers, body=body) + IMPLEMENTS WHEN client requests PUT (/[a-z0-9/${}]+) with token, revision (\S+), content-type (\S+), and empty body + path = expand_vars(get_next_match(), vars) + revision = expand_vars(get_next_match(), vars) + ctype = expand_vars(get_next_match(), vars) + body = '' + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + 'Revision': revision, + 'Content-Type': ctype, + } + vars['status_code'], vars['headers'], vars['body'] = put( + vars['url'] + path, headers=headers, body=body) + + IMPLEMENTS WHEN client requests PUT (/[a-z0-9/${}]+) with token, revision (\S+), content-type (\S+), and body "(.+)" + path = expand_vars(get_next_match(), vars) + revision = expand_vars(get_next_match(), vars) + ctype = expand_vars(get_next_match(), vars) + body = unescape(expand_vars(get_next_match(), vars)) + headers = { + 'Authorization': 'Bearer {}'.format(vars['token']), + 'Revision': revision, + 'Content-Type': ctype, + } + vars['status_code'], vars['headers'], vars['body'] = put( + vars['url'] + path, headers=headers, body=body) + IMPLEMENTS WHEN client requests DELETE (/.+) with token path = get_next_match() path = expand_vars(path, vars) @@ -136,6 +162,11 @@ Start a Qvarn running in the background. value = expand_vars(get_next_match(), vars) assertEqual(vars['headers'].get(header), value) + IMPLEMENTS THEN remember HTTP (\S+) header as (.+) + header = get_next_match() + name = get_next_match() + vars[name] = vars['headers'].get(header) + IMPLEMENTS THEN resource id is (\S+) import json name = get_next_match() @@ -149,6 +180,16 @@ Start a Qvarn running in the background. body = json.loads(vars['body']) vars[name] = body['revision'] + IMPLEMENTS THEN revisions (\S+) and (\S+) are different + rev1 = get_next_match() + rev2 = get_next_match() + assertNotEqual(vars[rev1], vars[rev2]) + + IMPLEMENTS THEN revisions (\S+) and (\S+) match + rev1 = get_next_match() + rev2 = get_next_match() + assertEqual(vars[rev1], vars[rev2]) + IMPLEMENTS THEN JSON body matches (.+) import json wanted = get_next_match() @@ -161,6 +202,11 @@ Start a Qvarn running in the background. print 'wanted3', repr(wanted) assertTrue(values_match(wanted, actual)) + IMPLEMENTS THEN body is "(.+)" + wanted = unescape(expand_vars(get_next_match(), vars)) + body = vars['body'] + assertTrue(values_match(wanted, body)) + IMPLEMENTS THEN search result contains (.+) import json wanted1 = get_next_match() diff --git a/yarns/lib.py b/yarns/lib.py index 82e9133..459a821 100644 --- a/yarns/lib.py +++ b/yarns/lib.py @@ -36,6 +36,24 @@ datadir = os.environ['DATADIR'] vars = Variables(datadir) +def hexdigit(c): + return ord(c) - ord('0') + + +def unescape(s): + t = '' + while s: + if s.startswith('\\x') and len(s) >= 4: + a = hexdigit(s[2]) + b = hexdigit(s[3]) + t += chr(a * 16 + b) + s = s[4:] + else: + t += s[0] + s = s[1:] + return t + + def add_postgres_config(config): pg = os.environ.get('QVARN_POSTGRES') if pg: @@ -48,7 +66,7 @@ def add_postgres_config(config): def get(url, headers=None): print('get: url={} headers={}'.format(url, headers)) r = requests.get(url, headers=headers) - return r.status_code, dict(r.headers), r.text + return r.status_code, dict(r.headers), r.content def post(url, headers=None, body=None): diff --git a/yarns/smoke.yarn b/yarns/smoke.yarn index e56406f..6c960b6 100644 --- a/yarns/smoke.yarn +++ b/yarns/smoke.yarn @@ -485,6 +485,107 @@ don't need to be created specially. FINALLY qvarn is stopped +# Manage blobs + +Blobs are like sub-resources, but they're arbitrary binary data, not +JSON. + + SCENARIO manage blobs + + GIVEN a running qvarn instance + + WHEN client gets an authorization token with scope + ... "uapi_subjects_post uapi_subjects_blob_put uapi_subjects_blog_get" + +Create a subject. + + WHEN client requests POST /subjects with token and body + ... { "type": "subject", "names": [ { "full_name": "Alfred" } ] } + THEN resource id is ID + AND revision is REV1 + +Newly created subject does not have a blob. + + WHEN client requests GET /subjects/${ID}/blob using token + THEN HTTP status code is 404 Not found + +Uploading an empty blob doesn't work. + + WHEN client requests PUT /subjects/${ID}/blob with token, + ... revision REV1, + ... content-type image/jpeg, + ... and empty body + THEN HTTP status code is 411 Length required + +Uploading a COMPLETELY VALID JPEG as a blob fails, if subject resource +revision is wrong. + + WHEN client requests PUT /subjects/${ID}/blob with token, + ... revision BADREV, + ... content-type image/jpeg, + ... and body "FAKE JPEG" + THEN HTTP status code is 409 Conflict + +Uploading with valid revision works. + + WHEN client requests PUT /subjects/${ID}/blob with token, + ... revision ${REV1}, + ... content-type image/jpeg, + ... and body "FAKE JPEG" + THEN HTTP status code is 200 OK + +Do we get the right blob back? Also, note that subject revision +should've changed. + + WHEN client requests GET /subjects/${ID}/blob using token + THEN HTTP status code is 200 OK + AND HTTP Content-Type header is image/jpeg + AND body is "FAKE JPEG" + AND remember HTTP Revision header as REV2 + AND revisions REV1 and REV2 are different + +Uploading with old revision fails. + + WHEN client requests PUT /subjects/${ID}/blob with token, + ... revision ${REV1}, + ... content-type image/jpeg, + ... and body "FAKE JPEG" + THEN HTTP status code is 409 Conflict + +Uploading a new blob with the current revision works. + + WHEN client requests PUT /subjects/${ID}/blob with token, + ... revision ${REV2}, + ... content-type image/jpeg, + ... and body "\x89" + THEN HTTP status code is 200 OK + AND remember HTTP Revision header as REV3 + +And it did get updated. + + WHEN client requests GET /subjects/${ID}/blob using token + THEN HTTP status code is 200 OK + AND HTTP Content-Type header is image/jpeg + AND body is "\x89" + +Updating parent doesn't affect the blob. + + WHEN client requests PUT /subjects/${ID} with token and body + ... { + ... "type": "subject", + ... "revision": "${REV3}", + ... "names": [ { "full_name": "Melissa" } ] + ... } + THEN revision is REV4 + + WHEN client requests GET /subjects/${ID}/blob using token + THEN HTTP status code is 200 OK + AND HTTP Content-Type header is image/jpeg + AND body is "\x89" + AND remember HTTP Revision header as REV5 + AND revisions REV4 and REV5 match + + FINALLY qvarn is stopped # Search subjects |