Implement task API with input validation; implement error handling; refactoring

This commit is contained in:
Lexi / Zoe 2022-09-23 20:38:34 +02:00
parent 19d264a03b
commit 50aff05614
Signed by: binaryDiv
GPG key ID: F8D4956E224DA232
24 changed files with 612 additions and 131 deletions

View file

@ -1 +1,3 @@
from .base_blueprint import BaseBlueprint
from .base_method_view import BaseMethodView
from .error_handler import RestApiErrorHandler

View file

@ -1,16 +1,11 @@
from abc import ABC, abstractmethod
from typing import Callable, Type, TYPE_CHECKING
from typing import TYPE_CHECKING
from flask import Blueprint
from flask.views import View
if TYPE_CHECKING:
from tofu_api.app import App
__all__ = [
'BaseBlueprint',
]
class BaseBlueprint(Blueprint, ABC):
"""
@ -62,11 +57,3 @@ class BaseBlueprint(Blueprint, ABC):
Register child blueprints and URL rules.
"""
raise NotImplementedError
@staticmethod
def create_view_func(view_cls: Type[View], *args, **kwargs) -> Callable:
"""
Helper function to create a view function from a `View` class using `view_cls.as_view()`.
All arguments are passed to the constructor of the view class.
"""
return view_cls.as_view(view_cls.__name__, *args, **kwargs)

View file

@ -0,0 +1,32 @@
from typing import Any
import flask
from flask.typing import RouteCallable
from flask.views import MethodView
from validataclass.validators import Validator
class BaseMethodView(MethodView):
"""
Base class for REST API views.
"""
@property
def request(self) -> flask.Request:
return flask.request
@classmethod
def as_view(cls, *args, **kwargs) -> RouteCallable:
return super().as_view(cls.__name__, *args, **kwargs)
@staticmethod
def empty_response(code: int = 204) -> tuple[str, int]:
return '', code
def validate_request_data(self, validator: Validator) -> Any:
"""
Parses request data as JSON and validates it using a validataclass validator.
"""
# TODO error handling: wrong content type; empty body; invalid json; validation errors
parsed_json = self.request.json
return validator.validate(parsed_json)

View file

@ -0,0 +1,73 @@
import logging
from typing import Union
from flask import Flask, Response, jsonify
from werkzeug.exceptions import HTTPException
from werkzeug.http import HTTP_STATUS_CODES
from tofu_api.common import string_utils
from tofu_api.common.exceptions import AppException
from .exceptions import InternalServerError
T_Response = Union[Response, tuple[Response, int]]
class RestApiErrorHandler:
"""
Error handler class for REST API errors.
"""
# Dependencies
logger: logging.Logger
# Options
debug_mode: bool = False
# Lookup table for HTTP status codes to API error codes
_http_to_api_error_codes: dict[int, str]
def __init__(self, *, debug_mode: bool = False):
self.logger = logging.getLogger(type(self).__name__)
self.debug_mode = debug_mode
# Generate lookup table for HTTP status codes
self._http_to_api_error_codes = {
http_status: string_utils.str_to_snake_case(name)
for http_status, name in HTTP_STATUS_CODES.items()
}
def register_error_handlers(self, app: Flask) -> None:
"""
Registers error handlers for different types of exceptions to the app.
"""
app.register_error_handler(AppException, self.handle_app_exception)
app.register_error_handler(HTTPException, self.handle_http_exception)
app.register_error_handler(Exception, self.handle_generic_exception)
@staticmethod
def handle_app_exception(exception: AppException) -> T_Response:
"""
Handles exceptions of type `AppException` that were not handled by any more specific handler.
"""
return jsonify(exception.to_dict()), exception.status_code
def handle_http_exception(self, exception: HTTPException) -> T_Response:
"""
Handles exceptions of type `HTTPException`, i.e. any werkzeug HTTP exceptions.
"""
if exception.code >= 500:
self.logger.exception('HTTP exception with status code %s: %s', exception.code, type(exception).__name__)
return jsonify({
'code': self._http_to_api_error_codes.get(exception.code, 'unknown_http_error'),
'message': exception.description,
}), exception.code
def handle_generic_exception(self, exception: Exception) -> T_Response:
"""
Fallback handler for any exceptions not handled by any other handler.
"""
self.logger.exception('Uncaught exception: %s', type(exception).__name__)
wrapped_exception = InternalServerError('There was an uncaught error on the server.', inner_exception=exception)
return jsonify(wrapped_exception.to_dict(debug=self.debug_mode)), wrapped_exception.status_code

View file

@ -0,0 +1,30 @@
__all__ = [
'InternalServerError'
]
import traceback
from typing import Optional
from tofu_api.common.exceptions import AppException
class InternalServerError(AppException):
"""
Wrapper exception for any uncaught exception.
"""
status_code = 500
code = 'internal_server_error'
inner_exception: Optional[Exception] = None
def __init__(self, message: str, *, inner_exception: Optional[Exception] = None):
super().__init__(message)
self.inner_exception = inner_exception
def to_dict(self, *, debug: bool = False) -> dict:
data = super().to_dict()
if debug:
data['_debug'] = {
'exception': str(self.inner_exception),
'traceback': traceback.format_exception(self.inner_exception),
}
return data