A type-safe HTTP router for Go that provides automatic validation and parameter binding using struct tags. Built on top of httprouter for high performance.
- Features
- Installation
- Quick Start
- Parameter Binding
- Validation
- Supported HTTP Methods
- Base Path
- Nested Routers
- Middleware
- Type Conversion
- Error Handling
- Custom Response Headers
- Unwrapping Response Payloads
- Empty Responses
- OpenAPI & Swagger
- Access to httprouter Features
- Complete Example
- Testing
- Requirements
- Dependencies
- License
- Contributing
- ✨ Type-safe handlers using Go generics
- 🔒 Automatic request & response validation via
go-playground/validator ⚠️ Typed error responses with automatic validation and status codes- 🎯 Multi-source parameter binding - path, query, headers, and body in one struct
- 📤 Response headers - set custom HTTP headers using struct tags
- 🧹 Auto-exclusion - routing/metadata fields automatically excluded from JSON
- 🔄 Automatic type conversion - strings to int, float, bool, etc.
- 📭 Empty responses - return
nilfor empty responses, validated against type contract - 🚀 High performance - powered by httprouter
- 📝 Self-documenting APIs - request/response contracts visible in code
go get github.com/mayask/sproutpackage main
import (
"context"
"log"
"net/http"
"github.com/mayask/sprout"
)
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
}
type CreateUserResponse struct {
ID int `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required"`
}
func main() {
router := sprout.New()
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*CreateUserResponse, error) {
// Request is already parsed and validated!
return &CreateUserResponse{
ID: 123,
Name: req.Name,
Email: req.Email,
}, nil
})
log.Fatal(http.ListenAndServe(":8080", router))
}Sprout can automatically extract and validate parameters from multiple sources using struct tags.
Extract dynamic segments from the URL path:
type GetUserRequest struct {
UserID string `path:"id" validate:"required,uuid4"`
}
// Route: /users/:id
sprout.GET(router, "/users/:id", func(ctx context.Context, req *GetUserRequest) (*UserResponse, error) {
// req.UserID contains the :id path parameter
return &UserResponse{ID: req.UserID}, nil
})Extract and validate query string parameters with automatic type conversion:
type SearchRequest struct {
Query string `query:"q" validate:"required,min=1"`
Page int `query:"page" validate:"omitempty,gte=1"`
Limit int `query:"limit" validate:"omitempty,gte=1,lte=100"`
Active bool `query:"active"`
}
// Route: /search?q=golang&page=2&limit=20&active=true
sprout.GET(router, "/search", func(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
// All query params are parsed and validated
return &SearchResponse{Results: []string{}}, nil
})Validate HTTP headers:
type SecureRequest struct {
AuthToken string `header:"Authorization" validate:"required"`
UserAgent string `header:"User-Agent" validate:"required"`
}
sprout.GET(router, "/secure", func(ctx context.Context, req *SecureRequest) (*Response, error) {
// Headers are validated
return &Response{Status: "ok"}, nil
})Parse and validate JSON request bodies:
type UpdateProfileRequest struct {
Name string `json:"name" validate:"required,min=3,max=100"`
Bio string `json:"bio" validate:"omitempty,max=500"`
Age int `json:"age" validate:"required,gte=18,lte=120"`
Website string `json:"website" validate:"omitempty,url"`
}
sprout.PUT(router, "/profile", func(ctx context.Context, req *UpdateProfileRequest) (*Response, error) {
// JSON body is parsed and validated
return &Response{Message: "Profile updated"}, nil
})Sprout supports nested objects with full validation:
type Address struct {
Street string `json:"street" validate:"required"`
City string `json:"city" validate:"required"`
ZipCode string `json:"zip_code" validate:"required,len=5"`
Country string `json:"country" validate:"required,len=2"` // ISO country code
}
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
Address Address `json:"address" validate:"required"`
}
// Example JSON payload:
// {
// "name": "John Doe",
// "email": "john@example.com",
// "address": {
// "street": "123 Main St",
// "city": "New York",
// "zip_code": "10001",
// "country": "US"
// }
// }
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) {
// Nested objects are automatically parsed and validated
return &UserResponse{ID: "123", Name: req.Name}, nil
})You can combine path, query, headers, and body (including nested objects) in a single request struct:
type Address struct {
Street string `json:"street" validate:"required"`
City string `json:"city" validate:"required"`
ZipCode string `json:"zip_code" validate:"required"`
}
type UpdateUserRequest struct {
// Path parameter
UserID string `path:"id" validate:"required,uuid4"`
// Header
AuthToken string `header:"Authorization" validate:"required,startswith=Bearer "`
// Query parameters
Notify bool `query:"notify"`
// JSON body fields (including nested objects)
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,gte=18"`
Address Address `json:"address" validate:"required"`
}
type UpdateUserResponse struct {
UserID string `json:"user_id" validate:"required"`
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required"`
Address Address `json:"address" validate:"required"`
Updated bool `json:"updated" validate:"required"`
}
sprout.PUT(router, "/users/:id", func(ctx context.Context, req *UpdateUserRequest) (*UpdateUserResponse, error) {
// All parameters from different sources are available, including nested objects
return &UpdateUserResponse{
UserID: req.UserID,
Name: req.Name,
Email: req.Email,
Address: req.Address,
Updated: true,
}, nil
})Sprout validates both requests and responses using go-playground/validator tags.
Note: Sprout initializes the validator with
validator.WithRequiredStructEnabled(), opting into the stricter nesting rules that will become default in validator v11+.
type ExampleRequest struct {
// String validations
Name string `validate:"required"` // Must be present
Username string `validate:"required,min=3,max=20"` // Length constraints
Email string `validate:"required,email"` // Email format
URL string `validate:"omitempty,url"` // URL format (optional)
// Numeric validations
Age int `validate:"required,gte=18,lte=120"` // Range constraints
Price float64 `validate:"required,gt=0"` // Greater than
Quantity uint `validate:"omitempty,lte=1000"` // Less than or equal
// Conditional validations
Password string `validate:"required_with=NewPassword,min=8"` // Required if NewPassword present
// Custom formats
UUID string `validate:"required,uuid4"` // UUID v4 format
Color string `validate:"required,hexcolor"` // Hex color
IP string `validate:"required,ip"` // IP address
}See the validator documentation for all available validation tags.
You can extend the shared validator instance to add custom rules or custom type handling:
import (
"reflect"
"github.com/go-playground/validator/v10"
)
router := sprout.New()
// Map custom types to validation-friendly values.
router.RegisterCustomTypeFunc(func(v reflect.Value) interface{} {
if v.Kind() == reflect.Ptr && !v.IsNil() {
v = v.Elem()
}
if wrapper, ok := v.Interface().(MyWrapper); ok {
return wrapper.Value
}
return nil
}, MyWrapper{}, (*MyWrapper)(nil))
// Register a custom validation tag.
router.RegisterValidation("is-foo", func(fl validator.FieldLevel) bool {
return fl.Field().String() == "foo"
})
type Payload struct {
Value MyWrapper `validate:"is-foo"`
}Both helpers delegate to go-playground/validator’s RegisterCustomTypeFunc and RegisterValidation, so any customizations are available to all routes mounted on the router (and its children).
All standard HTTP methods are supported:
sprout.GET(router, "/path", handler)
sprout.POST(router, "/path", handler)
sprout.PUT(router, "/path", handler)
sprout.PATCH(router, "/path", handler)
sprout.DELETE(router, "/path", handler)
sprout.HEAD(router, "/path", handler)
sprout.OPTIONS(router, "/path", handler)You can define a base path that will be prepended to all routes registered with a router. This is useful for API versioning or organizing routes under a common prefix.
config := &sprout.Config{
BasePath: "/api/v1",
}
router := sprout.NewWithConfig(config)
// Register routes without the base path
sprout.GET(router, "/users", handleListUsers) // Accessible at /api/v1/users
sprout.POST(router, "/users", handleCreateUser) // Accessible at /api/v1/users
sprout.GET(router, "/users/:id", handleGetUser) // Accessible at /api/v1/users/:id
sprout.DELETE(router, "/users/:id", handleDeleteUser) // Accessible at /api/v1/users/:idCreate nested routers with shared error handling and path prefixes using Mount:
router := sprout.New()
auth := router.Mount("/auth", nil)
sprout.POST(auth, "/login", handleAuthLogin) // -> /auth/login
sprout.POST(auth, "/register", handleSignUp) // -> /auth/register
api := router.Mount("/api", nil)
admin := api.Mount("/admin", nil)
sprout.GET(admin, "/users", handleAdminUsers) // -> /api/admin/usersChild routers automatically reuse the parent's error handler and validator. Their base path is the combination of the parent's base path, the mount prefix, and any optional base path provided via the child configuration:
apiV1 := router.Mount("/api", &sprout.Config{BasePath: "/v1"})
sprout.GET(apiV1, "/status", handleStatus) // -> /api/v1/statusPass a full sprout.Config when mounting to override behavior per router (for example a distinct error handler or StrictErrorTypes flag) while leaving the parent untouched.
Attach middleware to any router with Use(). Middleware runs in the order it is registered and respects router hierarchy—parent middleware always wraps child middleware and routes, just like Express.
router := sprout.New()
type AuthError struct {
_ struct{} `http:"status=401"`
Message string `json:"message" validate:"required"`
}
// Global logging middleware
router.Use(func(w http.ResponseWriter, r *http.Request, next sprout.Next) {
start := time.Now()
next(nil) // continue to handlers
log.Printf("%s %s (%s)", r.Method, r.URL.Path, time.Since(start))
})
api := router.Mount("/api", nil)
// Scoped middleware for /api/*
api.Use(func(w http.ResponseWriter, r *http.Request, next sprout.Next) {
if r.Header.Get("Authorization") == "" {
next(&AuthError{Message: "missing auth"})
return
}
next(nil)
})
sprout.GET(api, "/users/:id", func(ctx context.Context, req *GetUserRequest) (*GetUserResponse, error) {
// req already includes :id thanks to struct tags.
// Middleware can still inspect the raw params via sprout.Params(r).
return findUser(req.UserID), nil
})
// Route-level middleware using RouteOption
sprout.GET(api, "/reports", func(ctx context.Context, req *ReportRequest) (*ReportResponse, error) {
return generateReport(req)
}, sprout.WithMiddleware(func(w http.ResponseWriter, r *http.Request, next sprout.Next) {
if !hasReportAccess(r.Context()) {
next(&AuthError{Message: "forbidden"})
return
}
next(nil)
}))Typed handlers can opt to let downstream middleware handle a response by returning sprout.ErrNext. Middleware registered after the route will observe the fallthrough:
sprout.GET(router, "/dashboard", func(ctx context.Context, req *EmptyRequest) (*DashboardResponse, error) {
if isDeprecatedUser(ctx) {
return nil, sprout.ErrNext // skip to the next middleware
}
return &DashboardResponse{Message: "Welcome back!"}, nil
})
router.Use(func(w http.ResponseWriter, r *http.Request, next sprout.Next) {
// Runs when the handler called ErrNext or another middleware called next(nil)
http.Redirect(w, r, "/upgrade", http.StatusFound)
})Middleware receives the raw *http.Request. Use sprout.Params(r) to read httprouter.Params captured for the route, even in fallback middleware for 404/405 responses:
router.Use(func(w http.ResponseWriter, r *http.Request, next sprout.Next) {
if params := sprout.Params(r); params != nil {
log.Printf("matched route params: %#v", params)
}
next(nil)
})Order matters: Middleware registered before a route runs first. Middleware registered after a route only executes if the route (or earlier middleware) calls
next(nil)or returnssprout.ErrNext. Middleware defined on parent routers wraps middleware/routes defined on child routers, so global behaviour is applied automatically. Usenext(err)from any middleware to short-circuit the chain and run Sprout's error handling.
Sprout now generates an OpenAPI 3.0 document using kin-openapi. Every registered route contributes path metadata, request/response schemas, and declared errors.
- The document is served at
/swagger(or<BasePath>/swaggerwhen a base path is configured). - JSON is returned by default; append
?format=yamlfor a YAML response. - Programmatic access is available through
router.OpenAPIJSON()androuter.OpenAPIYAML().
router := sprout.New()
sprout.POST(router, "/users", handleCreateUser)
// Persist the generated spec
if data, err := router.OpenAPIJSON(); err == nil {
_ = os.WriteFile("openapi.json", data, 0o644)
}Schemas are derived from your request/response DTOs, path/query/header tags become parameters, and WithErrors contributes typed error responses—keeping the documentation aligned with the handlers.
Top-level OpenAPI metadata (title, version, contact details, etc.) is configured via router options:
router := sprout.NewWithConfig(nil, sprout.WithOpenAPIInfo(sprout.OpenAPIInfo{
Title: "Payments API",
Version: "2025.04",
Description: "Internal payments platform",
Terms: "https://example.com/terms",
Contact: &sprout.OpenAPIContact{
Name: "API Support",
Email: "support@example.com",
},
License: &sprout.OpenAPILicense{
Name: "Apache-2.0",
URL: "https://www.apache.org/licenses/LICENSE-2.0",
},
Servers: []sprout.OpenAPIServer{
{URL: "https://api.example.com", Description: "production"},
{URL: "http://localhost:8080", Description: "local"},
},
}))The same metadata is available from the /swagger endpoint and through OpenAPIJSON() / OpenAPIYAML().
A runnable example lives in cmd/demo/main.go. Start it with:
go run ./cmd/demoBrowse endpoints like:
GET http://localhost:8080/pingPOST http://localhost:8080/usersGET http://localhost:8080/swagger(append?format=yamlfor YAML)
Query parameters, path parameters, and headers are automatically converted from strings to the appropriate type:
| Go Type | Supported |
|---|---|
string |
✅ |
int, int8, int16, int32, int64 |
✅ |
uint, uint8, uint16, uint32, uint64 |
✅ |
float32, float64 |
✅ |
bool |
✅ |
Sprout automatically returns appropriate HTTP status codes:
| Status Code | When |
|---|---|
400 Bad Request |
Invalid JSON, parameter parsing errors, or validation failures |
500 Internal Server Error |
Handler errors or response validation failures |
Example error response for validation failure:
Request validation failed: Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag
For more control over error responses, define error types with struct tags for status codes:
// Define a typed error with status code in struct tag
type NotFoundError struct {
_ struct{} `http:"status=404"`
Resource string `json:"resource" validate:"required"`
ID string `json:"id" validate:"required"`
Message string `json:"message" validate:"required"`
}
func (e NotFoundError) Error() string {
return fmt.Sprintf("%s not found: %s", e.Resource, e.ID)
}
// Use in handlers
sprout.GET(router, "/users/:id", func(ctx context.Context, req *GetUserRequest) (*UserResponse, error) {
user, err := db.FindUser(req.UserID)
if err != nil {
return nil, NotFoundError{
Resource: "user",
ID: req.UserID,
Message: "user not found",
}
}
return &UserResponse{ID: user.ID, Name: user.Name}, nil
}, sprout.WithErrors(NotFoundError{}))Key features:
- Error response bodies are automatically validated using the same validation tags
- Status codes defined via struct tags:
http:"status=404" - The error struct itself is serialized as the response body
- Type-safe error responses with struct validation
- Optional error type registration via
WithErrors()for compile-time documentation and OpenAPI generation
You can register multiple expected error types for documentation and validation:
type ConflictError struct {
_ struct{} `http:"status=409"`
Field string `json:"field" validate:"required"`
Message string `json:"message" validate:"required"`
}
func (e ConflictError) Error() string { return e.Message }
type UnauthorizedError struct {
_ struct{} `http:"status=401"`
Message string `json:"message" validate:"required"`
}
func (e UnauthorizedError) Error() string { return e.Message }
// Register all possible error types
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) {
// Check authorization
if !isAuthorized(ctx) {
return nil, UnauthorizedError{Message: "invalid credentials"}
}
// Check for conflicts
if userExists(req.Email) {
return nil, ConflictError{Field: "email", Message: "email already exists"}
}
// Check if resource exists
if !resourceExists(req.OrgID) {
return nil, NotFoundError{Resource: "organization", ID: req.OrgID, Message: "organization not found"}
}
return &UserResponse{ID: "123", Name: req.Name}, nil
}, sprout.WithErrors(
NotFoundError{},
ConflictError{},
UnauthorizedError{},
))The WithErrors() option provides:
- Runtime validation: Enforces declared error types (configurable)
- Self-documentation: Makes possible error responses explicit in code
- Type safety: Error response bodies are validated before sending
- OpenAPI generation: Status codes and schemas accessible via reflection for documentation
By default, Sprout enforces that handlers only return error types explicitly declared via WithErrors(). This encourages well-documented APIs and prevents unexpected error responses.
If a handler returns an undeclared error type, Sprout returns 500 Internal Server Error:
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) {
if userExists(req.Email) {
// ❌ ConflictError is declared, so this works
return nil, ConflictError{Field: "email", Message: "email already exists"}
}
if !authorized {
// ❌ ERROR! UnauthorizedError is NOT declared - returns 500
return nil, UnauthorizedError{Message: "not authorized"}
}
return &UserResponse{ID: "123"}, nil
}, sprout.WithErrors(ConflictError{})) // Only ConflictError declaredLog output:
ERROR: handler returned undeclared error type: UnauthorizedError (expected one of: [ConflictError])
Client receives:
HTTP/1.1 500 Internal Server Error
undeclared_error_type: handler returned undeclared error type: UnauthorizedError
To allow undeclared error types (backward compatibility mode), set StrictErrorTypes to false:
falseVal := false
config := &sprout.Config{
StrictErrorTypes: &falseVal,
}
router := sprout.NewWithConfig(config)
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) {
// Now undeclared errors are allowed (with warning log)
return nil, UnauthorizedError{Message: "not authorized"}
}, sprout.WithErrors(ConflictError{}))Log output:
WARNING: handler returned unexpected error type: UnauthorizedError (expected one of: [ConflictError])
Client receives:
HTTP/1.1 401 Unauthorized
{"message": "not authorized"}
| Scenario | StrictErrorTypes = true (default) |
StrictErrorTypes = false |
|---|---|---|
| Declared error passes validation | Serialized directly from the error struct, ErrorHandler not invoked |
Same as strict |
| Declared error fails validation | Wrapped into *sprout.Error with ErrorKindErrorValidation and routed through ErrorHandler |
Validation is skipped, the original error struct is serialized, ErrorHandler not invoked |
| Undeclared error returned | Wrapped into *sprout.Error with ErrorKindUndeclaredError and routed through ErrorHandler |
Original error is passed to ErrorHandler unchanged (if configured); default handler still emits a 500 |
Notes
- Once a custom
ErrorHandleris invoked, Sprout does not modify the HTTP response—your handler must write status, headers, and body. - Typed error serialization happens before the
ErrorHandleris called; only when serialization fails or strict-mode rules apply will Sprout call your handler.
When using a custom error handler, you can detect and handle undeclared error types:
config := &sprout.Config{
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
var sproutErr *sprout.Error
if errors.As(err, &sproutErr) {
// Check if this is an undeclared error type
if sproutErr.Kind == sprout.ErrorKindUndeclaredError {
// Log to monitoring system
logToSentry(sproutErr)
// Return custom response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{
"error": "internal_error",
"message": "An unexpected error occurred",
})
return
}
}
// Handle other error kinds...
},
}Benefits of strict mode (default):
- Forces explicit error type declarations via
WithErrors() - Makes API contracts clear and self-documenting
- Catches missing error type declarations during development
- Helps generate accurate OpenAPI/Swagger documentation
When to disable strict mode:
- Migrating legacy code that doesn't use
WithErrors() - Prototyping where error handling isn't finalized
- Using dynamic error types that can't be known at compile time
Sprout allows you to customize how system errors (parsing errors, validation errors, etc.) are handled and returned to clients. This gives you full control over error response formatting.
Create a router with a custom error handler using NewWithConfig():
config := &sprout.Config{
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
// Extract sprout.Error for detailed error information
var sproutErr *sprout.Error
if errors.As(err, &sproutErr) {
// Return custom JSON error response
w.Header().Set("Content-Type", "application/json")
status := http.StatusInternalServerError
switch sproutErr.Kind {
case sprout.ErrorKindParse, sprout.ErrorKindValidation:
status = http.StatusBadRequest
case sprout.ErrorKindNotFound:
status = http.StatusNotFound
case sprout.ErrorKindMethodNotAllowed:
status = http.StatusMethodNotAllowed
case sprout.ErrorKindResponseValidation, sprout.ErrorKindErrorValidation,
sprout.ErrorKindUndeclaredError, sprout.ErrorKindSerialization:
status = http.StatusInternalServerError
}
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"kind": sproutErr.Kind,
"message": sproutErr.Message,
"details": sproutErr.Err.Error(),
},
})
return
}
// Handle other errors
http.Error(w, err.Error(), http.StatusInternalServerError)
},
}
router := sprout.NewWithConfig(config)Sprout provides specific error kinds to help you handle different error scenarios:
| Error Kind | Description | Default Status |
|---|---|---|
ErrorKindParse |
Failed to parse request parameters (query, path, headers) | 400 Bad Request |
ErrorKindValidation |
Request validation failed | 400 Bad Request |
ErrorKindNotFound |
No route matched the request (404) | 404 Not Found |
ErrorKindMethodNotAllowed |
HTTP method not allowed for route (405) | 405 Method Not Allowed |
ErrorKindResponseValidation |
Response validation failed (internal error) | 500 Internal Server Error |
ErrorKindErrorValidation |
Error response validation failed (internal error) | 500 Internal Server Error |
ErrorKindUndeclaredError |
Handler returned undeclared error type (when StrictErrorTypes is enabled) |
500 Internal Server Error |
ErrorKindSerialization |
JSON encoding failed (internal error) | 500 Internal Server Error |
The sprout.Error type provides detailed error context:
type Error struct {
Kind ErrorKind // Category of error
Message string // Human-readable message
Err error // Underlying error (can be nil)
}You can access the underlying error using errors.As() or Unwrap():
var sproutErr *sprout.Error
if errors.As(err, &sproutErr) {
log.Printf("Error kind: %s", sproutErr.Kind)
log.Printf("Message: %s", sproutErr.Message)
if sproutErr.Err != nil {
log.Printf("Underlying error: %v", sproutErr.Err)
}
}If no custom error handler is provided, Sprout uses sensible defaults:
- Parse/Validation errors: Returns
400 Bad Requestwith plain text error message - 404 Not Found: Returns
404 Not Foundwhen no route matches - 405 Method Not Allowed: Returns
405 Method Not Allowedwhen route exists but method doesn't match - Response/Error validation failures: Returns
500 Internal Server Errorwith plain text error message
// Uses default error handling
router := sprout.New()Note: 404 and 405 errors automatically go through your custom ErrorHandler (if configured), giving you consistent error formatting across all error types.
Response types can also define custom status codes using struct tags:
type CreatedResponse struct {
_ struct{} `http:"status=201"` // 201 Created
ID int `json:"id" validate:"required,gt=0"`
Message string `json:"message" validate:"required"`
}
sprout.POST(router, "/items", func(ctx context.Context, req *CreateItemRequest) (*CreatedResponse, error) {
return &CreatedResponse{
ID: 42,
Message: "Item created successfully",
}, nil
})Without the http struct tag, responses default to 200 OK.
You can set custom HTTP headers in both success and error responses using the header: tag:
type UserCreatedResponse struct {
_ struct{} `http:"status=201"`
Location string `header:"Location"` // Set Location header
ETag string `header:"ETag"` // Set ETag header
ID string `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
}
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*UserCreatedResponse, error) {
userID := "user-123"
return &UserCreatedResponse{
Location: fmt.Sprintf("/users/%s", userID),
ETag: `"v1.0"`,
ID: userID,
Name: req.Name,
}, nil
})The Location and ETag fields are automatically:
- Set as HTTP response headers
- Excluded from the JSON response body (no need for
json:"-"tags!)
This works for error responses too:
type RateLimitError struct {
_ struct{} `http:"status=429"`
RetryAfter string `header:"Retry-After"` // Set Retry-After header
RateLimit string `header:"X-Rate-Limit"` // Set custom header
Message string `json:"message" validate:"required"`
}
func (e RateLimitError) Error() string { return e.Message }Auto-exclusion from JSON: Fields with path, query, header, or http tags are automatically excluded from JSON serialization. You don't need to add json:"-" manually!
You can keep a struct response (for validation, headers, or status tags) and still emit a raw payload by marking exactly one field with sprout:"unwrap":
// UserResponse is the existing single-user DTO reused across the API.
type ListUsersResponse struct {
Users []UserResponse `json:"users" sprout:"unwrap" validate:"required,dive"`
}
sprout.GET(router, "/users", func(ctx context.Context, req *ListUsersRequest) (*ListUsersResponse, error) {
return &ListUsersResponse{
Users: []UserResponse{
{ID: "1", Name: "Alice", Email: "alice@example.com"},
{ID: "2", Name: "Bob", Email: "bob@example.com"},
},
}, nil
})The HTTP body produced by this handler is a bare JSON array ([{ "id": "1", "name": "Alice", ... }, ...]). The wrapper struct still participates in validation, can specify headers or status codes, and the generated OpenAPI schema reflects the unwrapped type.
Guidelines for sprout:"unwrap":
- Only one exported field per response struct may declare
sprout:"unwrap". - The tag is ignored on request DTOs; it's for responses only.
- Other fields in the struct continue to serialize normally (or are excluded if they carry routing/header tags).
For endpoints that don't need to return data (like DELETE operations), you can define empty response types and return nil:
// Define an empty response type
type EmptyResponse struct{}
// Or with a custom status code
type NoContentResponse struct {
_ struct{} `http:"status=204"`
}
// Handler can return nil
sprout.DELETE(router, "/users/:id", func(ctx context.Context, req *DeleteUserRequest) (*NoContentResponse, error) {
// ... delete logic ...
return nil, nil // ✅ Returns 204 No Content with empty JSON body {}
})How it works:
When a handler returns nil for the response, Sprout:
- Creates an empty instance of the declared response type
- Validates it against any validation tags
- If validation passes (no required fields), serializes it as
{} - If validation fails (has required fields), returns a validation error
Since Sprout embeds *httprouter.Router, you have full access to all httprouter configuration and features:
router := sprout.New()
// Configure httprouter settings
router.RedirectTrailingSlash = true
router.RedirectFixedPath = true
router.HandleMethodNotAllowed = true
router.HandleOPTIONS = true
// Set custom panic handler
router.PanicHandler = customPanicHandler
// Serve static files
router.ServeFiles("/static/*filepath", http.Dir("./public"))
// Use httprouter's native handlers for specific routes
router.Handle("GET", "/raw", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.Write([]byte("raw handler"))
})Here's a more complete example showing various features, including nested objects:
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/mayask/sprout"
)
// Nested types
type Address struct {
Street string `json:"street" validate:"required"`
City string `json:"city" validate:"required"`
ZipCode string `json:"zip_code" validate:"required,len=5"`
Country string `json:"country" validate:"required,len=2"`
}
type Preferences struct {
Language string `json:"language" validate:"required,oneof=en es fr de"`
Timezone string `json:"timezone" validate:"required"`
Notifications bool `json:"notifications"`
}
// List users with pagination
type ListUsersRequest struct {
Page int `query:"page" validate:"omitempty,gte=1"`
Limit int `query:"limit" validate:"omitempty,gte=1,lte=100"`
Token string `header:"Authorization" validate:"required"`
}
type ListUsersResponse struct {
Users []User `json:"users" validate:"required"`
Page int `json:"page" validate:"gte=1"`
Total int `json:"total" validate:"gte=0"`
}
// Get specific user
type GetUserRequest struct {
UserID string `path:"id" validate:"required,uuid4"`
Token string `header:"Authorization" validate:"required"`
}
type UserResponse struct {
ID string `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
Address Address `json:"address" validate:"required"`
Preferences Preferences `json:"preferences" validate:"required"`
}
// Create user with nested objects
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3,max=100"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,gte=18,lte=120"`
Address Address `json:"address" validate:"required"`
Preferences Preferences `json:"preferences" validate:"required"`
}
// Update user
type UpdateUserRequest struct {
UserID string `path:"id" validate:"required,uuid4"`
Token string `header:"Authorization" validate:"required"`
Name string `json:"name" validate:"omitempty,min=3,max=100"`
Email string `json:"email" validate:"omitempty,email"`
Address *Address `json:"address" validate:"omitempty"` // Optional update
Preferences *Preferences `json:"preferences" validate:"omitempty"` // Optional update
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Address Address `json:"address"`
Preferences Preferences `json:"preferences"`
}
func main() {
router := sprout.New()
// List users with pagination
sprout.GET(router, "/users", func(ctx context.Context, req *ListUsersRequest) (*ListUsersResponse, error) {
page := req.Page
if page == 0 {
page = 1
}
limit := req.Limit
if limit == 0 {
limit = 10
}
return &ListUsersResponse{
Users: []User{{
ID: "1",
Name: "John",
Email: "john@example.com",
Address: Address{
Street: "123 Main St",
City: "New York",
ZipCode: "10001",
Country: "US",
},
Preferences: Preferences{
Language: "en",
Timezone: "America/New_York",
Notifications: true,
},
}},
Page: page,
Total: 1,
}, nil
})
// Get user by ID with nested objects
sprout.GET(router, "/users/:id", func(ctx context.Context, req *GetUserRequest) (*UserResponse, error) {
return &UserResponse{
ID: req.UserID,
Name: "John Doe",
Email: "john@example.com",
Address: Address{
Street: "123 Main St",
City: "New York",
ZipCode: "10001",
Country: "US",
},
Preferences: Preferences{
Language: "en",
Timezone: "America/New_York",
Notifications: true,
},
}, nil
})
// Create new user with nested objects
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) {
return &UserResponse{
ID: "new-uuid",
Name: req.Name,
Email: req.Email,
Address: req.Address, // Nested object from request
Preferences: req.Preferences, // Nested object from request
}, nil
})
// Update user (partial update with optional nested objects)
sprout.PUT(router, "/users/:id", func(ctx context.Context, req *UpdateUserRequest) (*UserResponse, error) {
// Start with existing user data
response := &UserResponse{
ID: req.UserID,
Name: req.Name,
Email: req.Email,
Address: Address{
Street: "123 Main St",
City: "New York",
ZipCode: "10001",
Country: "US",
},
Preferences: Preferences{
Language: "en",
Timezone: "America/New_York",
Notifications: true,
},
}
// Update nested objects if provided
if req.Address != nil {
response.Address = *req.Address
}
if req.Preferences != nil {
response.Preferences = *req.Preferences
}
return response, nil
})
// Delete user
sprout.DELETE(router, "/users/:id", func(ctx context.Context, req *GetUserRequest) (*UserResponse, error) {
return &UserResponse{
ID: req.UserID,
Name: "Deleted User",
Email: "deleted@example.com",
Address: Address{
Street: "",
City: "",
ZipCode: "",
Country: "",
},
Preferences: Preferences{
Language: "en",
Timezone: "UTC",
Notifications: false,
},
}, nil
})
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", router))
}Sprout handlers are easy to test:
func TestCreateUser(t *testing.T) {
router := sprout.New()
sprout.POST(router, "/users", func(ctx context.Context, req *CreateUserRequest) (*UserResponse, error) {
return &UserResponse{
ID: "123",
Name: req.Name,
Email: req.Email,
}, nil
})
reqBody := CreateUserRequest{
Name: "John Doe",
Email: "john@example.com",
Age: 30,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}- Go 1.18+ (for generics support)
- julienschmidt/httprouter - High performance HTTP router
- go-playground/validator - Struct and field validation
MIT
Contributions are welcome! Please feel free to submit a Pull Request.