V for Validator

habr.png

Когда перед нами стоит выбор инструмента для валидации пользовательских данных, то речь чаще идет о интерфейсе задания правил. Сегодня таких инструментов превеликое множество от декларативных до объектных. Каждый валидатор пытается быть выразительным и простым в использовании. Но я хочу обратить ваше внимание на результат работы валидатора — отчеты. Каждый разработчик норовит сделать свое решение и если для интерфейсов от разнообразия лишь польза, то для получаемого результата наоборот. В общем, давайте взглянем на проблему.


Осторожно! После прочтения статьи вы, возможно, захотите выкинуть ваш любимый валидатор.


Сегодня средства валидации многообразны, но бедны в возможностях. Вы часто можете встретить сообщение об ошибке в виде: логин должен содержать цифры или буквы. Это классический пример плохого дизайна отчета об ошибках. Возьмем сообщение компилятора go, встретившего некорректный символ:


test.go:16:1: invalid character U+0023 '#'


Компилятор указывает место возникновения и причину ошибки. А теперь представьте, что компилятор заменит его на сообщение:


test.go: file should contain valid code


Как вам такое?! Почему мы ждем от инструмента подробного отчета, а пользователю возвращаем клочок информации. Чем исходный код отличается от значения логина «в глазах» программы?


Текущее положение дел


Вот список самых распространненных отчетов об ошибках:


  1. Валидатор возвращает строку, массив/объект строк.
  2. Валидатор возвращает true/false (npm validator).
  3. Валидатор выбрасывает исключение.
  4. Валидатор выводит отчет в консоль (npm prop-types).


Такие данные непригодны для дальнейшего использования, например для интернационализации или интерпретации, а значит и бесполезны. Как следствие, библиотеки не взаимозаменяемы, а компоненты системы завязываются на уникальное представление. Чтобы передать отчет на клиент, приходится писать собственные обертки.


Давайте попробуем это исправить и сформируем общие требования к представлению отчета.


Требования


Забегая вперед скажу, что этот вариант уже несколько лет успешно работает в продакшене.

Вот требования к отчету на которые опирался я:


  1. Удобная программная обработка: значения вместо сообщений.
  2. Представление объектов любой структуры: храним полные пути.
  3. Удобная интернационализация: использование ID для правил.
  4. Сохранение читаемости: использование человекопонятных кодов.
  5. Переносимость: отчет не привязан к среде исполнения или конкретному языку.


ValidationReport


Так появился ValidationReport — массив состоящий из объектов Issue. Каждый Issue — это объект, содержащий поля path, rule и details.


  • path — массив строк или чисел. Путь поля внутри объекта. Может быть пустым, в случае, если валидируемое значение — примитив.
  • rule — строка. Код ошибки.
  • details — объект. Объект произвольного вида, содержащий данные о причине ошибки.


JavaScript:


[
    {
        path: ['login'],
        rule: 'syntax',
        details: {
            pos: 1,
            expect: ['LETTER', 'NUMBER'],
            is: '$',
        },
    },
]


Go:


type Details map[string]interface{};

type Issue struct {
    Path []string `json:"path"`
    Rule string `json:"rule"`
    Details Details `json:"details"`
}

type Report []Issue;

//...
issue:= Issue{
    Path: []string{"login"},
    Rule: "syntax",
    Details: Details{
        "pos": 1,
        "expect": []string{"LETTER", "NUMBER"},
        "is": "$",
    },
}

report := Report{issue}


Такой отчет легко конвертируется в любое другое представление, он подробен и нагляден. Теперь вместо логин должен содержать цифры или буквы становится возможным вывести: Логин содержит недопустимый символ '$': позиция 1. При валидации вложенных структур, легко управлять путями.


Специфические коды ошибок могут быть представлены в виде URI.


Пример


В виде примера реализуем некоторые библиотечные функции, валидатор для логина и имплементацию на JavaScript в функциональном стиле. Готовый код на jsbin.


Библиотечные функции


Здесь будут реализованы два метода для создания Issue (createIssue) и для добавления префикса к значению Issue.path (pathRefixer):


function createIssue(path, rule, details = {}) {
    return {path, rule, details};
}

function pathPrefixer(...prefix) {
    return ({path, rule, details}) => ({
        path: [...prefix, ...path],
        rule,
        details,
    });
}


Валидатор логина


Собственно тот самый валидатор логина.


const LETTER = /[a-z]/;
const NUMBER = /[0-9]/;

function validCharsLength(login) {
    let i;
    for (i = 0; i < login.length; i++) {
        const char = login[i];

        if (i === 0) {
            if (! LETTER.test(char)) {
                break;
            }
        }
        else {
            if (! LETTER.test(char) && ! NUMBER.test(char)) {
                break;
            }
        }
    }

    return i;
}

function validateLogin(login) {
    const validLength = validCharsLength(login);

    if (validLength < login.length) {
        return [
            createIssue([], 'syntax', {
                pos: validLength,
                expect: validLength > 0 ? ['NUMBER', 'LETTER'] : ['LETTER'],
                is: login.slice(validLength, validLength + 1),
            }),
        ];
    }
    else {
        return [];
    }
}

function stringifySyntaxIssue({details}) {
  return `Invalid character "${details.is}" at postion ${details.pos}.`;
}


Имплементация


Реализация на уровне приложения. Добавляем функцию проверки модели и абстрактного запроса использующего модель:


function validateUser(user) {
    return validateSyntax(user.login)
    .map(pathPrefixer('login'));
}

function validateUsersRequest(request) {
    return request.users
    .reduce((reports, user, i) => {
        const report = validateUser(user)
        .map(pathPrefixer('users', i));

        return [...reports, ...report];
    }, []);
}

const usersRequest = {
    users: [
        {login: 'adm!n'},
        {login: 'u$er'},
        {login: '&@%#'},
    ],
};

function stringifyLoginSyntaxIssue(issue) {
    return `User #${issue.path[1]} login: ${stringifySyntaxIssue(issue)}`;
}

const report = validateUsersRequest(usersRequest);

const loginSyntaxIssues = report.filter(
  ({path, rule}) => path[2] === 'login' && rule === 'syntax'
);

console.log(report);
console.log(loginSyntaxIssues.map(stringifyLoginSyntaxIssue).join('\n'));


Заключение


Использование ValidationReport позволит комбинировать различные библиотеки для валидации и управлять процессом на свое усмотрение: например выполнить трудоемкие проверки параллельно, а затем конкатенировать результат. Отчеты от разных программ представляются однотипно и позволяют переиспользовать код их обработчиков.


Реализация


На сегодняшний день существует пакет для nodejs:


  • npm-пакет validation-report.

© Habrahabr.ru