Мне нужно было просто валидировать YAML. В итоге получилась библиотека с JSON Schema
Когда я начинал писать go-yamlvalidator, никакой большой идеи за этим не было. Мне нужен был нормальный способ проверять YAML-конфиг.
Я разрабатываю EasyP — инструмент для работы с Protobuf-контрактами, зависимостями и генерацией кода. Когда у пользователей что-то не работало, они часто писали нам в чат EasyP и присылали свои YAML-конфиги. В итоге нам самим приходилось глазами искать лишний отступ, неправильную вложенность, опечатку в ключе или ещё какую-нибудь мелочь.
Причём конфиг часто выглядел почти нормально:
generate: plugins: - name: go out: ./gen
Перед out не хватает всего одного пробела.
Можно ошибиться и в другую сторону:
generate: plugins: - name: go out: ./gen
Здесь пробелов уже слишком много.
В длинном конфиге, присланном в чат, такие вещи приходится буквально выискивать глазами. YAML parser ошибку найдёт, но хотелось сразу показывать пользователю конкретное место в файле, а не разбирать его конфиг вручную.
Были и ошибки другого рода:
generate: plugin: - name: go out: ./gen
Этот YAML уже синтаксически валиден. Только EasyP ожидает plugins, а пользователь написал plugin.
Есть и ошибки, которые становятся понятны только если знать схему конфига: обязательного поля нет, значение имеет неправильный тип, указаны две взаимоисключающие настройки.
Мне хотелось проверять всё это сразу после чтения файла и выдавать ошибку с нормальным путём, строкой и колонкой.
Так появилась первая версия go-yamlvalidator.
Я не хотел использовать yaml.Unmarshal
Можно было делать yaml.Unmarshal в Go-структуру, а потом валидировать получившийся объект.
Мне этот вариант не подходил.
Я хотел проверять именно исходный файл. Если с конфигом что-то не так, важно знать, где находится проблемное значение в YAML и что именно там написал пользователь.
Ещё мне не хотелось жёстко связывать схему конфига с Go-структурами. В YAML вполне могут быть произвольные map, несколько допустимых форм одного значения, условные поля и зависимости между ними.
Поэтому библиотека работает с yaml.Node.
У него уже есть структура документа и координаты каждого элемента в исходном YAML.
Сначала появилась простая схема
Первая версия строилась вокруг FieldSchema.
Например, хотим конфиг такого вида:
name: backendport: 8080
Схема для него:
schema := &yamlvalidator.FieldSchema{ Type: yamlvalidator.TypeMap, AllowedKeys: map[string]*yamlvalidator.FieldSchema{ "name": { Type: yamlvalidator.TypeString, Required: true, }, "port": { Type: yamlvalidator.TypeInt, }, },}
Дальше компилируем её:
validator, err := yamlvalidator.CompileFieldSchema(schema)if err != nil { return err}result := validator.ValidateBytes(data)
Если пользователь напишет:
name: backendprt: 8080
библиотека увидит неизвестный ключ.
Если:
port: value: 8080
то обнаружит, что вместо integer пришёл map.
Если убрать обязательный name, будет отдельная ошибка для отсутствующего поля.
Постепенно у FieldSchema появились nullable-поля, несколько допустимых типов, массивы, произвольные свойства и зависимости между полями.
Например, можно запретить одновременное использование file и url:
schema.MutuallyExclusive = []string{"file", "url"}
Или описать два допустимых способа авторизации:
schema.OneOfRequired = [][]string{ {"token"}, {"username", "password"},}
То есть проверять можно уже не только типы отдельных значений, но и структуру конфига целиком.
В какой-то момент стало понятно, что FieldSchema постепенно превращается в собственный небольшой язык описания схем.
А для этой задачи уже существует JSON Schema.
JSON Schema для YAML
Я не стал писать свой JSON Schema engine. Внутри используется готовая реализация, а go-yamlvalidator занимается связкой между JSON Schema и исходным YAML.
Например, берём обычную JSON Schema:
{ "type": "object", "properties": { "name": { "type": "string" }, "port": { "type": "integer", "minimum": 1 } }, "required": ["name"], "additionalProperties": false}
Компилируем:
schema, err := yamlvalidator.CompileJSONSchema(schemaData)if err != nil { return err}validator := yamlvalidator.NewValidator(schema)result := validator.ValidateBytes(yamlData)
А проверяем ей обычный YAML:
name: backendport: 8080
Или, например:
name: backendport: wrong
JSON Schema увидит нарушение типа, а библиотека сможет привязать эту ошибку обратно к строке port в YAML.
Внутри это выглядит примерно так:
YAML | vyaml.Node | vJSON data model | vJSON Schema engine
JSON Schema работает с моделью данных: object, array, string, number, boolean и null. Поэтому YAML можно привести к этой модели, сохранив рядом исходные yaml.Node.
Когда JSON Schema сообщает об ошибке по пути:
["plugins", "0", "name"]
библиотека находит соответствующий элемент исходного YAML и знает его строку и колонку.
Именно эта связка для меня была основной причиной не использовать JSON Schema validator напрямую.
Свои проверки на Go
Схемой можно описать далеко не любую прикладную проверку, поэтому в библиотеке есть custom validators.
Для частых случаев уже есть готовые.
Например, enum:
valuevalidator.EnumValidator{ Allowed: []string{"dev", "stage", "prod"},}
Или regexp:
valuevalidator.RegexValidator{ Pattern: regexp.MustCompile(`^[a-z0-9-]+$`),}
Их можно просто добавить к полю:
"environment": { Type: yamlvalidator.TypeString, Validators: []yamlvalidator.ValueValidator{ valuevalidator.EnumValidator{ Allowed: []string{"dev", "stage", "prod"}, }, },},
Если готового validator нет, можно написать свой:
type MyValidator struct{}func (MyValidator) Validate( node *yaml.Node, path string, ctx *yamlvalidator.ValidationContext,) { if node.Value == "forbidden" { ctx.AddError(yamlvalidator.ValidationError{ Level: yamlvalidator.LevelError, Path: path, Line: node.Line, Column: node.Column, Message: "value is forbidden", }) }}
Отдельный интерфейс есть и для проверки ключей map.
Расширять можно и JSON Schema
После появления JSON Schema мне всё равно хотелось иметь возможность добавлять проверки на Go.
Самый простой вариант — собственный format.
Например:
format := yamlvalidator.JSONSchemaFormat{ Name: "module-name", Validate: func(value any) error { name, ok := value.(string) if ok && !validModuleName(name) { return errors.New("invalid module name") } return nil },}
После регистрации его можно использовать прямо в JSON Schema:
{ "type": "string", "format": "module-name"}
Для более сложной логики есть custom keywords.
Например, можно добавить свой keyword:
{ "x-require-property": "name"}
а его поведение определить на Go.
Есть поддержка и более сложных extensions, где значение custom keyword само содержит вложенные JSON Schema.
При этом типы конкретного JSON Schema engine наружу не торчат: extension API принадлежит самой библиотеке.
$ref
Поддерживаются и обычные ссылки между JSON Schema.
Например:
{ "$ref": "common.json"}
Если schemas лежат в памяти, их можно передать через Resources.
Если нужно читать их с диска, можно создать file resolver:
resolver, err := yamlvalidator.NewJSONSchemaFileResolver(schemaDir)
и передать его при компиляции:
schema, err := yamlvalidator.CompileJSONSchemaWithOptions( schemaData, yamlvalidator.JSONSchemaCompileOptions{ Resolver: resolver, },)
Сам CompileJSONSchema произвольные файлы с диска не читает.
У CLI чуть более привычное поведение. Если рядом лежат:
schema.jsoncommon.json
то относительный:
{ "$ref": "common.json"}
будет работать автоматически в пределах директории schema-файла.
Ошибка как отдельный результат validation
Одной строки ошибки со временем стало мало.
Сейчас у diagnostic есть код, путь, координаты и дополнительные структурированные данные:
type ValidationError struct { Code string SchemaPath string Path string Line int Column int Message string Got string Expected string Details any}
За счёт этого один и тот же результат можно нормально показать в CLI или использовать, например, для подсветки ошибки в редакторе.
Для пользователя это в итоге выглядит примерно так:
config.yaml:17:5unknown key "plugin"did you mean "plugins"?
А для кода остаются отдельные Code, Path, Line, Column и Details.
Насколько полноценна поддержка JSON Schema
Мне не хотелось называть поддержку «JSON Schema», если она работает только на нескольких выбранных примерах.
Поэтому библиотека прогоняется через официальный JSON Schema Test Suite.
Сейчас результаты core tests такие:
draft-04: 618 / 618draft-06: 841 / 841draft-07: 929 / 9292019-09: 1261 / 12612020-12: 1301 / 1301total: 4950 / 4950
Также проходят 3884 применимых optional tests.
Один optional test draft-04 исключён отдельно: он проверяет различие между 1 и 1.0 именно как между типами host language, тогда как используемый JSON Schema engine рассматривает целые числа математически.
Для меня после этого вопрос «насколько здесь настоящая JSON Schema» в целом закрылся.
Что получилось
Всё началось с конфигов, где иногда нужно было минуту смотреть на несколько строк YAML, чтобы понять, что перед out не хватает одного пробела.
Теперь можно описать схему напрямую в Go:
schema := &yamlvalidator.FieldSchema{ Type: yamlvalidator.TypeMap, AllowedKeys: map[string]*yamlvalidator.FieldSchema{ "name": { Type: yamlvalidator.TypeString, Required: true, }, },}
либо взять обычную JSON Schema:
schema, err := yamlvalidator.CompileJSONSchema(schemaData)
а дальше валидировать тот же YAML с сохранением нормальных ошибок по исходному файлу.
Есть вложенные структуры, массивы, неизвестные и обязательные поля, зависимости между настройками, custom validators, JSON Schema extensions и $ref.
И в итоге пользователь больше не должен присылать нам конфиг в чат, чтобы мы глазами искали в нём два лишних пробела.
Он должен сразу увидеть, где именно ошибся.
Репозиторий:
github.com/Yakwilik/go-yamlvalidator
Установка:
go get github.com/Yakwilik/go-yamlvalidator@v1.0.0
ссылка на оригинал статьи https://habr.com/ru/articles/1086386/