From d3b6d190d132755c54e1074f67fe097b8345a61d Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Sat, 4 Apr 2026 21:08:13 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20implement=20input=20validat?= =?UTF-8?q?ion=20for=20API=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 44 +++++ CHANGELOG.md | 11 ++ adr/0011-validation-library-selection.md | 198 +++++++++++++++++++++++ adr/README.md | 1 + features/greet.feature | 12 +- go.mod | 6 + go.sum | 12 ++ pkg/bdd/steps/steps.go | 16 ++ pkg/greet/api_v2.go | 51 +++++- pkg/server/server.go | 29 ++-- pkg/validation/validator.go | 123 ++++++++++++++ 11 files changed, 489 insertions(+), 14 deletions(-) create mode 100644 adr/0011-validation-library-selection.md create mode 100644 pkg/validation/validator.go diff --git a/AGENTS.md b/AGENTS.md index be4ee3d..f70ce55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -388,6 +388,50 @@ curl http://localhost:8080/api/v1/greet/Alice # Response: {"message":"Hello Alice!"} ``` +### Greet Service v2 (Feature Flag Enabled) +```http +POST /api/v2/greet +``` + +**Request Body:** +```json +{ + "name": "John" +} +``` + +**Examples:** +```bash +# Valid request +curl -X POST http://localhost:8080/api/v2/greet \ + -H "Content-Type: application/json" \ + -d '{"name":"John"}' +# Response: {"message":"Hello my friend John!"} + +# Empty name (valid, returns default) +curl -X POST http://localhost:8080/api/v2/greet \ + -H "Content-Type: application/json" \ + -d '{"name":""}' +# Response: {"message":"Hello my friend!"} + +# Missing name field (valid, returns default) +curl -X POST http://localhost:8080/api/v2/greet \ + -H "Content-Type: application/json" \ + -d '{}' +# Response: {"message":"Hello my friend!"} + +# Name too long (validation error) +curl -X POST http://localhost:8080/api/v2/greet \ + -H "Content-Type: application/json" \ + -d '{"name":"ThisNameIsWayTooLongAndShouldFailValidationBecauseItExceedsTheMaximumAllowedLengthOf100Characters!!!!"}' +# Response: {"error":"validation_failed","message":"Invalid request data","details":[{"message":"Name failed validation for 'max' (parameter: 100)"}]}' +``` + +**Validation Rules:** +- `name`: Maximum length 100 characters (optional field) + +**Feature Flag:** Enable with `DLC_API_V2_ENABLED=true` or in config file with `api.v2_enabled: true` + ## 🔗 OpenTelemetry & Jaeger Integration The application supports OpenTelemetry for distributed tracing with Jaeger compatibility. diff --git a/CHANGELOG.md b/CHANGELOG.md index a317821..09ee793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,17 @@ vibe start --agent dancelessonscoachprogrammer - ✅ Updated configuration system to support API versioning - ✅ Added comprehensive test coverage for both enabled and disabled states +### 2026-04-04 - Input Validation Implementation +- ✅ Selected go-playground/validator for input validation +- ✅ Created ADR [0011-validation-library-selection.md](adr/0011-validation-library-selection.md) +- ✅ Added `pkg/validation/` package with custom validator wrapper +- ✅ Implemented request validation for v2 API endpoints +- ✅ Added structured validation error responses +- ✅ Extended BDD tests to cover validation scenarios +- ✅ Added validation for name field (max length: 100 characters) +- ✅ Maintained graceful degradation when validator fails to initialize +- ⚠️ **REMINDER**: Use `./scripts/build.sh` instead of `go build` directly for consistent builds + ## Compact History (Last 5 Entries) ### 2026-04-04 diff --git a/adr/0011-validation-library-selection.md b/adr/0011-validation-library-selection.md new file mode 100644 index 0000000..326a99a --- /dev/null +++ b/adr/0011-validation-library-selection.md @@ -0,0 +1,198 @@ +# 11. Validation Library Selection + +**Date:** 2026-04-04 +**Status:** Proposed +**Authors:** AI Agent + +## Context + +The DanceLessonsCoach project needs to add input validation for API requests, particularly for the new v2 API endpoints that accept JSON payloads. Currently, there is no structured validation in place, which could lead to invalid data being processed by the system. + +## Decision Drivers + +1. **Maturity and Stability**: Need a well-established library with proven track record +2. **Community Support**: Active maintenance and community adoption +3. **Feature Completeness**: Support for common validation scenarios (required fields, string lengths, numeric ranges, etc.) +4. **Performance**: Minimal impact on request processing times +5. **Integration**: Easy to integrate with existing Chi router and JSON handling +6. **Error Handling**: Clear, actionable error messages for API consumers +7. **Extensibility**: Ability to add custom validation rules + +## Considered Options + +### 1. go-playground/validator (v10) + +**Overview:** The most widely adopted validation library for Go, using struct tags for rule definition. + +**Pros:** +- ✅ **Most mature and stable** - Used in production by thousands of projects +- ✅ **Extensive built-in validators** - Covers 90%+ of common validation needs +- ✅ **Large community** - Active GitHub repository with frequent updates +- ✅ **Good documentation** - Comprehensive examples and guides +- ✅ **Struct tag-based** - Clean separation of validation rules from business logic +- ✅ **Custom validators** - Support for adding project-specific validation rules +- ✅ **Cross-field validation** - Can validate relationships between fields +- ✅ **JSON Schema generation** - Can generate schemas from validation tags + +**Cons:** +- ❌ **Reflection-based** - Slightly slower than compile-time alternatives +- ❌ **Tag syntax** - Can become verbose for complex validations +- ❌ **Error messages** - Requires some customization for API-friendly errors + +### 2. ozzo-validation + +**Overview:** Configurable and extensible data validation using code-based rules. + +**Pros:** +- ✅ **Code-based validation** - Rules defined in Go code rather than tags +- ✅ **Customizable errors** - Better control over error message formatting +- ✅ **Extensible** - Easy to add new validation rules +- ✅ **Good performance** - Faster than reflection-based validators + +**Cons:** +- ❌ **Less mature** - Smaller community than go-playground/validator +- ❌ **More verbose** - Requires more code for common validations +- ❌ **Learning curve** - Different approach than tag-based validation + +### 3. Valgo + +**Overview:** Type-safe, expressive, and extensible validator library. + +**Pros:** +- ✅ **Type-safe** - Compile-time type checking +- ✅ **Modern API** - Clean, expressive syntax +- ✅ **Good performance** - Type-safe approach can be faster +- ✅ **Extensible** - Easy to add custom validators + +**Cons:** +- ❌ **Newer library** - Less battle-tested than go-playground/validator +- ❌ **Smaller community** - Fewer resources and examples available +- ❌ **Breaking changes** - Still evolving API + +### 4. govalid + +**Overview:** Compile-time validation library that generates validation code. + +**Pros:** +- ✅ **Compile-time generation** - Up to 45x faster than reflection-based +- ✅ **No reflection overhead** - Better performance in hot paths +- ✅ **Type-safe** - Compile-time checking + +**Cons:** +- ❌ **Build complexity** - Requires code generation step +- ❌ **Less flexible** - Harder to add runtime validation rules +- ❌ **Smaller ecosystem** - Fewer built-in validators + +## Decision Outcome + +**Chosen option:** `go-playground/validator` (v10) + +**Rationale:** + +1. **Proven Track Record**: Used successfully in countless production Go applications +2. **Community Support**: Large ecosystem, active maintenance, and extensive documentation +3. **Feature Completeness**: Covers all our current and anticipated validation needs +4. **Integration**: Works seamlessly with our existing struct-based JSON handling +5. **Performance**: While not the fastest, the performance impact is negligible for our use case +6. **Error Handling**: Can be customized to provide API-friendly error messages +7. **Extensibility**: Supports custom validators for project-specific needs + +## Implementation Plan + +### Phase 1: Integration Setup +1. Add `github.com/go-playground/validator/v10` dependency +2. Create validation utility package in `pkg/validation/` +3. Set up validator instance with custom error handling +4. Add common validation tags and error message mappings + +### Phase 2: API v2 Validation +1. Add validation to `greetRequest` struct in `api_v2.go` +2. Implement request validation middleware +3. Create custom error responses for validation failures +4. Add comprehensive validation tests + +### Phase 3: Extend to Other Endpoints +1. Apply validation to existing v1 endpoints (optional) +2. Add validation to health/readiness endpoints (if needed) +3. Create validation documentation for API consumers + +### Phase 4: Advanced Features +1. Add custom validators for business rules +2. Implement internationalized error messages +3. Add validation performance monitoring + +## Validation Strategy + +### Request Validation Pattern + +```go +// Define validated struct +type GreetRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` +} + +// Validate in handler +func (h *apiV2GreetHandler) handleGreetPost(w http.ResponseWriter, r *http.Request) { + var req GreetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Handle JSON decode error + return + } + + if err := validator.Validate(req); err != nil { + // Return validation error response + return + } + + // Process valid request + message := h.greeter.GreetV2(r.Context(), req.Name) + h.writeJSONResponse(w, message) +} +``` + +### Error Response Format + +```json +{ + "error": "validation_failed", + "message": "Invalid request data", + "details": [ + { + "field": "name", + "error": "required", + "message": "Name is required" + } + ] +} +``` + +## Migration Path + +1. **Initial Integration**: Add validator to v2 endpoints only +2. **Testing**: Validate performance and error handling +3. **Documentation**: Update API docs with validation requirements +4. **Gradual Rollout**: Apply to other endpoints as needed +5. **Monitoring**: Track validation failures and adjust rules + +## Future Considerations + +- **Performance Optimization**: If validation becomes bottleneck, consider compile-time alternatives +- **Schema Generation**: Generate OpenAPI schemas from validation tags +- **Internationalization**: Support multiple languages for error messages +- **Rule Management**: Externalize validation rules for dynamic configuration + +## References + +- [go-playground/validator GitHub](https://github.com/go-playground/validator) +- [Validator Documentation](https://pkg.go.dev/github.com/go-playground/validator/v10) +- [Go Validation Libraries Comparison](https://leapcell.io/blog/exploring-golang-s-validation-libraries) + +## Changelog Entry + +``` +### 2026-04-04 - Validation Library Selection +- ✅ Selected go-playground/validator for input validation +- ✅ Created ADR 0011-validation-library-selection.md +- ✅ Planned integration strategy for API validation +- ✅ Designed error response format for validation failures +``` \ No newline at end of file diff --git a/adr/README.md b/adr/README.md index 0d443c4..f9d5f21 100644 --- a/adr/README.md +++ b/adr/README.md @@ -69,6 +69,7 @@ Chosen option: "[Option 1]" because [justification] * [0008-bdd-testing.md](0008-bdd-testing.md) - Adopt BDD with Godog for behavioral testing * [0009-hybrid-testing-approach.md](0009-hybrid-testing-approach.md) - Combine BDD and Swagger-based testing * [0010-api-v2-feature-flag.md](0010-api-v2-feature-flag.md) - API v2 implementation with feature flag control +* [0011-validation-library-selection.md](0011-validation-library-selection.md) - Selection of go-playground/validator for input validation ## How to Add a New ADR diff --git a/features/greet.feature b/features/greet.feature index efad690..5daf96f 100644 --- a/features/greet.feature +++ b/features/greet.feature @@ -20,4 +20,14 @@ Feature: Greet Service Scenario: v2 default greeting with empty name Given the server is running with v2 enabled When I send a POST request to v2 greet with name "" - Then the response should be "{\"message\":\"Hello my friend!\"}" \ No newline at end of file + Then the response should be "{\"message\":\"Hello my friend!\"}" + + Scenario: v2 greeting with missing name field + Given the server is running with v2 enabled + When I send a POST request to v2 greet with invalid JSON "{}" + Then the response should be "{\"message\":\"Hello my friend!\"}" + + Scenario: v2 greeting with name that is too long + Given the server is running with v2 enabled + When I send a POST request to v2 greet with name "ThisNameIsWayTooLongAndShouldFailValidationBecauseItExceedsTheMaximumAllowedLengthOf100Characters!!!!" + Then the response should contain error "validation_failed" \ No newline at end of file diff --git a/go.mod b/go.mod index 22d1bc7..64ed822 100644 --- a/go.mod +++ b/go.mod @@ -25,8 +25,12 @@ require ( github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gogo/protobuf v1.3.1 // indirect @@ -35,6 +39,7 @@ require ( github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -49,6 +54,7 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/go.sum b/go.sum index 6d01ce0..7e0a674 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= @@ -87,6 +89,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= @@ -190,6 +198,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -324,6 +334,8 @@ golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= diff --git a/pkg/bdd/steps/steps.go b/pkg/bdd/steps/steps.go index 7e641dd..19770e6 100644 --- a/pkg/bdd/steps/steps.go +++ b/pkg/bdd/steps/steps.go @@ -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 +} diff --git a/pkg/greet/api_v2.go b/pkg/greet/api_v2.go index 84b021e..5ea9576 100644 --- a/pkg/greet/api_v2.go +++ b/pkg/greet/api_v2.go @@ -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}) diff --git a/pkg/server/server.go b/pkg/server/server.go index e77906d..4de2f5e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -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) }) diff --git a/pkg/validation/validator.go b/pkg/validation/validator.go new file mode 100644 index 0000000..96b6f4a --- /dev/null +++ b/pkg/validation/validator.go @@ -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() +} \ No newline at end of file