summaryrefslogtreecommitdiff
path: root/api.py
diff options
context:
space:
mode:
authorLars Wirzenius <lwirzenius@wikimedia.org>2019-06-30 19:27:56 +0300
committerLars Wirzenius <lwirzenius@wikimedia.org>2019-06-30 19:27:56 +0300
commita906ff6c3f437c98438fad7ba0abfea2d4eec85e (patch)
treedc976325376381315b40df33ab727b093b0a5557 /api.py
parentc639e5b36d688953393d7e801204193e11515cde (diff)
downloadwmf-ci-arch-a906ff6c3f437c98438fad7ba0abfea2d4eec85e.tar.gz
Add: sketch for implementing HTTP APIs in Python simply
Diffstat (limited to 'api.py')
-rw-r--r--api.py138
1 files changed, 138 insertions, 0 deletions
diff --git a/api.py b/api.py
new file mode 100644
index 0000000..0eb850f
--- /dev/null
+++ b/api.py
@@ -0,0 +1,138 @@
+import logging
+import sys
+
+import Crypto.PublicKey.RSA
+import bottle
+import jwt
+
+
+class TokenParser:
+
+ def __init__(self, pubkey):
+ self._pubkey = pubkey
+
+ def parse_token(self, token_text):
+ return jwt.decode(
+ token_text,
+ key=self._pubkey.exportKey('OpenSSH'),
+ audience=None,
+ options={'verify_aud': False})
+
+
+class AccessChecker:
+
+ def __init__(self, pubkey):
+ self._parser = TokenParser(pubkey)
+
+ def access_is_allowed(self, headers, required_scopes):
+ token = self._get_token(headers)
+ logging.debug('Access token %r', token)
+ if token is None and len(required_scopes) != 0:
+ logging.error('No valid access token')
+ return False
+
+ scopes = token.get('scope', '').split()
+ missing = set(required_scopes).difference(scopes)
+ if missing:
+ logging.error(
+ 'Required scopes that are missing from token: %r', missing)
+ return False
+
+ return True
+
+ def _get_token(self, headers):
+ token_text = self._get_token_text(headers)
+ if token_text is None:
+ return None
+ return self._parser.parse_token(token_text)
+
+ def _get_token_text(self, headers):
+ v = headers.get('Authorization', '')
+ words = v.split()
+ if len(words) == 2:
+ keyword, token_text = words
+ if keyword.lower() == 'bearer':
+ return token_text
+
+
+class API:
+
+ def __init__(self, app, token_pubkey):
+ self._checker = AccessChecker(token_pubkey)
+ self._add_routes(app, self.get_routes())
+
+ def get_routes(self):
+ raise NotImplementedError()
+
+ def _add_routes(self, app, routes):
+ for route in routes:
+ func = route.pop('func')
+ scopes = route.pop('scopes')
+ callback = lambda **kwargs: self.check(func, scopes, kwargs)
+ route = dict(route)
+ route['callback'] = callback
+ app.route(**route)
+
+ def check(self, func, required_scopes, kwargs):
+ r = bottle.request
+ logging.debug('Checking access for request %s %s', r.method, r.path)
+
+ if self._checker.access_is_allowed(bottle.request.headers, required_scopes):
+ logging.info('Serving request %s %s', r.method, r.path)
+ ret = func(**kwargs)
+ logging.info('Result: %r', ret)
+ return ret
+
+ logging.error('Request denied %s %s', r.method, r.path)
+ return bottle.HTTPError(400)
+
+
+class Controller(API):
+
+ def get_routes(self):
+ return [
+ {
+ 'method': 'GET',
+ 'path': '/status',
+ 'func': self._status,
+ 'scopes': ['status'],
+ },
+ {
+ 'method': 'GET',
+ 'path': '/hello/<name>',
+ 'func': self._hello,
+ 'scopes': [],
+ },
+ ]
+
+ def _status(self):
+ return {
+ 'queue': [],
+ 'running': [],
+ 'finished': [],
+ }
+
+ def _hello(self, name=None):
+ return 'hello {}\n'.format(name)
+
+
+def get_key_from_file(filename):
+ with open(filename) as f:
+ key_text = f.read()
+ return Crypto.PublicKey.RSA.importKey(key_text)
+
+
+def main():
+ logging.basicConfig(
+ filename='api.log',
+ level=logging.DEBUG,
+ format='%(levelname)s %(message)s')
+
+ filename = sys.argv[1]
+ key = get_key_from_file(filename)
+
+ app = bottle.Bottle()
+ api = Controller(app, key)
+ app.run(host='localhost', port=2222)
+
+main()