Update Exceptions documentations (#1758)

Contributes towards #1531
This commit is contained in:
Robbe Sneyders
2023-10-29 09:58:05 +01:00
committed by GitHub
parent 452ffc7676
commit d8ceb52822
2 changed files with 163 additions and 116 deletions

View File

@@ -11,25 +11,32 @@ from .problem import problem
class ConnexionException(Exception): class ConnexionException(Exception):
pass """Base class for any exception thrown by the Connexion framework."""
class ResolverError(LookupError, ConnexionException): 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): class InvalidSpecification(ValidationError, ConnexionException):
pass """Error raised at startup when the provided specification cannot be validated."""
class MissingMiddleware(ConnexionException): 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 # HTTP ERRORS
class ProblemException(HTTPException, ConnexionException): 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__( def __init__(
self, self,
*, *,
@@ -41,10 +48,6 @@ class ProblemException(HTTPException, ConnexionException):
headers=None, headers=None,
ext=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.status = self.status_code = status
self.title = title self.title = title
self.detail = detail self.detail = detail
@@ -68,30 +71,41 @@ class ProblemException(HTTPException, ConnexionException):
# CLIENT ERRORS (4XX) # 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): def __init__(self, status: int = 400, title: str = None, *, detail: str = None):
super().__init__(status=status, title=title, detail=detail) 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): def __init__(self, detail=None):
super().__init__(status=400, title="Bad Request", detail=detail) super().__init__(status=400, title="Bad Request", detail=detail)
class ExtraParameterProblem(BadRequestProblem): 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]): def __init__(self, *, param_type: str, extra_params: t.Iterable[str]):
detail = f"Extra {param_type} parameter(s) {','.join(extra_params)} not in spec" detail = f"Extra {param_type} parameter(s) {','.join(extra_params)} not in spec"
super().__init__(detail=detail) super().__init__(detail=detail)
class TypeValidationError(BadRequestProblem): 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): 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}'" detail = f"Wrong type, expected '{schema_type}' for {parameter_type} parameter '{parameter_name}'"
super().__init__(detail=detail) super().__init__(detail=detail)
class Unauthorized(ClientError): class Unauthorized(ClientProblem):
"""Problem class for 401 Unauthorized errors."""
description = ( description = (
"The server could not verify that you are authorized to access" "The server could not verify that you are authorized to access"
@@ -105,14 +119,22 @@ class Unauthorized(ClientError):
class OAuthProblem(Unauthorized): class OAuthProblem(Unauthorized):
"""Problem class for 401 Unauthorized errors raised when there is an issue with the received
OAuth headers."""
pass pass
class OAuthResponseProblem(OAuthProblem): class OAuthResponseProblem(OAuthProblem):
"""Problem class for 401 Unauthorized errors raised when improper OAuth credentials are
retrieved from your OAuth server."""
pass pass
class Forbidden(HTTPException): class Forbidden(HTTPException):
"""Problem class for 403 Unauthorized errors."""
def __init__(self, detail: t.Optional[str] = None): def __init__(self, detail: t.Optional[str] = None):
if detail is None: if detail is None:
detail = ( detail = (
@@ -124,6 +146,8 @@ class Forbidden(HTTPException):
class OAuthScopeProblem(Forbidden): 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: def __init__(self, token_scopes: list, required_scopes: list) -> None:
self.required_scopes = required_scopes self.required_scopes = required_scopes
self.token_scopes = token_scopes self.token_scopes = token_scopes
@@ -134,7 +158,10 @@ class OAuthScopeProblem(Forbidden):
super().__init__(detail=detail) 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): def __init__(self, detail: t.Optional[str] = None):
super().__init__(status=415, title="Unsupported Media Type", detail=detail) super().__init__(status=415, title="Unsupported Media Type", detail=detail)
@@ -143,6 +170,9 @@ class UnsupportedMediaTypeProblem(ClientError):
class ServerError(ProblemException): class ServerError(ProblemException):
"""Base exception for any 5XX error. Returns 500 by default, however
:class:`InternalServerError` should be preferred for 500 errors."""
def __init__( def __init__(
self, self,
status: int = 500, status: int = 500,
@@ -157,6 +187,8 @@ class ServerError(ProblemException):
class InternalServerError(ServerError): class InternalServerError(ServerError):
"""Problem class for 500 Internal Server errors."""
def __init__(self, detail: t.Optional[str] = None): def __init__(self, detail: t.Optional[str] = None):
if detail is None: if detail is None:
detail = ( detail = (
@@ -167,11 +199,17 @@ class InternalServerError(ServerError):
class NonConformingResponse(InternalServerError): 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): def __init__(self, detail: t.Optional[str] = None):
super().__init__(detail=detail) super().__init__(detail=detail)
class NonConformingResponseBody(NonConformingResponse): 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): def __init__(self, detail: t.Optional[str] = None):
if detail is None: if detail is None:
detail = "Response body does not conform to specification" detail = "Response body does not conform to specification"
@@ -180,6 +218,9 @@ class NonConformingResponseBody(NonConformingResponse):
class NonConformingResponseHeaders(NonConformingResponse): 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): def __init__(self, detail: t.Optional[str] = None):
if detail is None: if detail is None:
detail = "Response headers do not conform to specification" detail = "Response headers do not conform to specification"
@@ -188,5 +229,8 @@ class NonConformingResponseHeaders(NonConformingResponse):
class ResolverProblem(ServerError): 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): def __init__(self, status: int = 501, *, detail: t.Optional[str] = None):
super().__init__(status=status, title="Not Implemented", detail=detail) super().__init__(status=status, title="Not Implemented", detail=detail)

View File

@@ -1,146 +1,149 @@
Exception Handling 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 Connexion allows you to register custom error handlers to convert Python ``Exceptions`` into HTTP
that are a subclass of ``connexion.problem``. So far exceptions thrown in the OAuth problem responses.
decorator have been converted.
Flask Error Handler Example .. tab-set::
---------------------------
The goal here is to make the api returning the 404 status code .. tab-item:: AsyncApp
when there is a NotFoundException (instead of 500) :sync: AsyncApp
.. code-block:: python You can register error handlers on:
def test_should_return_404(client): - The exception class to handle
invalid_id = 0 If this exception class is raised somewhere in your application or the middleware stack,
response = client.get(f"/api/data/{invalid_id}") it will be passed to your handler.
assert response.status_code == 404 - 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 app = AsyncApp(__name__)
class NotFoundException(RuntimeError): app.add_error_handler(FileNotFoundError, not_found)
"""Not found.""" app.add_error_handler(404, not_found)
class MyDataNotFound(NotFoundException): .. dropdown:: View a detailed reference of the :code:`add_middleware` method
def __init__(self, id): :icon: eye
super().__init__(f"ID '{id}' not found.")
.. automethod:: connexion.AsyncApp.add_error_handler
:noindex:
# init flask app .. tab-item:: FlaskApp
import connexion :sync: FlaskApp
def not_found_handler(error): You can register error handlers on:
return {
"detail": str(error),
"status": 404,
"title": "Not Found",
}, 404
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( .. code-block:: python
__name__, specification_dir="../api/")
connexion_app.add_api(
"openapi.yaml", validate_responses=True,
base_path="/")
# Handle NotFoundException from connexion import FlaskApp
connexion_app.add_error_handler( from connexion.lifecycle import ConnexionRequest, ConnexionResponse
NotFoundException, not_found_handler)
app = connexion_app.app def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse:
return app return ConnexionResponse(status_code=404, body=json.dumps({"error": "NotFound"}))
In this way, it's possible to raise anywhere the NotFoundException or its subclasses app = FlaskApp(__name__)
and we know the API will return 404 status code. 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 .. tab-item:: ConnexionMiddleware
from .models import MyData :sync: ConnexionMiddleware
You can register error handlers on:
def get_my_data(id, token_info=None): - The exception class to handle
try: If this exception class is raised somewhere in your application or the middleware stack,
data = MyData.query.filter(MyData.id == id).one() 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 { .. code-block:: python
"id": data.id,
"description": data.description,
}
except NoResultFound: from asgi_framework import App
raise MyDataNotFound(id) 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"}))
app = App(__name__)
app = ConnexionMiddleware(app)
app.add_error_handler(FileNotFoundError, not_found)
app.add_error_handler(404, not_found)
.. dropdown:: View a detailed reference of the :code:`add_middleware` method
:icon: eye
.. automethod:: connexion.ConnexionMiddleware.add_error_handler
:noindex:
.. note::
Error handlers can be ``async`` coroutines as well.
Default Exception Handling Default Exception Handling
-------------------------- --------------------------
By default connexion exceptions are JSON serialized according to By default connexion exceptions are JSON serialized according to
`Problem Details for HTTP APIs`_ `Problem Details for HTTP APIs`_
Application can return errors using ``connexion.problem`` or exceptions that inherit from both Application can return errors using ``connexion.problem.problem`` or raise exceptions that inherit
``connexion.ProblemException`` and a ``werkzeug.exceptions.HttpException`` subclass (for example either from ``connexion.ProblemException`` or one of its subclasses to achieve the same behavior.
``werkzeug.exceptions.Forbidden``). An example of this is the ``connexion.exceptions.OAuthProblem``
exception Using this, we can rewrite the handler above:
.. code-block:: python .. code-block:: python
class OAuthProblem(ProblemException, Unauthorized): from connexion.lifecycle import ConnexionRequest, ConnexionResponse
def __init__(self, title=None, **kwargs): from connexion.problem import problem
super(OAuthProblem, self).__init__(title=title, **kwargs)
def not_found(request: ConnexionRequest, exc: Exception) -> ConnexionResponse:
return problem(
title="NotFound",
detail="The requested resource was not found on the server",
status=404,
)
.. dropdown:: View a detailed reference of the :code:`problem` function
:icon: eye
.. autofunction:: connexion.problem.problem
.. _Problem Details for HTTP APIs: https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00 .. _Problem Details for HTTP APIs: https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
Examples of Custom Rendering Exceptions Connexion 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
.. code-block:: python
from flask import Response
import connexion
from connexion.exceptions import OAuthResponseProblem
def render_unauthorized(exception):
return Response(response=json.dumps({'error': 'There is an error in the oAuth token supplied'}), status=401, mimetype="application/json")
app = connexion.FlaskApp(__name__, specification_dir='./../swagger/', debug=False, swagger_ui=False)
app.add_error_handler(OAuthResponseProblem, render_unauthorized)
Custom Exceptions
-----------------
There are several exception types in connexion that contain extra information to help you render appropriate 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: messages to your user beyond the default description and status code:
OAuthProblem .. automodule:: connexion.exceptions
^^^^^^^^^^^^ :members:
This exception is thrown when there is some sort of validation issue with the Authorisation Header :show-inheritance:
:member-order: bysource
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
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