validator парсер-валидатор входных параметров JSON API
Очередной велосипед на тему парсинга и валидации получаемых сервисом API данных.
Заточенный под особенности встроенной в Go обработки JSON.
Данный пакет не является полностью универсальным решением и сделан на основе опыта решения задач, возникающих у меня
на работе.
Например, задача проверить равенство пароля и повтора пароля в сервисах, написанных именно на Go, мне не попадалась.
Пример использования
Вадидатор создаётся один раз при запуске программы и дальше используется по мере надобности.
package main
import (
"fmt"
v "github.com/eandr-67/validator"
o "github.com/eandr-67/validator/object"
)
// Создание валидатора
var VL = o.Obj(v.NotNull, o.Required("aaa"), o.Default("bbb", "12345")).
Add("aaa", v.Int(v.Null, v.Gt[int64](25), v.Le[int64](50))).
Add("bbb", v.String(v.NotNull, v.Regex("^\\d{5}$"))).
Validator()
func main() { // Примеры использования
res, err := VL.Do(nil)
fmt.Printf("%#v\n%#v\n\n", res, err)
res, err = VL.Do(map[string]any{})
fmt.Printf("%#v\n%#v\n\n", res, err)
res, err = VL.Do(map[string]any{"aaa": 10.0, "bbb": "98765"})
fmt.Printf("%#v\n%#v\n\n", res, err)
}
В данном случае в переменную VL записывается валидатор, требующий JSON-объект вида:
{
"aaa": 30,
"bbb": "vwxyz"
}
Поле aaa должно быть задано обязательно и либо иметь значение null, либо содержать целое число в диапазоне (25; 50].
Поле bbb опционально.
Если оно задано, то должно быть строкой, содержащей 5 цифр.
Если не задано, поле bbb создаётся со значением "12345".
Зачем???
Если делать типовой разбор JSON в многоуровневый struct с аннотациями, то, во первых, получаем крайне неудобные для
прямого возврата из API сообщения об ошибках, во вторых, не видим ошибок "неизвестные поля в запросе"
(да, я считаю это ошибкой, которая должна обрабатываться), и, в третьих, получаем головную боль с крайне хрупкими
аннотациями.
И если первую проблему validator ещё как-то решает, то вторую
ни validator, ни govalidator
решить в принципе не могут, а третью лишь усугубляют.
Но делать кодогенератор, по заданным формальным правилам создающий готовый go-код,
мне не интересно.
Так что остаётся создание набора кубиков для сборки валидатора.
И хотелось сделать этот набор достаточно типобезопасным и не слишком монструозным.
Используемый подход
Никаких struct и аннотаций.
Производится декодирование JSON в any и этот any подаётся на вход валидатора, который преобразует элементы any
в значения нужных типов и применяет к этим значениям правила валидации - получая на выходе преобразованный any
и список ошибок валидации. Да, такой валидатор получается более громоздким и при создании, и при использовании
результатов валидации (приходится явно конкретизировать типы компонентов any), но не настолько, чтобы лично для
меня это стало критичным.
N.B. При использовании Go бессмысленно рассуждать о громоздкости
Особенность такого разбора JSON в том, что any может содержать только: nil (без типа), string, bool,
float64 (да, все числа декодируются как вещественные, что соответствует семантике чисел в JavaScript),
[]any (массивы JSON), map[string]any (объекты JSON).
Таким образом, кроме собственно проверок значений в процессе обработки надо было обеспечить:
-
Автоматическое преобразование float64 -> int64.
-
Автоматическое преобразование string в значение требуемого типа (например Time или UUID).
-
Автоматическую рекурсивную проверку элементов []any.
Я сознательно ограничил "полёт креатива", запрещая гетерогенные массивы и устанавливая единственный набор правил
валидации, применяемый к каждому элементу массива.
-
Автоматическую рекурсивную проверку элементов map[string]any.
Здесь уже для каждого поля свой набор правил.
Но тут свои заморочки: обязательность / опциональность полей, значения по умолчанию для отсутствующих полей...
На данный момент я не стал добавлять в правила валидации сравнение значений разных полей и зависимость обязательности
одних полей от других, но в рамках существующей схемы это легко добавить.
Особый случай - значение nil (null в JSON).
Для того, чтобы корректно работать с nil, внутри валидатора используются не значения, а указатели на значения
и преобразование указателя в значение происходит только по завершении набора правил, применяемых к значению.
Само же значение nil может быть обработано одним из трёх способов:
-
Остановить процесс валидации значения и вернуть значение nil без ошибки (обычный nullable-тип).
-
Остановить процесс валидации значения и вернуть ошибку "значение не может быть null".
-
Заменить nil на заданное значение и продолжить валидацию с установленным новым значением.
И, разумеется, хотелось бы, чтобы это было не слишком монструозно и достаточно типобезопасно.
Базовый пакет
Здесь описывается только то, что непосредственно необходимо для
практического использования валидатора. Служебные компоненты,
необходимые для самостоятельного построения модулей валидации
других типов данных, описаны в исходном коде.
Валидатор (Validator)
Реализует интерфейс Validator, содержащий единственный метод:
type Validator interface { Do(any) (any, *errs.Errors) }
Собственно, всё использование валидатора сводится к вызову метода Do, получающего на вход значение типа any
(результат декодирования JSON) и возвращающего два значения: обработанный набор данных (опять же типа any)
и список зарегистрированных в процессе обработки ошибок.
Валидатор не имеет состояния и одновременные параллельные вызовы Do никак не влияют друг на друга.
Сам процесс валидации состоит из в общем случае рекурсивного применения заданных наборов действий к элементам поданного
на вход валидатора набора данных.
Создание валидатора производится в 3 этапа:
- Создаётся построитель требуемого типа данных.
- Построитель заполняется действиями, допустимыми для данного типа, и (если это построитель array или object)
дочерними построителями.
- Построитель генерирует валидатор.
Ошибки
Пока что валидатор различает 7 типов ошибок. При возникновении
ошибки текст (текстовый код) ошибки добавляется в список ошибок.
Поменять тексты ошибок можно простым присваиванием глобальному
массиву validator.ErrMsg. Для человекочитаемой работы с ошибками
каждому индексу этого массива назначена константа.
| Тип |
Константа |
Индекс |
Текст |
Описание |
| TypeIncorrect |
CodeTypeIncorrect |
0 |
type |
Ошибочный тип проверяемого значения |
| FormatIncorrect |
CodeFormatIncorrect |
1 |
format |
Ошибочный формат строки: проверяемого значения или исходного JSON |
| LengthIncorrect |
CodeLengthIncorrect |
2 |
length |
Ошибочная длина проверяемых строки или массива |
| ValueIncorrect |
CodeValueIncorrect |
3 |
value |
Значение не соответствует заданным правилам |
| ValueIsNull |
CodeValueIsNull |
4 |
is_null |
Недопустимое значение nil (null) |
| KeyMissed |
CodeKeyMissed |
5 |
missed |
В объекте отсутствует обязательное поле |
| KeyUnknown |
CodeKeyUnknown |
6 |
unknown |
В объекте присутствует поле, неизвестное валидатору |
Ошибки, возникающие при проверке значения в целом, записываются в список ошибок с ключом "" (пустая строка).
К ключам ошибок, возвращённых валидатором элементов массива, слева приписывается префикс "[индекс_элемента]".
К ключам ошибок, возвращённых валидаторами полей объекта (ассоциативного массива), слева приписывается префикс
".имя_элемента".
Таким образом, если у нас структура JSON:
{
"paginator": {
"page": 10,
"size": -3
},
"field": [
1,
2,
null,
4,
5
]
}
, то список ошибок будет иметь вид:
map[string][]sting{".paginator.size": ["value"], ".field[2]": ["is_null"]}
Действие (Action)
Базовая единица проверки значения. Процесс проверки значения
состоит из последовательного вызова действий, которые могут
менять значение, генерировать сообщения об ошибках и/или
останавливать цепочку действий.
Описывается типом:
type Action[T any] func (elem *T, field string, err *e.Errors) (*T, bool)
Действие получает на вход указатель на значение elem и возвращает
указатель на результат применения действия и флаг продолжения
процесса проверки - уже с новым возвращённым значением. Если
действие завершилось ошибкой (значение не соответствует заданному
условию), сообщение об ошибке записывается в err с ключом field.
Наличие ошибки и установка флага продолжения проверки в false
(остановка проверки) в общем случае не синхронизированы: может быть
остановка без ошибки (Null) и может быть ошибка без остановки
(действия в Object).
N.B. Изначально предполагалось, что Action ничего не будет знать
о регистрации ошибок и будет просто возвращать текст ошибки, но
необходимость в действиях, работающих не с отдельными значениями,
а со списками полей, привела к структуре параметров, которая мне
самому не нравится.
Чаще используются не действия в чистом виде, а генераторы действий:
функции, получающие набор параметров и возвращающие замыкание типа
Action, проверяющее соответствие действия заданным параметрам.
Список действий (генераторов действий) базового пакета:
Если это действие, а не генератор, в графе "Параметры" стоит прочерк.
Если действие не может вернуть ошибку, в графе "Ошибка стоит пропуск".
В описании параметр генератора обозначается именем par, а проверяемое значение именем elem.
Именем T обозначен тип проверяемого значения.
| Действие |
Тип значения |
Параметры |
Тип ошибки |
Описание (псевдокод) |
| Null |
any |
- |
- |
Остановка проверки, если elem == nil |
| NotNull |
any |
- |
ValueIsNull |
Ошибка, если elem == nil |
| IfNull |
any |
T |
- |
Подстановка par как значения, если elem == nil |
| Eq |
comparable |
T |
ValueIncorrect |
*elem == par |
| Ne |
comparable |
T |
ValueIncorrect |
*elem != par |
| In |
comparable |
...T |
ValueIncorrect |
*elem in par (присутствует в списке параметров) |
| NotIn |
comparable |
...T |
ValueIncorrect |
*elem not in par (отсутствует в списке параметров) |
| Lt |
ordered |
T |
ValueIncorrect |
*elem < par |
| Le |
ordered |
T |
ValueIncorrect |
*elem <= par |
| Gt |
ordered |
T |
ValueIncorrect |
*elem > par |
| Ge |
ordered |
T |
ValueIncorrect |
*elem >= par |
| Regex |
string |
string |
FormatIncorrect |
regexp(par).test(*elem) |
| NotRegex |
string |
string |
FormatIncorrect |
! regexp(par).test(*elem) |
| LenEq |
string |
int |
LengthIncorrect |
len(*elem) == par |
| LenNe |
string |
int |
LengthIncorrect |
len(*elem) != par |
| LenGe |
string |
int |
LengthIncorrect |
len(*elem) >= par |
| LenLe |
string |
int |
LengthIncorrect |
len(*elem) <= par |
| LenIn |
string |
...int |
LengthIncorrect |
len(*elem) in par |
| LenNotIn |
string |
...int |
LengthIncorrect |
len(*elem) not in par |
Построитель (Builder)
Тип данных, реализующий интерфейс validator.Builder, определяющий
единственный метод, генерирующий валидатор:
type Builder interface { Validator() Validator }
Все построители в базовом пакете являются экземплярами типа Build[T any], предназначенного для типов, не имеющих
внутренней структуры.
Для упрощения работы используется набор функций, сразу генерирующих построители заданного типа:
func Int(before ...Action[int64]) *Build[int64] // возвращает построитель валидатора целого числа
func Float(before ...Action[float64]) *Build[float64] // возвращает построитель валидатора вещественного числа
func String(before ...Action[string]) *Build[string] // возвращает построитель валидатора строки
func Bool(before ...Action[bool]) *Build[bool] // возвращает построитель валидатора вещественного значения
Построители валидаторов других типов данных вынесены в отдельные подпакеты.
Кроме указанного в сигнатурах функций способа задавать действия в момент создания построителя, Build имеет
отдельный метод для добавления действий:
func (b *Build[T]) Append(before ...Action[T]) *Build[T]
Так что нижеперечисленные варианты создания валидатора с двумя действиями эквивалентны:
vl = func v.String(v.NotNull, LenEq(5)).Validator()
vl = func v.String().Append(v.NotNull, LenEq(5)).Validator()
vl = func v.String(v.NotNull).Append(LenEq(5)).Validator()
Подпакет validator/time
Реализует автоматическое преобразование строки в значение time.Time.
Т.к. тип Time фактически не является comparable и при этом реализует ordered посредством своих методов,
то для validator/time пришлось создать свой набор действий, используемых только в нём.
Что, собственно, и стало причиной выноса этого типа в отдельный пакет.
Но главная проблема time.Time не в сравнении значений, а в том самом преобразовании строки в Time.
Если API принимает дату / время в единственном жёстко заданном формате, сложностей не возникает.
На такое возможно далеко не всегда и надо предусмотреть обработку разных форматов.
Так что валидатору должен передаваться набор форматов, допустимых для данного значения.
Кроме того, должна быть возможность установки часового пояса по умолчанию - глобальная, действующая на все валидаторы.
Список действий validator/time
| Действие |
Тип значения |
Параметры |
Тип ошибки |
Описание (псевдокод) |
| Eq |
time.Time |
time.Time |
ValueIncorrect |
*elem == par |
| Ne |
time.Time |
time.Time |
ValueIncorrect |
*elem != par |
| In |
time.Time |
...time.Time |
ValueIncorrect |
*elem in par |
| NotIn |
time.Time |
...time.Time |
ValueIncorrect |
*elem not in par |
| Lt |
time.Time |
time.Time |
ValueIncorrect |
*elem < par |
| Le |
time.Time |
time.Time |
ValueIncorrect |
*elem <= par |
| Gt |
time.Time |
time.Time |
ValueIncorrect |
*elem > par |
| Ge |
time.Time |
time.Time |
ValueIncorrect |
*elem >= par |
Построитель валидатора
Построитель создаётся генератором:
func Time(formats []string, before ...validator.Action[time.Time]) *Build
Первым параметром передаётся массив форматов даты/времени.
Если массив пуст, генерируется паника, содержащая строку "formats cannot be empty".
В остальном работа с построителем ничем не отличается от работы с построителями базового пакета.
Глобальная переменная Default пакета содержит набор готовых форматов - вероятно, не самый оптимальный.
Часовой пояс
Установка часового пояса производится вызовом функции:
func SetTimeZone(tz *time.Location)
По умолчанию выставлен часовой пояс time.UTC.
Обработка структурных данных
Если обработка простого значения - это выполнение одного набора действий, то обработка структурного значения состоит
из трёх последовательных этапов:
- Выполнение набора начальных (before) действий.
- Выполнение обработчика (handler), применяющего валидаторы к составным частям значения.
- Выполнение набора конечных (after) действий.
Вместо единственного метода Append у построителей базового пакета построители валидаторов структурных данных имеют
два метода: Before и After, добавляющие действия в соответствующие наборы.
На данный момент любое действие, предназначенное для структурных данных, может быть использовано как в наборе начальных,
так и в наборе конечных действий.
Обработка массивов (подпакет validator/array)
Пакет обрабатывает данные типа []any
Список действий
Список действий повторяет таковой в базовом пакете. Но попытка создать методы с шаблоном [T string|[]any] привела
к геморрою при их использовании.
Так что предпочёл сделать два набора идентичных методов, отличающихся только именем типа.
| Действие |
Тип значения |
Параметры |
Тип ошибки |
Описание (псевдокод) |
| LenEq |
[]any |
int |
LengthIncorrect |
len(*elem) == par |
| LenNe |
[]any |
int |
LengthIncorrect |
len(*elem) != par |
| LenGe |
[]any |
int |
LengthIncorrect |
len(*elem) >= par |
| LenLe |
[]any |
int |
LengthIncorrect |
len(*elem) <= par |
| LenIn |
[]any |
...int |
LengthIncorrect |
len(*elem) in par |
| LenNotIn |
[]any |
...int |
LengthIncorrect |
len(*elem) not in par |
Построитель валидатора
Построитель создаётся генератором:
func Arr(cell validator.Builder, before ...validator.Action[[]any]) *Build
Первым параметром передаётся построитель валидатора ячеек массива: этот валидатор будет применён к значениям во всех
ячейках массива.
Следующими параметрами передаются действия, добавляемые в набор начальный действий.
Конечный набор действий создаётся только вызовом метода After.
Обработка объектов (подпакет validator/object)
Пакет обрабатывает данные типа map[string]any, в котором ключи - имена полей объекта JSON.
В отличие от массива, имеющего один вадидатор для всех значений, валидатор объекта содержит отдельные валидаторы для
каждого своего поля.
Список действий
| Действие |
Тип значения |
Параметры |
Тип ошибки |
Описание |
| Default |
map[string]any |
string, any |
- |
если поля не существует, оно будет создано с заданным значением |
| DefaultList |
map[string]any |
map[string]any |
- |
если полей с ключами map не существует, они будут созданы с заданными значениями |
| Required |
map[string]any |
...string |
KeyMissed |
если полей с указанными именами не существует, будет выданы ошибки с ключами - именами отсутствующих полей |
Построитель валидатора
Сам построитель создаётся генератором:
func Obj(before ...validator.Action[map[string]any]) *Build
Начальные действия добавляются либо в Obj, либо вызовами метода Before, конечные действия добавляются вызовами
After.
Но такой построитель - пустой объект не имеющий полей. Для добавления проверяемых полей объекта используются два метода:
func (b *Build) Add(field string, build validator.Builder) *Build
Добавляет одно поле с именем field и построителем build.
func (b *Build) AddMap(fields map[string]validator.Builder) *Build
Добавляет группу полей с именами - ключами fields и построителями - значениями fields.
Немного синтаксического сахара
Т.к. в типовом JSON API на вход подаётся объект, то в пакет добавлены две функции, которые производят разбор потока
ввода или строки, содержащих объект JSON сразу в map[string]any без дополнительных ручных преобразований.
Обработка потока ввода:
func Parse(reader io.Reader, validator validator.Validator) (map[string]any, e.Errors)
Получает на вход поток ввода и валидатор. Возвращает обработанные данные и список ошибок.
Обработка строки:
func ParseString(str string, validator validator.Validator) (map[string]any, e.Errors)