From f53b4b9ea3955f23d4c5c05f493499c34f8f92a9 Mon Sep 17 00:00:00 2001 From: goddessana Date: Wed, 1 May 2024 20:32:52 +0900 Subject: [PATCH 1/3] Add `.idea` to `.gitignore` --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 68bc17f9..2dc53ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ From 983b7a0feade0a9ba078d9859d4416355b5afb6b Mon Sep 17 00:00:00 2001 From: goddessana Date: Sat, 4 May 2024 22:32:05 +0900 Subject: [PATCH 2/3] Improving error handlers --- src/flask_smorest/__init__.py | 7 +- src/flask_smorest/error_handler.py | 15 +-- src/flask_smorest/exceptions.py | 158 +++++++++++++++++++++++++++-- tests/test_error_handler.py | 58 ++++++++--- 4 files changed, 201 insertions(+), 37 deletions(-) diff --git a/src/flask_smorest/__init__.py b/src/flask_smorest/__init__.py index 7bcbdd6a..828bbaab 100644 --- a/src/flask_smorest/__init__.py +++ b/src/flask_smorest/__init__.py @@ -1,12 +1,11 @@ """Api extension initialization""" -from webargs.flaskparser import abort # noqa - -from .spec import APISpecMixin from .blueprint import Blueprint # noqa -from .pagination import Page # noqa from .error_handler import ErrorHandlerMixin +from .exceptions import abort # noqa from .globals import current_api # noqa +from .pagination import Page # noqa +from .spec import APISpecMixin from .utils import PrefixedMappingProxy, normalize_config_prefix diff --git a/src/flask_smorest/error_handler.py b/src/flask_smorest/error_handler.py index b06cc620..367f943b 100644 --- a/src/flask_smorest/error_handler.py +++ b/src/flask_smorest/error_handler.py @@ -1,9 +1,9 @@ """Exception handler""" -from werkzeug.exceptions import HTTPException - import marshmallow as ma +from flask_smorest.exceptions import ApiException + class ErrorSchema(ma.Schema): """Schema describing the error payload @@ -28,24 +28,19 @@ def _register_error_handlers(self): This method registers a default error handler for ``HTTPException``. """ - self._app.register_error_handler(HTTPException, self.handle_http_exception) + self._app.register_error_handler(ApiException, self.handle_http_exception) def handle_http_exception(self, error): """Return a JSON response containing a description of the error - This method is registered at app init to handle ``HTTPException``. + This method is registered at app init to handle ``ApiException``. - - When ``abort`` is called in the code, an ``HTTPException`` is + - When ``abort`` is called in the code, an ``ApiException`` is triggered and Flask calls this handler. - When an exception is not caught in a view, Flask makes it an ``InternalServerError`` and calls this handler. - flask-smorest republishes webargs's - :func:`abort `. This ``abort`` allows the - caller to pass kwargs and stores them in ``exception.data`` so that the - error handler can use them to populate the response payload. - Extra information expected by this handler: - `message` (``str``): a comment diff --git a/src/flask_smorest/exceptions.py b/src/flask_smorest/exceptions.py index a810ba2d..a880aafc 100644 --- a/src/flask_smorest/exceptions.py +++ b/src/flask_smorest/exceptions.py @@ -7,30 +7,166 @@ class FlaskSmorestError(Exception): """Generic flask-smorest exception""" +# Non-API exceptions class MissingAPIParameterError(FlaskSmorestError): """Missing API parameter""" -class NotModified(wexc.HTTPException, FlaskSmorestError): - """Resource was not modified (Etag is unchanged) +class CurrentApiNotAvailableError(FlaskSmorestError): + """`current_api` not available""" - Exception created to compensate for a lack in Werkzeug (and Flask) - """ - code = 304 - description = "Resource not modified since last request." +# API exceptions +class ApiException(wexc.HTTPException, FlaskSmorestError): + """A generic API error.""" + + @classmethod + def get_exception_by_code(cls, code): + for exception in cls.__subclasses__(): + if exception.code == code: + return exception + return InternalServerError + + +class BadRequest(wexc.BadRequest, ApiException): + """Bad request""" + + +class Unauthorized(wexc.Unauthorized, ApiException): + """Unauthorized access""" + + +class Forbidden(wexc.Forbidden, ApiException): + """Forbidden access""" + + +class NotFound(wexc.NotFound, ApiException): + """Resource not found""" + + +class MethodNotAllowed(wexc.MethodNotAllowed, ApiException): + """Method not allowed""" + + +class NotAcceptable(wexc.NotAcceptable, ApiException): + """Not acceptable""" + + +class RequestTimeout(wexc.RequestTimeout, ApiException): + """Request timeout""" + + +class Conflict(wexc.Conflict, ApiException): + """Conflict""" + + +class Gone(wexc.Gone, ApiException): + """Resource gone""" + + +class LengthRequired(wexc.LengthRequired, ApiException): + """Length required""" + + +class PreconditionFailed(wexc.PreconditionFailed, ApiException): + """Etag required and wrong ETag provided""" + + +class RequestEntityTooLarge(wexc.RequestEntityTooLarge, ApiException): + """Request entity too large""" + + +class RequestURITooLarge(wexc.RequestURITooLarge, ApiException): + """Request URI too large""" + + +class UnsupportedMediaType(wexc.UnsupportedMediaType, ApiException): + """Unsupported media type""" + + +class RequestedRangeNotSatisfiable(wexc.RequestedRangeNotSatisfiable, ApiException): + """Requested range not satisfiable""" -class PreconditionRequired(wexc.PreconditionRequired, FlaskSmorestError): +class ExpectationFailed(wexc.ExpectationFailed, ApiException): + """Expectation failed""" + + +class ImATeapot(wexc.ImATeapot, ApiException): + """I'm a teapot""" + + +class UnprocessableEntity(wexc.UnprocessableEntity, ApiException): + """Unprocessable entity""" + + +class Locked(wexc.Locked, ApiException): + """Locked""" + + +class FailedDependency(wexc.FailedDependency, ApiException): + """Failed dependency""" + + +class PreconditionRequired(wexc.PreconditionRequired, ApiException): """Etag required but missing""" # Overriding description as we don't provide If-Unmodified-Since description = 'This request is required to be conditional; try using "If-Match".' -class PreconditionFailed(wexc.PreconditionFailed, FlaskSmorestError): - """Etag required and wrong ETag provided""" +class TooManyRequests(wexc.TooManyRequests, ApiException): + """Too many requests""" -class CurrentApiNotAvailableError(FlaskSmorestError): - """`current_api` not available""" +class RequestHeaderFieldsTooLarge(wexc.RequestHeaderFieldsTooLarge, ApiException): + """Request header fields too large""" + + +class UnavailableForLegalReasons(wexc.UnavailableForLegalReasons, ApiException): + """Unavailable for legal reasons""" + + +class InternalServerError(wexc.InternalServerError, ApiException): + """Internal server error""" + + +class NotImplemented(wexc.NotImplemented, ApiException): + """Not implemented""" + + +class BadGateway(wexc.BadGateway, ApiException): + """Bad gateway""" + + +class ServiceUnavailable(wexc.ServiceUnavailable, ApiException): + """Service unavailable""" + + +class GatewayTimeout(wexc.GatewayTimeout, ApiException): + """Gateway timeout""" + + +class HTTPVersionNotSupported(wexc.HTTPVersionNotSupported, ApiException): + """HTTP version not supported""" + + +class NotModified(ApiException): + """Resource was not modified (Etag is unchanged) + + Exception created to compensate for a lack in Werkzeug (and Flask) + """ + + code = 304 + description = "Resource not modified since last request." + + +def abort(http_status_code, exc=None, **kwargs): + try: + raise ApiException.get_exception_by_code(http_status_code) + except ApiException as err: + err.data = kwargs + err.exc = exc + raise err + except Exception as err: + raise err diff --git a/tests/test_error_handler.py b/tests/test_error_handler.py index 0b0bc4c3..79068968 100644 --- a/tests/test_error_handler.py +++ b/tests/test_error_handler.py @@ -1,39 +1,73 @@ import pytest +from flask import abort as flask_abort from werkzeug.exceptions import InternalServerError, default_exceptions -from flask_smorest import Api, abort +from flask_smorest import Api +from flask_smorest import abort as api_abort +from flask_smorest.exceptions import ApiException class TestErrorHandler: @pytest.mark.parametrize("code", default_exceptions) - def test_error_handler_on_abort(self, app, code): + def test_error_handler_on_api_abort(self, app, code): client = app.test_client() - @app.route("/abort") + @app.route("/api-abort") def test_abort(): - abort(code) + api_abort(code) Api(app) - response = client.get("/abort") + response = client.get("/api-abort") assert response.status_code == code + assert response.content_type == "application/json" assert response.json["code"] == code assert response.json["status"] == default_exceptions[code]().name - def test_error_handler_on_unhandled_error(self, app): + @pytest.mark.parametrize("code", default_exceptions) + def test_error_handler_on_default_abort(self, app, code): + client = app.test_client() + + @app.route("/html-abort") + def test_abort(): + flask_abort(code) + + Api(app) + + response = client.get("/html-abort") + assert response.status_code == code + assert response.content_type == "text/html; charset=utf-8" + + def test_flask_error_handler_on_unhandled_error(self, app): # Unset TESTING to let Flask return 500 on unhandled exception app.config["TESTING"] = False client = app.test_client() - @app.route("/uncaught") + @app.route("/html-uncaught") def test_uncaught(): raise Exception("Oops, something really bad happened.") Api(app) - response = client.get("/uncaught") + response = client.get("/html-uncaught") assert response.status_code == 500 + assert response.content_type == "text/html; charset=utf-8" + assert "

Internal Server Error

" in response.text + + def test_api_error_handler_on_unhandled_error(self, app): + # Unset TESTING to let Flask return 500 on unhandled exception + app.config["TESTING"] = False + client = app.test_client() + + @app.route("/api-uncaught") + def test_uncaught(): + raise ApiException("Oops, something really bad happened.") + + Api(app) + + response = client.get("/api-uncaught") + assert response.content_type == "application/json" assert response.json["code"] == 500 assert response.json["status"] == InternalServerError().name @@ -45,19 +79,19 @@ def test_error_handler_payload(self, app): @app.route("/message") def test_message(): - abort(404, message="Resource not found") + api_abort(404, message="Resource not found") @app.route("/messages") def test_messages(): - abort(422, messages=messages, message="Validation issue") + api_abort(422, messages=messages, message="Validation issue") @app.route("/errors") def test_errors(): - abort(422, errors=errors, messages=messages, message="Wrong!") + api_abort(422, errors=errors, messages=messages, message="Wrong!") @app.route("/headers") def test_headers(): - abort( + api_abort( 401, message="Access denied", headers={"WWW-Authenticate": 'Basic realm="My Server"'}, From 14481906c0697d863e089d7e817183fdf4287261 Mon Sep 17 00:00:00 2001 From: goddessana Date: Mon, 6 May 2024 16:34:46 +0900 Subject: [PATCH 3/3] Revert "Add `.idea` to `.gitignore`" This reverts commit f53b4b9ea3955f23d4c5c05f493499c34f8f92a9. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2dc53ca3..68bc17f9 100644 --- a/.gitignore +++ b/.gitignore @@ -157,4 +157,4 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ +#.idea/