Модульная организация валидаторов

При росте приложения схемы валидации быстро превращаются в трудно поддерживаемый монолит. Повторяющиеся правила, длинные цепочки .object(), дублирование логики между API и внутренними сервисами приводят к усложнению кода и увеличению количества ошибок.

Модульная организация валидаторов в Joi решает несколько задач:

  • переиспользование схем;
  • централизованное хранение правил;
  • изоляция бизнес-логики;
  • упрощение тестирования;
  • масштабируемость проекта;
  • единый стиль валидации.

Структура валидаторов в проекте

Типичная структура крупного Node.js-приложения:

src/
├── validators/
│   ├── common/
│   │   ├── pagination.validator.js
│   │   ├── id.validator.js
│   │   └── password.validator.js
│   │
│   ├── user/
│   │   ├── user.create.validator.js
│   │   ├── user.update.validator.js
│   │   └── user.login.validator.js
│   │
│   ├── product/
│   │   ├── product.create.validator.js
│   │   └── product.update.validator.js
│   │
│   └── index.js
│
├── services/
├── controllers/
└── routes/

Главная идея — группировка схем по доменным областям.


Базовый модуль валидатора

Создание отдельного файла схемы

// validators/user/user.create.validator.js

const Joi = require('joi');

const createUserSchema = Joi.object({
    username: Joi.string()
        .min(3)
        .max(30)
        .required(),

    email: Joi.string()
        .email()
        .required(),

    password: Joi.string()
        .min(8)
        .required()
});

module.exports = createUserSchema;

Использование:

const createUserSchema = require('../validators/user/user.create.validator');

const result = createUserSchema.validate(req.body);

Центральный экспорт валидаторов

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

// validators/index.js

module.exports = {
    user: {
        create: require('./user/user.create.validator'),
        update: require('./user/user.update.validator'),
        login: require('./user/user.login.validator')
    },

    product: {
        create: require('./product/product.create.validator')
    }
};

Использование:

const validators = require('../validators');

validators.user.create.validate(data);

Выделение общих схем

Во многих проектах повторяются одинаковые правила:

  • UUID;
  • email;
  • пароль;
  • пагинация;
  • даты;
  • номера телефонов;
  • slug;
  • денежные значения.

Вместо дублирования создаются общие модули.


Общий валидатор идентификатора

// validators/common/id.validator.js

const Joi = require('joi');

module.exports = Joi.string()
    .uuid()
    .required();

Использование:

const Joi = require('joi');
const idSchema = require('../common/id.validator');

const schema = Joi.object({
    userId: idSchema
});

Общий валидатор пароля

// validators/common/password.validator.js

const Joi = require('joi');

module.exports = Joi.string()
    .min(8)
    .max(64)
    .pattern(/[A-Z]/)
    .pattern(/[a-z]/)
    .pattern(/[0-9]/)
    .required();

Использование:

const passwordSchema = require('../common/password.validator');

const schema = Joi.object({
    password: passwordSchema
});

Композиция схем

Joi позволяет собирать сложные схемы из маленьких компонентов.

Базовая схема пользователя

// validators/user/base-user.schema.js

const Joi = require('joi');

module.exports = {
    username: Joi.string().min(3).max(30),

    email: Joi.string().email(),

    age: Joi.number().integer().min(18)
};

Создание пользователя

// validators/user/user.create.validator.js

const Joi = require('joi');
const baseUser = require('./base-user.schema');

const schema = Joi.object({
    ...baseUser,

    password: Joi.string()
        .min(8)
        .required()
});

module.exports = schema;

Обновление пользователя

// validators/user/user.update.validator.js

const Joi = require('joi');
const baseUser = require('./base-user.schema');

const schema = Joi.object({
    ...baseUser
}).min(1);

module.exports = schema;

Наследование схем через concat

Метод .concat() объединяет схемы.

Базовая схема

const Joi = require('joi');

const baseSchema = Joi.object({
    name: Joi.string().required()
});

Дополнительная схема

const extraSchema = Joi.object({
    age: Joi.number().required()
});

Объединение

const finalSchema = baseSchema.concat(extraSchema);

Результат:

{
    name: string required,
    age: number required
}

Переиспользование частей объекта

Схема адреса

// validators/common/address.schema.js

const Joi = require('joi');

module.exports = Joi.object({
    city: Joi.string().required(),

    street: Joi.string().required(),

    zipCode: Joi.string().required()
});

Использование в нескольких схемах

const Joi = require('joi');
const addressSchema = require('../common/address.schema');

const schema = Joi.object({
    companyName: Joi.string().required(),

    address: addressSchema.required()
});

Фабрики валидаторов

Иногда схема зависит от параметров.

Динамический валидатор строки

// validators/factories/string-length.factory.js

const Joi = require('joi');

module.exports = (min, max) => {
    return Joi.string().min(min).max(max);
};

Использование:

const stringLength = require('./factories/string-length.factory');

const schema = Joi.object({
    title: stringLength(5, 100),

    shortTitle: stringLength(2, 20)
});

Генерация схем по ролям

const Joi = require('joi');

function createUserSchema(role) {
    const base = {
        username: Joi.string().required()
    };

    if (role === 'admin') {
        base.permissions = Joi.array()
            .items(Joi.string())
            .required();
    }

    return Joi.object(base);
}

module.exports = createUserSchema;

Использование:

const createUserSchema = require('./user.factory');

const adminSchema = createUserSchema('admin');
const userSchema = createUserSchema('user');

Разделение схем по операциям

Одна из распространённых ошибок — использование одной схемы для всех операций.

Неправильно:

const userSchema = Joi.object({
    username: Joi.string().required(),
    email: Joi.string().required(),
    password: Joi.string().required()
});

Проблемы:

  • обновление требует необязательных полей;
  • PATCH и PUT работают по-разному;
  • регистрация и редактирование имеют разные правила.

Правильное разделение

Create schema

const createSchema = Joi.object({
    username: Joi.string().required(),
    email: Joi.string().required(),
    password: Joi.string().required()
});

Update schema

const updateSchema = Joi.object({
    username: Joi.string(),
    email: Joi.string(),
    password: Joi.string()
}).min(1);

Login schema

const loginSchema = Joi.object({
    email: Joi.string().required(),
    password: Joi.string().required()
});

Организация middleware-валидаторов

В Express валидаторы обычно интегрируются через middleware.

Универсальный middleware

// middleware/validate.js

module.exports = (schema) => {
    return (req, res, next) => {
        const { error, value } = schema.validate(req.body, {
            abortEarly: false,
            stripUnknown: true
        });

        if (error) {
            return res.status(400).json({
                errors: error.details
            });
        }

        req.body = value;

        next();
    };
};

Использование

const express = require('express');

const validate = require('../middleware/validate');

const createUserSchema = require('../validators/user/user.create.validator');

router.post(
    '/users',
    validate(createUserSchema),
    controller.create
);

Модульная настройка сообщений об ошибках

Общие сообщения

// validators/messages.js

module.exports = {
    'string.empty': 'Поле не может быть пустым',

    'any.required': 'Поле обязательно',

    'string.email': 'Некорректный email'
};

Использование

const Joi = require('joi');
const messages = require('../messages');

const schema = Joi.object({
    email: Joi.string()
        .email()
        .required()
}).messages(messages);

Создание кастомных типов

Joi поддерживает расширение через .extend().

Валидатор ObjectId

const JoiBase = require('joi');

const Joi = JoiBase.extend((joi) => ({
    type: 'objectId',

    base: joi.string(),

    validate(value, helpers) {
        if (!/^[0-9a-fA-F]{24}$/.test(value)) {
            return {
                value,
                errors: helpers.error('objectId.invalid')
            };
        }
    },

    messages: {
        'objectId.invalid': 'Некорректный ObjectId'
    }
}));

Использование:

const schema = Joi.object({
    userId: Joi.objectId().required()
});

Глубокая модульность больших схем

Разделение заказа на части

order/
├── order-item.schema.js
├── customer.schema.js
├── payment.schema.js
└── order.create.validator.js

Схема товара

// order-item.schema.js

const Joi = require('joi');

module.exports = Joi.object({
    productId: Joi.string().required(),

    quantity: Joi.number()
        .integer()
        .min(1)
        .required()
});

Схема покупателя

// customer.schema.js

const Joi = require('joi');

module.exports = Joi.object({
    name: Joi.string().required(),

    phone: Joi.string().required()
});

Основная схема заказа

// order.create.validator.js

const Joi = require('joi');

const orderItemSchema = require('./order-item.schema');
const customerSchema = require('./customer.schema');

module.exports = Joi.object({
    customer: customerSchema.required(),

    items: Joi.array()
        .items(orderItemSchema)
        .min(1)
        .required()
});

Схемы для query-параметров

Валидацию query-параметров лучше хранить отдельно.

Pagination validator

// validators/common/pagination.validator.js

const Joi = require('joi');

module.exports = Joi.object({
    page: Joi.number()
        .integer()
        .min(1)
        .default(1),

    limit: Joi.number()
        .integer()
        .min(1)
        .max(100)
        .default(10)
});

Использование

const paginationSchema = require('../validators/common/pagination.validator');

paginationSchema.validate(req.query);

Разделение body, params и query

В крупных приложениях создаются отдельные схемы для разных частей HTTP-запроса.

Пример

module.exports = {
    body: Joi.object({
        email: Joi.string().email().required()
    }),

    params: Joi.object({
        id: Joi.string().uuid().required()
    }),

    query: Joi.object({
        expand: Joi.boolean()
    })
};

Универсальная middleware-проверка

module.exports = (schemas) => {
    return (req, res, next) => {
        const sections = ['body', 'params', 'query'];

        for (const section of sections) {
            if (!schemas[section]) {
                continue;
            }

            const { error, value } = schemas[section]
                .validate(req[section]);

            if (error) {
                return res.status(400).json({
                    section,
                    error: error.details
                });
            }

            req[section] = value;
        }

        next();
    };
};

Версионирование валидаторов

При развитии API старые схемы часто должны оставаться неизменными.

Структура

validators/
├── v1/
│   └── user/
├── v2/
│   └── user/

Пример

V1

module.exports = Joi.object({
    username: Joi.string().required()
});

V2

module.exports = Joi.object({
    username: Joi.string().required(),

    displayName: Joi.string()
});

Декларативный подход

Плохая практика:

if (!data.email) {
    throw new Error('Email required');
}

if (data.password.length < 8) {
    throw new Error('Password too short');
}

Хорошая практика:

const schema = Joi.object({
    email: Joi.string()
        .email()
        .required(),

    password: Joi.string()
        .min(8)
        .required()
});

Модульность особенно эффективна именно в декларативном стиле.


Тестирование модульных валидаторов

Unit-тест схемы

const createUserSchema = require('./user.create.validator');

describe('User create validator', () => {
    test('валидный объект проходит проверку', () => {
        const result = createUserSchema.validate({
            username: 'admin',
            email: 'admin@test.com',
            password: 'Password123'
        });

        expect(result.error).toBeUndefined();
    });

    test('невалидный email вызывает ошибку', () => {
        const result = createUserSchema.validate({
            username: 'admin',
            email: 'wrong-email',
            password: 'Password123'
        });

        expect(result.error).toBeDefined();
    });
});

Изоляция бизнес-правил

Схемы не должны содержать:

  • SQL-запросы;
  • HTTP-вызовы;
  • обращения к БД;
  • бизнес-операции;
  • побочные эффекты.

Неправильно:

Joi.string().custom(async (value) => {
    const user = await db.findUser(value);

    if (user) {
        throw new Error('User exists');
    }

    return value;
});

Лучше:

  • Joi отвечает только за структуру и формат;
  • бизнес-валидация выполняется сервисами.

Организация схем в монорепозиториях

В monorepo схемы часто выносятся в отдельный пакет.

Пример

packages/
├── api/
├── frontend/
└── validation/

Экспорт

// packages/validation/index.js

module.exports = {
    userSchemas: require('./user'),
    orderSchemas: require('./order')
};

Это позволяет использовать одинаковые схемы:

  • на backend;
  • в микросервисах;
  • в CLI;
  • в тестах;
  • в SSR;
  • в shared-модулях.

Подход Schema Registry

В очень больших системах создаётся централизованный реестр схем.

Пример

const registry = {
    USER_CREATE: require('./user/user.create.validator'),

    USER_UPDATE: require('./user/user.update.validator'),

    PRODUCT_CREATE: require('./product/product.create.validator')
};

module.exports = registry;

Использование:

const schemas = require('./registry');

schemas.USER_CREATE.validate(data);

Lazy-схемы

Для избежания циклических зависимостей используется Joi.lazy().

Пример древовидной структуры

const Joi = require('joi');

const categorySchema = Joi.object({
    name: Joi.string().required(),

    children: Joi.array().items(
        Joi.lazy(() => categorySchema)
    )
});

Организация кастомных helper-функций

Helper

// validators/helpers/required-string.js

const Joi = require('joi');

module.exports = () => {
    return Joi.string().trim().required();
};

Использование

const requiredString = require('../helpers/required-string');

const schema = Joi.object({
    firstName: requiredString(),

    lastName: requiredString()
});

Ошибки чрезмерной модульности

Слишком сильное дробление схем может ухудшить поддержку.

Плохо:

validators/
├── user/
│   ├── fields/
│   │   ├── username.js
│   │   ├── email.js
│   │   ├── age.js

Проблемы:

  • сложная навигация;
  • огромное количество файлов;
  • потеря контекста;
  • усложнение импорта.

Оптимальный уровень декомпозиции

Хороший баланс:

  • отдельные схемы для сущностей;
  • отдельные схемы для повторяемых блоков;
  • общие helper-функции;
  • фабрики для динамики;
  • middleware отдельно от схем.

Рекомендуемые практики

Именование файлов

Хорошо:

user.create.validator.js
user.update.validator.js
user.login.validator.js

Плохо:

validator1.js
schema-final.js
new-validator.js

Явное разделение ответственности

Схема отвечает только за:

  • типы;
  • структуру;
  • ограничения;
  • зависимости полей.

Избегание огромных файлов

Файл на 1000 строк со всеми схемами проекта быстро становится неуправляемым.


Переиспользование вместо копирования

Плохо:

email: Joi.string().email().required()

в десятках файлов подряд.

Лучше:

const emailSchema = require('../common/email.schema');

Централизация конфигурации Joi

const options = {
    abortEarly: false,
    stripUnknown: true,
    convert: true
};

Архитектурный пример крупного проекта

validators/
├── common/
│   ├── email.schema.js
│   ├── phone.schema.js
│   ├── id.schema.js
│   └── pagination.schema.js
│
├── user/
│   ├── base-user.schema.js
│   ├── user.create.validator.js
│   ├── user.update.validator.js
│   ├── user.login.validator.js
│   └── index.js
│
├── order/
│   ├── order-item.schema.js
│   ├── address.schema.js
│   ├── payment.schema.js
│   ├── order.create.validator.js
│   └── order.update.validator.js
│
├── middleware/
│   └── validate.js
│
├── helpers/
│   └── required-string.js
│
└── index.js