Skip to main content

Python exceptions serializable to Flask HTTP responses.

Project description

Flask-ApiExceptions is a Flask extension that provides the basic functionality for serializing uncaught exceptions as HTTP responses for a JSON-based REST API.

Installation

You can install this extension with pip:

$ pip install flask_apiexceptions

Or, you can clone the repository:

$ git clone https://github.com/jperras/flask_apiexceptions.git

Running the Tests

Tox is used to run the tests, which are written using PyTest. To run them, clone the repository (indicated above), ensure tox is installed and available, and run:

$ cd path/to/flask_apiexceptions
$ tox

Usage

This package includes an extension named JSONExceptionHandler, which can be added to your application in the usual way:

from flask import Flask
from flask_apiexceptions import JSONExceptionHandler

app = Flask(__name__)
exception_handler = JSONExceptionHandler(app)

The extension can also be initialized via deferred application init if you’re using an application factory:

exception_handler = JSONExceptionHandler()
exception_hander.init_app(app)

Once initialized, the extension doesn’t actually do anything by default. You’ll have to configure it to handle Werkzeug HTTP error codes or custom Exception classes.

Custom Exception Class Handling

An example showing how we can raise a custom exception within a view method, and have that exception be transformed into a JSON response:

class MissingUserError(Exception):
    status_code = 404
    message = 'No such user exists.'

@app.route('/not-found')
def testing():
    raise MissingUserError()

ext = JSONExceptionHandler(app)
ext.register(code_or_exception=MissingUserError)

with app.app_context():
    with app.test_client() as c:
        rv = c.get('/not-found')

assert rv.status_code == 404
assert rv.headers['content-type'] == 'application/json'
assert json.loads(rv.data)['message'] == 'No such user exists.'

This uses the JSONExceptionHandler.default_handler() to transform the CustomError exception class into a suitable response. It attempts to introspect the exception instance returned for a message or description attribute, and also checks to see if there exists a status_code attribute.

If any of those fields are found, the default handler will populate the response data with the given message, and set the response status code. If no message or status code is present, a default response of {"message": "An error occurred!"} with an HTTP/1.1 500 Internal Server Error status code is set.

If you’d like to handle custom exception classes in a different manner, say because you have more complex data captured within an exception instance, or the attributes are not conveniently named message or description, then you can specify a custom handler for the exception type:

from flask_apiexceptions import JSONExceptionHandler

app = Flask(__name__)
ext = JSONExceptionHandler(app)

class CaffeineError(Exception):
    teapot_code = 418
    special = {'foo': 'bar'}

def caffeine_handler(error):
    response = jsonify(data=error.special)
    response.status_code = error.teapot_code
    return response

@app.route('/testing')
def testing():
    raise CaffeineError()

ext.register(code_or_exception=CaffeineError, handler=caffeine_handler)

with app.app_context():
    with app.test_client() as c:
        rv = c.get('/testing')

assert rv.status_code == 418
assert rv.headers['content-type'] == 'application/json'
assert json.loads(rv.data)['data'] == CaffeineError.special

This is also how, incidentally, you could use a response content type other than application/json. Simply construct your own response object isntead of using jsonify() within your handler, as long as it produces a valid response as a return value.

Using ApiException and ApiError objects

Flask-ApiExceptions includes a few convenience classes and a handler method for setting up structured API error responses. They are entirely optional, but provide some sane defaults that should cover most situatiosn.

An ApiException instance wraps one or more ApiError instances. In this sense the ApiException is simply the container for the actual error message. The ApiError instance accepts optional code, message, and info attributes.

The idea is that the code should be an identifier for the type of error, for example invalid-data or does-not-exist. The message field should provide a more detailed and precise description of the error. The info field can be used for any additional metadata or unstructured information that may be required.

The info field, if utilized, should contain data that is JSON serializable.

To use these constructs, you need to register the appropriate exception class as well as an api_exception_handler that is provided for just this purpose:

from flask_apiexceptions import (
    JSONExceptionHandler, ApiException, ApiError, api_exception_handler)

app = Flask(__name__)
ext = JSONExceptionHandler(app)
ext.register(code_or_exception=ApiException, handler=api_exception_handler)

@app.route('/custom')
def testing():
    error = ApiError(code='teapot', message='I am a little teapot.')
    raise ApiException(status_code=418, error=error)


with app.app_context():
    with app.test_client() as c:
        rv = c.get('/custom')

        # JSON response looks like...
        # {"errors": [{"code": "teapot", "message": "I am a little teapot."}]}

assert rv.status_code == 418
assert rv.headers['content-type'] == 'application/json'

json_data = json.loads(rv.data)
assert json_data['errors'][0]['message'] == 'I am a little teapot.'
assert json_data['errors'][0]['code'] == 'teapot'
assert json_data['errors'][0]['info'] is None

Note that, when using the ApiException and ApiError classes, the status code is set on the ApiException instance. This makes more sense when you can set multiple ApiError objects to the same ApiException:

from flask_apiexceptions import ApiException, ApiError

# ...

@app.route('/testing')
def testing():
    exc = ApiException(status_code=400)
    invalid_address_error = ApiError(code='invalid-data',
                                     message='The address provided is invalid.')
    invalid_phone_error = ApiError(code='invalid-data',
                                   message='The phone number does not exist.',
                                   info={'area_code': '555'})
    exc.add_error(invalid_address_error)
    exc.add_error(invalid_phone_error)

    raise exc

    # JSON response format:
    # {"errors": [
    #     {"code": "invalid-data", "message": "The address provided is invalid."},
    #     {"code": "invalid-data", "message": "The phone number does not exist.", "info": {"area_code": "444"}}
    # ]}

If you only want a single error to be instantiated within the ApiException, this can be done via the constructor of the latter as a shorthand:

exc = ApiException(
    status_code=400,
    code='invalid-data',
    message='The address provided is invalid',
    info={'zip_code': '90210'})

which is the equivalent of:

exc = ApiException(status_code=400)
error=ApiError(
    code='invalid-data',
    message='The address provided is invalid',
    info={'zip_code': '90210'}))

exc.add_error(error)

A useful pattern is to subclass ApiException into distinctly useful exception types, on which you can define default class-level attributes that will be used to populate the correct error object on instantiation. For example:

class MissingResourceError(ApiException):
    status_code = 404
    message = "No such resource exists."
    code = 'not-found'

# ...

@app.route('/posts/<int:post_id>')
def post_by_id(post_id):
    """Fetch a single post by ID from the database."""

    post = Post.query.filter(Post.id == post_id).one_or_none()
    if post is None:
        raise MissingResourceError()

    # 404 response, wiht JSON body:
    # {"errors": [
    #     {"code": "not-found", "message": "No such resource exists."}
    # ]}

The nice thing about this particular pattern is that you can raise semantically correct exceptions within your codebase, and can choose to handle them in the call stack. If you don’t handle them, they simply bubble up to the exception handler (if you’ve configured the flask_apiexceptions.api_exception_handler or similar) registered with Flask, and are then transformed into a useful response for the requesting client.

class MissingResourceError(ApiException):
    status_code = 404
    message = "No such resource exists."
    code = 'not-found'

class Post(db.Model):
    # ...
    @classmethod
    def query_by_id(cls, post_id):
        """Query Post by ID, raise exception if not found."""
        result = cls.query.filter(cls.id == post_id).one_or_none()
        if result is None:
            raise MissingResourceError()

        return result

@app.route('/posts/<int:post_id>')
def post_by_id(post_id):
    """Fetch a single post by ID from the database."""

    try:
        post = Post.query_by_id(post_id)
    except MissingResourceError as e:
        # We can do whatever we want now that we've caught the exception.
        # For the sake of illustration, we're just going to log it.
        app.logger.exception("Could not locate post!")

        # Will bubble up the exception until it is rendered to JSON
        # for the client.
        raise e

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

Flask-ApiExceptions-1.1.2.tar.gz (8.9 kB view details)

Uploaded Source

Built Distribution

Flask_ApiExceptions-1.1.2-py2.py3-none-any.whl (8.2 kB view details)

Uploaded Python 2Python 3

File details

Details for the file Flask-ApiExceptions-1.1.2.tar.gz.

File metadata

File hashes

Hashes for Flask-ApiExceptions-1.1.2.tar.gz
Algorithm Hash digest
SHA256 98a6fcfc3e835cba73fe188dc23b8b055af04165456c7136465234a4f20be169
MD5 bb9a3d7ea7f2aecf996f16df95956169
BLAKE2b-256 bcb463e7039ba491353268335e04186ccecc3f2f0b3b31ea3f9eec911847710a

See more details on using hashes here.

File details

Details for the file Flask_ApiExceptions-1.1.2-py2.py3-none-any.whl.

File metadata

File hashes

Hashes for Flask_ApiExceptions-1.1.2-py2.py3-none-any.whl
Algorithm Hash digest
SHA256 db62d5751626f47a50d560e9828432f438c8d709b8bb48f42185ffd86bde9828
MD5 f60bb010434af1ef678763cbc4d8a7a5
BLAKE2b-256 eb8ce1203e99bccb5cb549e0892502fd8708c278dac5168dd2b9322ad71e906d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page