Input Validation

GraphQL does not define a way to perform input validation, beyond checking the types and non-null. Its error messages have location information, but only to the field, not to a specific argument or input field.

Magql provides a robust input validation system on top of GraphQL. One or more validator functions can be attached to any Field, Argument, InputObject, and InputField. Before a field’s resolver is called, validators are run on its input structure, depth first.

The structure of the inputs to a field can be arbitrarily complex. The type of an argument can be an InputObject instead of a scalar, and the type of an InputField can be another input object, and so on. A type may be wrapped in a List an arbitrary number of times. This makes the overall validation system potentially very complex, although in practice a well-designed API should not exhibit too much complexity.

There is overlap between the different validation points described below. There are multiple ways to validate the same data, in order to support arbitrary input, but you may want to settle on a consistent style to make it easier to reason about your API design.

Adding a Validator

There are a few ways to add a validator to a node. These examples will show Argument, the most common case. Other nodes behave the same, the only difference is the signature of the validator callable as described in the next two sections.

When creating the argument, one or more validator callables can be passed as a list to the validators parameter.

import magql

def validate_lowercase(info, value, data):
    if not value.islower():
        raise magql.ValidationError("Must be lowercase.")

field = magql.Field("String", args={
    "username": magql.Argument("String", validators=[validate_lowercase])
})

If the argument was already defined, you can add to its validators by decorating the function with Argument.validator().

import magql

field = magql.Field("String", args={"username": "String"})

@field.args["username"].validator
def validate_username(info, value, data):
    if not value.islower():
        raise magql.ValidationError("Must be lowercase.")

Validating an Argument or InputField

The most common task is to validate an individual value, regardless of the complexities of its type.

The validator callables to Argument and InputField are value validators. Value validator functions must take the following arguments:

  • info - The GraphQL resolver info.

  • value - The single input value being validated.

  • data - The dict of input values passed to the parent node. This can be used to check this value against another, but could also be handled by a data validator on the parent, described in the next section.

The function can raise a ValidationError with one of two values:

  • A single string will be appended to the list of messages for the argument.

  • A list of strings will extend the list of messages for the argument.

import magql

def validate_lower(info, value, data):
    if not value.islower():
        raise magql.ValidationError(f"'{info.field_name}' must be lower case.")
import magql

def validate_username(info, value, data):
    errors = []

    if len(value) < 8:
        errors.append("Username must be at least 8 characters.")

    if "@" in value:
        errors.append("Username must not contain '@'.")

    if errors:
        raise magql.ValidationError(errors)

Validating a Field or InputObject

Rather than validating an individual input value, you may want to validate the collection of input values. For example, you could validate that an input is required only if another input is given.

The validator callables to Field and InputObject are data validators. Data validator functions must take the following arguments:

  • info - The GraphQL resolver info.

  • data - The dict of input values passed to the node.

If validation fails, the function should raise a ValidationError with a message in one of three forms:

  • A single string will be appended to the list of messages for the field.

  • A list of strings will extend the list of messages for the field.

  • A dict will map error messages to specific input keys. Each value can be a string or a list, like above. The empty string "" key is used for the field itself, like above.

import magql

def validate_auth(info, data):
    if not current_user.is_admin:
        raise magql.ValidationError("Must be an admin to edit this data.")
import magql

def validate_required_together(info, data):
    if "first" in data and "second" not in data:
        raise magql.ValidationError(
            {"second": "'second' must be given if 'first' is."}
        )

Validating a List

Any type can be wrapped in a List, making the input a list of values of that type. In this case, you may want to treat the list as a single value to validate overall, or you may want to validate each item in the list.

For example, you may want to validate a list of numbers by checking that the list has at least one value, and that each value is positive.

import magql

def validate_size(info, value, data):
    if len(value) < 1:
        raise magql.ValidationError("Must provide at least one value.")

def validate_positive(info, value, data):
    if value <= 0:
        raise magql.ValidationError("Value must be greater than zero.")

field = magql.Field("[Int!]!", args={
    "values": Argument(Int.non_null.list.non_null, validators=[
        validate_size, [validate_positive]
    ])
})

The first validator in the validators list is referenced directly, it applies to the whole list as a single value. The second validator is wrapped in another list, which indicates that it should be applied to each item in the list value This can be nested arbitrarily, validators=[[[v]]] would apply v to each item in an input that is a list of lists.

Items that do not have errors will have null in the list of errors. For example, [null, "Must be greater than zero.", null] means there were three items and only the second was invalid. The errors will be nested in extra lists in the same way that validators was.

Errors in the Result

If any validation errors are raised, the GraphQL operation result will have the errors key set. The error raised by Magql will have the message magql argument validation, and its extensions will be the validation message structure.

Each individual input will be associated with a list of errors. Errors may be strings, or may be further mappings or lists with messages for nested input objects and list types. Each collection of inputs will be associated with a map that maps the names of inputs to the list of error message for that input, with an empty string "" key for messages related to the collection.

{"errors": [{
    "message": "magql argument validation",
    "extensions": {
        "name": ["Must be lowercase.", "Must be more than 2 characters."],
        "color": {"green": ["Must be less than 256."]},
        "people": [[null, {"age": "Must be greater than 0."}]],
    }
}]}

With this structure, it is possible to target exactly what input in the UI resulted in what error messages in the result. When writing a UI, you’ll presumably know how your API validates data, and can code specifically for the shape of the errors you expect.

Reusable Validators

Validators can be a callable class (defines __call__) instead of a plain function. This is a useful pattern for making configurable validators to be reused on different arguments. For example, a validator that enforces a max length.

import magql

class Length:
    def __init__(self, max: int):
        self.max = max

    def __call__(self, info, value, data):
        if len(value) > self.max:
            raise magql.ValidationError(f"Length must be at most {self.max}.")

info_field = magql.Field("Info", args={
    "short_help": magql.Argument("String", validators=[Length(200)])
    "full_help": magql.Argument("String", validators=[Length(800)])
})