|
|
@@ -1,142 +1,171 @@
|
|
|
-# Backend: Валидация входящих данных
|
|
|
+# Backend: Валидация — go-playground/validator + кастомные правила
|
|
|
|
|
|
-**Контекст**: Во всех handlers (`places.go`, `services.go`, `auth.go` и др.) валидация делается вручную через `if req.Field == ""` проверки. Нет единой схемы валидации, нет структурированных ошибок.
|
|
|
+**Контекст**: Во всех хендлерах (`places.go`, `auth.go`, `services.go` и др.) валидация входящих данных выполняется через `go-playground/validator` с struct tags и кастомными валидаторами. **Обновлено 2026-06 — код уже использует go-playground/validator, а не ручную валидацию, как было указано в первой версии заметки.**
|
|
|
|
|
|
## Суть
|
|
|
|
|
|
-**Ручная валидация — не масштабируема и ошибочна**:
|
|
|
-- Дублирование кода проверок
|
|
|
-- Неконсистентные сообщения об ошибках
|
|
|
-- Нет валидации типов, форматов (email, URL, UUID, координаты)
|
|
|
-- Сложно поддерживать и тестировать
|
|
|
-
|
|
|
-## Код (проблемное место)
|
|
|
-
|
|
|
-```go
|
|
|
-// backend/internal/handlers/places.go:120-147
|
|
|
-func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
|
- var req createPlaceRequest
|
|
|
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
- writeError(w, http.StatusBadRequest, "invalid request body")
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if req.Title == "" { // Ручная проверка
|
|
|
- writeError(w, http.StatusBadRequest, "title is required")
|
|
|
- return
|
|
|
- }
|
|
|
- if req.Type == "" { // Ручная проверка
|
|
|
- writeError(w, http.StatusBadRequest, "type is required")
|
|
|
- return
|
|
|
- }
|
|
|
- // ... нет валидации: lat/lng range, URL формат, enum для type, длина строк
|
|
|
-}
|
|
|
-```
|
|
|
-
|
|
|
-## Решение: go-playground/validator v10
|
|
|
-
|
|
|
-Стандарт де-факто в Go экосистеме. Поддерживает:
|
|
|
+В проекте реализован единый слой валидации в `backend/internal/validator/validator.go`:
|
|
|
- Struct tags: `validate:"required,email,max=255"`
|
|
|
-- Custom validators
|
|
|
-- Перевод ошибок
|
|
|
-- Вложенные структуры
|
|
|
-
|
|
|
-### Пример внедрения:
|
|
|
+- Кастомные валидаторы: `uuid`, `slug`, `latitude`, `longitude`, `place_type`, `user_role`, `currency`, `datetime` и др.
|
|
|
+- Функции: `Validate(s interface{})` и `ValidateVar(field interface{}, tag string)`
|
|
|
+- Алиас: `ValidationErrors = validator.ValidationErrors`
|
|
|
|
|
|
-```go
|
|
|
-// internal/validator/validator.go
|
|
|
-package validator
|
|
|
+## Реализация
|
|
|
|
|
|
-import (
|
|
|
- "github.com/go-playground/validator/v10"
|
|
|
-)
|
|
|
+### Пакет валидатора (`validator/validator.go`)
|
|
|
|
|
|
-var Validate *validator.Validate
|
|
|
+```go
|
|
|
+var validate *validator.Validate
|
|
|
|
|
|
func init() {
|
|
|
- Validate = validator.New()
|
|
|
- // Регистрация кастомных валидаторов
|
|
|
- Validate.RegisterValidation("coordinate", validateCoordinate)
|
|
|
+ validate = validator.New()
|
|
|
+
|
|
|
+ _ = validate.RegisterValidation("uuid", validateUUID)
|
|
|
+ _ = validate.RegisterValidation("slug", validateSlug)
|
|
|
+ _ = validate.RegisterValidation("latitude", validateLatitude)
|
|
|
+ _ = validate.RegisterValidation("longitude", validateLongitude)
|
|
|
+ _ = validate.RegisterValidation("place_type", validatePlaceType)
|
|
|
+ _ = validate.RegisterValidation("user_role", validateUserRole)
|
|
|
+ _ = validate.RegisterValidation("currency", validateCurrency)
|
|
|
+ _ = validate.RegisterValidation("user_status", validateUserStatus)
|
|
|
+ _ = validate.RegisterValidation("place_status", validatePlaceStatus)
|
|
|
+ _ = validate.RegisterValidation("service_status", validateServiceStatus)
|
|
|
+ _ = validate.RegisterValidation("booking_status", validateBookingStatus)
|
|
|
+ _ = validate.RegisterValidation("datetime", validateDateTime)
|
|
|
}
|
|
|
|
|
|
-func validateCoordinate(fl validator.FieldLevel) bool {
|
|
|
- lat := fl.Field().Float()
|
|
|
- return lat >= -90 && lat <= 90
|
|
|
-}
|
|
|
+func Validate(s interface{}) error { return validate.Struct(s) }
|
|
|
+func ValidateVar(field interface{}, tag string) error { return validate.Var(field, tag) }
|
|
|
```
|
|
|
|
|
|
+### Кастомные валидаторы
|
|
|
+
|
|
|
+| Валидатор | Формат | Пример |
|
|
|
+|---|---|---|
|
|
|
+| `uuid` | `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` | `550e8400-e29b-41d4-a716-446655440000` |
|
|
|
+| `slug` | `^[a-z0-9-]+$` | `portrait-photography` |
|
|
|
+| `latitude` | `>= -90 && <= 90` | `55.7558` |
|
|
|
+| `longitude` | `>= -180 && <= 180` | `37.6173` |
|
|
|
+| `place_type` | `place` или `studio` |
|
|
|
+| `user_role` | `customer`, `landlord`, `executor`, `moderator`, `superadmin` |
|
|
|
+| `currency` | `^[A-Z]{3}$` | `RUB`, `USD` |
|
|
|
+| `datetime` | `time.Parse(time.RFC3339)` | `2026-06-24T12:00:00Z` |
|
|
|
+
|
|
|
+### Использование в хендлерах
|
|
|
+
|
|
|
```go
|
|
|
-// internal/handlers/places.go — DTO с тегами
|
|
|
+// handlers/places.go — DTO с тегами валидации
|
|
|
type createPlaceRequest struct {
|
|
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
|
|
Description *string `json:"description" validate:"omitempty,max=5000"`
|
|
|
Address *string `json:"address" validate:"omitempty,max=500"`
|
|
|
- Lat float64 `json:"lat" validate:"required,coordinate,min=-90,max=90"`
|
|
|
- Lng float64 `json:"lng" validate:"required,coordinate,min=-180,max=180"`
|
|
|
- Type string `json:"type" validate:"required,oneof=place studio"`
|
|
|
- Tags []string `json:"tags" validate:"dive,required,alphanum"`
|
|
|
- Features []string `json:"features" validate:"dive,required,alphanum"`
|
|
|
+ Lat float64 `json:"lat" validate:"required,latitude"`
|
|
|
+ Lng float64 `json:"lng" validate:"required,longitude"`
|
|
|
+ Type string `json:"type" validate:"required,place_type"`
|
|
|
+ AccessInfo *string `json:"access_info" validate:"omitempty,max=2000"`
|
|
|
+ CoverImage *string `json:"cover_image" validate:"omitempty,url,max=500"`
|
|
|
+ Tags []string `json:"tags" validate:"dive,slug,max=50"`
|
|
|
+ Features []string `json:"features" validate:"dive,slug,max=50"`
|
|
|
HourlyRate *int `json:"hourly_rate" validate:"omitempty,min=0"`
|
|
|
- Currency string `json:"currency" validate:"omitempty,len=3,uppercase"`
|
|
|
- MinHours int `json:"min_hours" validate:"min=0"`
|
|
|
+ Currency string `json:"currency" validate:"omitempty,currency,len=3"`
|
|
|
+ MinHours int `json:"min_hours" validate:"min=0,max=100"`
|
|
|
}
|
|
|
-```
|
|
|
|
|
|
-```go
|
|
|
-// В handler:
|
|
|
func (h *PlaceHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
|
var req createPlaceRequest
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
- writeError(w, http.StatusBadRequest, "invalid request body")
|
|
|
+ writeError(w, http.StatusBadRequest, "invalid request body", err)
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
- if err := validator.Validate.Struct(req); err != nil {
|
|
|
- var ve validator.ValidationErrors
|
|
|
- if errors.As(err, &ve) {
|
|
|
- writeValidationError(w, ve) // Структурированный ответ
|
|
|
- return
|
|
|
- }
|
|
|
- writeError(w, http.StatusBadRequest, err.Error())
|
|
|
+ if err := validator.Validate(req); err != nil {
|
|
|
+ writeValidationError(w, err) // 422 Unprocessable Entity
|
|
|
return
|
|
|
}
|
|
|
- // ...
|
|
|
+ // ... бизнес-логика
|
|
|
}
|
|
|
```
|
|
|
|
|
|
-## Структурированный ответ об ошибках (RFC 7807 / Problem Details)
|
|
|
+## Обработка ошибок валидации
|
|
|
+
|
|
|
+### `writeValidationError` (`handlers/errors.go:39-48`)
|
|
|
+
|
|
|
+```go
|
|
|
+func writeValidationError(w http.ResponseWriter, err error) {
|
|
|
+ details := err.Error()
|
|
|
+ if AppEnv == "production" {
|
|
|
+ details = "validation failed" // не раскрываем детали в production
|
|
|
+ }
|
|
|
+ writeJSON(w, http.StatusUnprocessableEntity, map[string]interface{}{
|
|
|
+ "error": "validation failed",
|
|
|
+ "details": details,
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
|
|
|
+**Ответ**:
|
|
|
```json
|
|
|
{
|
|
|
- "type": "https://api.photoplaces.ru/errors/validation-error",
|
|
|
- "title": "Validation Failed",
|
|
|
- "status": 422,
|
|
|
- "detail": "One or more fields failed validation",
|
|
|
- "errors": [
|
|
|
- { "field": "title", "message": "title is required" },
|
|
|
- { "field": "lat", "message": "lat must be between -90 and 90" }
|
|
|
- ]
|
|
|
+ "error": "validation failed",
|
|
|
+ "details": "Key: 'createPlaceRequest.Title' Error:Field validation for 'Title' failed on the 'required' tag"
|
|
|
}
|
|
|
```
|
|
|
|
|
|
-## Альтернативы
|
|
|
+В production `details` заменяется на `"validation failed"` — защита от утечки структуры данных.
|
|
|
+
|
|
|
+### `validationMessage` (`handlers/auth.go:195-221`)
|
|
|
+
|
|
|
+Отдельная функция для маппинга validation tag → человекочитаемое сообщение:
|
|
|
+
|
|
|
+```go
|
|
|
+func validationMessage(tag, param string) string {
|
|
|
+ switch tag {
|
|
|
+ case "required": return "field is required"
|
|
|
+ case "email": return "invalid email format"
|
|
|
+ case "latitude": return "latitude must be between -90 and 90"
|
|
|
+ // ...
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**⚠️ Недостаток**: это дублирование логики `go-playground/validator`. Лучше использовать `RegisterTranslation()` вместо ручного switch. См. раздел "Улучшения".
|
|
|
+
|
|
|
+## Улучшения (Todo)
|
|
|
+
|
|
|
+1. **Регистрация переводов** — вместо `validationMessage` использовать `RegisterTranslation()`:
|
|
|
+ ```go
|
|
|
+ validate.RegisterTranslation("required", trans,
|
|
|
+ func(ut ut.Translator) error { return ut.Add("required", "{0} обязателен", true) },
|
|
|
+ func(ut ut.Translator, fe validator.FieldError) string { t, _ := ut.T("required", fe.Field()); return t },
|
|
|
+ )
|
|
|
+ ```
|
|
|
+
|
|
|
+2. **Структурированный ответ (RFC 7807)**:
|
|
|
+ ```json
|
|
|
+ {
|
|
|
+ "type": "https://api.photoplaces.dev/errors/validation",
|
|
|
+ "title": "Validation Failed",
|
|
|
+ "status": 422,
|
|
|
+ "errors": [
|
|
|
+ { "field": "email", "message": "invalid email format" }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ```
|
|
|
+
|
|
|
+3. **Cross-field validation** — `gtfield`, `ltefield` для зависимых полей (например, `start_time < end_time`)
|
|
|
|
|
|
-| Библиотека | Плюсы | Минусы |
|
|
|
-|------------|-------|--------|
|
|
|
-| **go-playground/validator (рекомендую)** | Стандарт, быстрый, теги, кастомные правила | Рефлексия (небольшой оверхед) |
|
|
|
-| **go-ozzo/ozzo-validation** | Функциональный стиль, без рефлексии | Менее популярный |
|
|
|
-| **Ручная (текущее)** | Нет зависимостей | Неподдерживаемо, баги |
|
|
|
+4. **Пайплайны** — `RegisterStructCtx` для кастомной логики после базовой валидации
|
|
|
|
|
|
## Связанные заметки
|
|
|
|
|
|
-- [[decision-validation-library]]
|
|
|
-- [[backend-auth-security]]
|
|
|
-- [[architecture-overview]]
|
|
|
+- [[decision-validation-library]] — Decision record: почему go-playground/validator
|
|
|
+- [[decision-validation-error-production]] — Скрытие деталей ошибок
|
|
|
+- [[architecture-overview]] — Общий обзор
|
|
|
+- [[atomic-global-appenv-package-var]] — Проблема глобальной `AppEnv`
|
|
|
|
|
|
## Источник
|
|
|
|
|
|
-Задача: Code review PhotoPlaces — отсутствие валидации входных данных
|
|
|
+Code review PhotoPlaces 2026-06. Актуализация — go-playground/validator уже внедрён во все хендлеры.
|
|
|
|
|
|
## Теги
|
|
|
|
|
|
-#backend #validation #go #best-practice #technical-debt
|
|
|
+#backend #validation #go #best-practice
|