LIVR — «незалежні від мови правила валідації» або валідація даних без проблем»

Кожен програміст неодноразово стикався з необхідністю перевірки користувальницького введення. Займаючись веб-розробкою вже більше 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'],
кількість: ['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'],
кількість: ['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",
"центр": "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


Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.