1

I am using the flask_expects_json library to validate the JSON schema in API requests.

This is working, however, when a request is not valid, the error is thrown in the logs but the details of the error message are not returned by the API. The response is just a generic message "Internal Server Error".

Error message:

{"event": "Exception on /ingest [PUT]", "logger": "app", "level": "error", "exception": "Traceback (most recent call last):\n  File \"/usr/local/lib/python3.8/site-packages/flask_expects_json/__init__.py\", line 30, in decorated_function\n    validate(data, schema)\n  File \"/usr/local/lib/python3.8/site-packages/jsonschema/validators.py\", line 934, in validate\n    raise error\njsonschema.exceptions.ValidationError: 'location' is a required property\n\nFailed validating 'required' in schema}

Response:

{
    "message": "Internal Server Error"
}

Question: How do I handle the errors from flask_expects_json and return the details of the error instead of the server error? In other words, I need the details of the error in the response, such as:

{
    "error": "'location' is a required property"
}

Here is the code I have so far.

from flask import Flask, request, jsonify, current_app
from flask_restx import Resource, Api
from flask_expects_json import expects_json

def create_app():
  app = Flask(__name__)
  api = Api(app, version='1.0', title='Test API', description='Test API Description')

  @api.route('/ingest', endpoint="ingest", methods=['GET', 'POST', 'PUT', 'DELETE'])
  class Ingest(Resource):
    @expects_json(test_schema)
    def get(self, *args, **kwargs):
      # logic removed for brevity
      result = get_result()
      return result

  return app

if __name__ == '__main__':
  app.run()

I also tried writing an error handler to catch these errors as per the flask documentation on error handling and other similar Stack Overflow questions but it does not appear to be doing anything.

class BadRequest(Exception):
  status_code = 400

  def __init__(self, message, status_code=None, payload=None):
    Exception.__init__(self)
    self.message = message
    if status_code is not None:
      self.status_code = status_code
    self.payload = payload

  def to_dict(self):
    rv = dict(self.payload or ())
    rv['message'] = self.message
    return rv

  @app.errorhandler(BadRequest)
  def handle_bad_request(error):
    response = jsonify(error.to_dict())
    response.status_code = error.status_code
    return response

UPDATE: This error is probably caused by the gunicorn structlog configuration that is attempting to convert a ValidationError to JSON and failing therefore throwing the 500 error.

Here is the gunicorn.conf.py

import structlog
import os
from datetime import datetime

if os.environ.get('ENV') == 'development':
  reload = True

hostname = os.uname().nodename
timestamp = datetime.today().strftime('%Y-%m-%d')

pre_chain = [
    structlog.stdlib.add_logger_name,
    structlog.stdlib.add_log_level,
    structlog.stdlib.PositionalArgumentsFormatter(),
    structlog.processors.StackInfoRenderer(),
    structlog.processors.format_exc_info,
    structlog.processors.UnicodeDecoder(),
    structlog.processors.TimeStamper(fmt='iso', utc=True),
]

logconfig_dict = {
    "version": 1,
    "disable_existing_loggers": True,
    "formatters": {
        "json_formatter": {
            "()": structlog.stdlib.ProcessorFormatter,
            "processor": structlog.processors.JSONRenderer(),
            "foreign_pre_chain": pre_chain,
        }
    },
    "handlers": {
        "error_console": {
            "class": "logging.FileHandler",
            "formatter": "json_formatter",
            "filename": "/home/logs/error_console_{}_{}.log".format(hostname, timestamp),
            "mode": "a"
        },
        "console": {
            "class": "logging.FileHandler",
            "formatter": "json_formatter",
            "filename": "/home/logs/console_{}_{}.log".format(hostname, timestamp),
            "mode": "a"
        }
    },
    "loggers": {
        'gunicorn.error': {
            'handlers': ['console'],
            'level': os.environ.get('APP_LOG_LEVEL', 'INFO'),
            'propagate': False,
        },
        'gunicorn.access': {
            'handlers': ['console'],
            'level': os.environ.get('APP_LOG_LEVEL', 'INFO'),
            'propagate': False,
        }
    }
}

Here is the 500 error that is occurring when it attempts to throw the 400 Bad Request error but then runs into a TypeError. TypeError: Object of type ValidationError is not JSON serializable.

Failed validating 'required' in schema
   
During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  
  File "/usr/local/lib/python3.8/json/__init__.py", line 231, in dumps
    return _default_encoder.encode(obj)
  File "/usr/local/lib/python3.8/json/encoder.py", line 199, in encode
    chunks = self.iterencode(o, _one_shot=True)
  File "/usr/local/lib/python3.8/json/encoder.py", line 257, in iterencode
    return _iterencode(o, 0)
  File "/usr/local/lib/python3.8/json/encoder.py", line 179, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type ValidationError is not JSON serializable

UPDATE 2 [WORKAROUND]: As mentioned I think this issue is due to an error handling issue when expects_json throws a ValidationError. It might be related to the gunicorn structlog config or it might be something that can be fixed in the expects_json library. As a workaround I removed expects_json and created a custom decorator to validate the json schema using the jsonschema library similar to the accepted answer in this related question. Flask: Decorator to verify JSON and JSON Schema

Here is the full code of my solution. Again this does not actually solve the issue directly but is an acceptable workaround and does provide a bit more control.

from flask import request, jsonify, make_response, current_app
import os, json, jsonschema

def validate_json_schema(schema_name):
  def decorator(f):
    def wrapper(*args, **kwargs):

      basedir = current_app.config['BASE_DIR']
      jsonmodelsdir = basedir + '/jsonmodels'

      if not request.json:
        error_msg = "Missing JSON data."
        return make_response(jsonify({'error': 'invalidRequest', 'message': error_msg}), 400)

      try:
        with open('{}/{}.json'.format(jsonmodelsdir, schema_name)) as json_file:
          json_model = json.load(json_file)
      except Exception as e:
        error_msg = "Server error: Unable to get json model."
        current_app.logger.error(error_msg)
        current_app.logger.error(e)
        return make_response(jsonify({'error': 'unavailable', 'message': error_msg}), 500)

      try:
        jsonschema.validate(request.json, json_model)
      except json.decoder.JSONDecodeError as e:
        error_msg = "Invalid JSON format: {}".format(e)
        return make_response(jsonify({'error': 'invalidRequest', 'message': error_msg}), 400)
      except jsonschema.exceptions.ValidationError as e:
        error_msg = "Invalid JSON schema: {}".format(e)
        return make_response(jsonify({'error': 'invalidRequest', 'message': error_msg}), 400)
      except Exception as e:
        error_msg = "Server error: Unable to validate json model."
        current_app.logger.error(e)
        return make_response(jsonify({'error': 'unavailable', 'message': error_msg}), 500)

      return f(*args, **kwargs)
    return wrapper
  return decorator
pengz
  • 2,279
  • 3
  • 48
  • 91

2 Answers2

0

Did you try running flask in debug mode ?

if __name__ == '__main__':
    app.run(debug=True)
Mathieu Rollet
  • 2,016
  • 2
  • 18
  • 31
  • Yes, no difference though. To clarify I want to catch the detail of the validation error and return it in the response. – pengz Oct 06 '20 at 20:07
0

The documentation has an example for flask_expects_json error handling: https://pypi.org/project/flask-expects-json/

My answer here expands on that documentation to further clarify how to do this,

imports

from flask import make_response, jsonify
from flask_expects_json import expects_json
from jsonschema import ValidationError

example schema

schema = {
    'type': 'object',
    'properties': {
        'data': {'type': 'string', "minLength": 31}
    },
    'required': ['data']
}

example error handling logic

@app.errorhandler(400)
def bad_request(error):
    if isinstance(error.description, ValidationError):
        original_error = error.description
        return make_response(jsonify({'error': original_error.message}), 400)
    # handle other "Bad Request"-errors
    return error

example post endpoint

@app.post("/temp")
@expects_json(schema)
def temp_post():
    if request.is_json:
        request_json = request.get_json()
        data = request_json.get("data")
        return make_response(jsonify({"data_sent": data}), 200)

When calling this endpoint with a JSON object that does not have the key "data" the following error message will return

{
    "error": "'data' is a required property"
}

(Maybe those examples did not exist when this question was originally asked)

smurphy
  • 148
  • 7