summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2017-10-09 21:19:37 +0300
committerLars Wirzenius <liw@liw.fi>2017-10-09 21:19:37 +0300
commitefa6ec4dd68865264073f1fa8ecb8bb4548be180 (patch)
treed090a8c8ccc7c106809828ab6fc69c7a8b15ff69
parent52b398761f0550c5a637007ab2aa780e9e1c53fe (diff)
parentfbb368b5752005535aebbe073824970b07bc395c (diff)
downloadqvisqve-efa6ec4dd68865264073f1fa8ecb8bb4548be180.tar.gz
Merge branch 'liw/blobs' to add blob support
-rw-r--r--NEWS2
-rw-r--r--qvarn/__init__.py2
-rw-r--r--qvarn/api.py110
-rw-r--r--qvarn/backend.py9
-rw-r--r--qvarn/objstore.py126
-rw-r--r--qvarn/objstore_tests.py36
-rw-r--r--qvarn/resource_type.py4
-rw-r--r--qvarn/resource_type_tests.py25
-rw-r--r--qvarn/sql.py5
-rw-r--r--yarns/900-implements.yarn46
-rw-r--r--yarns/lib.py20
-rw-r--r--yarns/smoke.yarn101
12 files changed, 452 insertions, 34 deletions
diff --git a/NEWS b/NEWS
index 573a7bc..ea24d88 100644
--- a/NEWS
+++ b/NEWS
@@ -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