Валидация конфигураций

Конфигурационные объекты используются практически в любом приложении:

  • настройки подключения;
  • параметры API;
  • конфигурации UI;
  • feature flags;
  • параметры окружения;
  • схемы форм;
  • пользовательские настройки.

Ошибки в конфигурации особенно опасны, поскольку часто проявляются не сразу, а в процессе выполнения программы. Библиотека Vest позволяет строить декларативные, масштабируемые и переиспользуемые схемы проверки конфигурационных объектов.


Особенности Vest при работе с конфигурациями

Валидация конфигураций в Vest отличается несколькими важными особенностями:

  • декларативное описание правил;
  • группировка тестов;
  • условная логика;
  • частичная проверка;
  • асинхронные проверки;
  • композиция suites;
  • поддержка сложных вложенных структур.

Базовая структура конфигурационной схемы

Простейшая схема валидации конфигурации:

import { create, test, enforce } from 'vest';

const configSuite = create((config = {}) => {
  test('host', 'Host обязателен', () => {
    enforce(config.host).isNotBlank();
  });

  test('port', 'Port должен быть числом', () => {
    enforce(config.port).isNumber();
  });

  test('secure', 'Secure должен быть boolean', () => {
    enforce(config.secure).isBoolean();
  });
});

Проверка:

const result = configSuite({
  host: 'localhost',
  port: 3000,
  secure: true
});

console.log(result.hasErrors());

Проверка обязательных параметров

Большинство конфигураций содержат обязательные поля.

Проверка строк

test('apiKey', 'API key обязателен', () => {
  enforce(config.apiKey).isNotBlank();
});

Проверка чисел

test('timeout', 'Timeout обязателен', () => {
  enforce(config.timeout).isNumber();
});

Проверка объектов

test('database', 'Database config обязателен', () => {
  enforce(config.database).isObject();
});

Проверка диапазонов значений

Конфигурации часто содержат ограничения.

Ограничение портов

test('port', 'Недопустимый порт', () => {
  enforce(config.port)
    .greaterThan(0)
    .lessThanOrEquals(65535);
});

Ограничение timeout

test('timeout', 'Timeout вне диапазона', () => {
  enforce(config.timeout)
    .greaterThanOrEquals(100)
    .lessThanOrEquals(30000);
});

Проверка перечислений

Многие конфигурации используют фиксированные наборы значений.

Проверка environment

const environments = [
  'development',
  'testing',
  'staging',
  'production'
];

test('env', 'Некорректное окружение', () => {
  enforce(environments.includes(config.env)).isTruthy();
});

Проверка уровня логирования

const levels = ['debug', 'info', 'warn', 'error'];

test('logLevel', 'Недопустимый log level', () => {
  enforce(levels.includes(config.logLevel)).isTruthy();
});

Валидация вложенных объектов

Конфигурации редко бывают плоскими.

Пример структуры

const config = {
  server: {
    host: 'localhost',
    port: 3000
  },
  database: {
    url: 'mongodb://localhost',
    poolSize: 10
  }
};

Проверка вложенных свойств

const configSuite = create((config = {}) => {
  test('server.host', 'Host обязателен', () => {
    enforce(config.server?.host).isNotBlank();
  });

  test('server.port', 'Некорректный port', () => {
    enforce(config.server?.port)
      .isNumber()
      .greaterThan(0);
  });

  test('database.url', 'Database URL обязателен', () => {
    enforce(config.database?.url).isNotBlank();
  });
});

Валидация массивов конфигураций

Часто конфигурация содержит списки.

Пример

const config = {
  servers: [
    { host: 'localhost', port: 3000 },
    { host: 'api.local', port: 4000 }
  ]
};

Проверка массива

test('servers', 'Servers должен быть массивом', () => {
  enforce(config.servers).isArray();
});

Проверка элементов массива

config.servers?.forEach((server, index) => {
  test(
    `servers[${index}].host`,
    'Host обязателен',
    () => {
      enforce(server.host).isNotBlank();
    }
  );

  test(
    `servers[${index}].port`,
    'Некорректный port',
    () => {
      enforce(server.port)
        .isNumber()
        .greaterThan(0);
    }
  );
});

Условная валидация конфигураций

Некоторые поля зависят друг от друга.

Проверка SSL-конфигурации

const configSuite = create((config = {}) => {
  test('ssl.enabled', 'SSL enabled должен быть boolean', () => {
    enforce(config.ssl?.enabled).isBoolean();
  });

  if (config.ssl?.enabled) {
    test('ssl.cert', 'SSL certificate обязателен', () => {
      enforce(config.ssl?.cert).isNotBlank();
    });

    test('ssl.key', 'SSL key обязателен', () => {
      enforce(config.ssl?.key).isNotBlank();
    });
  }
});

Проверка взаимосвязанных параметров

Vest хорошо подходит для сложных зависимостей.

Проверка авторизации

const configSuite = create((config = {}) => {
  if (config.auth?.type === 'jwt') {
    test('auth.secret', 'JWT secret обязателен', () => {
      enforce(config.auth.secret).isNotBlank();
    });
  }

  if (config.auth?.type === 'oauth') {
    test('auth.clientId', 'Client ID обязателен', () => {
      enforce(config.auth.clientId).isNotBlank();
    });

    test('auth.clientSecret', 'Client Secret обязателен', () => {
      enforce(config.auth.clientSecret).isNotBlank();
    });
  }
});

Группировка конфигурационных проверок

Крупные конфигурации удобно разбивать на блоки.

Отдельные suites

const databaseSuite = create((db = {}) => {
  test('url', 'Database URL обязателен', () => {
    enforce(db.url).isNotBlank();
  });

  test('poolSize', 'Некорректный pool size', () => {
    enforce(db.poolSize)
      .greaterThan(0)
      .lessThanOrEquals(100);
  });
});
const serverSuite = cre ate (( server = {}) => {
  test('host', 'Host обязателен', () => {
    enforce(server.host).isNotBlank();
  });

  test('port', 'Port обязателен', () => {
    enforce(server.port).isNumber();
  });
});

Композиция suites

const appSuite = create((config = {}) => {
  databaseSuite(config.database);
  serverSuite(config.server);
});

Частичная валидация конфигураций

Vest поддерживает selective validation.

Проверка только изменённых параметров

const configSuite = create((config = {}, changedField) => {
  only(changedField);

  test('host', 'Host обязателен', () => {
    enforce(config.host).isNotBlank();
  });

  test('port', 'Port обязателен', () => {
    enforce(config.port).isNumber();
  });
});

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

configSuite(config, 'host');

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

Некоторые проверки можно пропускать.

Пропуск тестов

import { skipWhen } from 'vest';

const configSuite = create((config = {}) => {
  skipWhen(config.env === 'development', () => {
    test('monitoring.url', 'Monitoring URL обязателен', () => {
      enforce(config.monitoring?.url).isNotBlank();
    });
  });
});

Асинхронная валидация конфигураций

Иногда конфигурацию необходимо проверять через внешние сервисы.

Проверка доступности endpoint

const configSuite = create((config = {}) => {
  test(
    'api.url',
    'API endpoint недоступен',
    async () => {
      const response = await fetch(config.api.url);

      enforce(response.ok).isTruthy();
    }
  );
});

Проверка уникальности конфигураций

Проверка списка сервисов

test('services', 'Имена сервисов должны быть уникальны', () => {
  const names = config.services.map(service => service.name);

  const unique = new Set(names);

  enforce(unique.size).equals(names.length);
});

Проверка environment variables

Vest особенно полезен при проверке env-переменных.

Пример env-конфигурации

const envSuite = create((env = process.env) => {
  test('NODE_ENV', 'NODE_ENV обязателен', () => {
    enforce(env.NODE_ENV).isNotBlank();
  });

  test('PORT', 'PORT должен быть числом', () => {
    enforce(Number(env.PORT)).isNumber();
  });

  test('DATABASE_URL', 'DATABASE_URL обязателен', () => {
    enforce(env.DATABASE_URL).isNotBlank();
  });
});

Нормализация данных перед валидацией

Часто конфигурации требуют предварительной обработки.

Пример подготовки

function normalizeConfig(rawConfig) {
  return {
    ...rawConfig,
    port: Number(rawConfig.port),
    secure: rawConfig.secure === 'true'
  };
}

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

const normalized = normalizeConfig(rawConfig);

configSuite(normalized);

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

Крупные проекты требуют унификации.

Валидатор URL

function validateUrl(value, field) {
  test(field, `${field} должен быть URL`, () => {
    enforce(value).matches(/^https?:\/\//);
  });
}

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

validateUrl(config.apiUrl, 'apiUrl');

Валидация feature flags

Пример

const featureSuite = create((features = {}) => {
  Object.entries(features).forEach(([key, value]) => {
    test(key, `${key} должен быть boolean`, () => {
      enforce(value).isBoolean();
    });
  });
});

Проверка совместимости параметров

Некоторые параметры могут конфликтовать.

Конфликтующие настройки

test(
  'cache',
  'Redis и memory cache нельзя использовать одновременно',
  () => {
    const invalid =
      config.redis?.enabled &&
      config.memoryCache?.enabled;

    enforce(invalid).isFalsy();
  }
);

Работа с динамическими конфигурациями

Vest позволяет строить правила динамически.

Динамическая схема

const fields = [
  'host',
  'port',
  'username',
  'password'
];

const configSuite = create((config = {}) => {
  fields.forEach(field => {
    test(field, `${field} обязателен`, () => {
      enforce(config[field]).isNotBlank();
    });
  });
});

Проверка сложных структур

Глубокая конфигурация

const config = {
  microservices: {
    auth: {
      retries: 3,
      timeout: 5000
    },
    payments: {
      retries: 5,
      timeout: 10000
    }
  }
};

Валидация

Object.entries(config.microservices).forEach(
  ([name, service]) => {
    test(
      `${name}.retries`,
      'Retries должен быть положительным',
      () => {
        enforce(service.retries)
          .isNumber()
          .greaterThan(0);
      }
    );

    test(
      `${name}.timeout`,
      'Timeout должен быть положительным',
      () => {
        enforce(service.timeout)
          .isNumber()
          .greaterThan(0);
      }
    );
  }
);

Работа с ошибками валидации

Получение ошибок

const result = configSuite(config);

console.log(result.getErrors());

Пример результата:

{
  host: ['Host обязателен'],
  port: ['Port должен быть числом']
}

Формирование отчётов об ошибках

Пользовательский формат

const result = configSuite(config);

const errors = Object.entries(result.getErrors())
  .map(([field, messages]) => ({
    field,
    messages
  }));

console.log(errors);

Использование warn вместо error

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

Пример предупреждения

import { warn } from 'vest';

const configSuite = create((config = {}) => {
  warn('timeout', 'Слишком большой timeout', () => {
    enforce(config.timeout)
      .lessThanOrEquals(30000);
  });
});

Валидация конфигурации микросервисов

Полный пример

const microserviceSuite = create((config = {}) => {
  test('name', 'Service name обязателен', () => {
    enforce(config.name).isNotBlank();
  });

  test('host', 'Host обязателен', () => {
    enforce(config.host).isNotBlank();
  });

  test('port', 'Некорректный port', () => {
    enforce(config.port)
      .isNumber()
      .greaterThan(0)
      .lessThanOrEquals(65535);
  });

  test('healthcheck.path', 'Healthcheck path обязателен', () => {
    enforce(config.healthcheck?.path)
      .isNotBlank();
  });

  if (config.auth?.enabled) {
    test('auth.token', 'Auth token обязателен', () => {
      enforce(config.auth.token).isNotBlank();
    });
  }

  if (config.rateLimit?.enabled) {
    test('rateLimit.maxRequests', 'Max requests обязателен', () => {
      enforce(config.rateLimit.maxRequests)
        .greaterThan(0);
    });
  }
});

Организация файлов конфигурационной валидации

Типичная структура проекта

config/
├── validation/
│   ├── databaseSuite.js
│   ├── serverSuite.js
│   ├── authSuite.js
│   ├── cacheSuite.js
│   └── index.js

Центральный aggregator

import { create } from 'vest';

import { databaseSuite } from './databaseSuite';
import { serverSuite } from './serverSuite';
import { authSuite } from './authSuite';

export const configSuite = create((config = {}) => {
  databaseSuite(config.database);
  serverSuite(config.server);
  authSuite(config.auth);
});

Практические рекомендации

Изоляция доменов

Каждый раздел конфигурации должен валидироваться отдельно:

  • database;
  • cache;
  • auth;
  • logging;
  • monitoring.

Минимизация дублирования

Повторяющиеся проверки лучше выносить:

function validatePositiveNumber(value, field) {
  test(field, `${field} должен быть положительным`, () => {
    enforce(value)
      .isNumber()
      .greaterThan(0);
  });
}

Проверка типов до сложной логики

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

enforce(config.port > 1000).isTruthy();

Правильно:

enforce(config.port).isNumber();

enforce(config.port).greaterThan(1000);

Осторожность с асинхронной валидацией

Асинхронные проверки:

  • увеличивают время запуска;
  • усложняют отладку;
  • могут зависеть от сети.

Их обычно используют только для действительно критичных проверок.


Централизация сообщений ошибок

Хорошая практика — хранить сообщения отдельно:

export const messages = {
  requiredHost: 'Host обязателен',
  invalidPort: 'Некорректный port'
};

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

test('host', messages.requiredHost, () => {
  enforce(config.host).isNotBlank();
});

Явное описание ограничений

Плохо:

enforce(config.poolSize).lessThan(200);

Лучше:

enforce(config.poolSize)
  .greaterThanOrEquals(1)
  .lessThanOrEquals(100);

Интеграция с процессом запуска приложения

Часто конфигурация проверяется при старте приложения.

Пример bootstrap-проверки

const result = configSuite(appConfig);

if (result.hasErrors()) {
  console.error(result.getErrors());

  process.exit(1);
}

Интеграция с CI/CD

Vest можно использовать в автоматизированных пайплайнах.

Проверка deployment-конфигурации

const result = deploymentSuite(config);

if (result.hasErrors()) {
  throw new Error(
    JSON.stringify(result.getErrors(), null, 2)
  );
}

Расширение enforce собственными правилами

Vest допускает создание кастомных matcher-функций.

Проверка semantic version

enforce.extend({
  isSemver(value) {
    return /^\d+\.\d+\.\d+$/.test(value);
  }
});

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

test('version', 'Некорректная версия', () => {
  enforce(config.version).isSemver();
});

Валидация JSON-конфигураций

Проверка после парсинга

const raw = fs.readFileSync('./config.json', 'utf8');

const parsed = JSON.parse(raw);

const result = configSuite(parsed);

Валидация YAML-конфигураций

Пример

import yaml from 'js-yaml';

const raw = fs.readFileSync('./config.yml', 'utf8');

const parsed = yaml.load(raw);

const result = configSuite(parsed);

Масштабирование схем валидации

При росте приложения полезны следующие подходы:

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

Совместное использование с TypeScript

Vest хорошо сочетается с интерфейсами.

Пример интерфейса

interface ServerConfig {
  host: string;
  port: number;
  secure: boolean;
}

Валидация

const suite = create((config: ServerConfig) => {
  test('host', 'Host обязателен', () => {
    enforce(config.host).isNotBlank();
  });

  test('port', 'Некорректный port', () => {
    enforce(config.port)
      .isNumber()
      .greaterThan(0);
  });
});

Типичные ошибки при валидации конфигураций

Отсутствие дефолтных значений

Плохо:

config.server.host

Лучше:

config.server?.host

Смешивание нормализации и проверки

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

enforce(Number(config.port))
  .greaterThan(0);

Лучше:

const normalizedPort = Number(config.port);

enforce(normalizedPort)
  .greaterThan(0);

Огромные монолитные suites

Плохо:

create(() => {
  // 1000 строк проверок
});

Лучше:

databaseSuite();
serverSuite();
cacheSuite();
authSuite();

Отсутствие проверки вложенных объектов

Плохо:

enforce(config.database.url)

Лучше:

enforce(config.database?.url)