Skip to content

Latest commit

History

54 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

sprout

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.

Table of Contents

Features

  • 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 nil for empty responses, validated against type contract
  • 🚀 High performance - powered by httprouter
  • 📝 Self-documenting APIs - request/response contracts visible in code

Installation

go get github.com/mayask/sprout

Quick Start

package main
import (
"context""log""net/http""github.com/mayask/sprout"
)
typeCreateUserRequeststruct {
Namestring`json:"name" validate:"required,min=3"`Emailstring`json:"email" validate:"required,email"`
}
typeCreateUserResponsestruct {
IDint`json:"id" validate:"required"`Namestring`json:"name" validate:"required"`Emailstring`json:"email" validate:"required"`
}
funcmain() {
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))
}

Parameter Binding

Sprout can automatically extract and validate parameters from multiple sources using struct tags.

Path Parameters

Extract dynamic segments from the URL path:

typeGetUserRequeststruct {
UserIDstring`path:"id" validate:"required,uuid4"`
}
// Route: /users/:idsprout.GET(router, "/users/:id", func(ctx context.Context, req*GetUserRequest) (*UserResponse, error) {
// req.UserID contains the :id path parameterreturn&UserResponse{ID: req.UserID}, nil
})

Query Parameters

Extract and validate query string parameters with automatic type conversion:

typeSearchRequeststruct {
Querystring`query:"q" validate:"required,min=1"`Pageint`query:"page" validate:"omitempty,gte=1"`Limitint`query:"limit" validate:"omitempty,gte=1,lte=100"`Activebool`query:"active"`
}
// Route: /search?q=golang&page=2&limit=20&active=truesprout.GET(router, "/search", func(ctx context.Context, req*SearchRequest) (*SearchResponse, error) {
// All query params are parsed and validatedreturn&SearchResponse{Results: []string{}}, nil
})

Headers

Validate HTTP headers:

typeSecureRequeststruct {
AuthTokenstring`header:"Authorization" validate:"required"`UserAgentstring`header:"User-Agent" validate:"required"`
}
sprout.GET(router, "/secure", func(ctx context.Context, req*SecureRequest) (*Response, error) {
// Headers are validatedreturn&Response{Status: "ok"}, nil
})

Request Body

Parse and validate JSON request bodies:

typeUpdateProfileRequeststruct {
Namestring`json:"name" validate:"required,min=3,max=100"`Biostring`json:"bio" validate:"omitempty,max=500"`Ageint`json:"age" validate:"required,gte=18,lte=120"`Websitestring`json:"website" validate:"omitempty,url"`
}
sprout.PUT(router, "/profile", func(ctx context.Context, req*UpdateProfileRequest) (*Response, error) {
// JSON body is parsed and validatedreturn&Response{Message: "Profile updated"}, nil
})

Raw Request Bodies

Use WithRawRequest() for multipart uploads or other handlers that need to read the original body themselves. Sprout still parses and validates path, query, and header fields, but skips JSON body parsing.

sprout.POST(router, "/uploads", func(ctx context.Context, req*UploadRequest) (*UploadResponse, error) {
httpReq:=sprout.HTTPRequest(ctx)
reader, err:=httpReq.MultipartReader()
iferr!=nil {
returnnil, err
}
// Read multipart parts from reader.return&UploadResponse{Status: "ok"}, nil
}, sprout.WithRawRequest())

Streaming Request Bodies

Use an explicit body field with *sprout.StreamBody when the handler must consume a large body incrementally without pre-reading, buffering, or temporary files. The contentType tag drives both runtime validation and the generated OpenAPI request body. Path, query, and header fields are populated and validated before the handler receives the live stream.

typeCSVUploadRequeststruct {
Column*int`query:"column" validate:"required,gte=0"`Body*sprout.StreamBody`body:"" contentType:"text/csv" validate:"required"`
}
sprout.POST(router, "/uploads", func(ctx context.Context, req*CSVUploadRequest) (*UploadResponse, error) {
// req.Body is the original live request stream. Process it incrementally.rows:=csv.NewReader(req.Body)
// ... read rows ...return&UploadResponse{Status: "ok"}, nil
}, sprout.WithRequestBodyLimit(1<<30))

Sprout closes the stream after the handler returns. Close is idempotent, so a handler may close it explicitly when returning early. WithRequestBodyLimit wraps the body in http.MaxBytesReader; reads beyond the limit return *http.MaxBytesError.

Consuming body formats can be added through the decoder registry. Decoders receive the request context, a body reader, parsed Content-Type parameters, and the target value. They may return cleanup for multipart temporary files or other resources. JSON is registered by default; StreamBody bypasses this registry because it must remain unread until the handler.

router.RegisterRequestBodyDecoder("application/xml", func(
ctx context.Context,
body io.Reader,
paramsmap[string]string,
targetany,
) (cleanupfunc() error, err*sprout.Error) {
// Decode body into target, then return optional cleanup.
})

The generated OpenAPI operation declares the configured media type with a string/binary schema. An explicit body field cannot be combined with WithRawRequest().

Nested Objects in Request Body

Sprout supports nested objects with full validation:

typeAddressstruct {
Streetstring`json:"street" validate:"required"`Citystring`json:"city" validate:"required"`ZipCodestring`json:"zip_code" validate:"required,len=5"`Countrystring`json:"country" validate:"required,len=2"`// ISO country code
}
typeCreateUserRequeststruct {
Namestring`json:"name" validate:"required,min=3"`Emailstring`json:"email" validate:"required,email"`AddressAddress`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 validatedreturn&UserResponse{ID: "123", Name: req.Name}, nil
})

Combining Multiple Sources

You can combine path, query, headers, and body (including nested objects) in a single request struct:

typeAddressstruct {
Streetstring`json:"street" validate:"required"`Citystring`json:"city" validate:"required"`ZipCodestring`json:"zip_code" validate:"required"`
}
typeUpdateUserRequeststruct {
// Path parameterUserIDstring`path:"id" validate:"required,uuid4"`// HeaderAuthTokenstring`header:"Authorization" validate:"required,startswith=Bearer "`// Query parametersNotifybool`query:"notify"`// JSON body fields (including nested objects)Namestring`json:"name" validate:"required,min=3"`Emailstring`json:"email" validate:"required,email"`Ageint`json:"age" validate:"required,gte=18"`AddressAddress`json:"address" validate:"required"`
}
typeUpdateUserResponsestruct {
UserIDstring`json:"user_id" validate:"required"`Namestring`json:"name" validate:"required"`Emailstring`json:"email" validate:"required"`AddressAddress`json:"address" validate:"required"`Updatedbool`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 objectsreturn&UpdateUserResponse{
UserID: req.UserID,
Name: req.Name,
Email: req.Email,
Address: req.Address,
Updated: true,
}, nil
})

Validation

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+.

Common Validation Tags

typeExampleRequeststruct {
// String validationsNamestring`validate:"required"`// Must be presentUsernamestring`validate:"required,min=3,max=20"`// Length constraintsEmailstring`validate:"required,email"`// Email formatURLstring`validate:"omitempty,url"`// URL format (optional)// Numeric validationsAgeint`validate:"required,gte=18,lte=120"`// Range constraintsPricefloat64`validate:"required,gt=0"`// Greater thanQuantityuint`validate:"omitempty,lte=1000"`// Less than or equal// Conditional validationsPasswordstring`validate:"required_with=NewPassword,min=8"`// Required if NewPassword present// Custom formatsUUIDstring`validate:"required,uuid4"`// UUID v4 formatColorstring`validate:"required,hexcolor"`// Hex colorIPstring`validate:"required,ip"`// IP address
}

See the validator documentation for all available validation tags.

Custom Validators

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{} {
ifv.Kind() ==reflect.Ptr&&!v.IsNil() {
v=v.Elem()
}
ifwrapper, ok:=v.Interface().(MyWrapper); ok {
returnwrapper.Value
}
returnnil
}, MyWrapper{}, (*MyWrapper)(nil))
// Register a custom validation tag.router.RegisterValidation("is-foo", func(fl validator.FieldLevel) bool {
returnfl.Field().String() =="foo"
})
typePayloadstruct {
ValueMyWrapper`validate:"is-foo"`
}

For validation that belongs to a Go type rather than a struct tag, register a type validator. Sprout recursively applies these callbacks to parsed request and response DTO values, which is useful for custom value objects such as string-backed enums:

router.RegisterTypeValidationFunc(func(v reflect.Value) error {
// Return nil for types this callback does not handle.returnnil
})

RegisterCustomTypeFunc and RegisterValidation delegate to go-playground/validator. Type validators are Sprout-level callbacks that run in addition to tag-based validation. All customizations are available to routes mounted on the router and its children.

Supported HTTP Methods

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)

Base Path

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 pathsprout.GET(router, "/users", handleListUsers) // Accessible at /api/v1/userssprout.POST(router, "/users", handleCreateUser) // Accessible at /api/v1/userssprout.GET(router, "/users/:id", handleGetUser) // Accessible at /api/v1/users/:idsprout.DELETE(router, "/users/:id", handleDeleteUser) // Accessible at /api/v1/users/:id

Nested Routers

Create 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/loginsprout.POST(auth, "/register", handleSignUp) // -> /auth/registerapi:=router.Mount("/api", nil)
admin:=api.Mount("/admin", nil)
sprout.GET(admin, "/users", handleAdminUsers) // -> /api/admin/users

Child 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/status

Pass 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.

Middleware

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()
typeAuthErrorstruct {
_struct{} `http:"status=401"`Messagestring`json:"message" validate:"required"`
}
// Global logging middlewarerouter.Use(func(w http.ResponseWriter, r*http.Request, next sprout.Next) {
start:=time.Now()
next(nil) // continue to handlerslog.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) {
ifr.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).returnfindUser(req.UserID), nil
})
// Route-level middleware using RouteOptionsprout.GET(api, "/reports", func(ctx context.Context, req*ReportRequest) (*ReportResponse, error) {
returngenerateReport(req)
}, sprout.WithMiddleware(func(w http.ResponseWriter, r*http.Request, next sprout.Next) {
if!hasReportAccess(r.Context()) {
next(&AuthError{Message: "forbidden"})
return
}
next(nil)
}))

Falling Through with ErrNext

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) {
ifisDeprecatedUser(ctx) {
returnnil, 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)
})

Accessing Route Parameters in Middleware

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) {
ifparams:=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 returns sprout.ErrNext. Middleware defined on parent routers wraps middleware/routes defined on child routers, so global behaviour is applied automatically. Use next(err) from any middleware to short-circuit the chain and run Sprout's error handling.

OpenAPI & Swagger

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>/swagger when a base path is configured).
  • JSON is returned by default; append ?format=yaml for a YAML response.
  • Programmatic access is available through router.OpenAPIJSON() and router.OpenAPIYAML().
router:=sprout.New()
sprout.POST(router, "/users", handleCreateUser)
// Persist the generated specifdata, 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.

Customizing Metadata

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"},
},
}))

Schema Resolver

Sprout generates OpenAPI schemas automatically from your Go types, but some custom types need explicit schema metadata. Enum types backed by string, struct-backed value objects with unexported fields, or domain types from internal/core that must not import Sprout—these all need a way to tell the OpenAPI generator what shape they have on the wire.

OpenAPISchemaResolver is a pure function that produces a *openapi3.SchemaRef for any Go type Sprout encounters during route registration:

typeOpenAPISchemaResolverfunc(t reflect.Type) *openapi3.SchemaRef

Return nil for types the resolver does not handle; Sprout falls back to its built-in generation. The resolver runs only during OpenAPI document construction (route registration), never in the request/response hot path.

Registering a resolver

Pass a resolver at construction time:

router:=sprout.NewWithConfig(nil, sprout.WithOpenAPISchemaResolver(myResolver))

Or set one after construction (must be called before route registration):

router:=sprout.New()
router.RegisterOpenAPISchemaResolver(myResolver)

Resolution order

When Sprout encounters a type during route registration, it resolves schemas in this order:

  1. Resolver — If registered, called first. If it returns a non-nil schema, that schema is used.
  2. Built-in time.Time — Resolves to {type: string, format: date-time} (see below).
  3. Struct / slice / map / scalar — Existing auto-generation from exported fields, element types, and Go kinds.

Both schemaRefLocked (struct, slice, map paths) and inlineSchemaRefLocked (inline scalar paths) consult the resolver, so named scalar types like type UserStatus string go through the resolver before falling back to plain string generation.

Component promotion and deduplication

For named types (t.Name() != ""), the resolver's output is promoted to a shared component in doc.Components.Schemas. Subsequent encounters of the same type (e.g. an enum type used in multiple response structs) get a $ref instead of inlining the schema. This keeps the generated OpenAPI document compact and ensures enum schemas are defined once.

Purity constraint

The resolver is called while the OpenAPI document mutex is held. It must be a pure function: given the same reflect.Type, it must return the same schema (or nil). It must not call back into Sprout APIs (RegisterRoute, RegisterOpenAPISchemaResolver, OpenAPIJSON, etc.) or it will deadlock.

Example: enum types

funcenumResolver(t reflect.Type) *openapi3.SchemaRef {
// Check if the type has an Enum() []string method (string-backed enums).ift.Kind() ==reflect.String {
v:=reflect.New(t).Elem().Interface()
ife, ok:=v.(interface{ Enum() []string }); ok {
schema:=openapi3.NewStringSchema()
schema.Enum=make([]any, 0)
for_, val:=rangee.Enum() {
schema.Enum=append(schema.Enum, val)
}
return&openapi3.SchemaRef{Value: schema}
}
}
returnnil
}
router:=sprout.NewWithConfig(nil,
sprout.WithOpenAPISchemaResolver(enumResolver),
)

Built-in: time.Time

As of the same change that introduced the resolver, time.Time is handled as a built-in in schemaRefLocked and resolves to {type: string, format: date-time}. This replaces the previous behavior of emitting an empty object schema. Applications can override this by returning a non-nil schema for time.Time from their resolver—the resolver always takes priority.

The same metadata is available from the /swagger endpoint and through OpenAPIJSON() / OpenAPIYAML().

Sample Server

A runnable example lives in cmd/demo/main.go. Start it with:

go run ./cmd/demo

Browse endpoints like:

  • GET http://localhost:8080/ping
  • POST http://localhost:8080/users
  • GET http://localhost:8080/swagger (append ?format=yaml for YAML)

Type Conversion

Query parameters, path parameters, and headers are automatically converted from strings to the appropriate type:

Go TypeSupported
string
int, int8, int16, int32, int64
uint, uint8, uint16, uint32, uint64
float32, float64
bool

Error Handling

Basic Error Responses

Sprout automatically returns appropriate HTTP status codes:

Status CodeWhen
400 Bad RequestInvalid JSON, parameter parsing errors, or validation failures
500 Internal Server ErrorHandler 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

Typed Error Responses

For more control over error responses, define error types with struct tags for status codes:

// Define a typed error with status code in struct tagtypeNotFoundErrorstruct {
_struct{} `http:"status=404"`Resourcestring`json:"resource" validate:"required"`IDstring`json:"id" validate:"required"`Messagestring`json:"message" validate:"required"`
}
func (eNotFoundError) Error() string {
returnfmt.Sprintf("%s not found: %s", e.Resource, e.ID)
}
// Use in handlerssprout.GET(router, "/users/:id", func(ctx context.Context, req*GetUserRequest) (*UserResponse, error) {
user, err:=db.FindUser(req.UserID)
iferr!=nil {
returnnil, 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

Multiple Error Types

You can register multiple expected error types for documentation and validation:

typeConflictErrorstruct {
_struct{} `http:"status=409"`Fieldstring`json:"field" validate:"required"`Messagestring`json:"message" validate:"required"`
}
func (eConflictError) Error() string { returne.Message }
typeUnauthorizedErrorstruct {
_struct{} `http:"status=401"`Messagestring`json:"message" validate:"required"`
}
func (eUnauthorizedError) Error() string { returne.Message }
// Register all possible error typessprout.POST(router, "/users", func(ctx context.Context, req*CreateUserRequest) (*UserResponse, error) {
// Check authorizationif!isAuthorized(ctx) {
returnnil, UnauthorizedError{Message: "invalid credentials"}
}
// Check for conflictsifuserExists(req.Email) {
returnnil, ConflictError{Field: "email", Message: "email already exists"}
}
// Check if resource existsif!resourceExists(req.OrgID) {
returnnil, 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

Strict Error Type Checking

By default, Sprout enforces that handlers only return error types explicitly declared via WithErrors(). This encourages well-documented APIs and prevents unexpected error responses.

Default Behavior (Strict Mode)

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) {
ifuserExists(req.Email) {
// ❌ ConflictError is declared, so this worksreturnnil, ConflictError{Field: "email", Message: "email already exists"}
}
if!authorized {
// ❌ ERROR! UnauthorizedError is NOT declared - returns 500returnnil, UnauthorizedError{Message: "not authorized"}
}
return&UserResponse{ID: "123"}, nil
}, sprout.WithErrors(ConflictError{})) // Only ConflictError declared

Log 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

Disabling Strict Mode

To allow undeclared error types (backward compatibility mode), set StrictErrorTypes to false:

falseVal:=falseconfig:=&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)returnnil, 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"}

Runtime Behavior Summary

ScenarioStrictErrorTypes = true (default)StrictErrorTypes = false
Declared error passes validationSerialized directly from the error struct, ErrorHandler not invokedSame as strict
Declared error fails validationWrapped into *sprout.Error with ErrorKindErrorValidation and routed through ErrorHandlerValidation is skipped, the original error struct is serialized, ErrorHandler not invoked
Undeclared error returnedWrapped into *sprout.Error with ErrorKindUndeclaredError and routed through ErrorHandlerOriginal error is passed to ErrorHandler unchanged (if configured); default handler still emits a 500

Notes

  • Once a custom ErrorHandler is invoked, Sprout does not modify the HTTP response—your handler must write status, headers, and body.
  • Typed error serialization happens before the ErrorHandler is called; only when serialization fails or strict-mode rules apply will Sprout call your handler.

Handling Undeclared Errors with Custom Error 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, errerror) {
varsproutErr*sprout.Erroriferrors.As(err, &sproutErr) {
// Check if this is an undeclared error typeifsproutErr.Kind==sprout.ErrorKindUndeclaredError {
// Log to monitoring systemlogToSentry(sproutErr)
// Return custom responsew.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

Custom Error Handler

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.

Using a Custom Error Handler

Create a router with a custom error handler using NewWithConfig():

config:=&sprout.Config{
ErrorHandler: func(w http.ResponseWriter, r*http.Request, errerror) {
// Extract sprout.Error for detailed error informationvarsproutErr*sprout.Erroriferrors.As(err, &sproutErr) {
// Return custom JSON error responsew.Header().Set("Content-Type", "application/json")
status:=http.StatusInternalServerErrorswitchsproutErr.Kind {
casesprout.ErrorKindParse, sprout.ErrorKindValidation:
status=http.StatusBadRequestcasesprout.ErrorKindNotFound:
status=http.StatusNotFoundcasesprout.ErrorKindMethodNotAllowed:
status=http.StatusMethodNotAllowedcasesprout.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 errorshttp.Error(w, err.Error(), http.StatusInternalServerError)
},
}
router:=sprout.NewWithConfig(config)

Error Kinds

Sprout provides specific error kinds to help you handle different error scenarios:

Error KindDescriptionDefault Status
ErrorKindParseMalformed JSON or failed to parse request parameters (query, path, headers)400 Bad Request
ErrorKindValidationRequest validation or JSON body field decode failure400 Bad Request
ErrorKindNotFoundNo route matched the request (404)404 Not Found
ErrorKindMethodNotAllowedHTTP method not allowed for route (405)405 Method Not Allowed
ErrorKindResponseValidationResponse validation failed (internal error)500 Internal Server Error
ErrorKindErrorValidationError response validation failed (internal error)500 Internal Server Error
ErrorKindUndeclaredErrorHandler returned undeclared error type (when StrictErrorTypes is enabled)500 Internal Server Error
ErrorKindSerializationJSON encoding failed (internal error)500 Internal Server Error

Error Structure

The sprout.Error type provides detailed error context:

typeErrorstruct {
KindErrorKind// Category of errorMessagestring// Human-readable messageErrerror// Underlying error (can be nil)
}

You can access the underlying error using errors.As() or Unwrap():

varsproutErr*sprout.Erroriferrors.As(err, &sproutErr) {
log.Printf("Error kind: %s", sproutErr.Kind)
log.Printf("Message: %s", sproutErr.Message)
ifsproutErr.Err!=nil {
log.Printf("Underlying error: %v", sproutErr.Err)
}
}

Field-Aware JSON Body Decoding

For JSON object field decode failures, Sprout produces per-field errors with field paths instead of a body-level parse error:

  • Malformed JSON (syntax errors, truncated body, top-level shape mismatch) → ErrorKindParse with the raw error
  • Object field decode errors (custom UnmarshalJSON validation failures, field-level type mismatches) → ErrorKindValidation with TypeValidationErrors containing field paths

The happy path (json.Unmarshal succeeds) has zero overhead. The field-aware fallback runs only when the initial decode fails, walking the raw JSON and struct fields to produce granular errors:

varsproutErr*sprout.Erroriferrors.As(err, &sproutErr) {
ifsproutErr.Kind==sprout.ErrorKindValidation {
vartypeErrs sprout.TypeValidationErrorsiferrors.As(sproutErr.Err, &typeErrs) {
for_, e:=rangetypeErrs {
log.Printf("Field: %s, Error: %v", e.Field, e.Err)
}
}
}
}

Response validation uses the same struct tag validators and TypeValidationFunc callbacks as request validation, but does not involve a decode step — handlers construct responses in-process.

Default Error Handling

If no custom error handler is provided, Sprout uses sensible defaults:

  • Parse/Validation errors: Returns 400 Bad Request with plain text error message
  • 404 Not Found: Returns 404 Not Found when no route matches
  • 405 Method Not Allowed: Returns 405 Method Not Allowed when route exists but method doesn't match
  • Response/Error validation failures: Returns 500 Internal Server Error with plain text error message
// Uses default error handlingrouter:=sprout.New()

Note: 404 and 405 errors automatically go through your custom ErrorHandler (if configured), giving you consistent error formatting across all error types.

Custom Success Status Codes

Response types can also define custom status codes using struct tags:

typeCreatedResponsestruct {
_struct{} `http:"status=201"`// 201 CreatedIDint`json:"id" validate:"required,gt=0"`Messagestring`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.

Custom Response Headers

You can set custom HTTP headers in both success and error responses using the header: tag:

typeUserCreatedResponsestruct {
_struct{} `http:"status=201"`Locationstring`header:"Location"`// Set Location headerETagstring`header:"ETag"`// Set ETag headerIDstring`json:"id" validate:"required"`Namestring`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:

typeRateLimitErrorstruct {
_struct{} `http:"status=429"`RetryAfterstring`header:"Retry-After"`// Set Retry-After headerRateLimitstring`header:"X-Rate-Limit"`// Set custom headerMessagestring`json:"message" validate:"required"`
}
func (eRateLimitError) Error() string { returne.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!

Unwrapping Response Payloads

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.typeListUsersResponsestruct {
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).

Empty Responses

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 typetypeEmptyResponsestruct{}
// Or with a custom status codetypeNoContentResponsestruct {
_struct{} `http:"status=204"`
}
// Handler can return nilsprout.DELETE(router, "/users/:id", func(ctx context.Context, req*DeleteUserRequest) (*NoContentResponse, error) {
// ... delete logic ...returnnil, nil// ✅ Returns 204 No Content with empty JSON body {}
})

How it works:

When a handler returns nil for the response, Sprout:

  1. Creates an empty instance of the declared response type
  2. Validates it against any validation tags
  3. If validation passes (no required fields), serializes it as {}
  4. If validation fails (has required fields), returns a validation error

Access to httprouter Features

Since Sprout embeds *httprouter.Router, you have full access to all httprouter configuration and features:

router:=sprout.New()
// Configure httprouter settingsrouter.RedirectTrailingSlash=truerouter.RedirectFixedPath=truerouter.HandleMethodNotAllowed=truerouter.HandleOPTIONS=true// Set custom panic handlerrouter.PanicHandler=customPanicHandler// Serve static filesrouter.ServeFiles("/static/*filepath", http.Dir("./public"))
// Use httprouter's native handlers for specific routesrouter.Handle("GET", "/raw", func(w http.ResponseWriter, r*http.Request, _ httprouter.Params) {
w.Write([]byte("raw handler"))
})

Complete Example

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 typestypeAddressstruct {
Streetstring`json:"street" validate:"required"`Citystring`json:"city" validate:"required"`ZipCodestring`json:"zip_code" validate:"required,len=5"`Countrystring`json:"country" validate:"required,len=2"`
}
typePreferencesstruct {
Languagestring`json:"language" validate:"required,oneof=en es fr de"`Timezonestring`json:"timezone" validate:"required"`Notificationsbool`json:"notifications"`
}
// List users with paginationtypeListUsersRequeststruct {
Pageint`query:"page" validate:"omitempty,gte=1"`Limitint`query:"limit" validate:"omitempty,gte=1,lte=100"`Tokenstring`header:"Authorization" validate:"required"`
}
typeListUsersResponsestruct {
Users []User`json:"users" validate:"required"`Pageint`json:"page" validate:"gte=1"`Totalint`json:"total" validate:"gte=0"`
}
// Get specific usertypeGetUserRequeststruct {
UserIDstring`path:"id" validate:"required,uuid4"`Tokenstring`header:"Authorization" validate:"required"`
}
typeUserResponsestruct {
IDstring`json:"id" validate:"required"`Namestring`json:"name" validate:"required"`Emailstring`json:"email" validate:"required,email"`AddressAddress`json:"address" validate:"required"`PreferencesPreferences`json:"preferences" validate:"required"`
}
// Create user with nested objectstypeCreateUserRequeststruct {
Namestring`json:"name" validate:"required,min=3,max=100"`Emailstring`json:"email" validate:"required,email"`Ageint`json:"age" validate:"required,gte=18,lte=120"`AddressAddress`json:"address" validate:"required"`PreferencesPreferences`json:"preferences" validate:"required"`
}
// Update usertypeUpdateUserRequeststruct {
UserIDstring`path:"id" validate:"required,uuid4"`Tokenstring`header:"Authorization" validate:"required"`Namestring`json:"name" validate:"omitempty,min=3,max=100"`Emailstring`json:"email" validate:"omitempty,email"`Address*Address`json:"address" validate:"omitempty"`// Optional updatePreferences*Preferences`json:"preferences" validate:"omitempty"`// Optional update
}
typeUserstruct {
IDstring`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`AddressAddress`json:"address"`PreferencesPreferences`json:"preferences"`
}
funcmain() {
router:=sprout.New()
// List users with paginationsprout.GET(router, "/users", func(ctx context.Context, req*ListUsersRequest) (*ListUsersResponse, error) {
page:=req.Pageifpage==0 {
page=1
}
limit:=req.Limitiflimit==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 objectssprout.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 objectssprout.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 requestPreferences: 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 dataresponse:=&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 providedifreq.Address!=nil {
response.Address=*req.Address
}
ifreq.Preferences!=nil {
response.Preferences=*req.Preferences
}
returnresponse, nil
})
// Delete usersprout.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))
}

Testing

Sprout handlers are easy to test:

funcTestCreateUser(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)
}

Requirements

  • Go 1.18+ (for generics support)

Dependencies

License

MIT

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

About

A type-safe HTTP router for Go that provides automatic validation and parameter binding using struct tags

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages