Поговорим о том, как валидировать данные, которые отправляют пользователи нашему веб-приложению. Это — третья статья из цикла о том, как разработать коммерческое веб-приложение с нуля (Часть 1, Часть 2).
- Вводная.
- Требования к валидации проекта.
- Валидатор.
- Middleware.
- Правила валидации запросов
- Тестирование.
- Итоги.
Валидировать данные запросов необходимо потому что:
- Пользователи могут не вводить обязательные данные без которых работа приложения невозможна.
- Пользователи могут вводить неверные данные из-за невнимательности.
- Злоумышленники могут целенаправленно вводить неверные данные.
Итог всех этих действий один — приложение может работать неверно, данные могут быть не консистентны.
Требования к валидации проекта
Когда команда разрабатывает приложение, создается единый набор правил валидации для разных типов данных, например:
- Имя должно содержать только буквы и иметь длину не более 30 символов.
- Дата должна соответствовать формату RFC3339.
- Телефонный номер должен соответствовать международным рекомендациям.
Следовать стандартам и рекомендациям хорошо, потому что:
- Под стандарты пишутся библиотеки кода, которые позволять работать с этими типами данных.
- Если необходимо интегрировать несколько серверных приложений, то интеграция будет проще, когда приложения работают с данными, отвечающим каким-либо стандартам.
Теперь, когда мы поняли что валидация необходима и она должна опираться на стандарты, давайте добавим ее в наше приложение.
Валидировать данные можно двумя путями:
- На уровне описания протокола.
- На уровне приложения.
Валидация на уровне описания протокола
Наше приложение использует protobuf, поэтому было бы заманчиво описать все правила валидации на уровне протокола. К тому уже есть готовые плагины для protoc, которые позволяют добавить правила валидации в описание сообщений:
У валидации на уровне протокола есть три проблемы:
- Одни и те же правила (например дата должна быть в формате RFC3339) необходимо добавлять в разные сообщения. Соответственно есть риск, что разработчик ошибется, и валидация будет работать неверно или не будет работать вообще.
- Для какого то типа данных необходимо изменить валидацию. Если вы не нашли способ, как не дублировать правила валидации, тогда ее изменение превратится в проблему.
- Допустим вы решили первые две проблемы, но в какой то момент времени ваше приложение будет интегрироваться с другим, которое умеет отдавать данные только в xml. Соответственно вам не удастся переиспользовать правила валидации и надо будет что то придумывать.
Валидацию на уровне протокола можно использовать, когда вы пишете узкоспециализированное приложение, которое никогда ни с кем ни при каких условиях не будет интегрироваться. Для всего остального есть валидация на уровне приложения.
Валидация на уровне приложения
В golang большое количество библиотек, осуществляющих валидацию данных, поэтому можно выбрать любую понравившуюся и добавить в проект. Стоит сказать, что со всеми подобными библиотеками есть одна проблема — они не умеют работать с архитектурой grpc «из коробки», поэтому придется писать адаптеры над ними.
Для написания адаптеров к нам на помощь приходят:
- google.golang.org/protobuf/reflect/protoreflect поможет нам итеративно получить значения по всем полям сообщения.
- github.com/go-playground/validator станет фундаментом, на котором мы построим механизм валидации.
Архитектура валидации будет иметь следующую логику:
- Запрос перехватывается middleware.
- Middleware получает правила валидации из MethodDescriptor.
- Если правила есть, выполняет проверку свойств сообщения.
- Если нет, передает обработку запроса дальше.
Валидатор
Для начала давайте опишем сам валидатор и форму:
package validator
import (
"context"
"github.com/go-playground/validator/v10"
"google.golang.org/protobuf/reflect/protoreflect"
)
var (
validate = validator.New()
)
// конкретная реализация для какого либо типа данных запроса
type Validator interface {
Validate(ctx context.Context, value protoreflect.Value) error
}
// структура, позволяющая описывать валидаторы для всего запроса
type Form map[string]Validator
func (f Form) Validate(ctx context.Context, message protoreflect.Message) error {
fields := message.Descriptor().Fields()
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
v, ok := f[string(field.Name())]
if !ok {
continue
}
err := v.Validate(ctx, message.Get(field))
if err != nil {
return err
}
}
return nil
}
Данный подход позволит нам использовать правила валидации как в middleware, так и в другом месте приложения.
Теперь давайте определимся с типами данных, которые будем валидировать:
- uuid — строка в формате UUID v4.
- телефонный номер — строка с префиксом «+» и длиной не более 15 символов.
- адрес доставки — строка длиной 255 символов.
- дата доставки — дата в формате RFC3339 не раньше сегодняшнего дня.
- сумма заказа — положительное число с плавающей точкой.
- перечисление — значение enum больше нуля и меньше или равно максимальному.
- сообщение — вложенное сообщение.
- массив сообщений — вложенный массив сообщений.
Теперь давайте реализуем валидаторы для разных форматов строк:
package validator
import (
"context"
"fmt"
"google.golang.org/protobuf/reflect/protoreflect"
)
func NewString(condition string) Validator {
return &stringValidator{
condition: condition,
}
}
func NewUUID() Validator {
return NewString("uuid4")
}
func NewPhoneNumber() Validator {
return NewString("e164")
}
func NewStringWithMaxLen(l int) Validator {
return NewString(fmt.Sprintf("required,max=%d", l))
}
func NewReqString() Validator {
return NewString("required")
}
func NewAlphanumeric() Validator {
return NewString("alphanum")
}
type stringValidator struct {
condition string
}
func (v stringValidator) Validate(ctx context.Context, value protoreflect.Value) error {
return validate.VarCtx(ctx, value.String(), v.condition)
}
Протестируем получившиеся валидаторы:
package validator_test
import (
"context"
"github.com/Pallinder/go-randomdata"
"github.com/byorty/enterprise-application/pkg/common/adapter/validator"
"github.com/google/uuid"
"github.com/stretchr/testify/suite"
"google.golang.org/protobuf/reflect/protoreflect"
"testing"
)
func TestStringValidatorSuite(t *testing.T) {
suite.Run(t, new(StringValidatorSuite))
}
type StringValidatorSuite struct {
suite.Suite
}
func (s *StringValidatorSuite) TestUUID() {
v := validator.NewUUID()
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
s.Nil(v.Validate(context.Background(), protoreflect.ValueOf(uuid.NewString())))
}
func (s *StringValidatorSuite) TestPhoneNumber() {
v := validator.NewPhoneNumber()
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
s.Nil(v.Validate(context.Background(), protoreflect.ValueOf("+79008007060")))
}
func (s *StringValidatorSuite) TestMaxLen() {
v := validator.NewStringWithMaxLen(10)
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
s.Nil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(9))))
}
func (s *StringValidatorSuite) TestAlphanumeric() {
v := validator.NewAlphanumeric()
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("")))
s.NotNil(v.Validate(context.Background(), protoreflect.ValueOf("qazwsx!@#%%^")))
s.Nil(v.Validate(context.Background(), protoreflect.ValueOf(randomdata.Alphanumeric(32))))
}
По аналогии реализуем другие валидаторы:
Мы создали единый набор правил валидации, который мы будем переиспользовать в разных доменах.
Middleware
Теперь необходимо вписать наши валидаторы в архитектуру grpc.
Расширяем MethodDescriptor:
type MethodDescriptor struct {
…
Form validator.Form
}
Реализуем новую middleware, которая будет брать форму из описания метода и валидировать запрос:
package grpc_option
import (
"context"
"github.com/byorty/enterprise-application/pkg/common/adapter/server/grpc"
gRPC "google.golang.org/grpc"
"google.golang.org/protobuf/reflect/protoreflect"
)
func NewFxValidatorOption(
methodDescriptorMap grpc.MethodDescriptorMap,
) grpc.MiddlewareOut {
return grpc.MiddlewareOut{
GrpcMiddleware: grpc.Middleware{
Priority: 97,
GrpcOption: func(ctx context.Context, req interface{}, info *gRPC.UnaryServerInfo, handler gRPC.UnaryHandler) (resp interface{}, err error) {
protoMessage, ok := req.(protoreflect.ProtoMessage)
if !ok {
return handler(ctx, req)
}
methodDescriptor, ok := methodDescriptorMap.GetByFullName(info.FullMethod)
if !ok {
return handler(ctx, req)
}
if len(methodDescriptor.Form) == 0 {
return handler(ctx, req)
}
err = methodDescriptor.Form.Validate(ctx, protoMessage.ProtoReflect())
if err != nil {
return nil, grpc.ErrInvalidArgument(err)
}
return handler(ctx, req)
},
},
}
}
Теперь наши запросы будут валидироваться в middleware, не захламляйте бизнес-код.
Правила валидации запросов
Все подготовительные работы окончены, и мы можем описать правила валидации для конкретных запросов. Давайте опишем правила для создании заказа.
package form
import "github.com/byorty/enterprise-application/pkg/common/adapter/validator"
var (
CreateOrder = validator.Form{
// идентификатором пользователя должен быть UUID v4
"user_uuid": validator.NewUUID(),
// параметрами запроса должнобыть сообщение
"params": validator.NewMessage(validator.Form{
// массив продуктов должен иметь хотя бы один элемент
"products": validator.NewReqList(
// элементом массива должно быть сообщение
validator.NewMessage(validator.Form{
// идентификатором продукта должен быть UUID v4
"product_uuid": validator.NewUUID(),
// пользователь должен добавить в заказ хотя бы одну единицу продукта
"count": validator.NewUint32Min(1),
}),
),
// любая строка не длиннее 255-ти символов
"address": validator.NewStringWithMaxLen(255),
// дата равная текущей + двое суток
"delivered_at": validator.NewDeliveredAt(),
}),
}
)
По аналогии опишем правила для других запросов:
Тестирование
Доработки кода приложения и инфраструктуры завершены, самое время протестировать наше приложение. Поднимаем приложение:
make up
Открываем https://enterprise.application.local
Давайте зарегистрируем пользователя с невалидным телефоном.
Получили ошибку валидации запроса.
Теперь возьмем валидный номер телефона.
Пользователь зарегистрирован.
А теперь создадим заказ без отложенных товаров.
Получили ошибку валидации запроса.
Отложим товары и создадим заказ.
Заказ создан.
Тесты прошли успешно, валидация запросов отрабатывает, как требуется.
Итоги
Итак, в этой статье мы научились:
- Разрабатывать архитектуру валидации protobuf сообщений.
- Реализовывать единый набор правил валидации.
- Вписывать правила валидации в архитектуру grpc.
- Описывать правила валидации для конкретных запросов.
Как и в предыдущей статье, код представлен в репозитории.
В следующей статье мы обсудим следующие темы:
- Выбор драйвера СУБД.
- Репозиторный слой приложения.
- Моки в тестах.