Flask JSON Input Validation

Creating Python Microservices, Part 2

We’ve created a simple Python microservice using Flask in Part 1. Using that as a starting point, let’s take a quick side-trip through input validation before we deploy the whole thing on Kubernetes.

Code for this can be found on GitHub. Or you can use this template as a starting point.

Input validation

We don’t have any validation yet, so let’s add that in. We want to have a 400 error of some sort, so let’s write a test for it:

# src/tests/app/test_validation.py

def test_hello_requires_greetee(client):
   request_payload = {}
   response = client.post("/hello", json=request_payload)
   result = response.get_json()

   assert response.status_code == 400
   assert result is not None
   assert "message" in result
   assert "is a required property" in result['message'][0]

This test doesn’t pass yet. In our case, we’re using json, and there are many ways input can go wrong—missing keys, bad types, weird structure. So let’s come up with a json schema to validate our greeting input. The flask extension flask_input can validate incoming json against the python jsonschema library.

This comes from the suggestions on validating API errors:

# /src/app/invalid_usage.py
class InvalidUsage(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

Then let’s register an error handler in main.py

# src/app/main.py

@app.errorhandler(InvalidUsage)
def handle_invalid_usage(error):
   response = jsonify(error.to_dict())
   response.status_code = error.status_code
   return response

Then rewrite our hello-handler to validate the input

# src/app/main.py

@app.route("/hello", methods=['POST'])
def hello() -> str:
   errors = validate_greeting(request)
   if errors is not None:
       print(errors)
       raise InvalidUsage(errors)
   greetee = request.json.get("greetee", None)
   response = {"message": say_hello_to(greetee)}
   return jsonify(response)

You’ll see that 400 errors can be created by raising an InvalidUsage exception, which is processed by our handler.

Flask_inputs is a bit behind in maintenance—I get some Deprecation warnings when I run it:

tests/app/test_main.py::test_hello_greets_greetee
  C:\Users\MikeBridge\venv\pythondemo\lib\site-packages\flask_inputs\inputs.py:37: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated, and in 3.8 it will stop working
    elif isinstance(input, collections.Iterable):

Ugh. Let’s get rid of the deprecation warning:

python -W ignore::DeprecationWarning -m pytest -s

Lastly, let’s implement the validation. Have a look at this and this for more information on how the json schema works:

# src/app/validation.py

from flask_inputs import Inputs
from flask_inputs.validators import JsonSchema

# https://pythonhosted.org/Flask-Inputs/#module-flask_inputs
# https://json-schema.org/understanding-json-schema/
# we want an object containing a required greetee  string value
greeting_schema = {
   'type': 'object',
   'properties': {
       'greetee': {
           'type': 'string',
       }
   },
   'required': ['greetee']
}


class GreetingInputs(Inputs):
   json = [JsonSchema(schema=greeting_schema)]


def validate_greeting(request):
   inputs = GreetingInputs(request)
   if inputs.validate():
       return None
   else:
       return inputs.errors

You could make this more succinct by triggering the validation as a method decorator, but I think it’s easier to unit test if it’s more explicit. Let’s test our schema out by creating a valid request via the fixture create_valid_greeting_request, then altering it in various ways to test different inputs:

# src/app/tests/conftest.py

import pytest
from app import main

# ...

@pytest.fixture()
def create_valid_greeting_request():
    """
    Helper function for creating a correctly-structured
    json request
    """
    def _create_valid_greeting_request(greetee="fixture"):
        return {
            "greetee": greetee
        }
    return _create_valid_greeting_request
# src/tests/app/test_validation.py

import pytest
from flask import Flask, request

from app.validation import validate_greeting

app = Flask(__name__)


@pytest.mark.parametrize("params", [
   {"greetee": 1},
   {"greetee": ["array"]}
])
def test_invalid_types_are_rejected(params, create_valid_greeting_request):
   json_input = create_valid_greeting_request(**params)
   with app.test_request_context('/', json=json_input):
       errors = validate_greeting(request)
       assert errors is not None


@pytest.mark.parametrize("required_parm_name", ["greetee"])
def test_missing_required_params_is_rejected(required_parm_name, create_valid_greeting_request):
   json_input = create_valid_greeting_request()
   del json_input[required_parm_name]
   with app.test_request_context('/', json=json_input):
       errors = validate_greeting(request)
       assert errors is not None


def test_valid_greetee_is_accepted(create_valid_greeting_request):
   json_input = create_valid_greeting_request(greetee="Tester")
   with app.test_request_context('/', json=json_input):
       errors = validate_greeting(request)
       assert errors is None

Our flask app is pretty much done at this point. Next, let’s dockerize it and ship it via Kubernetes.