summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2018-10-27 12:58:00 +0300
committerLars Wirzenius <liw@liw.fi>2018-10-28 10:10:47 +0200
commit8f5c7b0fc2b273e55b2116ddece21ac2c19fa863 (patch)
tree7f12c091b7ec63b2953695cee3f9be3685d4373d
parentcc2d1b21e67643e237d968793d31b7b9437a1640 (diff)
downloadmuck-poc-8f5c7b0fc2b273e55b2116ddece21ac2c19fa863.tar.gz
Add: HTTP API, with scenario tests
-rwxr-xr-xcheck4
-rw-r--r--muck/__init__.py1
-rw-r--r--muck/idgen.py22
-rw-r--r--muck/idgen_tests.py27
-rw-r--r--muck/mem.py57
-rw-r--r--muck/mem_tests.py226
-rwxr-xr-xmuck_poc186
-rw-r--r--test-key51
-rw-r--r--test-key.pub1
-rw-r--r--yarns/100-happy.yarn97
-rw-r--r--yarns/900-implements.yarn69
-rw-r--r--yarns/lib.py168
12 files changed, 824 insertions, 85 deletions
diff --git a/check b/check
index 6f31bc0..18bae3e 100755
--- a/check
+++ b/check
@@ -45,5 +45,9 @@ then
pylint3 --rcfile pylint.conf $modules
fi
+title Yarn scenario tests
+yarn --shell python2 --shell-arg '' --shell-library=yarns/lib.py --cd-datadir \
+ yarns/*.yarn "$@"
+
title OK
echo "All tests pass"
diff --git a/muck/__init__.py b/muck/__init__.py
index b286cf0..d78e998 100644
--- a/muck/__init__.py
+++ b/muck/__init__.py
@@ -14,6 +14,7 @@
from .exc import Error
+from .idgen import IdGenerator
from .change import (
Change,
CreateChange,
diff --git a/muck/idgen.py b/muck/idgen.py
new file mode 100644
index 0000000..a884b8d
--- /dev/null
+++ b/muck/idgen.py
@@ -0,0 +1,22 @@
+# Copyright (C) 2018 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 uuid
+
+
+class IdGenerator:
+
+ def new_id(self):
+ return str(uuid.uuid4())
diff --git a/muck/idgen_tests.py b/muck/idgen_tests.py
new file mode 100644
index 0000000..ecab4dd
--- /dev/null
+++ b/muck/idgen_tests.py
@@ -0,0 +1,27 @@
+# Copyright (C) 2018 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 unittest
+
+import muck
+
+
+class IdGeneratorTests(unittest.TestCase):
+
+ def test_returns_different_value_each_time(self):
+ gen = muck.IdGenerator()
+ id1 = gen.new_id()
+ id2 = gen.new_id()
+ self.assertNotEqual(id1, id2)
diff --git a/muck/mem.py b/muck/mem.py
index 437085d..3100a62 100644
--- a/muck/mem.py
+++ b/muck/mem.py
@@ -13,6 +13,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
+import copy
+
import muck
@@ -27,6 +29,61 @@ class MemoryStore:
def as_dict(self):
return self._dict
+ def __contains__(self, key):
+ return key in self._dict
+
+ def __getitem__(self, key):
+ meta, res = self._dict[key]
+ meta = copy.deepcopy(meta)
+ res = copy.deepcopy(res)
+ return meta, res
+
+ def search(self, conds):
+ hits = []
+ for rid in self._dict:
+ meta, res = self._dict[rid]
+ if any(self._matches(meta, res, cond) for cond in conds):
+ hits.append(rid)
+ return hits
+
+ def _matches(self, meta, res, cond):
+ if cond['where'] not in ('meta', 'data'): # pragma: no cover
+ return False
+
+ if cond['where'] == 'meta':
+ thing = meta
+ else:
+ thing = res
+
+ field = cond['field']
+ pattern = cond['pattern']
+
+ if field not in thing: # pragma: no cover
+ return False
+ value = thing[field]
+
+ funcs = {
+ '==': self._equal,
+ '>=': self._ge,
+ '<=': self._le,
+ }
+ if cond['op'] not in funcs: # pragma: no cover
+ return False
+ is_match = funcs[cond['op']]
+
+ if isinstance(value, list):
+ return any(is_match(pattern, item) for item in value)
+ return is_match(pattern, value)
+
+ def _equal(self, expected, actual):
+ return expected.lower() == actual.lower()
+
+ def _ge(self, expected, actual):
+ return expected.lower() == actual.lower()
+
+ def _le(self, expected, actual):
+ return expected.lower() == actual.lower()
+
def change(self, chg):
funcs = {
'create': self._create,
diff --git a/muck/mem_tests.py b/muck/mem_tests.py
index e858a33..6b7a069 100644
--- a/muck/mem_tests.py
+++ b/muck/mem_tests.py
@@ -20,119 +20,175 @@ import muck
class MemoryStoreTests(unittest.TestCase):
- def test_is_initially_empty(self):
- ms = muck.MemoryStore()
- self.assertEqual(len(ms), 0)
- self.assertEqual(ms.as_dict(), {})
-
- def test_creates_resource(self):
- meta = {
+ def setUp(self):
+ self.ms = muck.MemoryStore()
+ self.meta1 = {
'id': 'id-1',
'rev': 'rev-1',
}
-
- res = {
+ self.res1 = {
'foo': 'bar',
}
- chg = muck.CreateChange(meta=meta, res=res)
- ms = muck.MemoryStore()
- ms.change(chg)
- self.assertEqual(len(ms), 1)
+ def test_is_initially_empty(self):
+ self.assertEqual(len(self.ms), 0)
+ self.assertEqual(self.ms.as_dict(), {})
+ self.assertFalse('foo' in self.ms)
+
+ def test_creates_resource(self):
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
+
+ rid = self.meta1['id']
+ self.assertEqual(len(self.ms), 1)
+ self.assertTrue(rid in self.ms)
+ self.assertEqual(self.ms[rid], (self.meta1, self.res1))
self.assertEqual(
- ms.as_dict(),
+ self.ms.as_dict(),
{
- 'id-1': (meta, res),
+ rid: (self.meta1, self.res1),
})
def test_wont_create_resource_with_conflicting_id(self):
- meta = {
- 'id': 'id-1',
- 'rev': 'rev-1',
- }
-
- res = {
- 'foo': 'bar',
- }
-
- chg = muck.CreateChange(meta=meta, res=res)
- ms = muck.MemoryStore()
- ms.change(chg)
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
with self.assertRaises(muck.Error):
- ms.change(chg)
+ self.ms.change(chg)
def test_updates_resource(self):
- meta_1 = {
- 'id': 'id-1',
- 'rev': 'rev-1',
- }
+ meta2 = dict(self.meta1)
+ meta2['rev'] = 'rev-2'
- res_v1 = {
- 'foo': 'bar',
- }
-
- meta_2 = dict(meta_1)
- meta_2['rev'] = 'rev-2'
+ res2 = dict(self.res1)
+ res2['foo'] = 'yo'
- res_v2 = dict(res_v1)
- res_v2['foo'] = 'yo'
+ create = muck.CreateChange(meta=self.meta1, res=self.res1)
+ update = muck.UpdateChange(meta=meta2, res=res2)
- create = muck.CreateChange(meta=meta_1, res=res_v1)
- update = muck.UpdateChange(meta=meta_2, res=res_v2)
+ rid = self.meta1['id']
- ms = muck.MemoryStore()
- ms.change(create)
- ms.change(update)
- self.assertEqual(len(ms), 1)
+ self.ms.change(create)
+ self.ms.change(update)
+ self.assertEqual(len(self.ms), 1)
self.assertEqual(
- ms.as_dict(),
+ self.ms.as_dict(),
{
- 'id-1': (meta_2, res_v2),
+ rid: (meta2, res2),
})
def test_refuses_to_update_resource_that_didnt_exist(self):
- meta = {
- 'id': 'id-1',
- 'rev': 'rev-1',
- }
-
- res = {
- 'foo': 'bar',
- }
-
- update = muck.UpdateChange(meta=meta, res=res)
-
- ms = muck.MemoryStore()
+ update = muck.UpdateChange(meta=self.meta1, res=self.res1)
with self.assertRaises(muck.Error):
- ms.change(update)
+ self.ms.change(update)
def test_deletes_resource(self):
- meta = {
- 'id': 'id-1',
- 'rev': 'rev-1',
- }
-
- res = {
- 'foo': 'bar',
- }
-
- create = muck.CreateChange(meta=meta, res=res)
- delete = muck.DeleteChange(meta=meta)
+ create = muck.CreateChange(meta=self.meta1, res=self.res1)
+ delete = muck.DeleteChange(meta=self.meta1)
- ms = muck.MemoryStore()
- ms.change(create)
- ms.change(delete)
- self.assertEqual(len(ms), 0)
- self.assertEqual(ms.as_dict(), {})
+ self.ms.change(create)
+ self.ms.change(delete)
+ self.assertEqual(len(self.ms), 0)
+ self.assertEqual(self.ms.as_dict(), {})
def test_refuses_to_delete_resource_that_doesnt_exist(self):
- meta = {
- 'id': 'id-1',
- 'rev': 'rev-1',
- }
+ delete = muck.DeleteChange(meta=self.meta1)
+ with self.assertRaises(muck.Error):
+ self.ms.change(delete)
- delete = muck.DeleteChange(meta=meta)
+ def test_finds_nothing_for_impossible_condition(self):
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
- ms = muck.MemoryStore()
- with self.assertRaises(muck.Error):
- ms.change(delete)
+ cond = [
+ {
+ 'where': 'meta',
+ 'field': 'id',
+ 'pattern': 'does-not-exist',
+ 'op': '==',
+ },
+ ]
+ hits = self.ms.search(cond)
+ self.assertEqual(hits, [])
+
+ def test_finds_matching_resources_in_meta(self):
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
+
+ rid = self.meta1['id']
+ cond = [
+ {
+ 'where': 'meta',
+ 'field': 'id',
+ 'pattern': rid,
+ 'op': '==',
+ },
+ ]
+ hits = self.ms.search(cond)
+ self.assertEqual(hits, [rid])
+
+ def test_finds_matching_resources_in_data(self):
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
+
+ rid = self.meta1['id']
+ cond = [
+ {
+ 'where': 'data',
+ 'field': 'foo',
+ 'pattern': self.res1['foo'],
+ 'op': '==',
+ },
+ ]
+ hits = self.ms.search(cond)
+ self.assertEqual(hits, [rid])
+
+ def test_finds_matches_in_list(self):
+ self.res1['foo'] = ['bar', 'yo']
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
+
+ rid = self.meta1['id']
+ cond = [
+ {
+ 'where': 'data',
+ 'field': 'foo',
+ 'pattern': 'yo',
+ 'op': '==',
+ },
+ ]
+ hits = self.ms.search(cond)
+ self.assertEqual(hits, [rid])
+
+ def test_finds_ge(self):
+ self.res1['foo'] = ['bar', 'yo']
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
+
+ rid = self.meta1['id']
+ cond = [
+ {
+ 'where': 'data',
+ 'field': 'foo',
+ 'pattern': 'yo',
+ 'op': '>=',
+ },
+ ]
+ hits = self.ms.search(cond)
+ self.assertEqual(hits, [rid])
+
+ def test_finds_le(self):
+ self.res1['foo'] = ['bar', 'yo']
+ chg = muck.CreateChange(meta=self.meta1, res=self.res1)
+ self.ms.change(chg)
+
+ rid = self.meta1['id']
+ cond = [
+ {
+ 'where': 'data',
+ 'field': 'foo',
+ 'pattern': 'bar',
+ 'op': '<=',
+ },
+ ]
+ hits = self.ms.search(cond)
+ self.assertEqual(hits, [rid])
diff --git a/muck_poc b/muck_poc
new file mode 100755
index 0000000..40f59ba
--- /dev/null
+++ b/muck_poc
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+# Copyright (C) 2018 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 json
+import logging
+import os
+import sys
+
+import bottle
+
+import muck
+
+
+class MuckAPI:
+
+ def __init__(self, bottleapp, store, signing_key_text):
+ self._add_routes(bottleapp)
+ self._store = store
+ self._gen = muck.IdGenerator()
+ self._ac = muck.AuthorizationChecker(signing_key_text)
+
+ def _add_routes(self, bottleapp):
+ routes = [
+ {
+ 'method': 'POST',
+ 'path': '/res',
+ 'callback': self._check_authz(
+ 'POST', 'create', self._create_res),
+ },
+ {
+ 'method': 'PUT',
+ 'path': '/res',
+ 'callback': self._check_authz(
+ 'PUT', 'update', self._update_res),
+ },
+ {
+ 'method': 'GET',
+ 'path': '/res',
+ 'callback': self._check_authz(
+ 'GET', 'show', self._show_res),
+ },
+ {
+ 'method': 'DELETE',
+ 'path': '/res',
+ 'callback': self._check_authz(
+ 'DELETE', 'delete', self._delete_res),
+ },
+ {
+ 'method': 'GET',
+ 'path': '/search',
+ 'callback': self._check_authz(
+ 'GET', 'show', self._search_res),
+ },
+ ]
+
+ for route in routes:
+ bottleapp.route(**route)
+
+ def _check_authz(self, req_method, req_scope, callback):
+ def check_authz():
+ r = muck.Request(method=bottle.request.method)
+ r.add_headers(bottle.request.headers)
+ if self._ac.request_is_allowed(r, req_method, [req_scope]):
+ return callback()
+ logging.error('Access denied')
+ return bottle.HTTPError(401)
+
+ return check_authz
+
+ def _create_res(self):
+ res = self._get_json_body()
+ meta = {
+ 'id': self._gen.new_id(),
+ 'rev': self._gen.new_id(),
+ }
+ create = muck.CreateChange(meta, res)
+ self._store.change(create)
+ return self._create_response(201, 'create', meta, res)
+
+ def _update_res(self):
+ rid = self._get_resource_id()
+ try:
+ meta, _ = self._get_existing(rid)
+ except bottle.HTTPError as e:
+ return e
+
+ rev = self._get_resource_revision()
+ if meta['rev'] != rev:
+ return bottle.HTTPError(status=400, body='Wrong revision')
+
+ res = self._get_json_body()
+ meta['rev'] = self._gen.new_id()
+ update = muck.UpdateChange(meta, res)
+ self._store.change(update)
+ return self._create_response(200, 'change', meta, res)
+
+ def _show_res(self):
+ rid = self._get_resource_id()
+ try:
+ meta, res = self._get_existing(rid)
+ except bottle.HTTPError as e:
+ return e
+ return self._create_response(200, 'show', meta, res)
+
+ def _delete_res(self):
+ rid = self._get_resource_id()
+ try:
+ meta, res = self._get_existing(rid)
+ except bottle.HTTPError as e:
+ return e
+ delete = muck.DeleteChange(meta, res)
+ self._store.change(delete)
+ return self._create_response(200, 'delete', meta, res)
+
+ def _search_res(self):
+ body = self._get_json_body()
+ cond = body.get('cond')
+ ms = self._store.get_memory_store()
+ result = {
+ 'resources': ms.search(cond),
+ }
+ return bottle.HTTPResponse(status=200, body=json.dumps(result))
+
+ def _get_json_body(self):
+ f = bottle.request.body
+ body = f.read()
+ return json.loads(body, encoding='UTF-8')
+
+ def _get_resource_id(self):
+ return bottle.request.headers.get('Muck-Id')
+
+ def _get_resource_revision(self):
+ return bottle.request.headers.get('Muck-Revision')
+
+ def _get_existing(self, rid):
+ ms = self._store.get_memory_store()
+ if rid not in ms:
+ logging.warning('ERROR: {}: not found'.format(rid))
+ raise bottle.HTTPResponse(status=404)
+
+ return ms[rid]
+
+ def _create_response(self, status, operation, meta, res):
+ headers = self._meta_headers(meta)
+ return bottle.HTTPResponse(
+ status=status, headers=headers, body=json.dumps(res))
+
+ def _meta_headers(self, meta):
+ return {
+ 'Muck-ID': meta['id'],
+ 'Muck-Revision': meta['rev'],
+ }
+
+
+with open(sys.argv[1]) as f:
+ config = json.load(f)
+
+logging.basicConfig(
+ filename=config['log'], level=logging.DEBUG,
+ format='%(levelname)s %(message)s')
+
+logging.info('Muck starts')
+
+signing_key_text = open(config['signing-key-filename']).read()
+store = muck.Store(config['store'])
+
+pid = os.getpid()
+with open(config['pid'], 'w') as f:
+ f.write(str(pid))
+
+app = bottle.default_app()
+api = MuckAPI(app, store, signing_key_text)
+bottle.run(host='127.0.0.1', port=12765)
diff --git a/test-key b/test-key
new file mode 100644
index 0000000..beeac5d
--- /dev/null
+++ b/test-key
@@ -0,0 +1,51 @@
+-----BEGIN RSA PRIVATE KEY-----
+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
+-----END RSA PRIVATE KEY----- \ No newline at end of file
diff --git a/test-key.pub b/test-key.pub
new file mode 100644
index 0000000..d33c302
--- /dev/null
+++ b/test-key.pub
@@ -0,0 +1 @@
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDdr6DQ6injbCNNhbmmjQawqWtnhxRlEtIp+S2wPaucfrBb898C5+atfYd1cI4mVq4ByIhKGYoVlMsAZyUhayMB/vRakmm/RgZwE9dfT/hFAs+ak3Dnlip8i/DYxm4qpO+ZRUj94taApnOaYStRFC4GufvPNrjcGCqxbXf5dyjqscpnhJ9UmMBoANiT5OQEvjHKW8CfxLrQ5Io9bsajCOg9EVmADWjPCWIVDnTt1xDazq4qLF2mfta0H+TjbHLJeJnTZnthMZqd4tA0yAFm1D9hRHvxkHQpRieOz11LF5AX+nXXfvchQeHnZumFFLXnfqrv4gToU+ruFpwK5J3Mn3GGIidhUxOZ6oCs/SQvd4c823Te04bIE7DjxBO03ae255xhIheDsPhZrRoobAMo+P13lkheecAfPKZLgP4r4svLDtZeWX68BYUH4ubYTRaFe+AyniVdDDBU+tuNqzpTsI0YVy23hnd51Rf4CjH8el6ZM4kAIrLdtwbroQUUgjXMbKerIlabVjfaNrDe9WBSZiGw1SF0o71GfHGbUQek1NhsI4ukeKkPx9dA4hCgoxqALkYaoszceZpVQtHtwmEY5iI1y/trOkTALAixKB2x2QDLmFReGFJMnWlWrzGopymLSem3uC4zDFrTM1nh7KWI88aup5snZbNkPWVlZHxgUJzoiw== \ No newline at end of file
diff --git a/yarns/100-happy.yarn b/yarns/100-happy.yarn
new file mode 100644
index 0000000..dc6f6c1
--- /dev/null
+++ b/yarns/100-happy.yarn
@@ -0,0 +1,97 @@
+# A happy path scenario
+
+This scenario does some basic resource management via the Muck API.
+
+ SCENARIO Muck
+
+Start Muck. This also sets up access to it for the user by getting an
+access token, which will be used for all requests.
+
+ GIVEN a running Muck
+
+Create a simple resource. Remember its id.
+
+ WHEN user makes request POST /res with body { "foo": "bar" }
+ THEN status code is 201
+ THEN remember resource id as ID
+ THEN remember resource revision as REV1
+
+Retrieve the resource.
+
+ WHEN user makes request GET /res with header "Muck-Id: ${ID}"
+ THEN status code is 200
+ THEN response body is { "foo": "bar" }
+ THEN response has header "Muck-Id: ${ID}"
+ THEN response has header "Muck-Revision: ${REV1}"
+
+Update the resource.
+
+ WHEN user makes request PUT /res with header "Muck-Id: ${ID}" and
+ ... header "Muck-Revision: wrong" and
+ ... body { "foo": "foobar" }
+ THEN status code is 400
+
+ WHEN user makes request PUT /res with header "Muck-Id: ${ID}" and
+ ... header "Muck-Revision: ${REV1}" and
+ ... body { "foo": "foobar" }
+ THEN status code is 200
+ THEN remember resource revision as REV2
+
+Check the resource has been updated.
+
+ WHEN user makes request GET /res with header "Muck-Id: ${ID}"
+ THEN status code is 200
+ THEN response body is { "foo": "foobar" }
+ THEN response has header "Muck-Id: ${ID}"
+ THEN response has header "Muck-Revision: ${REV2}"
+
+Restart Muck. The resource should still exist.
+
+ WHEN Muck is restarted
+ WHEN user makes request GET /res with header "Muck-Id: ${ID}"
+ THEN status code is 200
+ THEN response body is { "foo": "foobar" }
+ THEN response has header "Muck-Id: ${ID}"
+ THEN response has header "Muck-Revision: ${REV2}"
+
+Search for the resource. First with a condition that is no longer
+true.
+
+ WHEN user makes request GET /search with body
+ ... {
+ ... "cond": [
+ ... {"where": "data", "field": "foo", "pattern": "bar", "op": "=="}
+ ... ]
+ ... }
+ THEN status code is 200
+ THEN response body is {"resources": []}
+
+Now search for the correct value.
+
+ WHEN user makes request GET /search with body
+ ... {
+ ... "cond": [
+ ... {"where": "data", "field": "foo", "pattern": "foobar",
+ ... "op": "=="}
+ ... ]
+ ... }
+ THEN status code is 200
+ THEN response body is {"resources": ["${ID}"]}
+
+Delete the resource.
+
+ WHEN user makes request DELETE /res with header "Muck-Id: ${ID}"
+ THEN status code is 200
+
+ WHEN user makes request GET /res with header "Muck-Id: ${ID}"
+ THEN status code is 404
+
+Restart Muck again. The resource should not exist.
+
+ WHEN Muck is restarted
+ WHEN user makes request GET /res with header "Muck-Id: ${ID}"
+ THEN status code is 404
+
+All done.
+
+ FINALLY Muck is stopped
diff --git a/yarns/900-implements.yarn b/yarns/900-implements.yarn
new file mode 100644
index 0000000..7eb0c86
--- /dev/null
+++ b/yarns/900-implements.yarn
@@ -0,0 +1,69 @@
+# Scenario step implementations
+
+## Start and stop Muck
+
+ IMPLEMENTS GIVEN a running Muck
+ start_muck()
+
+ IMPLEMENTS WHEN Muck is restarted
+ stop_muck()
+ start_muck()
+
+ IMPLEMENTS FINALLY Muck is stopped
+ stop_muck()
+
+## HTTP requests
+
+ IMPLEMENTS WHEN user makes request POST /res with body (.*)
+ body = get_expanded_match()
+ POST('/res', {}, json.loads(body))
+
+ IMPLEMENTS WHEN user makes request GET /res with header "(\S+): (.+)"
+ header = get_expanded_match()
+ value = get_expanded_match()
+ GET('/res', {header:value})
+
+ IMPLEMENTS WHEN user makes request GET /search with body (.+)
+ body = json.loads(get_expanded_match())
+ GET('/search', {}, body=body)
+
+ IMPLEMENTS WHEN user makes request PUT /res with header "(\S+): (.+)" and header "(\S+): (.+)" and body (.+)
+ header1 = get_expanded_match()
+ value1 = get_expanded_match()
+ header2 = get_expanded_match()
+ value2 = get_expanded_match()
+ body = get_expanded_match()
+ headers = {
+ header1: value1,
+ header2: value2,
+ }
+ PUT('/res', headers, json.loads(body))
+
+ IMPLEMENTS WHEN user makes request DELETE /res with header "(\S+): (.+)"
+ header = get_expanded_match()
+ value = get_expanded_match()
+ DELETE('/res', {header:value})
+
+## Checking HTTP responses
+
+ IMPLEMENTS THEN status code is (\d+)
+ expected = int(get_expanded_match())
+ assertEqual(V['status_code'], expected)
+
+ IMPLEMENTS THEN remember resource id as (\S+)
+ name = get_next_match()
+ save_header('Muck-Id', name)
+
+ IMPLEMENTS THEN remember resource revision as (\S+)
+ name = get_next_match()
+ save_header('Muck-Revision', name)
+
+ IMPLEMENTS THEN response has header "(\S+): (.+)"
+ name = get_next_match()
+ expected = get_expanded_match()
+ assertEqual(get_header(name), expected)
+
+ IMPLEMENTS THEN response body is (.+)
+ expected = get_expanded_match()
+ print 'expected:', expected
+ assertEqual(get_json_body(), json.loads(expected))
diff --git a/yarns/lib.py b/yarns/lib.py
new file mode 100644
index 0000000..ee36f9f
--- /dev/null
+++ b/yarns/lib.py
@@ -0,0 +1,168 @@
+# Copyright (C) 2018 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 json
+import os
+import re
+import signal
+import subprocess
+import time
+
+import Crypto.PublicKey.RSA
+import jwt
+import requests
+
+from yarnutils import *
+
+
+srcdir = os.environ['SRCDIR']
+datadir = os.environ['DATADIR']
+V = Variables(datadir)
+
+
+json_mime_type = 'application/json'
+
+
+def start_muck():
+ pathname = os.path.join(srcdir, 'muck_poc')
+ pubkey = os.path.join(srcdir, 'test-key.pub')
+
+ config = {
+ 'pid': 'muck.pid',
+ 'log': 'muck.log',
+ 'store': datadir,
+ 'signing-key-filename': pubkey,
+ }
+
+ config_filename = os.path.join(datadir, 'mock.conf')
+ with open(config_filename, 'w') as f:
+ json.dump(config, f)
+
+ out = os.path.join(datadir, 'muck.out')
+ err = os.path.join(datadir, 'muck.err')
+ argv = [
+ '/usr/sbin/daemonize', '-o', out, '-e', err, '-c', '.',
+ pathname, config_filename,
+ ]
+ subprocess.check_call(argv)
+ V['base_url'] = 'http://127.0.0.1:{}'.format(12765)
+
+ V['token'] = create_test_token()
+
+
+def stop_muck():
+ pid = int(read('muck.pid'))
+ os.kill(pid, signal.SIGTERM)
+
+
+def create_test_token():
+ key_filename = os.path.join(srcdir, 'test-key')
+ key_text = open(key_filename).read()
+
+ iss = 'test-issuer'
+ aud = 'test-audience'
+ sub = 'test-user'
+ scopes = ['create', 'update', 'show', 'delete']
+ lifetime = 3600
+
+ return create_token(key_text, iss, aud, sub, scopes, lifetime)
+
+def create_token(key_text, iss, aud, sub, scopes, lifetime):
+ key = Crypto.PublicKey.RSA.importKey(key_text)
+
+ now = int(time.time())
+ claims = {
+ 'iss': iss,
+ 'sub': sub,
+ 'aud': aud,
+ 'exp': now + lifetime,
+ 'scope': ' '.join(scopes),
+ }
+
+ token = jwt.encode(claims, key.exportKey('PEM'), algorithm='RS512')
+ return token.decode('ascii')
+
+
+def POST(path, headers, body):
+ return request(requests.post, path, headers, body)
+
+
+def PUT(path, headers, body):
+ return request(requests.put, path, headers, body)
+
+
+def GET(path, headers, body=None):
+ return request(requests.get, path, headers, body=body)
+
+
+def DELETE(path, headers):
+ return request(requests.delete, path, headers)
+
+
+def request(func, path, headers, body=None):
+ url = '{}{}'.format(V['base_url'], path)
+ if 'Content-Type' not in headers:
+ headers['Content-Type'] = json_mime_type
+ if 'Authorization' not in headers:
+ headers['Authorization'] = 'Bearer {}'.format(V['token'])
+ if body is not None:
+ body = json.dumps(body)
+ V['request_url'] = url
+ V['request_func'] = repr(func)
+ V['request_headers'] = repr(headers)
+ V['request_body'] = repr(body)
+ r = func(url, headers=headers, data=body)
+ V['status_code'] = r.status_code
+ V['response_body'] = r.text
+ V['response_headers'] = dict(r.headers)
+ return r
+
+
+def read(filename):
+ with open(filename, 'r') as f:
+ return f.read()
+
+
+def get_expanded_match():
+ match = get_next_match()
+ return expand(match, V)
+
+
+def expand(text, variables):
+ result = ''
+ while text:
+ m = re.search(r'\${(?P<name>[^}]+)}', text)
+ if not m:
+ result += text
+ break
+ name = m.group('name')
+ print('expanding ', name, repr(variables[name]))
+ result += text[:m.start()] + variables[name]
+ text = text[m.end():]
+ return result
+
+
+def get_json_body():
+ return json.loads(V['response_body'])
+
+
+def get_header(header_name):
+ headers = V['response_headers']
+ assert headers is not None
+ return headers.get(header_name, '')
+
+
+def save_header(header_name, var_name):
+ V[var_name] = get_header(header_name)