From 0b2b2c85b078b1aff67d15d946b1dc3e3e831e1a Mon Sep 17 00:00:00 2001 From: Xevion Date: Sat, 12 Dec 2020 17:06:34 -0600 Subject: [PATCH] flask restful based API handling & exceptions work --- requirements.txt | 1 + server/api.py | 55 ++++++++++++++++++++------- server/create_app.py | 24 +++++++----- server/exceptions.py | 90 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 23 deletions(-) create mode 100644 server/exceptions.py diff --git a/requirements.txt b/requirements.txt index 7e10602..9687a04 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ flask +flask-restful diff --git a/server/api.py b/server/api.py index e324ce4..1076e42 100644 --- a/server/api.py +++ b/server/api.py @@ -7,10 +7,9 @@ API interactions, static or dynamic. import copy import random -from flask import jsonify from flask_restful import Resource, reqparse, abort -from server import questions +from server import questions, exceptions from server.helpers import generate_id # Stores active questions in memory for checking answers. @@ -19,7 +18,10 @@ active_questions = {} # Question categories, key name, value function for acquiring a random question generator. categories = { - 'arithmetic': questions.get_arithmetic + 'arithmetic': ( + questions.get_arithmetic, + 'The basic four mathematical functions, plus fractions, exponents and a little more.' + ) } parser = reqparse.RequestParser() @@ -35,11 +37,17 @@ class Question(Resource): They are identified by a String ID. """ - def get(self, question_id): + def get(self, question_id=None): """Retrieve information about a given question, including the answer for it.""" - pass + if question_id is not None: + if question_id in active_questions.keys(): + return active_questions[question_id] + else: + raise exceptions.InvalidQuestion(404, question=question_id) + else: + abort(404, message='Use PUT to create questions, otherwise specify a question ID') - def put(self, question_id = None): + def put(self, question_id=None): """ Request a new question. @@ -48,26 +56,46 @@ class Question(Resource): The Question object is returned, although the answer is omitted. """ args = parser.parse_args() - q_id = None + + # Generate new Question ID + q_id = Nonewt while q_id in active_questions.keys() or q_id is None: q_id = generate_id(5) # Get category arg or choose one if not specified. if args.get('category') is not None: category = args.get('category') + # Check that the category is valid if category not in categories.keys(): - abort(404, message=f'Category {category} is not valid.') + raise exceptions.InvalidCategory(404, category=category) else: category = random.choice(list(categories.keys())) # Acquire a question generator and generate a function, then store the result. - active_questions[q_id] = categories[category]()() + active_questions[q_id] = categories[category][0]()() # Make a shallow copy, hide 'answer' key. question = copy.copy(active_questions[q_id]) del question['answer'] - return question, 200 + return 201, question + + def delete(self, question_id): + """Delete a question object from the running before it is automatically removed.""" + if question_id in active_questions.keys(): + del active_questions[question_id] + return {'message': f'Successfully deleted Question ID {question_id}'} + else: + raise exceptions.InvalidQuestion(404, question=question_id) + + +class Questions(Resource): + """ + Simple resource for listing all available question objects. + """ + + def get(self): + return active_questions class Category(Resource): @@ -78,7 +106,10 @@ class Category(Resource): def get(self, category_id): """Get all information about a category""" - pass + if category_id in categories.keys(): + return categories[category_id][1] + else: + raise exceptions.InvalidCategory(404, category=category_id) class Categories(Resource): @@ -90,5 +121,3 @@ class Categories(Resource): def get(self): """Get a list of all categories.""" return list(categories.keys()), 200 - - diff --git a/server/create_app.py b/server/create_app.py index d432d66..cb39263 100644 --- a/server/create_app.py +++ b/server/create_app.py @@ -1,6 +1,14 @@ -from flask import Flask, render_template +""" +create_app.py + +The main app creation, registering extensions, API routes, the Vue.js catch all redirect and configs. +""" + +from flask import Flask, render_template, jsonify from flask_restful import Api +from server import exceptions +from server.api import Question, Questions, Category, Categories from server.config import configs @@ -12,27 +20,23 @@ def create_app(env=None): ) # Instantiate Flask-Restful API and register appropriate routes - from server.api import Question, Category, Categories api = Api(app, prefix='/api/') api.add_resource(Question, '/question/', '/question/') api.add_resource(Category, '/category/') api.add_resource(Categories, '/categories/') + api.add_resource(Questions, '/questions/') if not env: env = app.config['ENV'] app.config.from_object(configs[env]) - # @app.shell_context_processor - # def shell_context(): - # pass - - with app.app_context(): - # noinspection PyUnresolvedReferences - from server import api - @app.route('/', defaults={'path': ''}) @app.route('/') def catch_all(path): return render_template("index.html") + @app.errorhandler(exceptions.APIException) + def api_exceptions(e): + return jsonify(e.json()), e.status_code + return app diff --git a/server/exceptions.py b/server/exceptions.py new file mode 100644 index 0000000..ee8ccbd --- /dev/null +++ b/server/exceptions.py @@ -0,0 +1,90 @@ +""" +exceptions.py + +Stores all API exceptions neatly for importing and usage elsewhere. +""" +from typing import Tuple + +# TODO: Improve exception management to cut down needless class definitions. +# TODO: Add 'extra' message parameter to base APIException kwargs. + +class APIException(Exception): + """Exception from which all API-related exceptions are derived and formatted with""" + MESSAGE = "A generic unhandled API Exception has occurred." + + def __init__(self, status_code: int = 500): + self.status_code = status_code + + def json(self): + return { + 'error': { + 'code': self.status_code, + 'message': self.MESSAGE + } + } + + +class UnspecifiedParam(APIException): + MESSAGE = 'This API Route requires a parameter that was not satisfied in the latest request.' + + def __init__(self, status_code: int): + super().__init__(status_code) + + def json(self): + return { + 'error': { + 'code': self.status_code, + 'message': self.MESSAGE + } + } + + +class UnspecifiedQueryParam(APIException): + MESSAGE = 'This API Route requires a query parameter that was not satisfied in the latest request.' + + def __init__(self, status_code: int): + super().__init__(status_code) + + +class UnspecifiedURIParam(APIException): + MESSAGE = 'This API Route requires a URI parameter that was not satisfied in the latest request.' + + def __init__(self, status_code: int): + super().__init__(status_code) + + +class InvalidQueryParam(APIException): + def __init__(self, status_code: int, query_item: Tuple[str, str]): + super().__init__(status_code) + self.query_item = query_item + + def json(self): + error = super().json() + error['error']['query'] = {'key': self.query_item[0], 'value': self.query_item[1]} + return error + + +class InvalidURIParam(APIException): + def __init__(self, status_code: int, route_param: str): + super().__init__(status_code) + self.route_param = route_param + + def json(self): + error = super().json() + error['error']['param'] = self.route_param + return error + + + +class InvalidQuestion(InvalidURIParam): + MESSAGE = "A invalid question was specified in the request URI and could not be resolved." + + def __init__(self, *args, question): + super().__init__(*args, route_param=question) + + +class InvalidCategory(InvalidURIParam): + MESSAGE = "A invalid category was specified in the request URI and could not be resolved." + + def __init__(self, status_code, category: str): + super().__init__(status_code, route_param=category)