From 531523c53446e64a028c707d61f36674fafa16b6 Mon Sep 17 00:00:00 2001 From: Sergi Delgado Segura Date: Tue, 17 Dec 2019 17:04:57 +0100 Subject: [PATCH] Refactors the API to run using dispatch instead of decorate The API was never made an object since I couldn't find a way or working around the Flask decorators. By using dispatch we can get around the issues in #14 and will be able to create better mocks for the API --- pisa/api.py | 270 ++++++++++++++++++------------------- pisa/pisad.py | 6 +- test/pisa/unit/test_api.py | 4 +- 3 files changed, 138 insertions(+), 142 deletions(-) diff --git a/pisa/api.py b/pisa/api.py index f0ec6ae..9468166 100644 --- a/pisa/api.py +++ b/pisa/api.py @@ -14,189 +14,185 @@ from common.constants import HTTP_OK, HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE # ToDo: #5-add-async-to-api app = Flask(__name__) logger = Logger("API") -watcher = None -@app.route("/", methods=["POST"]) -def add_appointment(): - """ - Add appointment endpoint, it is used as the main endpoint of the Watchtower. +class API: + def __init__(self, watcher): + self.watcher = watcher - The client sends requests (appointments) to this endpoint to request a job to the Watchtower. Requests must be json - encoded and contain an ``appointment`` field and optionally a ``signature`` and ``public_key`` fields. + def add_appointment(self): + """ + Main endpoint of the Watchtower. - Returns: - :obj:`tuple`: A tuple containing the response (``json``) and response code (``int``). For accepted appointments, - the ``rcode`` is always 0 and the response contains the signed receipt. For rejected appointments, the ``rcode`` - is a negative value and the response contains the error message. Error messages can be found at - :mod:`Errors `. - """ + The client sends requests (appointments) to this endpoint to request a job to the Watchtower. Requests must be json + encoded and contain an ``appointment`` field and optionally a ``signature`` and ``public_key`` fields. - remote_addr = request.environ.get("REMOTE_ADDR") - remote_port = request.environ.get("REMOTE_PORT") + Returns: + :obj:`tuple`: A tuple containing the response (``json``) and response code (``int``). For accepted appointments, + the ``rcode`` is always 0 and the response contains the signed receipt. For rejected appointments, the ``rcode`` + is a negative value and the response contains the error message. Error messages can be found at + :mod:`Errors `. + """ - logger.info("Connection accepted", from_addr_port="{}:{}".format(remote_addr, remote_port)) + remote_addr = request.environ.get("REMOTE_ADDR") + remote_port = request.environ.get("REMOTE_PORT") - # Check content type once if properly defined - request_data = json.loads(request.get_json()) - inspector = Inspector() - appointment = inspector.inspect( - request_data.get("appointment"), request_data.get("signature"), request_data.get("public_key") - ) + logger.info("Connection accepted", from_addr_port="{}:{}".format(remote_addr, remote_port)) - error = None - response = None + # Check content type once if properly defined + request_data = json.loads(request.get_json()) + inspector = Inspector() + appointment = inspector.inspect( + request_data.get("appointment"), request_data.get("signature"), request_data.get("public_key") + ) - if type(appointment) == Appointment: - appointment_added, signature = watcher.add_appointment(appointment) + error = None + response = None - if appointment_added: - rcode = HTTP_OK - response = {"locator": appointment.locator, "signature": signature} + if type(appointment) == Appointment: + appointment_added, signature = self.watcher.add_appointment(appointment) + + if appointment_added: + rcode = HTTP_OK + response = {"locator": appointment.locator, "signature": signature} + + else: + rcode = HTTP_SERVICE_UNAVAILABLE + error = "appointment rejected" + + elif type(appointment) == tuple: + rcode = HTTP_BAD_REQUEST + error = "appointment rejected. Error {}: {}".format(appointment[0], appointment[1]) else: - rcode = HTTP_SERVICE_UNAVAILABLE - error = "appointment rejected" + # We should never end up here, since inspect only returns appointments or tuples. Just in case. + rcode = HTTP_BAD_REQUEST + error = "appointment rejected. Request does not match the standard" - elif type(appointment) == tuple: - rcode = HTTP_BAD_REQUEST - error = "appointment rejected. Error {}: {}".format(appointment[0], appointment[1]) + logger.info( + "Sending response and disconnecting", + from_addr_port="{}:{}".format(remote_addr, remote_port), + response=response, + error=error, + ) - else: - # We should never end up here, since inspect only returns appointments or tuples. Just in case. - rcode = HTTP_BAD_REQUEST - error = "appointment rejected. Request does not match the standard" + if error is None: + return jsonify(response), rcode + else: + return jsonify({"error": error}), rcode - logger.info( - "Sending response and disconnecting", - from_addr_port="{}:{}".format(remote_addr, remote_port), - response=response, - error=error, - ) + # FIXME: THE NEXT THREE API ENDPOINTS ARE FOR TESTING AND SHOULD BE REMOVED / PROPERLY MANAGED BEFORE PRODUCTION! + # ToDo: #17-add-api-keys + def get_appointment(self): + """ + Gives information about a given appointment state in the Watchtower. - if error is None: - return jsonify(response), rcode - else: - return jsonify({"error": error}), rcode + The information is requested by ``locator``. + Returns: + :obj:`dict`: A json formatted dictionary containing information about the requested appointment. -# FIXME: THE NEXT THREE API ENDPOINTS ARE FOR TESTING AND SHOULD BE REMOVED / PROPERLY MANAGED BEFORE PRODUCTION! -# ToDo: #17-add-api-keys -@app.route("/get_appointment", methods=["GET"]) -def get_appointment(): - """ - Get appointment endpoint, it gives information about a given appointment state in the Watchtower. + A ``status`` flag is added to the data provided by either the :obj:`Watcher ` or the + :obj:`Responder ` that signals the status of the appointment. - The information is requested by ``locator``. + - Appointments hold by the :obj:`Watcher ` are flagged as ``being_watched``. + - Appointments hold by the :obj:`Responder ` are flagged as ``dispute_triggered``. + - Unknown appointments are flagged as ``not_found``. + """ - Returns: - :obj:`dict`: A json formatted dictionary containing information about the requested appointment. + locator = request.args.get("locator") + response = [] - A ``status`` flag is added to the data provided by either the :obj:`Watcher ` or the - :obj:`Responder ` that signals the status of the appointment. + # ToDo: #15-add-system-monitor + if not isinstance(locator, str) or len(locator) != LOCATOR_LEN_HEX: + response.append({"locator": locator, "status": "not_found"}) + return jsonify(response) - - Appointments hold by the :obj:`Watcher ` are flagged as ``being_watched``. - - Appointments hold by the :obj:`Responder ` are flagged as ``dispute_triggered``. - - Unknown appointments are flagged as ``not_found``. - """ + locator_map = self.watcher.db_manager.load_locator_map(locator) - locator = request.args.get("locator") - response = [] + if locator_map is not None: + for uuid in locator_map: + appointment_data = self.watcher.db_manager.load_watcher_appointment(uuid) - # ToDo: #15-add-system-monitor - if not isinstance(locator, str) or len(locator) != LOCATOR_LEN_HEX: - response.append({"locator": locator, "status": "not_found"}) - return jsonify(response) + if appointment_data is not None and appointment_data["triggered"] is False: + # Triggered is an internal flag + del appointment_data["triggered"] - locator_map = watcher.db_manager.load_locator_map(locator) + appointment_data["status"] = "being_watched" + response.append(appointment_data) - if locator_map is not None: - for uuid in locator_map: - appointment_data = watcher.db_manager.load_watcher_appointment(uuid) + tracker_data = self.watcher.db_manager.load_responder_tracker(uuid) - if appointment_data is not None and appointment_data["triggered"] is False: - # Triggered is an internal flag - del appointment_data["triggered"] + if tracker_data is not None: + tracker_data["status"] = "dispute_responded" + response.append(tracker_data) - appointment_data["status"] = "being_watched" - response.append(appointment_data) + else: + response.append({"locator": locator, "status": "not_found"}) - tracker_data = watcher.db_manager.load_responder_tracker(uuid) + response = jsonify(response) - if tracker_data is not None: - tracker_data["status"] = "dispute_responded" - response.append(tracker_data) + return response - else: - response.append({"locator": locator, "status": "not_found"}) + def get_all_appointments(self): + """ + Gives information about all the appointments in the Watchtower. - response = jsonify(response) + This endpoint should only be accessible by the administrator. Requests are only allowed from localhost. - return response + Returns: + :obj:`dict`: A json formatted dictionary containing all the appointments hold by the + :obj:`Watcher ` (``watcher_appointments``) and by the + :obj:`Responder ` (``responder_trackers``). + """ -@app.route("/get_all_appointments", methods=["GET"]) -def get_all_appointments(): - """ - Get all appointments endpoint, it gives information about all the appointments in the Watchtower. + # ToDo: #15-add-system-monitor + response = None - This endpoint should only be accessible by the administrator. Requests are only allowed from localhost. + if request.remote_addr in request.host or request.remote_addr == "127.0.0.1": + watcher_appointments = self.watcher.db_manager.load_watcher_appointments() + responder_trackers = self.watcher.db_manager.load_responder_trackers() - Returns: - :obj:`dict`: A json formatted dictionary containing all the appointments hold by the - :obj:`Watcher ` (``watcher_appointments``) and by the - :obj:`Responder ` (``responder_trackers``). + response = jsonify({"watcher_appointments": watcher_appointments, "responder_trackers": responder_trackers}) - """ + else: + abort(404) - # ToDo: #15-add-system-monitor - response = None + return response - if request.remote_addr in request.host or request.remote_addr == "127.0.0.1": - watcher_appointments = watcher.db_manager.load_watcher_appointments() - responder_trackers = watcher.db_manager.load_responder_trackers() + @staticmethod + def get_block_count(): + """ + Provides the block height of the Watchtower. - response = jsonify({"watcher_appointments": watcher_appointments, "responder_trackers": responder_trackers}) + This is a testing endpoint that (most likely) will be removed in production. Its purpose is to give information to + testers about the current block so they can define a dummy appointment without having to run a bitcoin node. - else: - abort(404) + Returns: + :obj:`dict`: A json encoded dictionary containing the block height. - return response + """ + return jsonify({"block_count": BlockProcessor.get_block_count()}) -@app.route("/get_block_count", methods=["GET"]) -def get_block_count(): - """ - Get block count endpoint, it provides the block height of the Watchtower. + def start(self): + """ + This function starts the Flask server used to run the API. Adds all the routes to the functions listed above. + """ - This is a testing endpoint that (most likely) will be removed in production. Its purpose is to give information to - testers about the current block so they can define a dummy appointment without having to run a bitcoin node. + routes = { + "/": (self.add_appointment, ["POST"]), + "/get_appointment": (self.get_appointment, ["GET"]), + "/get_all_appointments": (self.get_all_appointments, ["GET"]), + "/get_block_count": (self.get_block_count, ["GET"]), + } - Returns: - :obj:`dict`: A json encoded dictionary containing the block height. + for url, params in routes.items(): + app.add_url_rule(url, view_func=params[0], methods=params[1]) - """ + # Setting Flask log to ERROR only so it does not mess with out logging. Also disabling flask initial messages + logging.getLogger("werkzeug").setLevel(logging.ERROR) + os.environ["WERKZEUG_RUN_MAIN"] = "true" - return jsonify({"block_count": BlockProcessor.get_block_count()}) - - -def start_api(w): - """ - This function starts the Flask server used to run the API. - - Args: - w (:obj:`Watcher `): A ``Watcher`` object. - - """ - - # FIXME: Pretty ugly but I haven't found a proper way to pass it to add_appointment - global watcher - - # ToDo: #18-separate-api-from-watcher - watcher = w - - # Setting Flask log to ERROR only so it does not mess with out logging. Also disabling flask initial messages - logging.getLogger("werkzeug").setLevel(logging.ERROR) - os.environ["WERKZEUG_RUN_MAIN"] = "true" - - app.run(host=HOST, port=PORT) + app.run(host=HOST, port=PORT) diff --git a/pisa/pisad.py b/pisa/pisad.py index ecf9a6f..2ba2755 100644 --- a/pisa/pisad.py +++ b/pisa/pisad.py @@ -4,7 +4,7 @@ from signal import signal, SIGINT, SIGQUIT, SIGTERM from pisa.conf import DB_PATH from common.logger import Logger -from pisa.api import start_api +from pisa.api import API from pisa.watcher import Watcher from pisa.builder import Builder from pisa.conf import BTC_NETWORK, PISA_SECRET_KEY @@ -79,8 +79,8 @@ if __name__ == "__main__": watcher.appointments, watcher.locator_uuid_map = Builder.build_appointments(watcher_appointments_data) watcher.block_queue = Builder.build_block_queue(missed_blocks_watcher) - # Create an instance of the Watcher and fire the API - start_api(watcher) + # Fire the API + API(watcher).start() except Exception as e: logger.error("An error occurred: {}. Shutting down".format(e)) diff --git a/test/pisa/unit/test_api.py b/test/pisa/unit/test_api.py index 68a845b..be9f605 100644 --- a/test/pisa/unit/test_api.py +++ b/test/pisa/unit/test_api.py @@ -5,7 +5,7 @@ from time import sleep from threading import Thread from cryptography.hazmat.primitives import serialization -from pisa.api import start_api +from pisa.api import API from pisa.watcher import Watcher from pisa.tools import bitcoin_cli from pisa import HOST, PORT, c_logger @@ -40,7 +40,7 @@ def run_api(db_manager): ) watcher = Watcher(db_manager, sk_der) - api_thread = Thread(target=start_api, args=[watcher]) + api_thread = Thread(target=API(watcher).start) api_thread.daemon = True api_thread.start()