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:
2026-04-04 21:08:13 +02:00
parent 875eb09fb7
commit d3b6d190d1
11 changed files with 489 additions and 14 deletions

View File

@@ -388,6 +388,50 @@ curl http://localhost:8080/api/v1/greet/Alice
# Response: {"message":"Hello 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 ## 🔗 OpenTelemetry & Jaeger Integration
The application supports OpenTelemetry for distributed tracing with Jaeger compatibility. The application supports OpenTelemetry for distributed tracing with Jaeger compatibility.

View File

@@ -62,6 +62,17 @@ vibe start --agent dancelessonscoachprogrammer
- ✅ Updated configuration system to support API versioning - ✅ Updated configuration system to support API versioning
- ✅ Added comprehensive test coverage for both enabled and disabled states - ✅ 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) ## Compact History (Last 5 Entries)
### 2026-04-04 ### 2026-04-04

View File

@@ -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
```

View File

@@ -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 * [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 * [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 * [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 ## How to Add a New ADR

View File

@@ -20,4 +20,14 @@ Feature: Greet Service
Scenario: v2 default greeting with empty name Scenario: v2 default greeting with empty name
Given the server is running with v2 enabled Given the server is running with v2 enabled
When I send a POST request to v2 greet with name "" When I send a POST request to v2 greet with name ""
Then the response should be "{\"message\":\"Hello my friend!\"}" 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"

6
go.mod
View File

@@ -25,8 +25,12 @@ require (
github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/cucumber/messages/go/v21 v21.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // 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/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // 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/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gogo/protobuf v1.3.1 // 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-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect

12
go.sum
View File

@@ -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.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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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/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 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= 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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 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-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 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/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.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 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-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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/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-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=

View File

@@ -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$`, sc.theServerIsRunning)
ctx.Step(`^the server is running with v2 enabled$`, sc.theServerIsRunningWithV2Enabled) 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 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 { func (sc *StepContext) iRequestAGreetingFor(name string) error {
@@ -91,3 +93,17 @@ func (sc *StepContext) iSendPOSTRequestToV2GreetWithName(name string) error {
requestBody := map[string]string{"name": name} requestBody := map[string]string{"name": name}
return sc.client.Request("POST", "/api/v2/greet", requestBody) 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
}

View File

@@ -3,9 +3,12 @@ package greet
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"strconv"
"io" "io"
"net/http" "net/http"
"DanceLessonsCoach/pkg/validation"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -19,11 +22,12 @@ type ApiV2Greet interface {
} }
type apiV2GreetHandler struct { type apiV2GreetHandler struct {
greeter GreeterV2 greeter GreeterV2
validator *validation.Validator
} }
func NewApiV2GreetHandler(greeter GreeterV2) ApiV2Greet { func NewApiV2GreetHandler(greeter GreeterV2, validator *validation.Validator) ApiV2Greet {
return &apiV2GreetHandler{greeter: greeter} return &apiV2GreetHandler{greeter: greeter, validator: validator}
} }
func (h *apiV2GreetHandler) RegisterRoutes(router chi.Router) { func (h *apiV2GreetHandler) RegisterRoutes(router chi.Router) {
@@ -33,7 +37,7 @@ func (h *apiV2GreetHandler) RegisterRoutes(router chi.Router) {
} }
type greetRequest struct { type greetRequest struct {
Name string `json:"name"` Name string `json:"name" validate:"max=100"`
} }
type greetResponse struct { type greetResponse struct {
@@ -55,6 +59,17 @@ func (h *apiV2GreetHandler) handleGreetPost(w http.ResponseWriter, r *http.Reque
return 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 // Call service
message := h.greeter.GreetV2(r.Context(), req.Name) 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) 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) { func (h *apiV2GreetHandler) writeJSONResponse(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(greetResponse{Message: message}) json.NewEncoder(w).Encode(greetResponse{Message: message})

View File

@@ -10,6 +10,7 @@ import (
"DanceLessonsCoach/pkg/config" "DanceLessonsCoach/pkg/config"
"DanceLessonsCoach/pkg/greet" "DanceLessonsCoach/pkg/greet"
"DanceLessonsCoach/pkg/validation"
"DanceLessonsCoach/pkg/telemetry" "DanceLessonsCoach/pkg/telemetry"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace" sdktrace "go.opentelemetry.io/otel/sdk/trace"
@@ -20,19 +21,29 @@ import (
) )
type Server struct { type Server struct {
router *chi.Mux router *chi.Mux
readyCtx context.Context readyCtx context.Context
withOTEL bool withOTEL bool
config *config.Config config *config.Config
tracerProvider *sdktrace.TracerProvider tracerProvider *sdktrace.TracerProvider
validator *validation.Validator
} }
func NewServer(cfg *config.Config, readyCtx context.Context) *Server { 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{ s := &Server{
router: chi.NewRouter(), router: chi.NewRouter(),
readyCtx: readyCtx, readyCtx: readyCtx,
withOTEL: cfg.GetTelemetryEnabled(), withOTEL: cfg.GetTelemetryEnabled(),
config: cfg, config: cfg,
validator: validator,
} }
s.setupRoutes() s.setupRoutes()
return s return s
@@ -76,7 +87,7 @@ func (s *Server) registerApiV1Routes(r chi.Router) {
func (s *Server) registerApiV2Routes(r chi.Router) { func (s *Server) registerApiV2Routes(r chi.Router) {
greetServiceV2 := greet.NewServiceV2() greetServiceV2 := greet.NewServiceV2()
greetHandlerV2 := greet.NewApiV2GreetHandler(greetServiceV2) greetHandlerV2 := greet.NewApiV2GreetHandler(greetServiceV2, s.validator)
r.Route("/greet", func(r chi.Router) { r.Route("/greet", func(r chi.Router) {
greetHandlerV2.RegisterRoutes(r) greetHandlerV2.RegisterRoutes(r)
}) })

123
pkg/validation/validator.go Normal file
View 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()
}