#!/usr/bin/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 . import copy import json import logging import os import sys import uuid import bottle import requests class HTTPAPI: def POST(self, url, headers, body): raise NotImplementedError() def PUT(self, url, headers, body): raise NotImplementedError() def GET(self, url, headers=None, body=None): raise NotImplementedError() def DELETE(self, url, headers): raise NotImplementedError() class RealHTTPAPI(HTTPAPI): def POST(self, url, headers, body): assert 0 return requests.post(url, headers=headers, data=body) def PUT(self, url, headers, body): return requests.put(url, headers=headers, data=body) def GET(self, url, headers=None, body=None): return requests.get(url, headers=headers, data=body) def DELETE(self, url, headers): return requests.delete(url, headers=headers, data=body) class FakeResponse: def __init__(self, status_code, headers, body): self.status_code = status_code self.headers = dict(headers) self.text = body logging.debug( 'FakeREsponse: status=%d headers=%r body=%r', status_code, headers, body) @property def ok(self): return self.status_code in (200, 201) def json(self): return json.loads(self.text.strip()) class FakeHTTPAPI(HTTPAPI): def __init__(self): self._memb = {} def POST(self, url, headers, body): logging.debug( 'FakeHTTPAPI.POST url=%r headers=%r body=%r', url, headers, body) rid = str(uuid.uuid4()) self._memb[rid] = copy.deepcopy(body) headers = { 'Muck-Id': rid, } return FakeResponse(201, headers, copy.deepcopy(body)) def PUT(self, url, headers, body): logging.debug( 'FakeHTTPAPI.PUT url=%r headers=%r body=%r', url, headers, body) rid = headers.get('Muck-Id') if not rid: return FakeResponse(400, {}, 'No Muck-Id in request') self._memb[rid] = copy.deepcopy(body) headers = { 'Muck-Id': rid, } return FakeResponse(200, headers, copy.deepcopy(body)) def GET(self, url, headers=None, body=None): logging.info( 'FakeHTTPAPI.GET url=%r headers=%r body=%r', url, headers, body) if url.endswith('/status'): return self._get_status() if url.endswith('/search'): return self._get_search(body) if url.endswith('/res'): return self._get_resource(headers) logging.error('Cannot serve url') assert 0 def _get_status(self): body = { 'resources': len(self._memb), } return FakeResponse(200, {}, json.dumps(body)) def _get_search(self, body): cond = json.loads(body) logging.debug('_get_search: cond: %r', cond) matches = [ rid for rid in self._memb if self._matches_cond(rid, cond) ] headers = { 'Content-Type': 'application/json', } result = { 'resources': matches, } return FakeResponse(200, headers, json.dumps(result)) def _matches_cond(self, rid, cond): assert cond == { 'cond': [ { 'where': 'meta', 'op': '>=', 'field': 'id', 'pattern': '', } ] } return True def _get_resource(self, headers): if headers is None: logging.warning('FakeHTTPAPI.GET: no resource id in headers') return FakeResponse(400, {}, 'Missing headers') rid = headers.get('Muck-Id') if not rid: logging.warning('FakeHTTPAPI.GET: empty resource id in headers') return FakeResponse(404, {}, 'No such member') if rid not in self._memb: logging.debug('Members: %r', self._memb) return FakeResponse(404, {}, 'Retrieving member that does not exist') memb = self._memb[rid] headers = { 'Muck-Id': rid, 'Content-Type': 'application/json', } return FakeResponse(200, headers, memb) def DELETE(self, url, headers): logging.debug('FakeHTTPAPI.DELETE url=%r headers=%r', url, headers) rid = headers.get('Muck-Id') if not rid: logging.warning('FakeHTTPAPI.DELETE: empty resource id in headers') return FakeResponse(404, {}, 'No such member') if rid not in self._memb: logging.debug('Members: %r', self._memb) return FakeResponse(404, {}, 'Deleting member that does not exist') del self._memb[rid] return FakeResponse(200, {}, {}) class MuckError(Exception): pass class MuckAPI: _copy_headers = [ 'Authorization', 'Muck-Id', 'Muck-Revision', 'Muck-Owner', ] def __init__(self, url, httpapi): self._url = url self._httpapi = httpapi def _get_headers(self): h = {} for name in self._copy_headers: value = bottle.request.get_header(name) if value is not None: h[name] = value logging.debug('Copy header from request: %s: %s', name, value) return h def url(self, path): return '{}{}'.format(self._url, path) def status(self): url = self.url('/status') r = self._httpapi.GET(url) if r.ok: return r.json() return {'resources': 0} def show(self, rid): url = self.url('/res') headers = self._get_headers() headers['Muck-Id'] = rid logging.info('show copied headers') r = self._httpapi.GET(url, headers=headers) logging.info('Show result: %s %s', r.status_code, r.text) if not r.ok: raise MuckError('{} {}'.format(r.status_code, r.text)) return r.json() def _write(self, member, func): url = self.url('/res') headers = self._get_headers() headers['Content-Type'] = 'application/json' r = func(url, headers, json.dumps(member)) logging.info('Write result: %s %s', r.status_code, r.text) if not r.ok: raise MuckError('{} {}'.format(r.status_code, r.text)) rid = r.headers.get('Muck-Id') if not rid: raise MuckError('Muck did not return Muck-Id') return rid, r.json() def create(self, member): return self._write(member, self._httpapi.POST) def update(self, member): return self._write(member, self._httpapi.PUT) def delete(self): url = self.url('/res') headers = self._get_headers() r = self._httpapi.DELETE(url, headers) logging.info('Delete result: %s %s', r.status_code, r.text) if not r.ok: raise MuckError('{} {}'.format(r.status_code, r.text)) def search(self, cond): url = self.url('/search') headers = self._get_headers() headers['Content-Type'] = 'application/json' r = self._httpapi.GET(url, headers=headers, body=json.dumps(cond)) logging.info('Search result: %s %s', r.status_code, r.text) if not r.ok: raise MuckError('{} {}'.format(r.status_code, r.text)) return r.json() class API: def __init__(self, httpapi, bottleapp, config): self._add_routes(bottleapp) self._muck = MuckAPI(config['muck-url'], httpapi) def _add_routes(self, bottleapp): routes = [ { 'method': 'GET', 'path': '/status', 'callback': self._call(self._show_status), }, { 'method': 'GET', 'path': '/memb', 'callback': self._call(self._show_member), }, { 'method': 'GET', 'path': '/search', 'callback': self._call(self._search), }, { 'method': 'POST', 'path': '/memb', 'callback': self._call(self._create), }, { 'method': 'PUT', 'path': '/memb', 'callback': self._call(self._update), }, { 'method': 'DELETE', 'path': '/memb', 'callback': self._call(self._delete), }, ] for route in routes: logging.debug('Adding route', repr(route)) bottleapp.route(**route) def _call(self, callback): def helper(): logging.debug('_call called') try: r = bottle.request logging.info('Request: method=%s', r.method) logging.info('Request: path=%s', r.path) logging.info('Request: content type: %s', r.content_type) for h in r.headers: logging.info('Request: headers: %s: %s', h, r.get_header(h)) logging.info('Request: body: %r', r.body.read()) ret = callback() except BaseException as e: logging.error(str(e), exc_info=True) raise else: return ret return helper def _get_token(self): r = bottle.request authz = r.get_header('Authorization') if not authz: return None w = authz.split() if len(w) != 2: return None if w[0].lower() != 'bearer': return None return w[1] def _show_status(self): status = self._muck.status() return response(200, None, status) def _show_member(self): rid = bottle.request.get_header('Muck-Id') if rid is None: return response(400, None, 'no muck-id') try: logging.debug('API _show_member rid=%r', rid) res = self._muck.show(rid) return response(200, rid, res) except MuckError as e: return response(404, None, str(e)) def _search(self): logging.debug('_search callback called') cond = bottle.request.json logging.debug('_search callback: %r', cond) result = self._muck.search(cond) return response(200, None, result) def _create(self): r = bottle.request if r.content_type != 'application/json': return response(400, None, 'wrong content type') obj = bottle.request.json logging.info('CREATE %r', repr(obj)) rid, newobj = self._muck.create(obj) return response(201, rid, newobj) def _update(self): r = bottle.request if r.content_type != 'application/json': return response(400, None, 'wrong content type') obj = bottle.request.json logging.info('UPDATE %r', repr(obj)) rid, newobj = self._muck.update(obj) return response(200, rid, newobj) def _delete(self): r = bottle.request self._muck.delete() return response(200, None, None) def response(status, rid, body): headers = { 'Content-Type': 'application/json', } if rid is not None: headers['Muck-Id'] = rid return bottle.HTTPResponse( status=status, body=json.dumps(body), headers=headers) with open(sys.argv[1]) as f: config = json.load(f) logging.basicConfig( filename=config['log'], level=logging.DEBUG, format='%(asctime)s %(levelname)s %(message)s') logging.info('Effi API starts') if config.get('pid'): pid = os.getpid() with open(config['pid'], 'w') as f: f.write(str(pid)) if config.get('fake'): logging.info("Using FakeHTTPAPI") httpapi = FakeHTTPAPI() else: logging.info("Using RealHTTPAPI") httpapi = RealHTTPAPI() logging.debug('Creating API') app = bottle.default_app() api = API(httpapi, app, config) logging.info('Starting application') app.run(host='127.0.0.1', port=8080) logglng.critical('Application ended')