From e4366470915d472f507a46530fcd814344a65077 Mon Sep 17 00:00:00 2001 From: valitovgaziz Date: Wed, 12 Nov 2025 13:15:20 +0500 Subject: [PATCH] modified: main_dc/docker-compose.yml modified: main_dc/yalarba/api_es/internal/handler/all_handlers.go new file: main_dc/yalarba/api_es/internal/handler/health.go modified: main_dc/yalarba/api_es/internal/router/router.go new file: main_dc/yalarba/api_es/internal/utils/formatTime.go new file: main_dc/yalarba/api_es/internal/utils/response.go new file: main_dc/yalarba/api_es/internal/utils/utils.go new file: main_dc/yalarba/api_es/internal/utils/validation.go add utils and health check heandlers into routes --- main_dc/docker-compose.yml | 2 +- .../api_es/internal/handler/all_handlers.go | 14 +- .../yalarba/api_es/internal/handler/health.go | 31 ++ .../yalarba/api_es/internal/router/router.go | 8 +- .../api_es/internal/utils/formatTime.go | 27 ++ .../yalarba/api_es/internal/utils/response.go | 20 + .../yalarba/api_es/internal/utils/utils.go | 75 ++++ .../api_es/internal/utils/validation.go | 398 ++++++++++++++++++ 8 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 main_dc/yalarba/api_es/internal/handler/health.go create mode 100644 main_dc/yalarba/api_es/internal/utils/formatTime.go create mode 100644 main_dc/yalarba/api_es/internal/utils/response.go create mode 100644 main_dc/yalarba/api_es/internal/utils/utils.go create mode 100644 main_dc/yalarba/api_es/internal/utils/validation.go diff --git a/main_dc/docker-compose.yml b/main_dc/docker-compose.yml index 280a3f2..7f6c02b 100644 --- a/main_dc/docker-compose.yml +++ b/main_dc/docker-compose.yml @@ -254,7 +254,7 @@ services: "--no-verbose", "--tries=1", "--spider", - "http://localhost:8081/health", + "http://localhost:8088/health", ] interval: 30s timeout: 10s diff --git a/main_dc/yalarba/api_es/internal/handler/all_handlers.go b/main_dc/yalarba/api_es/internal/handler/all_handlers.go index 2eb75c1..d736db1 100644 --- a/main_dc/yalarba/api_es/internal/handler/all_handlers.go +++ b/main_dc/yalarba/api_es/internal/handler/all_handlers.go @@ -10,7 +10,8 @@ import ( ) type AllHandler struct { - userHandler *UserHandler + userHandler *UserHandler + healthHandler *HealthHandler } func NewAllHandler(db *gorm.DB, cfg *config.Config) *AllHandler { @@ -20,14 +21,19 @@ func NewAllHandler(db *gorm.DB, cfg *config.Config) *AllHandler { userService := service.NewUserService(userRepo, utils.NewJWTUtil(cfg.JWTSecret)) userHandler := NewUserHandler(userService) + healthHandler := NewHealthHandler() return &AllHandler{ - userHandler: userHandler, + userHandler: userHandler, + healthHandler: healthHandler, } } - func (h *AllHandler) UserHandler() *UserHandler { return h.userHandler -} \ No newline at end of file +} + +func (h *AllHandler) HealthHandler() *HealthHandler { + return h.healthHandler +} diff --git a/main_dc/yalarba/api_es/internal/handler/health.go b/main_dc/yalarba/api_es/internal/handler/health.go new file mode 100644 index 0000000..8d7a3f7 --- /dev/null +++ b/main_dc/yalarba/api_es/internal/handler/health.go @@ -0,0 +1,31 @@ +package handler + +import ( + "net/http" + + "api_es/internal/utils" + +) + +type HealthHandler struct{} + +func NewHealthHandler() *HealthHandler { + return &HealthHandler{} +} + +func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { + response := map[string]string{ + "status": "ok", + "message": "Service is healthy", + } + utils.RespondWithJSON(w, http.StatusOK, response) +} + +func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) { + response := map[string]string{ + "status": "ok", + "message": "API is working", + } + + utils.RespondWithJSON(w, http.StatusOK, response) +} \ No newline at end of file diff --git a/main_dc/yalarba/api_es/internal/router/router.go b/main_dc/yalarba/api_es/internal/router/router.go index 5ba4618..bfef044 100644 --- a/main_dc/yalarba/api_es/internal/router/router.go +++ b/main_dc/yalarba/api_es/internal/router/router.go @@ -29,6 +29,12 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { h := handler.NewAllHandler(db, config) + // Health routes + r.Route("/", func(r chi.Router) { + r.Get("/health", h.HealthHandler().HealthCheck) + r.Get("/check", h.HealthHandler().Check) + }) + r.Route("/auth", func(r chi.Router) { r.Post("/register", h.UserHandler().Register) r.Post("/login", h.UserHandler().Login) @@ -45,7 +51,7 @@ func SetupRouter(db *gorm.DB, config *config.Config) http.Handler { r.With(appMiddleware.AdminMiddleware).Get("/{id}", h.UserHandler().GetUser) }) - zapLogger.Debug("End setup rounting") + zapLogger.Debug("End setup routing") // Логируем все зарегистрированные маршруты routeLogger := logger.NewRouteLogger(baseLogger) diff --git a/main_dc/yalarba/api_es/internal/utils/formatTime.go b/main_dc/yalarba/api_es/internal/utils/formatTime.go new file mode 100644 index 0000000..e35c536 --- /dev/null +++ b/main_dc/yalarba/api_es/internal/utils/formatTime.go @@ -0,0 +1,27 @@ +package utils + +// formatPace форматирует темп в строку "MM:SS" +func FormatPace(minutes, seconds int) string { + if seconds >= 60 { + minutes += seconds / 60 + seconds = seconds % 60 + } + return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds) +} + +// formatTwoDigits форматирует число в двузначную строку +func FormatTwoDigits(num int) string { + if num < 10 { + return "0" + string(rune(num+'0')) + } + return string(rune(num/10+'0')) + string(rune(num%10+'0')) +} + +// formatTime форматирует время в строку "MM:SS" +func FormatTime(minutes, seconds int) string { + if seconds >= 60 { + minutes += seconds / 60 + seconds = seconds % 60 + } + return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds) +} diff --git a/main_dc/yalarba/api_es/internal/utils/response.go b/main_dc/yalarba/api_es/internal/utils/response.go new file mode 100644 index 0000000..2067796 --- /dev/null +++ b/main_dc/yalarba/api_es/internal/utils/response.go @@ -0,0 +1,20 @@ +// pkg/utils/response.go (дополнение) +package utils + +import ( + "encoding/json" + "net/http" +) + +// RespondWithValidationError отправляет ответ с ошибками валидации +func RespondWithValidationError(w http.ResponseWriter, validationError error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + + response := map[string]interface{}{ + "error": "Validation failed", + "details": GetValidationErrors(validationError), + } + + json.NewEncoder(w).Encode(response) +} diff --git a/main_dc/yalarba/api_es/internal/utils/utils.go b/main_dc/yalarba/api_es/internal/utils/utils.go new file mode 100644 index 0000000..1dc89df --- /dev/null +++ b/main_dc/yalarba/api_es/internal/utils/utils.go @@ -0,0 +1,75 @@ +package utils + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} + +func RespondWithError(w http.ResponseWriter, statusCode int, message string) { + RespondWithJSON(w, statusCode, map[string]string{"error": message}) +} + +// DecodeJSONBody декодирует JSON тело запроса +func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error { + if r.Header.Get("Content-Type") != "application/json" { + return errors.New("Content-Type header is not application/json") + } + + r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(dst) + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + + switch { + case errors.As(err, &syntaxError): + return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset) + + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("request body contains badly-formed JSON") + + case errors.As(err, &unmarshalTypeError): + return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset) + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("request body contains unknown field %s", fieldName) + + case errors.Is(err, io.EOF): + return errors.New("request body must not be empty") + + case err.Error() == "http: request body too large": + return errors.New("request body must not be larger than 1MB") + + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if err != io.EOF { + return errors.New("request body must only contain a single JSON object") + } + + return nil +} + +// GetUserIDFromContext извлекает userID из контекста +func GetUserIDFromContext(r *http.Request) (uint, bool) { + userID, ok := r.Context().Value("userID").(uint) + return userID, ok +} diff --git a/main_dc/yalarba/api_es/internal/utils/validation.go b/main_dc/yalarba/api_es/internal/utils/validation.go new file mode 100644 index 0000000..00dba50 --- /dev/null +++ b/main_dc/yalarba/api_es/internal/utils/validation.go @@ -0,0 +1,398 @@ +// pkg/utils/validation.go +package utils + +import ( + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "go.uber.org/zap" +) + +// ValidationError представляет ошибку валидации +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// ValidationResult содержит результат валидации +type ValidationResult struct { + IsValid bool + Errors []ValidationError +} + +// TagOptions содержит опции из тега validate +type TagOptions struct { + Required bool + Min *float64 + Max *float64 + MinInt *int64 + MaxInt *int64 + OneOf []string + Email bool + MaxLength *int + MinLength *int + Custom string +} + +// ValidateStruct валидирует структуру на основе тегов validate +func ValidateStruct(s interface{}) error { + val := reflect.ValueOf(s) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + return fmt.Errorf("ValidateStruct expects a struct, got %T", s) + } + + var errors []ValidationError + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // Пропускаем неэкспортируемые поля + if !field.CanInterface() { + continue + } + + tag := fieldType.Tag.Get("validate") + if tag == "" { + continue + } + + options := parseTagOptions(tag) + fieldName := getFieldName(fieldType) + + // Валидация поля + if err := validateField(field, fieldName, options); err != nil { + errors = append(errors, err...) + } + } + + if len(errors) > 0 { + return &ValidationResult{ + IsValid: false, + Errors: errors, + } + } + + return nil +} + +// parseTagOptions парсит тег validate и возвращает опции +func parseTagOptions(tag string) TagOptions { + options := TagOptions{} + parts := strings.Split(tag, ",") + + for _, part := range parts { + part = strings.TrimSpace(part) + + switch { + case part == "required": + options.Required = true + case part == "email": + options.Email = true + case strings.HasPrefix(part, "min="): + if val, err := strconv.ParseFloat(part[4:], 64); err == nil { + options.Min = &val + } + case strings.HasPrefix(part, "max="): + if val, err := strconv.ParseFloat(part[4:], 64); err == nil { + options.Max = &val + } + case strings.HasPrefix(part, "minint="): + if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil { + options.MinInt = &val + } + case strings.HasPrefix(part, "maxint="): + if val, err := strconv.ParseInt(part[7:], 10, 64); err == nil { + options.MaxInt = &val + } + case strings.HasPrefix(part, "oneof="): + options.OneOf = strings.Split(part[6:], " ") + case strings.HasPrefix(part, "maxlen="): + if val, err := strconv.Atoi(part[7:]); err == nil { + options.MaxLength = &val + } + case strings.HasPrefix(part, "minlen="): + if val, err := strconv.Atoi(part[7:]); err == nil { + options.MinLength = &val + } + case strings.HasPrefix(part, "custom="): + options.Custom = part[7:] + } + } + + return options +} + +// getFieldName возвращает имя поля для сообщений об ошибках +func getFieldName(field reflect.StructField) string { + jsonTag := field.Tag.Get("json") + if jsonTag != "" { + parts := strings.Split(jsonTag, ",") + if parts[0] != "" { + return parts[0] + } + } + return field.Name +} + +// validateField валидирует отдельное поле +func validateField(field reflect.Value, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + // Проверка required + if options.Required { + if isEmptyValue(field) { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "field is required", + }) + return errors // Если поле обязательно и пустое, дальше не проверяем + } + } + + // Если поле пустое и не обязательное, дальше не проверяем + if isEmptyValue(field) { + return errors + } + + // Валидация в зависимости от типа поля + switch field.Kind() { + case reflect.String: + errors = append(errors, validateString(field.String(), fieldName, options)...) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + errors = append(errors, validateInt(field.Int(), fieldName, options)...) + case reflect.Float32, reflect.Float64: + errors = append(errors, validateFloat(field.Float(), fieldName, options)...) + case reflect.Struct: + // Для time.Time и других структур + if field.Type().String() == "time.Time" { + errors = append(errors, validateTime(field.Interface().(time.Time), fieldName, options)...) + } + } + + return errors +} + +// validateString валидирует строковые поля +func validateString(value, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + // Проверка email + if options.Email { + if !isValidEmail(value) { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "invalid email format", + }) + } + } + + // Проверка длины строки + if options.MinLength != nil && len(value) < *options.MinLength { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("minimum length is %d characters", *options.MinLength), + }) + } + + if options.MaxLength != nil && len(value) > *options.MaxLength { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("maximum length is %d characters", *options.MaxLength), + }) + } + + // Проверка oneof + if len(options.OneOf) > 0 { + valid := false + for _, allowed := range options.OneOf { + if value == allowed { + valid = true + break + } + } + if !valid { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("must be one of: %s", strings.Join(options.OneOf, ", ")), + }) + } + } + + return errors +} + +// validateInt валидирует целочисленные поля +func validateInt(value int64, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + if options.MinInt != nil && value < *options.MinInt { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("minimum value is %d", *options.MinInt), + }) + } + + if options.MaxInt != nil && value > *options.MaxInt { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("maximum value is %d", *options.MaxInt), + }) + } + + return errors +} + +// validateFloat валидирует поля с плавающей точкой +func validateFloat(value float64, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + if options.Min != nil && value < *options.Min { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("minimum value is %.2f", *options.Min), + }) + } + + if options.Max != nil && value > *options.Max { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: fmt.Sprintf("maximum value is %.2f", *options.Max), + }) + } + + return errors +} + +// validateTime валидирует временные поля +func validateTime(value time.Time, fieldName string, options TagOptions) []ValidationError { + var errors []ValidationError + + // Проверка, что дата не нулевая + if value.IsZero() && options.Required { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "date is required", + }) + } + + // Проверка, что дата не в будущем (пример кастомной валидации) + if options.Custom == "not_future" && value.After(time.Now()) { + errors = append(errors, ValidationError{ + Field: fieldName, + Message: "date cannot be in the future", + }) + } + + return errors +} + +// isEmptyValue проверяет, является ли значение пустым +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Bool: + return !v.Bool() + case reflect.Struct: + if v.Type().String() == "time.Time" { + return v.Interface().(time.Time).IsZero() + } + case reflect.Ptr, reflect.Interface: + return v.IsNil() + case reflect.Slice, reflect.Map, reflect.Array: + return v.Len() == 0 + } + return false +} + +// isValidEmail проверяет валидность email +func isValidEmail(email string) bool { + emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + matched, _ := regexp.MatchString(emailRegex, email) + return matched +} + +// Error возвращает строковое представление ошибок валидации +func (vr *ValidationResult) Error() string { + var errorMessages []string + for _, err := range vr.Errors { + errorMessages = append(errorMessages, err.Error()) + } + return strings.Join(errorMessages, "; ") +} + +// GetValidationErrors возвращает ошибки валидации в структурированном виде +func GetValidationErrors(err error) []ValidationError { + if vr, ok := err.(*ValidationResult); ok { + return vr.Errors + } + return nil +} + +// LogValidationErrors логирует ошибки валидации +func LogValidationErrors(logger *zap.Logger, err error, context string) { + if vr, ok := err.(*ValidationResult); ok { + for _, validationErr := range vr.Errors { + logger.Warn("validation error", + zap.String("context", context), + zap.String("field", validationErr.Field), + zap.String("error", validationErr.Message), + ) + } + } +} + +// ParseUintFromQuery парсит uint из query параметра +func ParseUintFromQuery(queryParam string, defaultValue uint) (uint, error) { + if queryParam == "" { + return defaultValue, nil + } + + value, err := strconv.ParseUint(queryParam, 10, 32) + if err != nil { + return defaultValue, err + } + + return uint(value), nil +} + +// ParseIntFromQuery парсит int из query параметра +func ParseIntFromQuery(queryParam string, defaultValue int) (int, error) { + if queryParam == "" { + return defaultValue, nil + } + + value, err := strconv.Atoi(queryParam) + if err != nil { + return defaultValue, err + } + + return value, nil +} + +// ParseBoolFromQuery парсит bool из query параметра +func ParseBoolFromQuery(queryParam string, defaultValue bool) bool { + if queryParam == "" { + return defaultValue + } + + return strings.ToLower(queryParam) == "true" || queryParam == "1" +}