✨ feat: implement input validation for API v2
- Added go-playground/validator dependency - Created pkg/validation/ package with custom validator wrapper - Implemented request validation for v2 greet endpoint - Added structured validation error responses - Extended BDD tests to cover validation scenarios - Updated AGENTS.md with v2 API documentation - Created ADR 0011-validation-library-selection.md - Simplified server handler creation code - Updated CHANGELOG with implementation details
This commit is contained in:
@@ -29,6 +29,8 @@ func InitializeAllSteps(ctx *godog.ScenarioContext, client *testserver.Client) {
|
||||
ctx.Step(`^the server is running$`, sc.theServerIsRunning)
|
||||
ctx.Step(`^the server is running with v2 enabled$`, sc.theServerIsRunningWithV2Enabled)
|
||||
ctx.Step(`^I send a POST request to v2 greet with name "([^"]*)"$`, sc.iSendPOSTRequestToV2GreetWithName)
|
||||
ctx.Step(`^I send a POST request to v2 greet with invalid JSON "([^"]*)"$`, sc.iSendPOSTRequestToV2GreetWithInvalidJSON)
|
||||
ctx.Step(`^the response should contain error "([^"]*)"$`, sc.theResponseShouldContainError)
|
||||
}
|
||||
|
||||
func (sc *StepContext) iRequestAGreetingFor(name string) error {
|
||||
@@ -91,3 +93,17 @@ func (sc *StepContext) iSendPOSTRequestToV2GreetWithName(name string) error {
|
||||
requestBody := map[string]string{"name": name}
|
||||
return sc.client.Request("POST", "/api/v2/greet", requestBody)
|
||||
}
|
||||
|
||||
func (sc *StepContext) iSendPOSTRequestToV2GreetWithInvalidJSON(invalidJSON string) error {
|
||||
// Send raw invalid JSON
|
||||
return sc.client.Request("POST", "/api/v2/greet", invalidJSON)
|
||||
}
|
||||
|
||||
func (sc *StepContext) theResponseShouldContainError(expectedError string) error {
|
||||
// Check if the response contains the expected error
|
||||
body := string(sc.client.GetLastBody())
|
||||
if !strings.Contains(body, expectedError) {
|
||||
return fmt.Errorf("expected response to contain error %q, got %q", expectedError, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ package greet
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"DanceLessonsCoach/pkg/validation"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -19,11 +22,12 @@ type ApiV2Greet interface {
|
||||
}
|
||||
|
||||
type apiV2GreetHandler struct {
|
||||
greeter GreeterV2
|
||||
greeter GreeterV2
|
||||
validator *validation.Validator
|
||||
}
|
||||
|
||||
func NewApiV2GreetHandler(greeter GreeterV2) ApiV2Greet {
|
||||
return &apiV2GreetHandler{greeter: greeter}
|
||||
func NewApiV2GreetHandler(greeter GreeterV2, validator *validation.Validator) ApiV2Greet {
|
||||
return &apiV2GreetHandler{greeter: greeter, validator: validator}
|
||||
}
|
||||
|
||||
func (h *apiV2GreetHandler) RegisterRoutes(router chi.Router) {
|
||||
@@ -33,7 +37,7 @@ func (h *apiV2GreetHandler) RegisterRoutes(router chi.Router) {
|
||||
}
|
||||
|
||||
type greetRequest struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name" validate:"max=100"`
|
||||
}
|
||||
|
||||
type greetResponse struct {
|
||||
@@ -55,6 +59,17 @@ func (h *apiV2GreetHandler) handleGreetPost(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request if validator is available
|
||||
if h.validator != nil {
|
||||
log.Trace().Str("name", req.Name).Msg("Validating request")
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
log.Trace().Err(err).Msg("Validation failed")
|
||||
h.handleValidationError(w, err)
|
||||
return
|
||||
}
|
||||
log.Trace().Msg("Validation passed")
|
||||
}
|
||||
|
||||
// Call service
|
||||
message := h.greeter.GreetV2(r.Context(), req.Name)
|
||||
|
||||
@@ -62,6 +77,34 @@ func (h *apiV2GreetHandler) handleGreetPost(w http.ResponseWriter, r *http.Reque
|
||||
h.writeJSONResponse(w, message)
|
||||
}
|
||||
|
||||
func (h *apiV2GreetHandler) handleValidationError(w http.ResponseWriter, err error) {
|
||||
var validationErr *validation.ValidationError
|
||||
if errors.As(err, &validationErr) {
|
||||
// Create structured validation error response
|
||||
response := map[string]interface{}{
|
||||
"error": "validation_failed",
|
||||
"message": "Invalid request data",
|
||||
"details": make([]map[string]string, 0, len(validationErr.Messages)),
|
||||
}
|
||||
|
||||
// Parse validation messages into structured format
|
||||
for _, msg := range validationErr.Messages {
|
||||
// Simple parsing - in production, use proper parsing
|
||||
detail := map[string]string{
|
||||
"message": msg,
|
||||
}
|
||||
response["details"] = append(response["details"].([]map[string]string), detail)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
} else {
|
||||
// Fallback for other types of errors
|
||||
http.Error(w, `{"error":"validation_error","message":`+strconv.Quote(err.Error())+`}`, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *apiV2GreetHandler) writeJSONResponse(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(greetResponse{Message: message})
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"DanceLessonsCoach/pkg/config"
|
||||
"DanceLessonsCoach/pkg/greet"
|
||||
"DanceLessonsCoach/pkg/validation"
|
||||
"DanceLessonsCoach/pkg/telemetry"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
@@ -20,19 +21,29 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
router *chi.Mux
|
||||
readyCtx context.Context
|
||||
withOTEL bool
|
||||
config *config.Config
|
||||
router *chi.Mux
|
||||
readyCtx context.Context
|
||||
withOTEL bool
|
||||
config *config.Config
|
||||
tracerProvider *sdktrace.TracerProvider
|
||||
validator *validation.Validator
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, readyCtx context.Context) *Server {
|
||||
// Create validator instance
|
||||
validator, err := validation.GetValidatorFromConfig(cfg)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create validator, continuing without validation")
|
||||
} else {
|
||||
log.Trace().Msg("Validator created successfully")
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
router: chi.NewRouter(),
|
||||
readyCtx: readyCtx,
|
||||
withOTEL: cfg.GetTelemetryEnabled(),
|
||||
config: cfg,
|
||||
router: chi.NewRouter(),
|
||||
readyCtx: readyCtx,
|
||||
withOTEL: cfg.GetTelemetryEnabled(),
|
||||
config: cfg,
|
||||
validator: validator,
|
||||
}
|
||||
s.setupRoutes()
|
||||
return s
|
||||
@@ -76,7 +87,7 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
|
||||
|
||||
func (s *Server) registerApiV2Routes(r chi.Router) {
|
||||
greetServiceV2 := greet.NewServiceV2()
|
||||
greetHandlerV2 := greet.NewApiV2GreetHandler(greetServiceV2)
|
||||
greetHandlerV2 := greet.NewApiV2GreetHandler(greetServiceV2, s.validator)
|
||||
r.Route("/greet", func(r chi.Router) {
|
||||
greetHandlerV2.RegisterRoutes(r)
|
||||
})
|
||||
|
||||
123
pkg/validation/validator.go
Normal file
123
pkg/validation/validator.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"DanceLessonsCoach/pkg/config"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/locales/en"
|
||||
en_translations "github.com/go-playground/validator/v10/translations/en"
|
||||
"github.com/go-playground/validator/v10"
|
||||
ut "github.com/go-playground/universal-translator"
|
||||
)
|
||||
|
||||
// Validator wraps the go-playground/validator with custom error handling
|
||||
type Validator struct {
|
||||
validate *validator.Validate
|
||||
translator ut.Translator
|
||||
}
|
||||
|
||||
// NewValidator creates a new validator instance with custom error handling
|
||||
func NewValidator() (*Validator, error) {
|
||||
// Create validator instance
|
||||
validate := validator.New()
|
||||
|
||||
// Register translations
|
||||
english := en.New()
|
||||
uni := ut.New(english, english)
|
||||
trans, _ := uni.GetTranslator("en")
|
||||
|
||||
// Register translation for default validator
|
||||
if err := en_translations.RegisterDefaultTranslations(validate, trans); err != nil {
|
||||
return nil, fmt.Errorf("failed to register translations: %w", err)
|
||||
}
|
||||
|
||||
// Register custom validations
|
||||
if err := registerCustomValidations(validate); err != nil {
|
||||
return nil, fmt.Errorf("failed to register custom validations: %w", err)
|
||||
}
|
||||
|
||||
return &Validator{
|
||||
validate: validate,
|
||||
translator: trans,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Validate validates a struct and returns validation errors
|
||||
func (v *Validator) Validate(structObj interface{}) error {
|
||||
if structObj == nil {
|
||||
return errors.New("validation: nil object provided")
|
||||
}
|
||||
|
||||
// Get the type of the struct
|
||||
structType := reflect.TypeOf(structObj)
|
||||
if structType.Kind() != reflect.Struct {
|
||||
return fmt.Errorf("validation: expected struct, got %s", structType.Kind())
|
||||
}
|
||||
|
||||
// Perform validation
|
||||
if err := v.validate.Struct(structObj); err != nil {
|
||||
if _, ok := err.(*validator.InvalidValidationError); ok {
|
||||
return fmt.Errorf("validation: invalid validation error: %w", err)
|
||||
}
|
||||
|
||||
// Convert validation errors to custom format
|
||||
validationErrors := err.(validator.ValidationErrors)
|
||||
return v.formatValidationErrors(validationErrors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatValidationErrors converts validator.ValidationErrors to a custom error type
|
||||
func (v *Validator) formatValidationErrors(errors validator.ValidationErrors) error {
|
||||
var errorMessages []string
|
||||
|
||||
for _, err := range errors {
|
||||
field := err.Field()
|
||||
tag := err.Tag()
|
||||
param := err.Param()
|
||||
|
||||
// Get custom error message
|
||||
message := fmt.Errorf("%s failed validation for '%s'", field, tag).Error()
|
||||
|
||||
// Add parameter if available
|
||||
if param != "" {
|
||||
message += fmt.Sprintf(" (parameter: %s)", param)
|
||||
}
|
||||
|
||||
errorMessages = append(errorMessages, message)
|
||||
}
|
||||
|
||||
return &ValidationError{
|
||||
Messages: errorMessages,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidationError represents multiple validation errors
|
||||
type ValidationError struct {
|
||||
Messages []string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return strings.Join(e.Messages, "; ")
|
||||
}
|
||||
|
||||
// registerCustomValidations registers any custom validation functions
|
||||
func registerCustomValidations(validate *validator.Validate) error {
|
||||
// Add custom validations here as needed
|
||||
// Example:
|
||||
// if err := validate.RegisterValidation("custom_tag", customValidationFunc); err != nil {
|
||||
// return err
|
||||
// }
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValidatorFromConfig creates a validator instance based on application config
|
||||
func GetValidatorFromConfig(cfg *config.Config) (*Validator, error) {
|
||||
// For now, config doesn't affect validator creation
|
||||
// But this allows future configuration (e.g., language, strict mode)
|
||||
return NewValidator()
|
||||
}
|
||||
Reference in New Issue
Block a user