summaryrefslogtreecommitdiff
path: root/yarns
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2019-01-01 15:03:12 +0200
committerLars Wirzenius <liw@liw.fi>2019-01-03 10:09:00 +0200
commitcac861f9a8a61912bfe92fbf51fedf6e65d737e9 (patch)
tree9089b2c3fcbf440dfcde923bc7f770cbe78ade4b /yarns
parent13876d39226b7c1b9d40a165ce9393d2a018e7f6 (diff)
downloadeffi-reg-cac861f9a8a61912bfe92fbf51fedf6e65d737e9.tar.gz
Add: scaffolding for yarns
Diffstat (limited to 'yarns')
-rw-r--r--yarns/000.yarn87
-rw-r--r--yarns/lib.py165
2 files changed, 239 insertions, 13 deletions
diff --git a/yarns/000.yarn b/yarns/000.yarn
index 9bd0432..1c3f55e 100644
--- a/yarns/000.yarn
+++ b/yarns/000.yarn
@@ -3,11 +3,14 @@ title: Effiapi test suite
author: Lars Wirzenius
...
-# Test scenarios
+[yarn]: https://liw.fi/cmdtest/
+
+# Introduction
-This chapter descibes the effiapi API, as a [yarn][] automated
-scenario test. It is meant to be understandable to Effi sysadmins, as
-well as be an executable test suite for the API.
+This chapter descibes the effiapi API, using [yarn][] automated
+scenario tests. It is meant to be understandable to Effi sysadmins,
+and those writing applications using the API, as well as be an
+executable test suite for the API.
The API is a simple RESTful HTTP API using JSON. This means that:
@@ -24,22 +27,27 @@ The API is a simple RESTful HTTP API using JSON. This means that:
* standard HTTP status codes are used to indicate result of the
request (200 = OK, 404 = not found, etc)
+Examples will be provided.
+
+# Test scenarios
+
## Manage memberships
This section shows the API calls to manage a memberhip: to create the
member, to update and retrieve it, and to search memberships.
-~~~
-SCENARIO Manage memberships
+ SCENARIO Manage memberships
+
+ GIVEN An effiapi instance
+ WHEN admin requests POST /memb with body { "fullname": "James Bond" }
+ THEN HTTP status is 201
+ AND the member id is ID
-GIVEN An effiapi instance
-WHEN admin requests POST /memb with body { "fullname": "James Bond" }
-THEN the member id is ID
+ WHEN admin requests GET /memb with header Muck-Id: ${ID}
+ THEN HTTP status is 200
+ AND HTTP body matches { "fullname": "James Bond" }
-WHEN admin requests GET /memb with header Muck-Id: ${ID}
-THEN HTTP status 200
-AND HTTP body matches { "fullname": "James Bond" }
-~~~
+ FINALLY Effiapi is terminated
TODO:
@@ -50,3 +58,56 @@ TODO:
* member follows authn link emailed to them
# Appendix: Yarn scenario step implementations
+
+## Start and stop effiapi
+
+ IMPLEMENTS GIVEN An effiapi instance
+ effiapi.write_config()
+ effiapi.start()
+
+ IMPLEMENTS FINALLY Effiapi is terminated
+ effiapi.terminate()
+
+## Make HTTP requests
+
+ IMPLEMENTS WHEN admin requests POST /memb with body (.+)
+ body = get_json_match()
+ effiapi.POST('/memb', {}, body)
+
+ IMPLEMENTS WHEN admin requests GET /memb with header (\S+): (\S+)
+ header = get_next_match()
+ print('header', repr(header))
+ value = get_expanded_match()
+ print('value', repr(value))
+ headers = {
+ header: value,
+ }
+ V['xx'] = {
+ 'header': header,
+ 'value': value,
+ }
+ effiapi.GET('/memb', headers, None)
+
+## Inspect HTTP responses
+
+ IMPLEMENTS THEN the member id is (\S+)
+ print('member id')
+ name = get_next_match()
+ print 'name', repr(name), name
+ value = effiapi.get_header('Muck-Id')
+ print 'value', repr(value)
+ save_for_expansion(name, value)
+
+ IMPLEMENTS THEN HTTP status is (\d+)
+ expected = int(get_next_match())
+ actual = effiapi.get_status_code()
+ print 'actual:', repr(actual)
+ print 'expecting:', repr(expected)
+ assertEqual(effiapi.get_status_code(), expected)
+
+ IMPLEMENTS THEN HTTP body matches (.+)
+ expected = get_json_match()
+ actual = effiapi.get_json_body()
+ print 'expected:', expected
+ print 'actual: ', actual
+ assertEqual(actual, expected)
diff --git a/yarns/lib.py b/yarns/lib.py
new file mode 100644
index 0000000..d7c8a13
--- /dev/null
+++ b/yarns/lib.py
@@ -0,0 +1,165 @@
+# Copyright 2019 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 base64
+import errno
+import json
+import os
+import random
+import re
+import signal
+import socket
+import subprocess
+import sys
+import time
+import urllib
+
+import cliapp
+import requests
+
+from yarnutils import *
+
+
+
+class EffiAPI:
+
+ config_filename = 'effiapi.json'
+
+ def __init__(self, variables):
+ self.v = variables
+
+ def write_config(self):
+ with open(self.config_filename, 'w') as f:
+ json.dump(self.get_config(), f, indent=4)
+
+ def get_config(self):
+ return {
+ 'faks': True,
+ 'muck-url': 'xxx',
+ 'log': 'effiapi.log',
+ 'pid': 'effiapi.pid',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ }
+
+ def start(self):
+ config = self.get_config()
+ self.v['baseurl'] = 'http://{}:{}'.format(
+ config['host'], config['port'])
+
+ effiapi = os.path.join(srcdir, 'effiapi')
+ self.daemonize([effiapi, self.config_filename])
+
+ self.wait_for_port(config['host'], config['port'])
+
+ def terminate(self):
+ config = self.get_config()
+ with open(config['pid']) as f:
+ pid = int(f.read().strip())
+ os.kill(pid, signal.SIGTERM)
+
+ def daemonize(self, argv):
+ prefix = [
+ '/usr/sbin/daemonize', '-o', 'stdout', '-e', 'stderr', '-c', '.'
+ ]
+ subprocess.check_call(prefix + argv)
+
+ def wait_for_port(self, host, port):
+ MAX = 5
+ t = time.time()
+ while time.time() < t + MAX:
+ try:
+ s = socket.socket()
+ s.connect((host, port))
+ except socket.error:
+ time.sleep(0.1)
+ except OSError as e:
+ raise
+ else:
+ return
+
+ def POST(self, path, headers, body):
+ headers['Content-Type'] = 'application/json'
+ body = json.dumps(body)
+ self.request(requests.post, path, headers, body)
+
+ def GET(self, path, headers, body):
+ self.request(requests.get, path, headers, body)
+
+ def request(self, func, path, headers, body):
+ url = '{}{}'.format(self.v['baseurl'], path)
+ self.v['request'] = {
+ 'url': url,
+ 'func': repr(func),
+ 'headers': headers,
+ 'body': body,
+ }
+
+ r = func(url, headers=headers, data=body)
+ self.v['response'] = {
+ 'status_code': r.status_code,
+ 'body': r.text,
+ 'headers': dict(r.headers),
+ }
+
+ def get_status_code(self):
+ r = self.v['response']
+ return r['status_code']
+
+ def get_header(self, name):
+ r = self.v['response']
+ return r['headers'].get(name, '')
+
+ def get_json_body(self):
+ r = self.v['response']
+ return json.loads(r['body'])
+
+
+def get_json_match():
+ match = get_next_match()
+ return json.loads(match)
+
+
+def save_for_expansion(name, value):
+ V[name] = value
+
+
+def get_expanded_match():
+ match = get_next_match()
+ print 'match', match
+ return expand(match, V)
+
+
+def expand(text, variables):
+ result = ''
+ while text:
+ print 'expand: text=%r' % 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():]
+ print 'expand: result=%r' % result
+ return result
+
+
+srcdir = os.environ['SRCDIR']
+datadir = os.environ['DATADIR']
+V = Variables(datadir)
+effiapi = EffiAPI(V)