diff --git a/connexion/exceptions.py b/connexion/exceptions.py index a29dda590..42fde153f 100644 --- a/connexion/exceptions.py +++ b/connexion/exceptions.py @@ -11,25 +11,32 @@ class ConnexionException(Exception): - pass + """Base class for any exception thrown by the Connexion framework.""" class ResolverError(LookupError, ConnexionException): - pass + """Error raised at startup when the resolver cannot find a view function for an endpoint in + your specification, and no ``resolver_error`` is configured.""" class InvalidSpecification(ValidationError, ConnexionException): - pass + """Error raised at startup when the provided specification cannot be validated.""" class MissingMiddleware(ConnexionException): - pass + """Error raised when you're leveraging behavior that depends on a specific middleware, + and that middleware is not part of your middleware stack.""" # HTTP ERRORS class ProblemException(HTTPException, ConnexionException): + """ + This exception holds arguments that are going to be passed to the + `connexion.problem` function to generate a proper response. + """ + def __init__( self, *, @@ -41,10 +48,6 @@ def __init__( headers=None, ext=None, ): - """ - This exception holds arguments that are going to be passed to the - `connexion.problem` function to generate a proper response. - """ self.status = self.status_code = status self.title = title self.detail = detail @@ -68,30 +71,41 @@ def to_problem(self): # CLIENT ERRORS (4XX) -class ClientError(ProblemException): +class ClientProblem(ProblemException): + """Base exception for any 4XX error. Returns 400 by default, however + :class:`BadRequestProblem` should be preferred for 400 errors.""" + def __init__(self, status: int = 400, title: str = None, *, detail: str = None): super().__init__(status=status, title=title, detail=detail) -class BadRequestProblem(ClientError): +class BadRequestProblem(ClientProblem): + """Problem class for 400 Bad Request errors.""" + def __init__(self, detail=None): super().__init__(status=400, title="Bad Request", detail=detail) class ExtraParameterProblem(BadRequestProblem): + """Problem class for 400 Bad Request errors raised when extra query or form parameters are + detected and ``strict_validation`` is enabled.""" + def __init__(self, *, param_type: str, extra_params: t.Iterable[str]): detail = f"Extra {param_type} parameter(s) {','.join(extra_params)} not in spec" super().__init__(detail=detail) class TypeValidationError(BadRequestProblem): + """Problem class for 400 Bad Request errors raised when path, query or form parameters with + an incorrect type are detected.""" + def __init__(self, schema_type: str, parameter_type: str, parameter_name: str): - """Exception raised when type validation fails""" detail = f"Wrong type, expected '{schema_type}' for {parameter_type} parameter '{parameter_name}'" super().__init__(detail=detail) -class Unauthorized(ClientError): +class Unauthorized(ClientProblem): + """Problem class for 401 Unauthorized errors.""" description = ( "The server could not verify that you are authorized to access" @@ -105,14 +119,22 @@ def __init__(self, detail: str = description): class OAuthProblem(Unauthorized): + """Problem class for 401 Unauthorized errors raised when there is an issue with the received + OAuth headers.""" + pass class OAuthResponseProblem(OAuthProblem): + """Problem class for 401 Unauthorized errors raised when improper OAuth credentials are + retrieved from your OAuth server.""" + pass class Forbidden(HTTPException): + """Problem class for 403 Unauthorized errors.""" + def __init__(self, detail: t.Optional[str] = None): if detail is None: detail = ( @@ -124,6 +146,8 @@ def __init__(self, detail: t.Optional[str] = None): class OAuthScopeProblem(Forbidden): + """Problem class for 403 Unauthorized errors raised because of OAuth scope validation errors.""" + def __init__(self, token_scopes: list, required_scopes: list) -> None: self.required_scopes = required_scopes self.token_scopes = token_scopes @@ -134,7 +158,10 @@ def __init__(self, token_scopes: list, required_scopes: list) -> None: super().__init__(detail=detail) -class UnsupportedMediaTypeProblem(ClientError): +class UnsupportedMediaTypeProblem(ClientProblem): + """Problem class for 415 Unsupported Media Type errors which are raised when Connexion + receives a request with an unsupported media type header.""" + def __init__(self, detail: t.Optional[str] = None): super().__init__(status=415, title="Unsupported Media Type", detail=detail) @@ -143,6 +170,9 @@ def __init__(self, detail: t.Optional[str] = None): class ServerError(ProblemException): + """Base exception for any 5XX error. Returns 500 by default, however + :class:`InternalServerError` should be preferred for 500 errors.""" + def __init__( self, status: int = 500, @@ -157,6 +187,8 @@ def __init__( class InternalServerError(ServerError): + """Problem class for 500 Internal Server errors.""" + def __init__(self, detail: t.Optional[str] = None): if detail is None: detail = ( @@ -167,11 +199,17 @@ def __init__(self, detail: t.Optional[str] = None): class NonConformingResponse(InternalServerError): + """Problem class for 500 Internal Server errors raised because of a returned response not + matching the specification if response validation is enabled.""" + def __init__(self, detail: t.Optional[str] = None): super().__init__(detail=detail) class NonConformingResponseBody(NonConformingResponse): + """Problem class for 500 Internal Server errors raised because of a returned response body not + matching the specification if response validation is enabled.""" + def __init__(self, detail: t.Optional[str] = None): if detail is None: detail = "Response body does not conform to specification" @@ -180,6 +218,9 @@ def __init__(self, detail: t.Optional[str] = None): class NonConformingResponseHeaders(NonConformingResponse): + """Problem class for 500 Internal Server errors raised because of a returned response headers + not matching the specification if response validation is enabled.""" + def __init__(self, detail: t.Optional[str] = None): if detail is None: detail = "Response headers do not conform to specification" @@ -188,5 +229,8 @@ def __init__(self, detail: t.Optional[str] = None): class ResolverProblem(ServerError): + """Problem class for 501 Not Implemented errors raised when the resolver cannot find a view + function to handle the incoming request.""" + def __init__(self, status: int = 501, *, detail: t.Optional[str] = None): super().__init__(status=status, title="Not Implemented", detail=detail) diff --git a/docs/exceptions.rst b/docs/exceptions.rst index 967c6de05..8cfd22a35 100644 --- a/docs/exceptions.rst +++ b/docs/exceptions.rst @@ -1,146 +1,149 @@ Exception Handling ================== -Rendering Exceptions through the Flask Handler ----------------------------------------------- -Flask by default contains an exception handler, which connexion's app can proxy -to with the ``add_error_handler`` method. You can hook either on status codes -or on a specific exception type. -Connexion is moving from returning flask responses on errors to throwing exceptions -that are a subclass of ``connexion.problem``. So far exceptions thrown in the OAuth -decorator have been converted. +Connexion allows you to register custom error handlers to convert Python ``Exceptions`` into HTTP +problem responses. -Flask Error Handler Example ---------------------------- +.. tab-set:: -The goal here is to make the api returning the 404 status code -when there is a NotFoundException (instead of 500) + .. tab-item:: AsyncApp + :sync: AsyncApp -.. code-block:: python + You can register error handlers on: - def test_should_return_404(client): - invalid_id = 0 - response = client.get(f"/api/data/{invalid_id}") - assert response.status_code == 404 + - The exception class to handle + If this exception class is raised somewhere in your application or the middleware stack, + it will be passed to your handler. + - The HTTP status code to handle + Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues + with a request or response. You can intercept these exceptions with specific status codes + if you want to return custom responses. + .. code-block:: python -Firstly, it's possible to declare what Exception must be handled + from connexion import AsyncApp + from connexion.lifecycle import ConnexionRequest, ConnexionResponse -.. code-block:: python + def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse: + return ConnexionResponse(status_code=404, body=json.dumps({"error": "NotFound"})) - # exceptions.py - class NotFoundException(RuntimeError): - """Not found.""" + app = AsyncApp(__name__) + app.add_error_handler(FileNotFoundError, not_found) + app.add_error_handler(404, not_found) - class MyDataNotFound(NotFoundException): - def __init__(self, id): - super().__init__(f"ID '{id}' not found.") + .. dropdown:: View a detailed reference of the :code:`add_middleware` method + :icon: eye + .. automethod:: connexion.AsyncApp.add_error_handler + :noindex: - # init flask app - import connexion + .. tab-item:: FlaskApp + :sync: FlaskApp - def not_found_handler(error): - return { - "detail": str(error), - "status": 404, - "title": "Not Found", - }, 404 + You can register error handlers on: - def create_app(): + - The exception class to handle + If this exception class is raised somewhere in your application or the middleware stack, + it will be passed to your handler. + - The HTTP status code to handle + Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues + with a request or response. The underlying Flask application will raise + ``werkzeug.HTTPException`` errors. You can intercept both of these exceptions with + specific status codes if you want to return custom responses. - connexion_app = connexion.FlaskApp( - __name__, specification_dir="../api/") - connexion_app.add_api( - "openapi.yaml", validate_responses=True, - base_path="/") + .. code-block:: python - # Handle NotFoundException - connexion_app.add_error_handler( - NotFoundException, not_found_handler) + from connexion import FlaskApp + from connexion.lifecycle import ConnexionRequest, ConnexionResponse - app = connexion_app.app - return app + def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse: + return ConnexionResponse(status_code=404, body=json.dumps({"error": "NotFound"})) -In this way, it's possible to raise anywhere the NotFoundException or its subclasses -and we know the API will return 404 status code. + app = FlaskApp(__name__) + app.add_error_handler(FileNotFoundError, not_found) + app.add_error_handler(404, not_found) -.. code-block:: python + .. dropdown:: View a detailed reference of the :code:`add_middleware` method + :icon: eye - from sqlalchemy.orm.exc import NoResultFound + .. automethod:: connexion.FlaskApp.add_error_handler + :noindex: - from .exceptions import MyDataNotFound - from .models import MyData + .. tab-item:: ConnexionMiddleware + :sync: ConnexionMiddleware + You can register error handlers on: - def get_my_data(id, token_info=None): - try: - data = MyData.query.filter(MyData.id == id).one() + - The exception class to handle + If this exception class is raised somewhere in your application or the middleware stack, + it will be passed to your handler. + - The HTTP status code to handle + Connexion will raise ``starlette.HTTPException`` errors when it encounters any issues + with a request or response. You can intercept these exceptions with specific status codes + if you want to return custom responses. + Note that this might not catch ``HTTPExceptions`` with the same status code raised by + your wrapped ASGI/WSGI framework. - return { - "id": data.id, - "description": data.description, - } + .. code-block:: python - except NoResultFound: - raise MyDataNotFound(id) + from asgi_framework import App + from connexion import ConnexionMiddleware + from connexion.lifecycle import ConnexionRequest, ConnexionResponse + def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse: + return ConnexionResponse(status_code=404, body=json.dumps({"error": "NotFound"})) -Default Exception Handling --------------------------- -By default connexion exceptions are JSON serialized according to -`Problem Details for HTTP APIs`_ + app = App(__name__) + app = ConnexionMiddleware(app) -Application can return errors using ``connexion.problem`` or exceptions that inherit from both -``connexion.ProblemException`` and a ``werkzeug.exceptions.HttpException`` subclass (for example -``werkzeug.exceptions.Forbidden``). An example of this is the ``connexion.exceptions.OAuthProblem`` -exception + app.add_error_handler(FileNotFoundError, not_found) + app.add_error_handler(404, not_found) -.. code-block:: python + .. dropdown:: View a detailed reference of the :code:`add_middleware` method + :icon: eye - class OAuthProblem(ProblemException, Unauthorized): - def __init__(self, title=None, **kwargs): - super(OAuthProblem, self).__init__(title=title, **kwargs) + .. automethod:: connexion.ConnexionMiddleware.add_error_handler + :noindex: -.. _Problem Details for HTTP APIs: https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 +.. note:: -Examples of Custom Rendering Exceptions ---------------------------------------- -To custom render an exception when you boot your connexion application you can hook into a custom -exception and render it in some sort of custom format. For example + Error handlers can be ``async`` coroutines as well. +Default Exception Handling +-------------------------- +By default connexion exceptions are JSON serialized according to +`Problem Details for HTTP APIs`_ -.. code-block:: python +Application can return errors using ``connexion.problem.problem`` or raise exceptions that inherit +either from ``connexion.ProblemException`` or one of its subclasses to achieve the same behavior. - from flask import Response - import connexion - from connexion.exceptions import OAuthResponseProblem +Using this, we can rewrite the handler above: - def render_unauthorized(exception): - return Response(response=json.dumps({'error': 'There is an error in the oAuth token supplied'}), status=401, mimetype="application/json") +.. code-block:: python - app = connexion.FlaskApp(__name__, specification_dir='./../swagger/', debug=False, swagger_ui=False) - app.add_error_handler(OAuthResponseProblem, render_unauthorized) + from connexion.lifecycle import ConnexionRequest, ConnexionResponse + from connexion.problem import problem -Custom Exceptions ------------------ -There are several exception types in connexion that contain extra information to help you render appropriate -messages to your user beyond the default description and status code: + def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse: + return problem( + title="NotFound", + detail="The requested resource was not found on the server", + status=404, + ) -OAuthProblem -^^^^^^^^^^^^ -This exception is thrown when there is some sort of validation issue with the Authorisation Header +.. dropdown:: View a detailed reference of the :code:`problem` function + :icon: eye -OAuthResponseProblem -^^^^^^^^^^^^^^^^^^^^ -This exception is thrown when there is a validation issue from your OAuth 2 Server. It contains a -``token_response`` property which contains the full http response from the OAuth 2 Server + .. autofunction:: connexion.problem.problem -OAuthScopeProblem -^^^^^^^^^^^^^^^^^ -This scope indicates the OAuth 2 Server did not generate a token with all the scopes required. This -contains 3 properties -- ``required_scopes`` - The scopes that were required for this endpoint -- ``token_scopes`` - The scopes that were granted for this endpoint +.. _Problem Details for HTTP APIs: https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 +Connexion Exceptions +-------------------- +There are several exception types in connexion that contain extra information to help you render appropriate +messages to your user beyond the default description and status code: +.. automodule:: connexion.exceptions + :members: + :show-inheritance: + :member-order: bysource