Implement task API with input validation; implement error handling; refactoring
This commit is contained in:
parent
19d264a03b
commit
50aff05614
24 changed files with 612 additions and 131 deletions
1
tofu_api/common/exceptions/__init__.py
Normal file
1
tofu_api/common/exceptions/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .base import AppException
|
||||
23
tofu_api/common/exceptions/base.py
Normal file
23
tofu_api/common/exceptions/base.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from typing import Optional
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""
|
||||
Base class for application specific exceptions that can also be used as API error responses.
|
||||
"""
|
||||
code: str = 'unspecified_error'
|
||||
status_code: int = 400
|
||||
message: str
|
||||
|
||||
def __init__(self, message: str, *, code: Optional[str] = None, status_code: Optional[int] = None):
|
||||
if code is not None:
|
||||
self.code = code
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'code': self.code,
|
||||
'message': self.message,
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
from .base_blueprint import BaseBlueprint
|
||||
from .base_method_view import BaseMethodView
|
||||
from .error_handler import RestApiErrorHandler
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
32
tofu_api/common/rest/base_method_view.py
Normal file
32
tofu_api/common/rest/base_method_view.py
Normal 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)
|
||||
73
tofu_api/common/rest/error_handler.py
Normal file
73
tofu_api/common/rest/error_handler.py
Normal 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
|
||||
30
tofu_api/common/rest/exceptions.py
Normal file
30
tofu_api/common/rest/exceptions.py
Normal 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
|
||||
28
tofu_api/common/string_utils.py
Normal file
28
tofu_api/common/string_utils.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
__all__ = [
|
||||
'SNAKE_CASE_CHARACTERS',
|
||||
'is_snake_case',
|
||||
'str_to_snake_case',
|
||||
]
|
||||
|
||||
import string
|
||||
|
||||
SNAKE_CASE_CHARACTERS = string.ascii_lowercase + string.digits + '_'
|
||||
|
||||
|
||||
def is_snake_case(input_str: str) -> bool:
|
||||
"""
|
||||
Returns True if the input string only consists of snake case characters (lowercase letters, digits, underscore).
|
||||
"""
|
||||
return all(c in SNAKE_CASE_CHARACTERS for c in input_str)
|
||||
|
||||
|
||||
def str_to_snake_case(input_str: str) -> str:
|
||||
"""
|
||||
Converts any string to a snake case string: Whitespaces are replaced with an underscore, uppercase letters are
|
||||
converted to lowercase, and any non-alphanumeric character is removed.
|
||||
"""
|
||||
# First, lowercase string and replace any consecutive whitespaces with a single underscore
|
||||
almost_snake_case = '_'.join(input_str.lower().split())
|
||||
|
||||
# Now, remove all characters that are neither letters, digits, nor underscores
|
||||
return ''.join(filter(lambda c: c in SNAKE_CASE_CHARACTERS, almost_snake_case))
|
||||
Loading…
Add table
Add a link
Reference in a new issue