python

LIVR — «независимые от языка правила валидации» или валидация данных без «проблем»

  • понедельник, 15 июня 2015 г. в 02:10:53
http://habrahabr.ru/post/246521/

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

Основные проблемы, которые встречаются в библиотеках валидации данных

Проблема №1. Многие валидаторы проверяют только те данные, для которых описаны правила проверки. Для меня важно, чтобы любой пользовательский ввод, который явно не разрешен, был проигнорирован. То есть, валидатор должен вырезать все данные для которых не описаны правила валидации. Это просто фундаментально требование.

Проблема №2. Процедурное описание правил валидации. Я не хочу каждый раз думать про алгоритм валидации, я просто хочу описать декларативно, как должны выглядеть правильные данные. По сути, я хочу задать схему данных (почему не «JSON Schema» — в конце поста).

Проблема №3. Описание правил валидации в виде кода. Казалось бы, это не так страшно, но это сразу сводит на нет все попытки сериализации правил валидации и использования одних и тех же правил валидации на бекенде и фронтенде.

Проблема №4. Валидация останавливается на первом же поле с ошибкой. Такой подход не дает возможности подсветить сразу все ошибочные/обязательные поля в форме.

Проблема №5. Нестандартизированные сообщения об ошибках. Например, «Field name is required». Такую ошибку я не могу показать пользователю по ряду причин:
  • поле в интерфейсе может называться совсем по другому
  • интерфейс может быть не на английском
  • нужно различать тип ошибки. Например, ошибки на пустое значение показывать специальным образом

То есть, нужно возвращать не сообщение об ошибках, а стандартизированные коды ошибок.

Проблема №6. Числовые коды ошибок. Это просто неудобно в использовании. Я хочу, чтобы коды ошибок были интуитивно понятны. Согласитесь, что код ошибки «REQUIRED» понятней, чем код «27». Логика аналогична работе с классами исключений.

Проблема №7. Нет возможности проверять иерархические структуры данных. Сегодня, во времена разных JSON API, без этого просто не обойтись. Кроме самой валидации иерархических данных, нужно предусмотреть и возврат кодов ошибок для каждого поля.

Проблема №8. Ограниченный набор правил. Стандартных правил всегда не хватает. Валидатор должен быть расширяемый и позволять добавлять в него правила любой сложности.

Проблема №9. Слишком широкая сфера ответственности. Валидатор не должен генерировать формы, не должен генерировать код, не должен делать ничего, кроме валидации.

Проблема №10. Невозможность провести дополнительную обработку данных. Практически всегда, где есть валидация, есть необходимость в какой-то дополнительной (часто предварительной) обработке данных: вырезать запрещенные символы, привести в нижний регистр, удалить лишние пробелы. Особенно актуально — это удаление пробелов в начале и в конце строки. В 99% случаев они там не нужны. Я знаю, что я до этого говорил, что валидатор не должен делать ничего кроме валидации.

3 года назад, было решено написать валидатор, который не будет иметь всех вышеописанных проблем. Так появился LIVR (Language Independent Validation Rules). Есть реализации на Perl, PHP, JavaScript, Python (мы на python не пишем — фидбек по ней дать не могу). Валидатор используется в продакшене уже несколько лет практически в каждом проекте компании. Валидатор работает, как на сервере, так и на клиенте. Поиграться с валидатором можно тут — webbylab.github.io/livr-playground.

Ключевой идеей было то, что ядро валидатора должно быть минимальным и вся логика валидации находится должна в правилах (вернее в их реализации). То есть, для валидатора нет разницы между правилами «required» (проверяет наличие значения), «max_length» (проверяет максимальную длину), «to_lc» (приводит данные в нижний регистра), «list_of_objects» (помогает описать правила для поля, которое содержит массив объектов).

Другими словами, валидатор ничего не знает ничего:
  • о кодах ошибок
  • о том, что он умеет валидировать иерархические объекты
  • о том, что он умеет преобразовывать/чистить данные
  • о многом другом

Все это ответственность правил валидации.

Спецификация LIVR

Поскольку задача стояла сделать валидатор независимым от языка программирования, этакий себе mustache/handlebars, но только в мире валидации данных, то начали мы с написания спецификации.

Цели спецификации:
  1. Стандартизировать формат описания данных.
  2. Описать минимальный набор правил валидации, которые должны поддерживаться каждой реализацией.
  3. Стандартизировать коды ошибок.
  4. Быть единой базовой документацией для всех реализаций.
  5. Иметь набор тестовых данных, которые позволяет проверить реализацию на соответствие спецификации

Спецификация доступна по адресу livr-spec.org

Основной идеей было то, что описание правил валидации должно выглядеть, как схема данных и максимально быть похожими на данные, только вместо значений правила.

Пример описания правил валидации для формы авторизации (демо):
{
    email: ['required', 'email'],
    password: 'required'
}

Пример правил валидации для регистрационной формы (демо):
{
    name: 'required',
    email: ['required', 'email'],
    gender: { one_of: ['male', 'female'] },
    phone: {max_length: 10},
    password: ['required', {min_length: 10} ]
    password2: { equal_to_field: 'password' }
}

Пример валидации вложенного объекта (демо):
{
    name: 'required',
    phone: {max_length: 10},
    address: { 'nested_object': {
        city: 'required',
        zip: ['required', 'positive_integer']
    }}
}

Правила валидации

Как описываются правила валиции? Каждое правило состоит из имени и аргументов (практически, как вызов функции) и в общем случае описывается следующим образом {«RULE_NAME»: ARRAY_OF_ARGUMENTS}. Для каждого поля описывается массив правил, которые применяются в порядке следования.

Например,
{
    "login": [ { length_between: [ 5, 10 ] } ]
}

То есть, у нас есть поле «login» и правило «length_between», которое имеет 2 аргумента ( «5» и «10» ). Это наиболее полная форма, но разрешены следующие упрощения

  • Если правило к полю одно, то массив не обязателен
  • Если у правила один аргумент, то можно передавать только его (не обрамляя в массив)
  • Если у правила не аргументов, то можно записать просто название правила.

Все 3 записи идентичны:
"login": [ { required: [] } ]

"login": [ "required" ]

"login": "required"

Более детально расписано в спецификации в разделе «How it works».

Поддерживаемые правила

Все правила можно разделить на 3 глобальных группы:
  • Правила, которые валидируют данные (числа, строки и тд). Например, «max_length».
  • Правила, которые позволяют составлять более сложные правила с более простых. Например, «nested_object».
  • Правила, которые преобразовывают данные. Например, «to_lc»

но сам валидатор не делает различия между ними, для него они все равноправны.

Вот общий список правил, которые должны поддерживаться каждой реализаций валидатора:

Базовые правила
  • required — поле обязательно и значение должно быть не пустым
  • not_empty — поле не обязательно, но если оно есть, то не может быть пустым
  • not_empty_list — значение должно содержать не пустой массив

Правила для проверки строк
  • one_of
  • max_length
  • min_length
  • length_between
  • length_equal
  • like

Правила для проверки чисел
  • integer
  • positive_integer
  • decimal
  • positive_decimal
  • max_number
  • min_number
  • number_between

Правила для специальных форматов
  • email
  • url
  • iso_date
  • equal_to_field

Правила для описания более сложных правил (метаправила)
  • nested_object — описывает правила для вложенного объекта
  • list_of — описывает правила, которым должен соответствовать каждый элемент списка
  • list_of_objects — значение должно быть массивом объектов нужном формате
  • list_of_different_objects — используйте, когда нужно проверить массив объектов разных типов.

Правила для преобразования данных (названия начинаются с глагола)
  • trim — убирает пробелы в начале в конце
  • to_lc — приводит к нижнему регистру
  • to_uc — приводит к верхнему регистру
  • remove — удаляет указанные символы
  • leave_only — оставляет только указанные символы

Метаправила

Пример и коды ошибок для каждого правила можно найти в LIVR-спецификации. Немного детальней остановимся лишь на метаправилах. Метаправила — это правила, которые позволяет скомбинировать простые правила в более сложные для валидации сложных иерархических структур данных. Важно понимать, что валидатор не делает различия между простыми правилами и метаправилами. Метаправила ничем не отличаются от того же “required” (да, я повторяюсь).

nested_object
Позволяет описывать правила валидации для вложенных объектов. Этим правилом вы будете пользоваться постоянно.
Код ошибки зависит от вложенных правил. Если вложенный объект не является хешом (словарем), то поле будет содержать ошибку: “FORMAT_ERROR”.
Пример использования (демо):

address: { 'nested_object': {
    city: 'required',
    zip: ['required', 'positive_integer']
}}

list_of
Позволяет описать правила валидации для списка значений. Каждое правило будет применяться для каждого элемента списка.
Код ошибки зависит от вложенных правил.
Пример использования (демо):

{ product_ids: { 'list_of': [ 'required',  'positive_integer'] }}

list_of_objects
Позволяет описать правила валидации для массива хешей(словарей). Похоже на «nested_object», но ожидает массив объектов. Правила применяются для каждого элемента в массиве.
Код ошибки зависит от вложенных правил. В случае если значение не является массивом, для поля будет возвращен код “FORMAT_ERROR”.
Пример использования (демо):
products: ['required', { 'list_of_objects': {
    product_id: ['required','positive_integer'],
    quantity: ['required', 'positive_integer']
}}]

list_of_different_objects
Аналогичен «list_of_objects», но бывает, что массив, который нам приходит, содержит объекты разного типа. Тип объекта мы можем определить по какому-то полю, например, «type». «list_of_different_objects» позволяет описать правила для списка объектов разного вида.
Код ошибки зависит от вложенных правил валидации. Если вложенных объект не является хешом, то поле будет содержать ошибку “FORMAT_ERROR”.
Пример использования (демо):

{
    products: ['required', { 'list_of_different_objects': [
        product_type, {
            material: {
                product_type: 'required',
                material_id: ['required', 'positive_integer'],
                quantity: ['required', {'min_number': 1} ],
                warehouse_id: 'positive_integer'
            },
            service: {
                product_type: 'required',
                name: ['required', {'max_length': 20} ]
            }
        }
    ]}]
}

В этом примере валидатор будут смотреть на “product_type” в каждом хеше и, в завимости от значения этого поля, будет использовать соответствующие правила валидации.

Формат ошибок

Как уже было сказано, правила возвращают строковые коды ошибок, которые понятны разработчику, например, «REQUIRED», «WRONG_EMAIL», «WRONG_DATE» и тд. Теперь разработчик может понять, в чем ошибка, осталось удобно донести в каких полях она возникла. Для этого валидатор возвращает структуру аналогичную идентичную переданной ему на валидацию, но она содержит только поля в которых возникли ошибки и вместо исходных значений в полях строковые коды ошибок.

Например, есть правила:
{
    name: 'required',
    phone: {max_length: 10},
    address: { 'nested_object': {
        city: 'required',
        zip: ['required', 'positive_integer']
    }}
}

и данные для валидации:
{
    phone: 12345678901,
    address: {
       city: 'NYC' 
    }
}

на выходе получим следующую ошибку
{
    "name": "REQUIRED",
    "phone": "TOO_LONG",
    "address": {
        "zip": "REQUIRED"
    }
}

демо валидации

REST API и формат ошибок

Возврат вменяемых ошибок всегда требует дополнительных усилий от разработчиков. И очень мало REST API, которые дают детальную информацию в ошибках. Часто это просто «Bad request» и все. Хочется, чтобы глядя на ошибку, к какому полю она относится и просто пути поля недостаточно, поскольку данные могут быть иерархическими и содержать массивы объектов… У нас в компании мы поступаем следующим образом — абсолютно для каждого запроса описываем правила валидации при помощи LIVR. В случае ошибки валидации, мы возвращаем объект ошибки клиенту. Объект ошибки содержит глобальный код ошибки и ошибку полученную от LIVR валидатора.

Например, вы передается данные на сервер:

{
    "email": "user_at_mail_com",
    "age": 10,
    "address": {
        "country": "USQ"
    }
}

и в ответ получаете (демо валидации на livr playground):

{"error": {
    "code": "FORMAT_ERROR",
    "fields": {
        "email": "WRONG_EMAIL",
        "age": "TOO_LOW",
        "fname": "REQUIRED",
        "lname": "REQUIRED",
        "address":  {
            "country": "NOT_ALLOWED_VALUE",
            "city": "REQUIRED",
            "zip": "REQUIRED"
        }
    }
}}

Это значительно информативнее, чем какой-то «Bad request».

Работа с псевдонимами и регистрация собственных правил

Спецификацию содержит только наиболее используемые правила, но у каждого проекта своя специфика и постоянно возникают ситуации, когда каких-то правил не хватает. В связи с этим, одним из ключевых требований к валидатору была возможность его расширения собственными правилами любого типа. Изначально каждая реализация имела свой механизм описания правил, но начиная со спецификации версии 0.4 мы ввели стандартный способ создания правил на базе других правил (создание псевдонимов), это покрывает 70% ситуаций. Рассмотрим оба варианта.

Создание псевдонима
Способ, каким регистрируется псевдоним зависит от реализации, но то как псевдоним описывается — регламентировано спецификацией. Такой подход, например, позволяет сериализировать описания псевдонимов и использовать их с разными реализациями (например, на Perl-бекенде и JavaScript-фронтенде)

// Регистрация псевдонима "valid_address"
validator. registerAliasedRule({
    name: 'valid_address',
    rules: { nested_object: {
        country: 'required',
        city: 'required',
        zip: 'positive_integer'
    }}
});

// Регистрация псевдонима "adult_age"
validator.registerAliasedRule( {
    name: 'adult_age',
    rules: [ 'positive_integer', { min_number: 18 } ]
});

// Теперь псевдонимы доступны, как обычные правила.
{
    name: 'required',
    age: ['required', 'adult_age' ],
    address: ['required', 'valid_address']
}


Более того, можно устанавливать свои коды ошибок для правил.

Например,
validator.registerAliasedRule({
    name: 'valid_address',
    rules: { nested_object: {
        country: 'required',
        city: 'required',
        zip: 'positive_integer'
    }},
    error: 'WRONG_ADDRESS'
});

и в случае ошибки при валидации адреса, мы получим следующее:
{
    address: 'WRONG_ADDRESS'
}

Регистрация полноценного правила на примере JavaScript реализации
Для валидации используются функции обратного вызова, которые осуществляют проверку значений. Попробуем описать новое правило под названием “strong_password”. Будем проверять, что значение больше 8 символов и содержит цифры и буквы в верхнем и нижнем регистрах.

var LIVR = require('livr');

var rules = {password: ['required', 'strong_password']};

var validator = new LIVR.Validator(rules);

validator.registerRules({
    strong_password: function() {
        return function(val) {
            // пропускаем пустые значение. Для проверки на обязательность у нас и так есть правило "required"
            if (val === undefined || val === null || val === '' ) return;
            
            if ( length(val) < 8 || !val.match([0-9]) || !val.match([a-z] || !val.match([A-Z] ) ) {
                return 'WEAK_PASSWORD';
            }

            return;
          }
    }
});

Теперь добавим возможность задавать минимальное количество символов в пароле и зарегистрируем это правило как глобальное (доступное во всех экземплярах валидатора).

var LIVR = require('livr');

var rules = {password: ['required', {'strong_password': 10}]};

var validator = new LIVR.Validator(rules);

var strongPassword = function(minLength) {
    if (!minLength) throw "[minLength] parameter required";

    return function(val) {
        // пропускаем пустые значение. Для проверки на обязательность у нас и так есть правило "required"
        if (val === undefined || val === null || val === '' ) return;
            
        if ( length(val) < minLength || !val.match([0-9]) || !val.match([a-z] || !val.match([A-Z] ) ) {
            return 'WEAK_PASSWORD';
        }

        return;
    }
};

LIVR.Validator.registerDefaultRules({ strong_password: strongPassword });


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

Есть возможность регистрации правил, которые будут не только валидировать значение, но и изменять его. Например, приводить к верхнему регистру или удалять лишние пробелы.

Своя реализация по спецификации

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

  • «positive» — позитивные тесты для основных правил
  • «negative» — негативные тесты для основных правил
  • «aliases_positive» — позитивные тесты для псевдонимов правил
  • «aliases_negative» — негативные тесты для псевдонимов правил

По сути, каждый тест содержит несколько файлов:

  • rules.json — описание правил валидации
  • input.json — структура, которая передается валидатору на проверку
  • output.json — очищенная структура, которая получается после валидации

Каждый негативный тест вместо «output.json» содержит «errors.json» с описанием ошибки, которая должна возникнуть в результате валидации. В тестах псевдонимов есть файл «aliases.json» с псевдонимами, которые необходимо предварительно зарегистрировать.

Почему не JSON Schema?

Часто задаваемый вопрос. Если коротко, то причин несколько:
  • Сложный формат для правил. Хочется, чтобы структура с правилами была максимально близка к структуре с данными. Попробуйте описать этот пример на JSON Schema
  • Формат ошибок никак не специфицирован и разные реализации возвращают ошибки в разном формате.
  • Нет преобразования данных, например «to_lc».

JSON Schema содержит и интересные вещи, как-то возможность задать максимальное количество элементов в списке, но в LIVR это реализуется просто добавлением еще одного правила.

Ссылки по LIVR