CTEC2712 Web application development

Data Validation

Rejecting bad user data

Contents

  1. 400 Bad Request
  2. User data are always strings
  3. Controllers need to validate data
  4. Validation Schema
  5. Validator functions
  6. Validating fields and schema
  7. Extracting more information
  8. Controllers need to validate data
  9. Integrating errors into views

400 Bad Request

Sometimes our users provide bad data

A form with error messages telling the user what went wrong

In these cases, we need to provide sensible feedback with a 400 status code.

1

User data are always strings

We always need to handle strings.

Via search parameters in the URL like /mypage?anything=string.

1
2
const url = new URL(request.url);
const userString = url.searchParams.get("anything");

Via named groups in the URL like /users/string.

1
2
3
const pattern = new URLPattern({pathname: "/users/:anything"});
const match = pattern.exec(request.url);
const userString = match.pathname.groups.anything;

Via form data in POST requests.

1
2
const formData = await request.formData();
const userString = formData.get('anything');

HTTP only provides us with strings.

2

Controllers need to validate data

It is the responsibility of the controller to decide how to respond to the incoming request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export function itemsController() {
    const items = getItems();
    return render(itemsView, { items });
}

export async function createItemController({ request }) {
    const formData = await request.formData();
 
    // some kind of complex validation code goes here. 
    const { isValid, errors, validated } = validateSchema(formData, newItemSchema);

    // With this data, we can keep the code simple.
    if (!isValid) {
        const items = getItems();
        return render(itemsView, {items, errors}, 400);
    }
    createItem(validated);
    return redirect("/");
}

If the request contains invalid data, we need to present the user with feedback.

3

Validation Schema

Our solution will rely on data structures we define which describe the constraints we want to impose on our user data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { isDate, maxLength, minLength, required } from "../tools/validation.js";

export const newItemSchema = {
    'label': {
        validators: [required, minLength(3), maxLength(10)],
        displayName: "Label"
    },
    'due': {
        validators: [required, isDate],
        displayName: "Due date"
    }
};

the validators are simple functions which we can define.

4

Validator functions

Our validator functions detect errors and return error messages.

A simple required function detects whether we have an empty string.

1
2
3
export function required(name, value) { 
    if (!value) return `'${name}' is a required field.`;
}

We can check if the provided string makes a valid Date object

1
2
3
4
5
export function isDate(name, value) { 
    if(new Date(value).toString() == "Invalid Date") {
        return `'${name}' must be a valid date.`;
    }
}

We can use closures to return a function for any minimum (or maximum) length.

1
2
3
4
5
6
7
export function minLength(min) {
    return (name, value) => { 
        if(value.length < min) {
            return `'${name}' must have at least ${min} characters.`
        }
    }
}

Returning nothing (undefined) means validation passed.

5

Validating fields and schema

In our schema, each field can be given many validator functions.

Validating a field, the first error message is returned or we implicitly return undefined.

1
2
3
4
5
6
export function validateField(name, value, validators) {
    for (const validator of validators) {
        const error = validator(name, value);
        if (error) return error;
    }
}

Validating our formData against an entire schema is a bit more complex. Relying on Object.entries and Object.fromEntries to convert our schema object into an array and back to an object.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export function validateSchema(formData, schema) { 
    const schemaEntries = Object.entries(schema);
    const errorEntries = schemaEntries.map(([key, {validators, displayName}]) => {
        const value = formData.get(key);
        const message = validateField(displayName || key, value, validators) || "";
        return [key, {value, message, error: !!message}];
    });
    const errors = Object.fromEntries(errorEntries);
    return errors;
}

The validateSchema function can be expanded to provide whatever data we need.

6

Extracting more information

To be really useful, our validateSchema function needs to return more information. We can add isValid boolean and validated data.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
export function validateSchema(formData, schema) { 
    let isValid = true;
    const validated = {};
    const schemaEntries = Object.entries(schema);
    const errorEntries = schemaEntries.map(([key, {validators, displayName}]) => {
        const value = formData.get(key);
        const message = validateField(displayName || key, value, validators) || "";
        if(message) {
            isValid = false;
        } else {
            validated[key] = value;
        }
        return [key, {value, message, error: !!message}];
    });
    const errors = Object.fromEntries(errorEntries);
    return { errors, isValid, validated };
}

Notice that ONLY fields which are present in the schema are being considered. If the user provides additional fields, they are ignored.

7

Controllers need to validate data

It is the responsibility of the controller to decide how to respond to the incoming request.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export function itemsController() {
    const items = getItems();
    return render(itemsView, { items });
}

export async function createItemController({ request }) {
    const formData = await request.formData();
 
    // some kind of complex validation code goes here. 
    const { isValid, errors, validated } = validateSchema(formData, newItemSchema);

    // With this data, we can keep the code simple.
    if (!isValid) {
        const items = getItems();
        return render(itemsView, {items, errors}, 400);
    }
    createItem(validated);
    return redirect("/");
}

If the request contains invalid data, we need to present the user with feedback.

8

Integrating errors into views

The form integrates errors using optional chaining.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
function itemToHTML(item) {
    return `<li><span class="label">${item.label}</span>
        <span>Due ${item.due.toLocaleDateString("en-gb", fmt)}</span>
        <form method="POST" action="/items/${item.id}/delete"><button>&times;</button></form></li>`;
}
export function itemsView({ items, errors = {} }) {    
    return `<section aria-labelledby="items-heading" id="items">
        <h2 id="items-heading">Items</h2>
        <ul class="items">${items.map(itemToHTML).join("\n")}</ul>
        <form method="POST" id="new-item">
            <h3>Create new item</h3>
            <label for="label">Label: </label>
            <input id="label" name="label" placeholder="e.g. 'apples'" value="${errors.label?.value || ""}">
            <span class="error">${errors.label?.message || ""}</span>
            <label for="due">Due date: </label>
            <input id="due" name="due" type="datetime-local" value="${errors.due?.value || ""}">
            <span class="error">${errors.due?.message || ""}</span>
            <button>add</button>
        </form>
    </section>`
} 

Use the CSS :empty pseudoclass to set the error messages to display: none when no message is provided.

9

Data Validation

If you have any questions, now is a good time to ask.

Thanks for listening