Ом-ням-ням и валидация данных

    Всем привет! Поговорим немного про валидацию данных. Что в этом сложного и зачем вообще это может понадобиться, скажем, в проекте написанном на typescript? Typescript довольно неплохо всё контролирует, остаётся максимум проверять ввод пользователя. То есть закинуть в проект десяток регулярок и всё, казалось бы тему можно закрывать, но… Далеко не всегда, а в случае с вебом примерно никогда, весь проект находится в единой кодовой базе и использует одни и теже типы. На стыке таких кодовых баз как раз и возникают ситуации, когда ожидание не соответствует реальности и тут typescript уже не помощник. Несколько примеров:


    • Клиентская часть приложения получает данные от АПИ и валидирует их. Во-первых, АПИ может неожиданно и порой без уведомлений меняться, во-вторых, "серверные ребята" иногда сами не в курсе на что способно их АПИ, например, в каком-то поле вместо гарантированного массива, пусть и пустого, в полнолуние может отдаваться null. Описывая данные на клиенте, программисты как бы определяют с чем умеет работать клиент и если что-то идёт не так, то намного приятнее сразу видеть сообщение в консоли о первоисточнике проблемы, а не расковыривать непонятный баг уже там, где это вылезло во view-слое (и хорошо ещё если это сразу будет замечено). Также сейчас уже есть решения (1, 2) позволяющие переносить типы с сервера на клиент. Я пока не пробовал так делать, но, вполне возможно, за этим будущее.
    • Обратная ситуация, когда сервер проверяет присланные параметры чтобы немедленно прекратить обработку запроса при их несоответствии ожидаемым. Думаю тут не нужны какие-то подробности о том, почему это важно делать.
    • Валидация данных перед сохранением их в базе данных тоже будет совсем не лишней. Для примера можно посмотреть, как это организовано в одном из моих велосипедов: MaraquiaORM#Валидация.

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


    type-check


    Github


    Маленькая и довольно удобная библиотека. Схема описывается в виде строки. Используя многострочные строки можно описывать довольно сложные структуры:


    `{
        ID: String,
        creator: {
            fname: String | Null,
            mname: String | Null,
            lname: String | Null,
            email: [String]
        } | Undefined,
        sender: Maybe {
            name: String,
            email: String
        },
        type: Number,
        subject: String,
        ...
    }`

    Есть и довольно серьёзные недостатки:


    • IDE никак не помогает с набором схемы, что особенно стало напрягать при переходе на typescript.
    • Практически бесполезные сообщения об ошибках. Не использовал эту библиотеку уже больше года и возможно что-то изменилось (судя по коду, нет). Сообщения были в стиле "Ожидалась строка, получено null". А теперь представьте, у вас приходит массив штук на 200 объектов в каждом из которых есть поля со строками и лишь в одном объекте одно из полей сломалось. Как найти это поле? Просматривать все 200 элементов? Я так несколько раз помучился и это сильно сломало мне жизнь испортило впечатление о библиотеке. Обычно я вообще не хочу знать, что там ожидалось и получено, а хочу открыть схему данных и найти нужное в ней поле и тоже самое в самих данных. Другими словами в сообщении об ошибке критически важно иметь keypath до нужного места в данных/схеме, а что там ожидалось и пришло вообще можно не выводить.
    • Совсем мелочь конечно, но отступы в примере выше при сжатии кода никуда не исчезнут.

    Joi


    Github
    Версия для браузера: joi-browser


    Наверное самая известная библиотека на данную тему с кучей возможностей и бесконечным API. Сначала я использовал её на сервере и она отлично себя показала. В какой-то момент я решил заменить ею type-check на клиенте. На тот момент я почти не контролировал размер бандла, никаких проблем с этим просто не было. Но за год он сильно вырос и на мобильном Интернете первая загрузка приложения стала совсем не комфортной. Было решено организовать ленивую загрузку компонентов. Отчёт webpack-bundle-analyzer показал кучу гигантов в бандле и все они легко отправлялись в создаваемые webpack-ом чанки. Все кроме Joi. Многие компоненты общаются с сервером и все ответы сервера валидируются, то есть выносить Joi в какой-то чанк не имеет смысла, он просто будет всегда загружаться сразу после основного. В какой-то момент основной бандл выглядел так: тыц. Конечно же возникло непроходящее желание что-то с этим сделать. Хотелось такую же удобную библиотеку, но намного меньше.


    Yup


    Github


    В ридми обещают примерно тот же Joi, но по размеру пригодный для фронтэнда. На деле же он всего примерно в два раза меньше, то есть Yup по прежнему оставался самой большой библиотекой в основном бандле. Кроме того появились дополнительные минусы:


    • Библиотека по умолчанию пропускает все undefined. Постоянно писать .required() не особо приятно, да и мне больше нравится когда изначально всё нельзя и где надо разрешается. В Joi есть опция presence: 'required' позволяющая настроить это поведение. Я создал запрос с адским номером 666, но пока авторы молчат.
    • Нет возможности проверить значения объекта допуская все ключи. В Joi для этого используется object.pattern с первым аргументом допускающим любые строки. Наверно здесь ещё можно было бы как-то выкрутиться, да и первый минус авторы может поправят, но учитывая размер, ждать или что-то править самому совсем не хотелось.

    Ow


    Github


    Следующий претендент наконец-то оказался действительно маленьким, плюс не заставлял постоянно писать () там, где без этого можно обойтись. Например, записать валидатор допускающий строку или undefined можно так:


    let optionalStringValidator = ow.optional.string;
    
    ow(optionalStringValidator, '1'); // Ok
    ow(optionalStringValidator, undefined); // Ok

    Шикарно! А что с null? Перевернув всю документацию я нашёл следующий способ:


    ow.any(ow.optional.string, ow.null);

    О ужас! При попытке переписать часть валидации в проекте я чуть пальцы себе не сломал набирая это. Завёл issue на добавление ow.nullable, на что был отправлен сюда. Если кратко, там говорят, что null вообще не нужен. Приводимые там аргументы тоже вполне адекватные учитывая первую строку в их ридми:


    Function argument validation for humans

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




    Примерно здесь, я уже совсем устал разочаровываться и решил написать свою библиотеку с блекджеком и девственницами. Да да, я опять к вам с очередным велосипедом :). Знакомьтесь:


    OmYumYum


    Немного примеров:


    import om from 'omyumyum';
    
    const isOptionalNumber = om.number.or.undefined;
    
    isOptionalNumber('1');
    // => false
    isOptionalNumber(1);
    // => true
    isOptionalNumber(undefined);
    // => true

    .or можно использовать сколько угодно раз бесконечно увеличивая допустимые варианты:


    om.number.or.string.or.null.or.undefined;

    При этом постоянно генерируется почти обычная функция, принимающая любой аргумент и возврацающая boolean.
    Если нужно чтобы функция в случае неудачи проверки бросала ошибку:


    om(om.number, '1');
    // бросает TypeError

    Или с каррированием:


    const isNumberOrThrow = om(om.number);
    
    isNumberOrThrow('1')
    // бросает TypeError

    Получаемая функция не совсем обычная, так как имеет дополнительные методы. .or уже показан, часть методов будет зависеть от выбранного типа (см. API), например, строку можно усилить регулярным выражением:


    const isNonEmptyString = om.string.pattern(/\S/); // == `om.string.nonEmpty`
    
    isNonEmptyString('    ');
    // => false
    isNonEmptyString('1');
    // => true

    А для объекта можно указать его форму:


    const isUserData = om.object.shape({
        name: om.string,
        age: om.number.or.vacuum // `.or.vacuum` == `.or.null.or.undefined`
    });
    
    isUserData({});
    // => false
    isUserData({ age: 20 })
    // => false
    isUserData({ name: 'Иванушка' });
    // => true
    isUserData({ name: 'Иванушка', age: null });
    // => true
    isUserData({ name: 'Иванушка', age: 20 });
    // => true

    Обещанный keypath до проблемного места:


    om(om.array.of(om.object.shape({ name: om.string })), [{ name: 'Иванушка' }, { name: null }]);
    // бросает TypeError('Type mismatch at "[1].name"')

    Если встроенных возможностей не хватает, всегда можно использовать .custom(validator: (value: any) => boolean) :


    const isEmailOrPhone = om.custom(require('is-email')).or.custom(require('is-phone'));
    
    isEmailOrPhone('test@test.test');
    // => true

    В наличии так же ожидаемый .and используемый для объединения и улучшения типов:


    const isNonZeroString = om.string.and.custom(str => str.length > 0); // == `om.string.nonZero`
    
    isNonZeroString('');
    // => false
    isNonZeroString('1');
    // => true

    .and имеет преимущество над .or, но так как .custom() принимает валидатор точно того же вида, что и создаётся библиотекой, то это можно обойти:


    // объект может содержать либо поле `age`, либо поле `birthday`
    om.object.shape({ name: om.string }).and.custom(
      om.object.shape({ age: om.number })
        .or.object.shape({ birthday: om.date })]
    );

    Можно продолжать улучшать ранее созданные валидаторы. Старые при этом никак не портятся. Попробуем улучшить созданный ранее isUserData:


    const isImprovedUserData = isUserData.and.object.shape({
        friends: om.array.of(isUserData).or.vacuum
    });
    
    isImprovedUserData({
        name: 'Иванушка',
        age: 20,
        friends: [{ name: 'Алёнушка', age: 18 }]
    });
    // => true

    Ну и остался .not:


    const isNotVacuum = om.not.null.and.not.undefined; // == `om.not.vacuum`
    
    isNotVacuum(1);
    // => true
    isNotVacuum(null);
    // => false
    isNotVacuum(undefined);
    // => false

    Остальные доступные методы можно посмотреть в API библиотеки.


    Плюсы библиотеки:


    • Лаконичный синтаксис с .or, .and, .not и минимумом скобок. В сочетании с автодополнением typescript-а набор превращается в одно удовольствие.
    • Крохотный вес даже по сравнению с Ow (почти в 10 раз менеше (minify+gzip)), а в сравнении с Joi библиотека — как пёрышко рядом с горой.
    • Няшное название :)

    Минусы библиотеки:


    • Меньше типов и их модификаторов. И вряд ли их станет сильно больше. Все три приведённых в начале статьи сценария использования (что-то там про стыки и кодовые базы) предполагают передачу обычных текстовых данных, в большинстве случаев это JSON. То есть, на мой взгляд, такая библиотека должна поддерживать типы возможные в JSON, плюс undefined и немного часто используемых типов. Тот же Ow зачем-то напичкан поддержкой всяких типизированных массивов и прочей ерундой. Я думаю это лишнее.
    • Не умеет преобразовывать данные как Joi. Я думаю Joi тоже довольно плохо с этим справляется. По крайней мере мне его возможностей совсем не хватает и я, при необходимости, делаю преобразования совсем другими инструментами. Возможно это дальнейшее направление развития для omyumyum.

    Всё! Если понравилась статья ставь лайк, подписывайся на канал и удачи)).

    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 8

      0
      Приветствую! Подскажите, думали ли вы о том, как быть если нужны человеко-читаемые сообщения об ошибках в валидации?
      Например для валидации om.number.and.custom(value => value > 18) — тут может быть два сообщения — или «введите», или «должен быть совершеннолетним»

      Т.е. не только указать туда, где ошибка, но еще и сказать какая?
        0

        Привет. Можно научить понимать возвращаемую валидатором строку как сообщение об ошибке при неудачной проверке:


        om.number.and.custom(value => value > 18 || 'Вы ещё слишком маленький')

        Можно в этой строке некоторые простые замены делать, например, {keypath} заменять на место где случилась ошибка.


        Ещё вариант — научить .custom() понимать не только функцию, но и объект с функцией и строкой ошибки:


        om.number.and.custom({
            validator: value => value > 18,
            message: 'Вы ещё слишком маленький ({keypath})'
        });

        Если нужно такое, создайте issue чтобы я не забыл. Сделаю в скором времени.

          0
          > value > 18
          > или «должен быть совершеннолетним»

          На всякий позанудствую — совершеннолетний, это value >= 18
          Мало ли кто скопирует
            0

            Позанудствую тоже, но есть страны, где совершеннолетие наступает не в 18 лет

            0

            Реализовал оба варианта. keypath подставляется сам когда есть вложенность. Вместо message можно использовать type, в таком случае сообщение об ошибке будет сформировано с учётом .or:


            om(om.custom({ validator, type: 'type1' }).or.custom({ validator, type: 'type2' }), type3);
            // бросит TypeError('Expected "type1" or "type2"')

            Примеры в сообщении выше тоже рабочие.

              0
              Круто, спасибо!
            0
            Насколько я понимаю, io-ts тоже можно использовать для валидации
            github.com/gcanti/io-ts
              0

              Интересная идея с возможностью извлекать статический тип из валидатора. Надо будет попробовать так сделать.

            Only users with full accounts can post comments. Log in, please.