The Rivaas Router provides a high-performance routing system. Includes built-in middleware, OpenTelemetry support, and complete request handling.
Overview
The Rivaas Router is a production-ready HTTP router for cloud-native applications. It combines high performance with a rich feature set. It delivers 8.4M+ requests per second at 119ns per operation. It includes automatic request binding, validation, content negotiation, API versioning, and native OpenTelemetry tracing.
Key Features
Core Routing & Request Handling
Radix tree routing - Path matching with bloom filters for static route lookups.
Compiled route tables - Pre-compiled routes for static and dynamic path matching.
Path Parameters: /users/:id, /posts/:id/:action - Array-based storage for route parameters.
Wildcard Routes: /files/*filepath - Catch-all routing for file serving.
Route Groups: Organize routes with shared prefixes and middleware.
Middleware Chain: Global, group-level, and route-level middleware support.
Standard library only. No external dependencies for core routing.
Install the Router
Add the router to your Go project:
go get rivaas.dev/router
Verify Installation
Create a simple test file to verify the installation:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()r.GET("/",func(c*router.Context){c.String(http.StatusOK,"Router is working!")})http.ListenAndServe(":8080",r)}
Run the test:
go run main.go
Visit http://localhost:8080/ in your browser - you should see “Router is working!”
Optional Dependencies
Middleware
For built-in middleware like structured logging and metrics:
# For AccessLog middleware (structured logging)go get rivaas.dev/logging
# For Metrics middlewarego get rivaas.dev/metrics
OpenTelemetry Tracing
For OpenTelemetry tracing support:
# Core OpenTelemetry librariesgo get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/trace
go get go.opentelemetry.io/otel/sdk
# Example: Jaeger exportergo get go.opentelemetry.io/otel/exporters/jaeger
Validation
For tag-based validation (go-playground/validator):
go get github.com/go-playground/validator/v10
The router automatically detects and uses validator if available.
Project Structure
Recommended project structure for a router-based application:
Learn the fundamentals of the Rivaas Router - from your first router to handling requests.
This guide introduces the core concepts of the Rivaas Router through progressive examples.
Your First Router
Let’s start with the simplest possible router:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Panics on invalid config (use at startup)r.GET("/",func(c*router.Context){c.JSON(http.StatusOK,map[string]string{"message":"Hello, Rivaas Router!",})})http.ListenAndServe(":8080",r)}
What’s happening here:
router.MustNew() creates a new router instance. Panics on invalid config.
r.GET("/", handler) registers a handler for GET requests to /.
The handler function receives a *router.Context with request and response information.
funcmain(){r:=router.MustNew()r.GET("/users",listUsers)// List all usersr.POST("/users",createUser)// Create a new userr.GET("/users/:id",getUser)// Get a specific userr.PUT("/users/:id",updateUser)// Update a user (full replacement)r.PATCH("/users/:id",patchUser)// Partial updater.DELETE("/users/:id",deleteUser)// Delete a userr.HEAD("/users/:id",headUser)// Check if user existsr.OPTIONS("/users",optionsUsers)// Get available methodshttp.ListenAndServe(":8080",r)}funclistUsers(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funccreateUser(c*router.Context){c.JSON(201,map[string]string{"message":"User created"})}funcgetUser(c*router.Context){c.JSON(200,map[string]string{"user_id":c.Param("id")})}funcupdateUser(c*router.Context){c.JSON(200,map[string]string{"message":"User updated"})}funcpatchUser(c*router.Context){c.JSON(200,map[string]string{"message":"User patched"})}funcdeleteUser(c*router.Context){c.Status(204)// No Content}funcheadUser(c*router.Context){c.Status(200)// OK, no body}funcoptionsUsers(c*router.Context){c.Header("Allow","GET, POST, OPTIONS")c.Status(200)}
Reading Request Data
Query Parameters
Access query string parameters with c.Query():
// GET /search?q=golang&limit=10r.GET("/search",func(c*router.Context){query:=c.Query("q")limit:=c.Query("limit")c.JSON(200,map[string]string{"query":query,"limit":limit,})})
// POST /login with form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")password:=c.FormValue("password")// Validate credentials...c.JSON(200,map[string]string{"username":username,"status":"logged in",})})
Always handle errors and provide meaningful responses:
r.GET("/users/:id",func(c*router.Context){userID:=c.Param("id")// Validate user IDifuserID==""{c.JSON(400,map[string]string{"error":"User ID is required",})return}// Simulate user lookupuser,err:=findUser(userID)iferr!=nil{iferr==ErrUserNotFound{c.JSON(404,map[string]string{"error":"User not found",})}else{c.JSON(500,map[string]string{"error":"Internal server error",})}return}c.JSON(200,user)})
Response Types
The router supports multiple response formats:
JSON Responses
// Standard JSONr.GET("/json",func(c*router.Context){c.JSON(200,map[string]string{"message":"JSON response"})})// Indented JSON (for debugging)r.GET("/json-pretty",func(c*router.Context){c.IndentedJSON(200,map[string]string{"message":"Pretty JSON"})})
Plain Text
r.GET("/text",func(c*router.Context){c.String(200,"Plain text response")})// With formattingr.GET("/text-formatted",func(c*router.Context){c.Stringf(200,"Hello, %s!","World")})
Here’s a complete example combining all the concepts:
packagemainimport("encoding/json""net/http""rivaas.dev/router")typeUserstruct{IDstring`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}varusers=map[string]User{"1":{ID:"1",Name:"Alice",Email:"alice@example.com"},"2":{ID:"2",Name:"Bob",Email:"bob@example.com"},}funcmain(){r:=router.MustNew()// List all usersr.GET("/users",func(c*router.Context){userList:=make([]User,0,len(users))for_,user:=rangeusers{userList=append(userList,user)}c.JSON(200,userList)})// Get a specific userr.GET("/users/:id",func(c*router.Context){id:=c.Param("id")user,exists:=users[id]if!exists{c.JSON(404,map[string]string{"error":"User not found",})return}c.JSON(200,user)})// Create a new userr.POST("/users",func(c*router.Context){varreqUseriferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON",})return}// Generate ID (simplified)req.ID="3"users[req.ID]=reqc.JSON(201,req)})// Update a userr.PUT("/users/:id",func(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found",})return}varreqUseriferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON",})return}req.ID=idusers[id]=reqc.JSON(200,req)})// Delete a userr.DELETE("/users/:id",func(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found",})return}delete(users,id)c.Status(204)})http.ListenAndServe(":8080",r)}
Next Steps
Now that you understand the basics:
Route Patterns: Learn about route patterns including wildcards and constraints
Wildcard routes capture the rest of the path using *param:
// Serve files from any path under /files/r.GET("/files/*filepath",func(c*router.Context){filepath:=c.Param("filepath")c.JSON(200,map[string]string{"filepath":filepath})})
Wildcards match everything after their position, including slashes
Only one wildcard per route
Wildcard must be the last segment
// ✅ Validr.GET("/static/*filepath",handler)r.GET("/api/v1/files/*path",handler)// ❌ Invalid - wildcard must be lastr.GET("/files/*path/metadata",handler)// Won't work// ❌ Invalid - only one wildcardr.GET("/files/*path1/other/*path2",handler)// Won't work
Route Matching Priority
When multiple routes could match a request, the router follows this priority order:
The router optimizes parameter storage for routes with ≤8 parameters using fast array-based storage. Routes with >8 parameters fall back to map-based storage.
Optimization Threshold
≤8 parameters: Array-based storage (fastest, zero allocations)
>8 parameters: Map-based storage (one allocation per request)
Instead of many path parameters, use query parameters:
// ❌ BAD: Too many path parametersr.GET("/search/:category/:subcategory/:type/:status/:sort/:order/:page/:limit",handler)// ✅ GOOD: Use query parameters for filtersr.GET("/search/:category",handler)// Query: ?subcategory=electronics&type=product&status=active&sort=price&order=asc&page=1&limit=20
3. Use Request Body for Complex Data
For complex operations, use the request body:
// ❌ BAD: Many path parametersr.POST("/api/:version/:resource/:action/:target/:scope/:context/:mode/:format",handler)// ✅ GOOD: Use request bodyr.POST("/api/v1/operations",handler)// Body: {"resource": "...", "action": "...", "target": "...", ...}
4. Restructure Routes
Flatten hierarchies or consolidate parameters:
// ❌ BAD: 10 parameters in pathr.GET("/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j",handler)// ✅ GOOD: Flatten hierarchy or use query parametersr.GET("/items",handler)// Use query: ?a=...&b=...&c=...
Runtime Warnings
The router automatically logs a warning when registering routes with >8 parameters:
WARN: route has more than 8 parameters, using map storage instead of fast array
method=GET
path=/api/:v1/:r1/:r2/:r3/:r4/:r5/:r6/:r7/:r8/:r9
param_count=9
recommendation=consider restructuring route to use query parameters or request body for additional data
When >8 Parameters Are Acceptable
Low-frequency endpoints (<100 req/s)
Legacy API compatibility requirements
Complex hierarchical resource structures that can’t be flattened
Performance Impact
≤8 params: ~119ns/op, 0 allocations
>8 params: ~119ns/op, 1 allocation (~24 bytes)
Real-world impact: Negligible for most applications (<1% overhead)
// Standard REST endpointsr.GET("/users",listUsers)// List allr.POST("/users",createUser)// Create newr.GET("/users/:id",getUser)// Get oner.PUT("/users/:id",updateUser)// Update (full)r.PATCH("/users/:id",patchUser)// Update (partial)r.DELETE("/users/:id",deleteUser)// Delete
Nested Resources
// Comments belong to postsr.GET("/posts/:post_id/comments",listComments)r.POST("/posts/:post_id/comments",createComment)r.GET("/posts/:post_id/comments/:id",getComment)r.PUT("/posts/:post_id/comments/:id",updateComment)r.DELETE("/posts/:post_id/comments/:id",deleteComment)
Action Routes
// Actions on resourcesr.POST("/users/:id/activate",activateUser)r.POST("/users/:id/deactivate",deactivateUser)r.POST("/posts/:id/publish",publishPost)r.POST("/orders/:id/cancel",cancelOrder)
// ❌ BAD: Too deepr.GET("/api/v1/organizations/:org/teams/:team/projects/:proj/tasks/:task/comments/:id",handler)// ✅ GOOD: Flatten or use query parametersr.GET("/api/v1/comments/:id",handler)// Include org/team/proj/task in query or auth context
Next Steps
Route Groups: Learn to organize routes with groups and prefixes
Middleware: Add middleware for authentication, logging, etc.
Organize routes with groups, shared prefixes, and group-specific middleware.
Route groups help organize related routes. They share a common prefix. They can apply middleware to specific sets of routes.
Basic Groups
Create a group with a common prefix:
funcmain(){r:=router.MustNew()r.Use(Logger())// Global middleware// API v1 groupv1:=r.Group("/api/v1")v1.GET("/users",listUsersV1)v1.POST("/users",createUserV1)v1.GET("/users/:id",getUserV1)http.ListenAndServe(":8080",r)}
Routes created:
GET /api/v1/users
POST /api/v1/users
GET /api/v1/users/:id
Group-Specific Middleware
Apply middleware that only affects routes in the group:
funcmain(){r:=router.MustNew()r.Use(Logger())// Global - applies to all routes// Public API - no auth requiredpublic:=r.Group("/api/public")public.GET("/health",healthHandler)public.GET("/version",versionHandler)// Private API - auth requiredprivate:=r.Group("/api/private")private.Use(AuthRequired())// Group middlewareprivate.GET("/profile",profileHandler)private.POST("/settings",updateSettingsHandler)http.ListenAndServe(":8080",r)}funcAuthRequired()router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}c.Next()}}
Middleware execution:
/api/public/health → Logger only.
/api/private/profile → Logger + AuthRequired.
Nested Groups
Groups can be nested for hierarchical organization:
funcmain(){r:=router.MustNew()r.Use(Logger())api:=r.Group("/api"){v1:=api.Group("/v1")v1.Use(RateLimitV1())// V1-specific rate limiting{// User endpointsusers:=v1.Group("/users")users.Use(UserAuth()){users.GET("/",listUsers)// GET /api/v1/users/users.POST("/",createUser)// POST /api/v1/users/users.GET("/:id",getUser)// GET /api/v1/users/:idusers.PUT("/:id",updateUser)// PUT /api/v1/users/:idusers.DELETE("/:id",deleteUser)// DELETE /api/v1/users/:id}// Admin endpointsadmin:=v1.Group("/admin")admin.Use(AdminAuth()){admin.GET("/stats",getStats)// GET /api/v1/admin/statsadmin.DELETE("/users/:id",adminDeleteUser)// DELETE /api/v1/admin/users/:id}}v2:=api.Group("/v2")v2.Use(RateLimitV2())// V2-specific rate limiting{v2.GET("/users",listUsersV2)v2.POST("/users",createUsersV2)}}http.ListenAndServe(":8080",r)}
Routes created:
GET /api/v1/users/
POST /api/v1/users/
GET /api/v1/users/:id
PUT /api/v1/users/:id
DELETE /api/v1/users/:id
GET /api/v1/admin/stats
DELETE /api/v1/admin/users/:id
GET /api/v2/users
POST /api/v2/users
Middleware Execution Order
For nested groups, middleware executes from outer to inner:
funcmain(){r:=router.MustNew()r.Use(func(c*router.Context){fmt.Println("1. Global middleware")c.Next()})api:=r.Group("/api")api.Use(func(c*router.Context){fmt.Println("2. API middleware")c.Next()})v1:=api.Group("/v1")v1.Use(func(c*router.Context){fmt.Println("3. V1 middleware")c.Next()})v1.GET("/test",func(c*router.Context){fmt.Println("4. Handler")c.String(200,"OK")})http.ListenAndServe(":8080",r)}
Request to /api/v1/test prints:
1. Global middleware
2. API middleware
3. V1 middleware
4. Handler
Composing Group Middleware
Create reusable middleware bundles:
// Middleware bundlesfuncPublicAPI()[]router.HandlerFunc{return[]router.HandlerFunc{CORS(),RateLimit(1000),}}funcAuthenticatedAPI()[]router.HandlerFunc{return[]router.HandlerFunc{CORS(),RateLimit(100),AuthRequired(),}}funcAdminAPI()[]router.HandlerFunc{return[]router.HandlerFunc{CORS(),RateLimit(50),AuthRequired(),AdminOnly(),}}funcmain(){r:=router.MustNew()r.Use(Logger(),Recovery())// Public endpointspublic:=r.Group("/api/public")public.Use(PublicAPI()...)public.GET("/status",statusHandler)// User endpointsuser:=r.Group("/api/user")user.Use(AuthenticatedAPI()...)user.GET("/profile",profileHandler)// Admin endpointsadmin:=r.Group("/api/admin")admin.Use(AdminAPI()...)admin.GET("/users",listUsersAdmin)http.ListenAndServe(":8080",r)}
funcmain(){r:=router.MustNew()r.Use(Logger())// Version 1 - Stable APIv1:=r.Group("/api/v1")v1.Use(JSONContentType()){v1.GET("/users",listUsersV1)v1.GET("/users/:id",getUserV1)v1.GET("/posts",listPostsV1)}// Version 2 - New featuresv2:=r.Group("/api/v2")v2.Use(JSONContentType()){v2.GET("/users",listUsersV2)// Enhanced user listv2.GET("/users/:id",getUserV2)// Additional fieldsv2.GET("/posts",listPostsV2)// Pagination supportv2.GET("/posts/:id/likes",getPostLikesV2)// New endpoint}// Beta featuresbeta:=r.Group("/api/beta")beta.Use(JSONContentType(),BetaWarning()){beta.GET("/experimental",experimentalFeature)}http.ListenAndServe(":8080",r)}
// ✅ GOOD: Related routes groupedusers:=r.Group("/api/users")users.GET("/",listUsers)users.POST("/",createUser)users.GET("/:id",getUser)// ❌ BAD: Scattered registrationr.GET("/api/users",listUsers)r.GET("/api/posts",listPosts)r.POST("/api/users",createUser)
2. Apply Middleware at the Right Level
// ✅ GOOD: Auth only where neededpublic:=r.Group("/api/public")public.GET("/status",statusHandler)private:=r.Group("/api/private")private.Use(AuthRequired())private.GET("/profile",profileHandler)// ❌ BAD: Auth on everythingr.Use(AuthRequired())// Public endpoints won't work!r.GET("/api/status",statusHandler)
// ✅ GOOD: 2-3 levelsapi:=r.Group("/api")v1:=api.Group("/v1")v1.GET("/users",handler)// ⚠️ OKAY: 4 levels (limit)api:=r.Group("/api")v1:=api.Group("/v1")users:=v1.Group("/users")users.GET("/:id",handler)// ❌ BAD: Too deep (5+ levels)api:=r.Group("/api")v1:=api.Group("/v1")orgs:=v1.Group("/orgs")teams:=orgs.Group("/:org/teams")projects:=teams.Group("/:team/projects")projects.GET("/",handler)// /api/v1/orgs/:org/teams/:team/projects/
Complete Example
packagemainimport("fmt""net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Global middlewarer.Use(Logger(),Recovery())// Public routes (no auth)public:=r.Group("/api/public")public.Use(CORS()){public.GET("/health",healthHandler)public.GET("/version",versionHandler)}// API v1v1:=r.Group("/api/v1")v1.Use(CORS(),JSONContentType()){// User routes (auth required)users:=v1.Group("/users")users.Use(AuthRequired()){users.GET("/",listUsers)users.POST("/",createUser)users.GET("/:id",getUser)users.PUT("/:id",updateUser)users.DELETE("/:id",deleteUser)}// Admin routes (admin auth required)admin:=v1.Group("/admin")admin.Use(AuthRequired(),AdminOnly()){admin.GET("/stats",adminStats)admin.GET("/users",adminListUsers)}}fmt.Println("Server starting on :8080")http.ListenAndServe(":8080",r)}// MiddlewarefuncLogger()router.HandlerFunc{returnfunc(c*router.Context){fmt.Printf("[%s] %s\n",c.Request.Method,c.Request.URL.Path)c.Next()}}funcRecovery()router.HandlerFunc{returnfunc(c*router.Context){deferfunc(){iferr:=recover();err!=nil{c.JSON(500,map[string]string{"error":"Internal server error"})}}()c.Next()}}funcCORS()router.HandlerFunc{returnfunc(c*router.Context){c.Header("Access-Control-Allow-Origin","*")c.Next()}}funcJSONContentType()router.HandlerFunc{returnfunc(c*router.Context){c.Header("Content-Type","application/json")c.Next()}}funcAuthRequired()router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}c.Next()}}funcAdminOnly()router.HandlerFunc{returnfunc(c*router.Context){// Check if user is admin...c.Next()}}// Handlers (simplified)funchealthHandler(c*router.Context){c.String(200,"OK")}funcversionHandler(c*router.Context){c.String(200,"v1.0.0")}funclistUsers(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funccreateUser(c*router.Context){c.JSON(201,map[string]string{"id":"1"})}funcgetUser(c*router.Context){c.JSON(200,map[string]string{"id":c.Param("id")})}funcupdateUser(c*router.Context){c.JSON(200,map[string]string{"id":c.Param("id")})}funcdeleteUser(c*router.Context){c.Status(204)}funcadminStats(c*router.Context){c.JSON(200,map[string]int{"users":100})}funcadminListUsers(c*router.Context){c.JSON(200,[]string{"all","users"})}
Add cross-cutting concerns like logging, authentication, and error handling with middleware.
Middleware functions execute before route handlers. They perform cross-cutting concerns like authentication, logging, and rate limiting.
Basic Usage
Middleware is a function that wraps your handlers:
funcLogger()router.HandlerFunc{returnfunc(c*router.Context){start:=time.Now()path:=c.Request.URL.Pathc.Next()// Continue to next handlerduration:=time.Since(start)fmt.Printf("[%s] %s - %v\n",c.Request.Method,path,duration)}}funcmain(){r:=router.MustNew()// Apply middleware globallyr.Use(Logger())r.GET("/",handler)http.ListenAndServe(":8080",r)}
Key concepts:
c.Next() - Continues to the next middleware or handler.
Call c.Next() to proceed. Don’t call it to stop the chain.
Middleware runs in registration order.
Middleware Scope
Global Middleware
Applied to all routes:
r:=router.MustNew()// These apply to ALL routesr.Use(Logger())r.Use(Recovery())r.Use(CORS())r.GET("/",handler)r.GET("/users",usersHandler)
Group Middleware
Applied only to routes in a group:
r:=router.MustNew()r.Use(Logger())// Global// Public routes - no authpublic:=r.Group("/api/public")public.GET("/status",statusHandler)// Private routes - auth requiredprivate:=r.Group("/api/private")private.Use(AuthRequired())// Group-levelprivate.GET("/profile",profileHandler)
Route-Specific Middleware
Applied to individual routes:
r:=router.MustNew()r.Use(Logger())// Global// Auth only for this router.GET("/admin",AdminAuth(),adminHandler)// Multiple middleware for one router.POST("/upload",RateLimit(),ValidateFile(),uploadHandler)
Built-in Middleware
The router includes production-ready middleware in sub-packages. See the Middleware Reference for complete options.
import"rivaas.dev/router/middleware/requestid"// UUID v7 by default (36 chars, time-ordered, RFC 9562)r.Use(requestid.New())// Use ULID for shorter IDs (26 chars)r.Use(requestid.New(requestid.WithULID()))// Custom header namer.Use(requestid.New(requestid.WithHeader("X-Correlation-ID")))// Later in handlers:funchandler(c*router.Context){id:=requestid.Get(c)fmt.Println("Request ID:",id)}
import"rivaas.dev/router/middleware/ratelimit"r.Use(ratelimit.New(ratelimit.WithRequestsPerSecond(1000),ratelimit.WithBurst(100),ratelimit.WithKeyFunc(func(c*router.Context)string{returnc.ClientIP()// Rate limit by IP}),))
The order in which middleware is applied matters. Recommended order:
r:=router.MustNew()// 1. Request ID - Generate early for logging/tracingr.Use(requestid.New())// 2. AccessLog - Log all requests including failed onesr.Use(accesslog.New())// 3. Recovery - Catch panics from all other middlewarer.Use(recovery.New())// 4. Security/CORS - Set security headers earlyr.Use(security.New())r.Use(cors.New())// 5. Body Limit - Reject large requests before processingr.Use(bodylimit.New())// 6. Rate Limit - Reject excessive requests before processingr.Use(ratelimit.New())// 7. Timeout - Set time limits for downstream processingr.Use(timeout.New())// 8. Authentication - Verify identity after rate limitingr.Use(auth.New())// 9. Compression - Compress responses (last)r.Use(compression.New())// 10. Your application routesr.GET("/",handler)
Why this order?
RequestID first - Generates a unique ID that other middleware can use
Logger early - Captures all activity including errors
Recovery early - Catches panics to prevent crashes
Security/CORS - Applies security policies before business logic
RateLimit - Blocks excessive requests before expensive operations
Timeout - Sets deadlines for request processing
Auth - Authenticates after rate limiting but before business logic
Compression - Compresses response bodies (should be last)
Writing Custom Middleware
Basic Middleware Pattern
funcMyMiddleware()router.HandlerFunc{returnfunc(c*router.Context){// Before request processingfmt.Println("Before handler")c.Next()// Execute next middleware/handler// After request processingfmt.Println("After handler")}}
Middleware with Configuration
funcRateLimit(requestsPerSecondint)router.HandlerFunc{// Setup (runs once when middleware is created)limiter:=rate.NewLimiter(rate.Limit(requestsPerSecond),requestsPerSecond)returnfunc(c*router.Context){// Per-request logicif!limiter.Allow(){c.JSON(429,map[string]string{"error":"Too many requests",})return// Don't call c.Next() - stop the chain}c.Next()}}// Usager.Use(RateLimit(100))// 100 requests per second
Middleware with Dependencies
funcAuth(db*Database)router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")user,err:=db.ValidateToken(token)iferr!=nil{c.JSON(401,map[string]string{"error":"Unauthorized",})return}// Store user in request context for handlersctx:=context.WithValue(c.Request.Context(),"user",user)c.Request=c.Request.WithContext(ctx)c.Next()}}// Usagedb:=NewDatabase()r.Use(Auth(db))
Conditional Middleware
funcConditionalAuth()router.HandlerFunc{returnfunc(c*router.Context){// Skip auth for public endpointsifc.Request.URL.Path=="/public"{c.Next()return}// Require auth for other endpointstoken:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized",})return}c.Next()}}
Middleware Patterns
Pattern: Error Handling Middleware
funcErrorHandler()router.HandlerFunc{returnfunc(c*router.Context){deferfunc(){iferr:=recover();err!=nil{log.Printf("Panic: %v",err)c.JSON(500,map[string]string{"error":"Internal server error",})}}()c.Next()}}
funcJWTAuth(secretstring)router.HandlerFunc{returnfunc(c*router.Context){authHeader:=c.Request.Header.Get("Authorization")ifauthHeader==""{c.JSON(401,map[string]string{"error":"Missing authorization header",})return}// Extract token (Bearer <token>)parts:=strings.SplitN(authHeader," ",2)iflen(parts)!=2||parts[0]!="Bearer"{c.JSON(401,map[string]string{"error":"Invalid authorization header format",})return}token:=parts[1]claims,err:=validateJWT(token,secret)iferr!=nil{c.JSON(401,map[string]string{"error":"Invalid token",})return}// Store claims in request contextctx:=c.Request.Context()ctx=context.WithValue(ctx,"user_id",claims.UserID)ctx=context.WithValue(ctx,"user_email",claims.Email)c.Request=c.Request.WithContext(ctx)c.Next()}}
Pattern: Request ID Middleware
The built-in requestid middleware handles this pattern with UUID v7 or ULID:
import"rivaas.dev/router/middleware/requestid"// UUID v7 (default) - time-ordered, 36 charsr.Use(requestid.New())// ULID - shorter, 26 charsr.Use(requestid.New(requestid.WithULID()))// Access in handlersfunchandler(c*router.Context){id:=requestid.Get(c)// Get from context// Or from header: c.Response.Header().Get("X-Request-ID")}
If you need a custom implementation:
funcRequestID()router.HandlerFunc{returnfunc(c*router.Context){// Check for existing request IDrequestID:=c.Request.Header.Get("X-Request-ID")ifrequestID==""{// Generate new UUID v7requestID=uuid.Must(uuid.NewV7()).String()}// Store in request context and response headerctx:=context.WithValue(c.Request.Context(),"request_id",requestID)c.Request=c.Request.WithContext(ctx)c.Header("X-Request-ID",requestID)c.Next()}}
Best Practices
1. Always Call c.Next()
Unless you want to stop the middleware chain:
// ✅ GOOD: Calls c.Next() to continuefuncLogger()router.HandlerFunc{returnfunc(c*router.Context){start:=time.Now()c.Next()// Continue to handlerduration:=time.Since(start)log.Printf("Duration: %v",duration)}}// ✅ GOOD: Doesn't call c.Next() to stop chainfuncAuth()router.HandlerFunc{returnfunc(c*router.Context){if!isAuthorized(c){c.JSON(401,map[string]string{"error":"Unauthorized"})return// Don't call c.Next()}c.Next()}}
2. Keep Middleware Focused
Each middleware should do one thing:
// ✅ GOOD: Single responsibilityfuncLogger()router.HandlerFunc{...}funcAuth()router.HandlerFunc{...}funcRateLimit()router.HandlerFunc{...}// ❌ BAD: Does too muchfuncSuperMiddleware()router.HandlerFunc{returnfunc(c*router.Context){// Logging// Auth// Rate limiting// ...c.Next()}}
3. Use Functional Options for Configuration
typeConfigstruct{LimitintBurstint}typeOptionfunc(*Config)funcWithLimit(limitint)Option{returnfunc(c*Config){c.Limit=limit}}funcWithBurst(burstint)Option{returnfunc(c*Config){c.Burst=burst}}funcRateLimit(opts...Option)router.HandlerFunc{config:=&Config{Limit:100,Burst:10,}for_,opt:=rangeopts{opt(config)}limiter:=rate.NewLimiter(rate.Limit(config.Limit),config.Burst)returnfunc(c*router.Context){if!limiter.Allow(){c.JSON(429,map[string]string{"error":"Too many requests"})return}c.Next()}}// Usager.Use(RateLimit(WithLimit(1000),WithBurst(100),))
packagemainimport("fmt""log""log/slog""net/http""os""time""rivaas.dev/router""rivaas.dev/router/middleware/accesslog""rivaas.dev/router/middleware/cors""rivaas.dev/router/middleware/recovery""rivaas.dev/router/middleware/requestid""rivaas.dev/router/middleware/security")funcmain(){logger:=slog.New(slog.NewJSONHandler(os.Stdout,nil))r:=router.MustNew()// Global middleware (applies to all routes)r.Use(requestid.New())r.Use(accesslog.New(accesslog.WithLogger(logger)))r.Use(recovery.New())r.Use(security.New())r.Use(cors.New(cors.WithAllowedOrigins("*"),cors.WithAllowedMethods("GET","POST","PUT","DELETE"),))// Public routesr.GET("/health",healthHandler)r.GET("/public",publicHandler)// API routes with authapi:=r.Group("/api")api.Use(JWTAuth("your-secret-key")){api.GET("/profile",profileHandler)api.POST("/posts",createPostHandler)// Admin routes with additional middlewareadmin:=api.Group("/admin")admin.Use(RequireAdmin()){admin.GET("/users",listUsersHandler)admin.DELETE("/users/:id",deleteUserHandler)}}log.Fatal(http.ListenAndServe(":8080",r))}// Custom middlewarefuncJWTAuth(secretstring)router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}// Validate token...c.Next()}}funcRequireAdmin()router.HandlerFunc{returnfunc(c*router.Context){// Check if user is admin...c.Next()}}// HandlersfunchealthHandler(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})}funcpublicHandler(c*router.Context){c.JSON(200,map[string]string{"message":"Public endpoint"})}funcprofileHandler(c*router.Context){c.JSON(200,map[string]string{"user":"john@example.com"})}funccreatePostHandler(c*router.Context){c.JSON(201,map[string]string{"message":"Post created"})}funclistUsersHandler(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funcdeleteUserHandler(c*router.Context){c.Status(204)}
Next Steps
Context API: Learn about the Context and its lifecycle
// ✅ CORRECT: Normal handler - context used within handlerfunchandler(c*router.Context){userID:=c.Param("id")c.JSON(200,map[string]string{"id":userID})// Context automatically returned to pool by router}// ✅ CORRECT: Async operation with copied datafunchandler(c*router.Context){// Copy needed data before starting goroutineuserID:=c.Param("id")gofunc(idstring){// Process async work with copied data...processAsync(id)}(userID)}
Incorrect Usage
// ❌ WRONG: Retaining context referencevarglobalContext*router.Contextfunchandler(c*router.Context){globalContext=c// BAD! Memory leak and data corruption}// ❌ WRONG: Passing context to goroutinefunchandler(c*router.Context){gofunc(ctx*router.Context){// BAD! Context may be reused by another requestprocessAsync(ctx.Param("id"))}(c)}// ❌ WRONG: Storing context in structtypeServicestruct{ctx*router.Context// BAD! Never do this}
// GET /search?q=golang&limit=10&page=2r.GET("/search",func(c*router.Context){query:=c.Query("q")// "golang"limit:=c.Query("limit")// "10"page:=c.Query("page")// "2"c.JSON(200,map[string]string{"query":query,"limit":limit,"page":page,})})
Form Data
// POST with form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")password:=c.FormValue("password")c.JSON(200,map[string]string{"username":username,})})
Response Methods
JSON Responses
// Standard JSON (HTML-escaped)c.JSON(200,data)// Indented JSON (for debugging)c.IndentedJSON(200,data)// Pure JSON (no HTML escaping - 35% faster!)c.PureJSON(200,data)// Secure JSON (anti-hijacking prefix)c.SecureJSON(200,data)// ASCII JSON (pure ASCII with \uXXXX)c.AsciiJSON(200,data)// JSONP (with callback)c.JSONP(200,data,"callback")
Other Response Formats
// YAMLc.YAML(200,config)// Plain textc.String(200,"Hello, World!")c.Stringf(200,"Hello, %s!",name)// HTMLc.HTML(200,"<h1>Welcome</h1>")// Binary datac.Data(200,"image/png",imageBytes)// Stream from reader (zero-copy!)c.DataFromReader(200,size,"video/mp4",file,nil)// Status onlyc.Status(204)// No Content
File Serving
// Serve filec.ServeFile("/path/to/file.pdf")// Force downloadc.Download("/path/to/file.pdf","custom-name.pdf")
funchandler(c*router.Context){ifc.IsJSON(){// Request has JSON content-type}ifc.AcceptsJSON(){c.JSON(200,data)}elseifc.AcceptsHTML(){c.HTML(200,htmlContent)}}
Client Information
funchandler(c*router.Context){clientIP:=c.ClientIP()// Real IP (considers X-Forwarded-For)isSecure:=c.IsHTTPS()// HTTPS check}
// Set cookiec.SetCookie("session_id",// name"abc123",// value3600,// max age (seconds)"/",// path"",// domainfalse,// securetrue,// httpOnly)// Get cookiesessionID,err:=c.GetCookie("session_id")
Passing Values Between Middleware
Use context.WithValue() to pass values between middleware and handlers:
// Define context keys to avoid collisionstypecontextKeystringconstuserKeycontextKey="user"// In middleware - create new request with valuefuncAuthMiddleware()router.HandlerFunc{returnfunc(c*router.Context){user:=authenticateUser(c)// Create new context with valuectx:=context.WithValue(c.Request.Context(),userKey,user)c.Request=c.Request.WithContext(ctx)c.Next()}}// In handler - retrieve value from request contextfunchandler(c*router.Context){user,ok:=c.Request.Context().Value(userKey).(*User)if!ok||user==nil{c.JSON(401,map[string]string{"error":"Unauthorized"})return}c.JSON(200,user)}
Note
Use typed keys (like contextKey) instead of string keys to avoid collisions between packages.
File Uploads
r.POST("/upload",func(c*router.Context){// Single filefile,err:=c.File("avatar")iferr!=nil{c.JSON(400,map[string]string{"error":"avatar required"})return}// File infofmt.Printf("Name: %s, Size: %d, Type: %s\n",file.Name,file.Size,file.ContentType)// Save fileiferr:=file.Save("./uploads/"+file.Name);err!=nil{c.JSON(500,map[string]string{"error":"failed to save"})return}c.JSON(200,map[string]string{"filename":file.Name})})// Multiple filesr.POST("/upload-many",func(c*router.Context){files,err:=c.Files("documents")iferr!=nil{c.JSON(400,map[string]string{"error":"documents required"})return}for_,f:=rangefiles{f.Save("./uploads/"+f.Name)}c.JSON(200,map[string]int{"count":len(files)})})
Performance Tips
Extract Data Immediately
// ✅ GOOD: Extract data earlyfunchandler(c*router.Context){userID:=c.Param("id")query:=c.Query("q")// Use extracted dataresult:=processData(userID,query)c.JSON(200,result)}// ❌ BAD: Don't store context referencevarglobalContext*router.Contextfunchandler(c*router.Context){globalContext=c// Memory leak!}
Choose the Right Response Method
// Use PureJSON for HTML content (35% faster than JSON)c.PureJSON(200,dataWithHTMLStrings)// Use Data() for binary (98% faster than JSON)c.Data(200,"image/png",imageBytes)// Avoid YAML in hot paths (9x slower than JSON)// c.YAML(200, data) // Only for config/admin endpoints
Complete Example
packagemainimport("encoding/json""net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Request parametersr.GET("/users/:id",func(c*router.Context){id:=c.Param("id")c.JSON(200,map[string]string{"id":id})})// Query parametersr.GET("/search",func(c*router.Context){q:=c.Query("q")c.JSON(200,map[string]string{"query":q})})// Form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")c.JSON(200,map[string]string{"username":username})})// JSON request bodyr.POST("/users",func(c*router.Context){varreqstruct{Namestring`json:"name"`Emailstring`json:"email"`}iferr:=json.NewDecoder(c.Request.Body).Decode(&req);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON"})return}c.JSON(201,req)})// Headers and cookiesr.GET("/info",func(c*router.Context){userAgent:=c.Request.Header.Get("User-Agent")session,_:=c.GetCookie("session_id")c.Header("X-Custom","value")c.JSON(200,map[string]string{"user_agent":userAgent,"session":session,})})http.ListenAndServe(":8080",r)}
Request binding parses request data (query parameters, URL parameters, form data, JSON) into Go structs.
Two Approaches
The router provides basic strict JSON binding directly on Context. For full binding capabilities (query, form, headers, cookies), use the separate binding package.
Router Context Methods
The router Context provides basic binding and data access methods.
Returns appropriate HTTP status codes. 400 for malformed. 422 for type errors.
Manual Parameter Access
For simple cases, access parameters directly:
// Query parametersr.GET("/search",func(c*router.Context){query:=c.Query("q")limit:=c.QueryDefault("limit","10")c.JSON(200,map[string]string{"query":query,"limit":limit,})})// URL parametersr.GET("/users/:id",func(c*router.Context){userID:=c.Param("id")c.JSON(200,map[string]string{"user_id":userID})})// Form datar.POST("/login",func(c*router.Context){username:=c.FormValue("username")password:=c.FormValue("password")// ...})
Content Type Validation
Validate content type before binding:
r.POST("/users",func(c*router.Context){if!c.RequireContentTypeJSON(){return// 415 Unsupported Media Type already sent}varreqCreateUserRequestiferr:=c.BindStrict(&req,router.BindOptions{});err!=nil{return}c.JSON(201,req)})
Streaming Large Payloads
For large arrays, stream instead of loading into memory:
Validate requests with multiple strategies: interface methods, struct tags, or JSON Schema.
Request validation ensures incoming data meets your requirements before processing.
Validation Approaches
The router provides strict JSON binding with BindStrict(). For comprehensive validation with struct tags and multi-source binding, use the binding package with the validation package.
Validation Strategies
Strategy Selection
Need complex business logic or request-scoped rules?
├─ Yes → Use Validate/ValidateContext interface methods
└─ No → Continue ↓
Validating against external/shared schema?
├─ Yes → Use JSON Schema validation
└─ No → Continue ↓
Simple field constraints (required, min, max, format)?
├─ Yes → Use struct tags (binding package + go-playground/validator)
└─ No → Use manual validation
Interface Validation
Implement the Validate or ValidateContext interface on your request structs:
Basic Validation
typeTransferRequeststruct{FromAccountstring`json:"from_account"`ToAccountstring`json:"to_account"`Amountfloat64`json:"amount"`}func(t*TransferRequest)Validate()error{ift.FromAccount==t.ToAccount{returnerrors.New("cannot transfer to same account")}ift.Amount>10000{returnerrors.New("amount exceeds daily limit")}returnnil}
Context-Aware Validation
typeCreatePostRequeststruct{Titlestring`json:"title"`Tags[]string`json:"tags"`}func(p*CreatePostRequest)ValidateContext(ctxcontext.Context)error{// Get user tier from contexttier:=ctx.Value("user_tier")iftier=="free"&&len(p.Tags)>3{returnerrors.New("free users can only use 3 tags")}returnnil}
Use the binding package with struct tags for declarative validation:
import("rivaas.dev/binding""rivaas.dev/validation")typeCreateUserRequeststruct{Emailstring`json:"email" validate:"required,email"`Usernamestring`json:"username" validate:"required,min=3,max=20"`Ageint`json:"age" validate:"required,min=18,max=120"`}funccreateUser(c*router.Context){varreqCreateUserRequest// Bind JSON using binding packageiferr:=binding.JSON(c.Request,&req);err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}// Validate with struct tagsiferr:=validation.Validate(&req);err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}c.JSON(201,req)}
Common Validation Tags
typeExamplestruct{Requiredstring`validate:"required"`// Must be presentEmailstring`validate:"email"`// Valid email formatURLstring`validate:"url"`// Valid URLMinint`validate:"min=10"`// Minimum valueMaxint`validate:"max=100"`// Maximum valueRangeint`validate:"min=10,max=100"`// RangeLengthstring`validate:"min=3,max=50"`// String lengthOneOfstring`validate:"oneof=active pending"`// EnumOptionalstring`validate:"omitempty,email"`// Optional but validates if present}
JSON Schema Validation
Implement the JSONSchemaProvider interface for contract-based validation:
For a complete solution, combine strict binding with interface validation:
typeCreateOrderRequeststruct{CustomerIDstring`json:"customer_id"`Items[]OrderItem`json:"items"`Notesstring`json:"notes"`}func(r*CreateOrderRequest)Validate()error{iflen(r.Items)==0{returnerrors.New("order must have at least one item")}fori,item:=ranger.Items{ifitem.Quantity<=0{returnfmt.Errorf("item %d: quantity must be positive",i)}}returnnil}funccreateOrder(c*router.Context){varreqCreateOrderRequest// Strict JSON bindingiferr:=c.BindStrict(&req,router.BindOptions{MaxBytes:1<<20});err!=nil{return// Error already written}// Business logic validationiferr:=req.Validate();err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}c.JSON(201,req)}
Partial Validation (PATCH)
For PATCH requests, use pointer fields and check for presence:
typeUpdateUserRequeststruct{Email*string`json:"email,omitempty"`Username*string`json:"username,omitempty"`Bio*string`json:"bio,omitempty"`}func(r*UpdateUserRequest)Validate()error{ifr.Email!=nil&&*r.Email==""{returnerrors.New("email cannot be empty if provided")}ifr.Username!=nil&&len(*r.Username)<3{returnerrors.New("username must be at least 3 characters")}ifr.Bio!=nil&&len(*r.Bio)>500{returnerrors.New("bio cannot exceed 500 characters")}returnnil}funcupdateUser(c*router.Context){varreqUpdateUserRequestiferr:=c.BindStrict(&req,router.BindOptions{});err!=nil{return}iferr:=req.Validate();err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}// Update only non-nil fieldsifreq.Email!=nil{// Update email}c.JSON(200,map[string]string{"status":"updated"})}
Structured Validation Errors
Return detailed errors for better API usability:
typeValidationErrorstruct{Fieldstring`json:"field"`Messagestring`json:"message"`}typeValidationErrorsstruct{Errors[]ValidationError`json:"errors"`}func(r*CreateUserRequest)Validate()*ValidationErrors{varerrs[]ValidationErrorifr.Email==""{errs=append(errs,ValidationError{Field:"email",Message:"email is required",})}iflen(r.Username)<3{errs=append(errs,ValidationError{Field:"username",Message:"username must be at least 3 characters",})}iflen(errs)>0{return&ValidationErrors{Errors:errs}}returnnil}funccreateUser(c*router.Context){varreqCreateUserRequestiferr:=c.BindStrict(&req,router.BindOptions{});err!=nil{return}ifverrs:=req.Validate();verrs!=nil{c.JSON(400,verrs)return}c.JSON(201,req)}
Best Practices
Do:
Use interface methods (Validate()) for business logic validation
Use pointer fields (*string) for optional PATCH fields
Return structured errors with field paths
Validate early, fail fast
Use BindStrict() for size limits and strict JSON parsing
Don’t:
Return sensitive data in validation error messages
Perform expensive validation (DB lookups) in Validate() - use ValidateContext() for those
Skip validation for internal endpoints
Complete Example
packagemainimport("errors""net/http""rivaas.dev/router")typeCreateUserRequeststruct{Emailstring`json:"email"`Usernamestring`json:"username"`Ageint`json:"age"`}func(r*CreateUserRequest)Validate()error{ifr.Email==""{returnerrors.New("email is required")}iflen(r.Username)<3{returnerrors.New("username must be at least 3 characters")}ifr.Age<18||r.Age>120{returnerrors.New("age must be between 18 and 120")}returnnil}funcmain(){r:=router.MustNew()r.POST("/users",func(c*router.Context){varreqCreateUserRequest// Bind JSON with strict validationiferr:=c.BindStrict(&req,router.BindOptions{MaxBytes:1<<20});err!=nil{return// Error response already sent}// Run business validationiferr:=req.Validate();err!=nil{c.JSON(400,map[string]string{"error":err.Error()})return}c.JSON(201,req)})http.ListenAndServe(":8080",r)}
Next Steps
Binding Package: Full binding documentation at binding guide
c.ServeFile("/path/to/file.pdf")c.Download("/path/to/file.pdf","custom-name.pdf")// Force download
Performance Tips
Choose the Right Method
// Use PureJSON for HTML content (35% faster than JSON)c.PureJSON(200,dataWithHTMLStrings)// Use Data() for binary (98% faster than JSON)c.Data(200,"image/png",imageBytes)// Avoid YAML in hot paths (9x slower than JSON)// c.YAML(200, data) // Only for config/admin endpoints// Reserve IndentedJSON for debugging// c.IndentedJSON(200, data) // Development only
Performance Benchmarks
Method
ns/op
Overhead vs JSON
Use Case
JSON
4,189
-
Production APIs
PureJSON
2,725
-35% ✨
HTML/markdown content
SecureJSON
4,835
+15%
Compliance/old browsers
IndentedJSON
8,111
+94%
Debug/development
AsciiJSON
1,593
-62% ✨
Legacy compatibility
YAML
36,700
+776%
Config/admin APIs
Data
90
-98% ✨
Binary/custom formats
Complete Example
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Standard JSONr.GET("/json",func(c*router.Context){c.JSON(200,map[string]string{"message":"Hello"})})// Pure JSON (faster for HTML content)r.GET("/pure-json",func(c*router.Context){c.PureJSON(200,map[string]string{"content":"<h1>Title</h1><p>Paragraph</p>",})})// YAMLr.GET("/yaml",func(c*router.Context){c.YAML(200,map[string]interface{}{"server":map[string]interface{}{"port":8080,"host":"localhost",},})})// Binary datar.GET("/image",func(c*router.Context){imageData:=loadImage()c.Data(200,"image/png",imageData)})// File downloadr.GET("/download",func(c*router.Context){c.Download("/path/to/report.pdf","report-2024.pdf")})http.ListenAndServe(":8080",r)}
r.GET("/data",func(c*router.Context){charset:=c.AcceptsCharsets("utf-8","iso-8859-1")// Set response charset based on preferencec.Header("Content-Type","text/html; charset="+charset)})
Encoding Negotiation
r.GET("/data",func(c*router.Context){encoding:=c.AcceptsEncodings("gzip","br","deflate")ifencoding=="gzip"{// Compress response with gzip}elseifencoding=="br"{// Compress response with brotli}})
Language Negotiation
r.GET("/content",func(c*router.Context){lang:=c.AcceptsLanguages("en-US","en","es","fr")// Serve content in preferred languagecontent:=getContentInLanguage(lang)c.String(200,content)})
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.New(router.WithVersioning(// Choose your version detection methodrouter.WithHeaderVersioning("API-Version"),// Set default version (when client doesn't specify)router.WithDefaultVersion("v2"),// Optional: Only allow these versionsrouter.WithValidVersions("v1","v2","v3"),),)// Create version 1 routesv1:=r.Version("v1")v1.GET("/users",listUsersV1)// Create version 2 routesv2:=r.Version("v2")v2.GET("/users",listUsersV2)http.ListenAndServe(":8080",r)}
Using Multiple Methods
You can enable multiple detection methods. The router checks them in order:
r:=router.New(router.WithVersioning(router.WithHeaderVersioning("API-Version"),// Primaryrouter.WithQueryVersioning("version"),// For testingrouter.WithPathVersioning("/v{version}/"),// Legacy supportrouter.WithAcceptVersioning("application/vnd.myapi.v{version}+json"),router.WithDefaultVersion("v2"),),)
router.WithCustomVersionDetector(func(req*http.Request)string{// Your custom logicifisLegacyClient(req){return"v1"}returnextractVersionSomehow(req)})
Migration Patterns
Share Business Logic
Keep business logic the same, change only the response format:
// Business logic (shared between versions)funcgetUserByID(idstring)(*User,error){// Database query, business rules, etc.return&User{ID:id,Name:"Alice"},nil}// Version 1 handlerfunclistUsersV1(c*router.Context){users,_:=getUsersFromDB()// V1 format: flat structurec.JSON(200,map[string]any{"users":users,})}// Version 2 handlerfunclistUsersV2(c*router.Context){users,_:=getUsersFromDB()// V2 format: with metadatac.JSON(200,map[string]any{"data":users,"meta":map[string]any{"total":len(users),"version":"v2",},})}
typeUserV2struct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`// Required now}funccreateUserV2(c*router.Context){varuserUserV2iferr:=c.Bind(&user);err!=nil{c.JSON(400,map[string]string{"error":"validation failed","detail":"email is required in API v2",})return}// Create user...}
Version-Specific Middleware
Apply different middleware to different versions:
v1:=r.Version("v1")v1.Use(legacyAuthMiddleware)v1.GET("/users",listUsersV1)v2:=r.Version("v2")v2.Use(jwtAuthMiddleware)// Different auth methodv2.GET("/users",listUsersV2)
Change Data Structure
Example: Flat to nested structure
// V1: Flat structuretypeUserV1struct{IDint`json:"id"`Namestring`json:"name"`Citystring`json:"city"`Countrystring`json:"country"`}// V2: Nested structuretypeUserV2struct{IDint`json:"id"`Namestring`json:"name"`Addressstruct{Citystring`json:"city"`Countrystring`json:"country"`}`json:"address"`}// Helper to convertfuncconvertV1ToV2(v1UserV1)UserV2{v2:=UserV2{ID:v1.ID,Name:v1.Name,}v2.Address.City=v1.Cityv2.Address.Country=v1.Countryreturnv2}
Deprecation Strategy
Mark Versions as Deprecated
Tell the router when a version should stop working:
r:=router.New(router.WithVersioning(// Mark v1 as deprecated with end daterouter.WithDeprecatedVersion("v1",time.Date(2025,12,31,23,59,59,0,time.UTC),),// Track version usagerouter.WithVersionObserver(router.WithOnDetected(func(version,methodstring){// Record metricsmetrics.RecordVersionUsage(version,method)}),router.WithOnMissing(func(){// Client didn't specify versionlog.Warn("client using default version")}),router.WithOnInvalid(func(attemptedstring){// Client used invalid versionmetrics.RecordInvalidVersion(attempted)}),),),)
Deprecation Headers
The router automatically adds headers for deprecated versions:
These tell clients when the version will stop working.
Deprecation Timeline
6 months before end:
Announce in release notes
Add deprecation header
Write migration guide
Contact major users
3 months before end:
Add sunset header with date
Email active users
Monitor usage (should go down)
Offer help with migration
1 month before end:
Send final warnings
Return 410 Gone for deprecated endpoints
Link to migration guide
After end date:
Remove old version code
Always return 410 Gone
Keep migration documentation
Best Practices
1. Use Semantic Versioning
Major (v1, v2, v3): Breaking changes
Minor (v2.1, v2.2): New features, backward compatible
Patch (v2.1.1): Bug fixes only
2. Know When to Version
Don’t version for:
Bug fixes
Performance improvements
Internal refactoring
Adding optional fields
Making validation less strict
Do version for:
Removing fields
Changing field types
Making optional field required
Major behavior changes
Changing error codes
3. Keep Backward Compatibility
// Good: Add optional fieldtypeUserV2struct{IDint`json:"id"`Namestring`json:"name"`Emailstring`json:"email,omitempty"`// New, optional}// Bad: Remove field (breaks clients)typeUserV2struct{IDint`json:"id"`// Name removed - BREAKING CHANGE!}
4. Document Version Differences
Keep clear documentation for each version:
## API Versions
### v2 (Current)
- Added email field (optional)
- Added address nested object
- Added PATCH support for partial updates
### v1 (Deprecated - Ends 2025-12-31)
- Original API
- Only GET/POST/PUT/DELETE
- Flat structure only
5. Organize Routes by Version
Group version routes together:
v1:=r.Version("v1"){v1.GET("/users",listUsersV1)v1.GET("/users/:id",getUserV1)v1.POST("/users",createUserV1)}v2:=r.Version("v2"){v2.GET("/users",listUsersV2)v2.GET("/users/:id",getUserV2)v2.POST("/users",createUserV2)v2.PATCH("/users/:id",updateUserV2)// New in v2}
6. Validate Versions
Reject invalid versions early:
router.WithVersioning(router.WithValidVersions("v1","v2","v3","beta"),router.WithVersionObserver(router.WithOnInvalid(func(attemptedstring){log.Warn("invalid API version","version",attempted)}),),)
r:=router.New(router.WithVersioning(router.WithHeaderVersioning("Stripe-Version"),router.WithDefaultVersion("2024-11-20"),router.WithValidVersions("2024-11-20","2024-10-28","2024-09-30",),),)// Version by datev20241120:=r.Version("2024-11-20")v20241120.GET("/charges",listCharges)
Choose header-based versioning for most cases. Use query parameters for testing. Document your changes clearly. Give clients time to migrate before removing old versions.
12 - Observability
Native OpenTelemetry tracing support with zero overhead when disabled, plus diagnostic events.
The router includes native OpenTelemetry tracing support and optional diagnostic events.
The router provides methods for serving static files and directories.
Directory Serving
Serve an entire directory.
r:=router.MustNew()// Serve ./public/* at /assets/*r.Static("/assets","./public")// Serve /var/uploads/* at /uploads/*r.Static("/uploads","/var/uploads")
packagemainimport("embed""net/http""rivaas.dev/router")//go:embed web/dist/*varwebAssetsembed.FSfuncmain(){r:=router.MustNew()// Serve your frontend at the rootr.StaticEmbed("/",webAssets,"web/dist")// API routesr.GET("/api/status",func(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})})http.ListenAndServe(":8080",r)}
Now http://localhost:8080/ serves index.html, and http://localhost:8080/css/style.css serves your CSS.
Tip
If you don’t need the convenience method, you can also use StaticFS with http.FS:
Testing router-based applications is straightforward using Go’s httptest package.
Testing Routes
Basic Route Test
packagemainimport("net/http""net/http/httptest""testing""rivaas.dev/router")funcTestGetUser(t*testing.T){r:=router.MustNew()r.GET("/users/:id",func(c*router.Context){c.JSON(200,map[string]string{"user_id":c.Param("id"),})})req:=httptest.NewRequest("GET","/users/123",nil)w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=http.StatusOK{t.Errorf("Expected status 200, got %d",w.Code)}}
Testing JSON Responses
funcTestCreateUser(t*testing.T){r:=router.MustNew()r.POST("/users",func(c*router.Context){c.JSON(201,map[string]string{"id":"123"})})body:=strings.NewReader(`{"name":"John"}`)req:=httptest.NewRequest("POST","/users",body)req.Header.Set("Content-Type","application/json")w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=201{t.Errorf("Expected status 201, got %d",w.Code)}varresponsemap[string]stringiferr:=json.Unmarshal(w.Body.Bytes(),&response);err!=nil{t.Fatal(err)}ifresponse["id"]!="123"{t.Errorf("Expected id '123', got %v",response["id"])}}
Testing Middleware
funcTestAuthMiddleware(t*testing.T){r:=router.MustNew()r.Use(AuthRequired())r.GET("/protected",func(c*router.Context){c.JSON(200,map[string]string{"message":"success"})})// Test without auth headerreq:=httptest.NewRequest("GET","/protected",nil)w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=401{t.Errorf("Expected status 401, got %d",w.Code)}// Test with auth headerreq=httptest.NewRequest("GET","/protected",nil)req.Header.Set("Authorization","Bearer valid-token")w=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=200{t.Errorf("Expected status 200, got %d",w.Code)}}
Table-Driven Tests
funcTestRoutes(t*testing.T){r:=setupRouter()tests:=[]struct{namestringmethodstringpathstringexpectedStatusint}{{"Home","GET","/",200},{"Users","GET","/users",200},{"Not Found","GET","/invalid",404},{"Method Not Allowed","POST","/",405},}for_,tt:=rangetests{t.Run(tt.name,func(t*testing.T){req:=httptest.NewRequest(tt.method,tt.path,nil)w:=httptest.NewRecorder()r.ServeHTTP(w,req)ifw.Code!=tt.expectedStatus{t.Errorf("Expected status %d, got %d",tt.expectedStatus,w.Code)}})}}
This guide provides complete, working examples for common use cases.
REST API Server
Complete REST API with CRUD operations:
packagemainimport("encoding/json""net/http""rivaas.dev/router")typeUserstruct{IDstring`json:"id"`Namestring`json:"name"`Emailstring`json:"email"`}varusers=map[string]User{"1":{ID:"1",Name:"Alice",Email:"alice@example.com"},"2":{ID:"2",Name:"Bob",Email:"bob@example.com"},}funcmain(){r:=router.MustNew()r.Use(Logger(),Recovery(),CORS())api:=r.Group("/api/v1")api.Use(JSONContentType()){api.GET("/users",listUsers)api.POST("/users",createUser)api.GET("/users/:id",getUser)api.PUT("/users/:id",updateUser)api.DELETE("/users/:id",deleteUser)}http.ListenAndServe(":8080",r)}funclistUsers(c*router.Context){userList:=make([]User,0,len(users))for_,user:=rangeusers{userList=append(userList,user)}c.JSON(200,userList)}funcgetUser(c*router.Context){id:=c.Param("id")user,exists:=users[id]if!exists{c.JSON(404,map[string]string{"error":"User not found"})return}c.JSON(200,user)}funccreateUser(c*router.Context){varuserUseriferr:=json.NewDecoder(c.Request.Body).Decode(&user);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON"})return}users[user.ID]=userc.JSON(201,user)}funcupdateUser(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found"})return}varuserUseriferr:=json.NewDecoder(c.Request.Body).Decode(&user);err!=nil{c.JSON(400,map[string]string{"error":"Invalid JSON"})return}user.ID=idusers[id]=userc.JSON(200,user)}funcdeleteUser(c*router.Context){id:=c.Param("id")if_,exists:=users[id];!exists{c.JSON(404,map[string]string{"error":"User not found"})return}delete(users,id)c.Status(204)}
Microservice Gateway
API gateway with service routing:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()r.Use(Logger(),RateLimit(),Tracing())// Service discovery and routingr.GET("/users/*path",proxyToUserService)r.GET("/orders/*path",proxyToOrderService)r.GET("/payments/*path",proxyToPaymentService)// Health checksr.GET("/health",healthCheck)r.GET("/metrics",metricsHandler)http.ListenAndServe(":8080",r)}funcproxyToUserService(c*router.Context){path:=c.Param("path")// Proxy to user service...c.JSON(200,map[string]string{"service":"users","path":path})}funcproxyToOrderService(c*router.Context){path:=c.Param("path")// Proxy to order service...c.JSON(200,map[string]string{"service":"orders","path":path})}funcproxyToPaymentService(c*router.Context){path:=c.Param("path")// Proxy to payment service...c.JSON(200,map[string]string{"service":"payments","path":path})}funchealthCheck(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})}funcmetricsHandler(c*router.Context){c.String(200,"# HELP requests_total Total requests\n# TYPE requests_total counter\n")}
Static File Server with API
Serve static files alongside API routes:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()// Serve static filesr.Static("/assets","./public")r.StaticFile("/favicon.ico","./static/favicon.ico")// API routesapi:=r.Group("/api"){api.GET("/status",statusHandler)api.GET("/users",listUsersHandler)}http.ListenAndServe(":8080",r)}funcstatusHandler(c*router.Context){c.JSON(200,map[string]string{"status":"OK"})}funclistUsersHandler(c*router.Context){c.JSON(200,[]string{"user1","user2"})}
Authentication & Authorization
Complete auth example with JWT:
packagemainimport("net/http""rivaas.dev/router")funcmain(){r:=router.MustNew()r.Use(Logger(),Recovery())// Public routesr.POST("/login",loginHandler)r.POST("/register",registerHandler)// Protected routesapi:=r.Group("/api")api.Use(JWTAuth()){api.GET("/profile",profileHandler)api.PUT("/profile",updateProfileHandler)// Admin routesadmin:=api.Group("/admin")admin.Use(RequireAdmin()){admin.GET("/users",listUsersHandler)admin.DELETE("/users/:id",deleteUserHandler)}}http.ListenAndServe(":8080",r)}funcloginHandler(c*router.Context){// Authenticate user and generate JWT...c.JSON(200,map[string]string{"token":"jwt-token-here"})}funcregisterHandler(c*router.Context){// Create new user...c.JSON(201,map[string]string{"message":"User created"})}funcprofileHandler(c*router.Context){// Get user from context (set by JWT middleware)c.JSON(200,map[string]string{"user":"john@example.com"})}funcupdateProfileHandler(c*router.Context){c.JSON(200,map[string]string{"message":"Profile updated"})}funclistUsersHandler(c*router.Context){c.JSON(200,[]string{"user1","user2"})}funcdeleteUserHandler(c*router.Context){c.Status(204)}funcJWTAuth()router.HandlerFunc{returnfunc(c*router.Context){token:=c.Request.Header.Get("Authorization")iftoken==""{c.JSON(401,map[string]string{"error":"Unauthorized"})return}// Validate JWT...c.Next()}}funcRequireAdmin()router.HandlerFunc{returnfunc(c*router.Context){// Check if user is admin...c.Next()}}