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
This commit is contained in:
Sergi Delgado Segura
2019-12-17 17:04:57 +01:00
parent 6129ae9b25
commit 531523c534
3 changed files with 138 additions and 142 deletions

View File

@@ -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 <pisa.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 <pisa.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 <pisa.watcher.Watcher>` or the
:obj:`Responder <pisa.responder.Responder>` that signals the status of the appointment.
The information is requested by ``locator``.
- Appointments hold by the :obj:`Watcher <pisa.watcher.Watcher>` are flagged as ``being_watched``.
- Appointments hold by the :obj:`Responder <pisa.responder.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 <pisa.watcher.Watcher>` or the
:obj:`Responder <pisa.responder.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 <pisa.watcher.Watcher>` are flagged as ``being_watched``.
- Appointments hold by the :obj:`Responder <pisa.responder.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 <pisa.watcher.Watcher>` (``watcher_appointments``) and by the
:obj:`Responder <pisa.responder.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 <pisa.watcher.Watcher>` (``watcher_appointments``) and by the
:obj:`Responder <pisa.responder.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 <pisa.watcher.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)

View File

@@ -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))

View File

@@ -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()