This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Guides

Learning-oriented guides for building applications with Rivaas

Comprehensive guides to help you learn and master Rivaas features. These learning-focused tutorials walk you through practical examples and real-world scenarios.


Core Framework

Build web applications with integrated observability and production-ready defaults.

Application Framework

A complete web framework built on the Rivaas router. Includes integrated observability, lifecycle management, graceful shutdown, and sensible defaults for rapid application development.

Explore App Guide →

HTTP Router

High-performance HTTP routing for cloud-native applications. Features radix tree routing, middleware chains, content negotiation, API versioning, and native OpenTelemetry support.

Explore Router Guide →


Request Processing

Handle incoming requests with type-safe binding and validation.

Request Data Binding

Bind HTTP request data from various sources (query parameters, form data, JSON bodies, headers, cookies, path parameters) to Go structs with type safety and zero-allocation performance.

Explore Binding Guide →

Request Validation

Flexible, multi-strategy validation for Go structs. Supports struct tags via go-playground/validator, JSON Schema, and custom interfaces with detailed error messages.

Explore Validation Guide →


Configuration & Documentation

Manage application settings and generate API documentation.

Configuration Management

Configuration management following the Twelve-Factor App methodology. Load from files, environment variables, or Consul with hierarchical merging and struct binding.

Explore Config Guide →

OpenAPI Specification

Automatic OpenAPI 3.0.4 and 3.1.2 specification generation from Go code. Uses struct tags and reflection with built-in Swagger UI support and security scheme configuration.

Explore OpenAPI Guide →


Observability

Monitor, trace, and debug your applications in production.

Structured Logging

Production-ready structured logging using Go’s standard log/slog. Features multiple output formats, context-aware logging, sensitive data redaction, log sampling, and dynamic log levels.

Explore Logging Guide →

Metrics Collection

OpenTelemetry-based metrics collection with support for Prometheus, OTLP, and stdout exporters. Includes built-in HTTP metrics, custom metrics support, and thread-safe operations.

Explore Metrics Guide →

Distributed Tracing

OpenTelemetry-based distributed tracing with automatic context propagation across services. Supports multiple exporters including OTLP (gRPC and HTTP) with HTTP middleware integration.

Explore Tracing Guide →

1 - Application Framework

A complete web framework built on the Rivaas router. Includes integrated observability, lifecycle management, and sensible defaults for production-ready applications.

The Rivaas App package provides a high-level framework with pre-configured observability, graceful shutdown, and common middleware for rapid application development.

Overview

The App package is a complete web framework built on top of the Rivaas router. It provides a simple API for building web applications. It includes integrated observability with metrics, tracing, and logging. It has lifecycle management, graceful shutdown, and common middleware patterns.

Key Features

  • Complete Framework - Pre-configured with sensible defaults for rapid development.
  • Integrated Observability - Built-in metrics with Prometheus/OTLP, tracing with OpenTelemetry, and structured logging with slog.
  • Request Binding & Validation - Automatic request parsing with validation strategies.
  • OpenAPI Generation - Automatic OpenAPI spec generation with Swagger UI.
  • Lifecycle Hooks - OnStart, OnReady, OnShutdown, OnStop for initialization and cleanup.
  • Health Endpoints - Kubernetes-compatible liveness and readiness probes.
  • Graceful Shutdown - Proper server shutdown with configurable timeouts.
  • Environment-Aware - Development and production modes with appropriate defaults.

When to Use

Use App Package When

  • Building a complete web application - Need a full framework with all features included.
  • Want integrated observability - Metrics and tracing configured out of the box.
  • Need quick development - Sensible defaults help you start immediately.
  • Building a REST API - Pre-configured with common middleware and patterns.
  • Prefer convention over configuration - Defaults that work well together.

Use Router Package Directly When

  • Building a library or framework - Need full control over the routing layer.
  • Have custom observability setup - Already using specific metrics or tracing solutions.
  • Maximum performance is critical - Want zero overhead from default middleware.
  • Need complete flexibility - Don’t want any opinions or defaults imposed.
  • Integrating into existing systems - Need to fit into established patterns.

Performance Note: The app package adds about 1-2% latency compared to using router directly. Latency goes from 119ns to about 121-122ns. However, it provides significant development speed and maintainability benefits. This comes through integrated observability and sensible defaults.

Quick Start

Simple Application

Create a minimal application with defaults:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    // Create app with defaults
    a, err := app.New()
    if err != nil {
        log.Fatalf("Failed to create app: %v", err)
    }

    // Register routes
    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Hello from Rivaas App!",
        })
    })

    // Setup graceful shutdown
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    // Start server with graceful shutdown
    if err := a.Start(ctx); err != nil {
        log.Fatalf("Server error: %v", err)
    }
}

Create a production-ready application with full observability:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
    
    "rivaas.dev/app"
    "rivaas.dev/logging"
    "rivaas.dev/metrics"
    "rivaas.dev/tracing"
)

func main() {
    // Create app with full observability
    a, err := app.New(
        app.WithServiceName("my-api"),
        app.WithServiceVersion("v1.0.0"),
        app.WithEnvironment("production"),
        // Observability: logging, metrics, tracing
        app.WithObservability(
            app.WithLogging(logging.WithJSONHandler()),
            app.WithMetrics(), // Prometheus is default
            app.WithTracing(tracing.WithOTLP("localhost:4317")),
            app.WithExcludePaths("/livez", "/readyz", "/metrics"),
        ),
        // Health endpoints: GET /livez (liveness), GET /readyz (readiness)
        app.WithHealthEndpoints(
            app.WithHealthTimeout(800 * time.Millisecond),
            app.WithReadinessCheck("database", func(ctx context.Context) error {
                return db.PingContext(ctx)
            }),
        ),
        // Server configuration
        app.WithServer(
            app.WithReadTimeout(15 * time.Second),
            app.WithWriteTimeout(15 * time.Second),
        ),
    )
    if err != nil {
        log.Fatalf("Failed to create app: %v", err)
    }

    // Register routes
    a.GET("/users/:id", func(c *app.Context) {
        userID := c.Param("id")
        
        // Request-scoped logger with automatic context
        c.Logger().Info("processing request", "user_id", userID)
        
        c.JSON(http.StatusOK, map[string]any{
            "user_id":    userID,
            "name":       "John Doe",
            "trace_id":   c.TraceID(),
        })
    })

    // Setup graceful shutdown
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    // Start server
    if err := a.Start(ctx); err != nil {
        log.Fatalf("Server error: %v", err)
    }
}

Learning Path

Follow this structured path to master the Rivaas App framework:

1. Getting Started

Start with the basics:

2. Request Handling

Handle requests effectively:

  • Context - Use the app context for binding, validation, and error handling
  • Routing - Organize routes with groups, versioning, and static files
  • Middleware - Add cross-cutting concerns with built-in middleware

3. Observability

Monitor your application:

4. Production Readiness

Prepare for production:

  • Lifecycle - Use lifecycle hooks for initialization and cleanup
  • Server - Configure HTTP, HTTPS, and mTLS servers with graceful shutdown
  • OpenAPI - Generate OpenAPI specs and Swagger UI automatically

5. Testing & Migration

Test and migrate:

  • Testing - Test your routes and handlers without starting a server
  • Migration - Migrate from the router package to the app package
  • Examples - Complete working examples and patterns

Common Use Cases

The Rivaas App excels in these scenarios:

  • REST APIs - Full-featured JSON APIs with observability and validation
  • Microservices - Cloud-native services with health checks and graceful shutdown
  • Web Applications - Complete web apps with middleware and lifecycle management
  • Production Services - Production-ready defaults with integrated monitoring

Next Steps

Need Help?

1.1 - Installation

Install the Rivaas App package and set up your development environment.

Requirements

  • Go 1.25 or later - The app package requires Go 1.25 or higher. It uses the latest language features and standard library.
  • Module support - Your project must use Go modules. It needs a go.mod file.

Installation

Install the app package using go get:

go get rivaas.dev/app

This downloads the app package and all its dependencies. These include:

  • rivaas.dev/router - High-performance HTTP router.
  • rivaas.dev/binding - Request binding and parsing.
  • rivaas.dev/validation - Request validation.
  • rivaas.dev/errors - Error formatting.
  • rivaas.dev/logging - Structured logging (optional).
  • rivaas.dev/metrics - Metrics collection (optional).
  • rivaas.dev/tracing - OpenTelemetry tracing (optional).
  • rivaas.dev/openapi - OpenAPI generation (optional).

Verify Installation

Create a simple main.go to verify the installation:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    a, err := app.New()
    if err != nil {
        log.Fatal(err)
    }

    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Installation successful!",
        })
    })

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    log.Println("Server starting on :8080")
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

Run the application:

go run main.go

Test the endpoint:

curl http://localhost:8080/

You should see:

{"message":"Installation successful!"}

Project Structure

A typical Rivaas app project structure:

myapp/
├── go.mod
├── go.sum
├── main.go              # Application entry point
├── handlers/            # HTTP handlers
│   ├── users.go
│   └── orders.go
├── middleware/          # Custom middleware
│   └── auth.go
├── models/              # Data models
│   └── user.go
├── services/            # Business logic
│   └── user_service.go
└── config/              # Configuration
    └── config.yaml

Development Tools

Hot Reload (Optional)

For development, you can use a hot reload tool like air:

# Install air
go install github.com/cosmtrek/air@latest

# Initialize air in your project
air init

# Run with hot reload
air

Testing Tools

The app package includes built-in testing utilities. No additional tools required:

package main

import (
    "net/http/httptest"
    "testing"
)

func TestHome(t *testing.T) {
    a, _ := app.New()
    a.GET("/", homeHandler)
    
    req := httptest.NewRequest("GET", "/", nil)
    resp, err := a.Test(req)
    if err != nil {
        t.Fatal(err)
    }
    
    if resp.StatusCode != 200 {
        t.Errorf("expected 200, got %d", resp.StatusCode)
    }
}

Optional Dependencies

Observability

If you plan to use observability features, you may want to configure exporters:

# For Prometheus metrics (default, no additional setup needed)

# For OTLP metrics/tracing (to send to Jaeger, Tempo, etc.)
# No additional packages needed - built into the tracing package

OpenAPI

If you plan to use OpenAPI spec generation:

# No additional packages needed - included in app

Next Steps

Troubleshooting

Import Errors

If you see import errors:

cannot find package "rivaas.dev/app"

Make sure you’ve run go get rivaas.dev/app and your Go version is 1.25+:

go version  # Should show go1.25 or later
go mod tidy  # Clean up dependencies

Module Issues

If you see module-related errors, ensure your project is using Go modules:

# Initialize a new module (if not already done)
go mod init myapp

# Download dependencies
go mod download

Version Conflicts

If you encounter version conflicts with other Rivaas packages:

# Update all Rivaas packages to latest versions
go get -u rivaas.dev/app
go get -u rivaas.dev/router
go get -u rivaas.dev/binding
go mod tidy

1.2 - Basic Usage

Learn the fundamentals of creating and running Rivaas applications.

Creating an App

Using New()

The recommended way to create an app is with app.New(). It returns an error if configuration is invalid.

package main

import (
    "log"
    
    "rivaas.dev/app"
)

func main() {
    a, err := app.New()
    if err != nil {
        log.Fatalf("Failed to create app: %v", err)
    }
    
    // Use the app...
}

Using MustNew()

For initialization code where errors should panic (like main() functions), use app.MustNew():

package main

import (
    "rivaas.dev/app"
)

func main() {
    a := app.MustNew(
        app.WithServiceName("my-api"),
        app.WithServiceVersion("v1.0.0"),
    )
    
    // Use the app...
}

MustNew() panics if configuration is invalid. It follows the Go idiom of Must* constructors like regexp.MustCompile().

Registering Routes

Basic Routes

Register routes using HTTP method shortcuts.

a.GET("/", func(c *app.Context) {
    c.JSON(http.StatusOK, map[string]string{
        "message": "Hello, World!",
    })
})

a.POST("/users", func(c *app.Context) {
    c.JSON(http.StatusCreated, map[string]string{
        "message": "User created",
    })
})

a.PUT("/users/:id", func(c *app.Context) {
    id := c.Param("id")
    c.JSON(http.StatusOK, map[string]string{
        "id": id,
        "message": "User updated",
    })
})

a.DELETE("/users/:id", func(c *app.Context) {
    c.Status(http.StatusNoContent)
})

Path Parameters

Extract path parameters using c.Param():

a.GET("/users/:id", func(c *app.Context) {
    userID := c.Param("id")
    
    c.JSON(http.StatusOK, map[string]string{
        "user_id": userID,
    })
})

a.GET("/posts/:postID/comments/:commentID", func(c *app.Context) {
    postID := c.Param("postID")
    commentID := c.Param("commentID")
    
    c.JSON(http.StatusOK, map[string]string{
        "post_id": postID,
        "comment_id": commentID,
    })
})

Query Parameters

Access query parameters using c.Query():

a.GET("/search", func(c *app.Context) {
    query := c.Query("q")
    page := c.QueryDefault("page", "1")
    
    c.JSON(http.StatusOK, map[string]string{
        "query": query,
        "page": page,
    })
})

Wildcard Routes

Use wildcards to match remaining path segments:

a.GET("/files/*filepath", func(c *app.Context) {
    filepath := c.Param("filepath")
    
    c.JSON(http.StatusOK, map[string]string{
        "filepath": filepath,
    })
})

Request Handlers

Handler Function Signature

Handlers receive an *app.Context which provides access to the request, response, and app features:

func handler(c *app.Context) {
    // Access request
    method := c.Request.Method
    path := c.Request.URL.Path
    
    // Access parameters
    id := c.Param("id")
    query := c.Query("q")
    
    // Send response
    c.JSON(http.StatusOK, map[string]string{
        "method": method,
        "path": path,
        "id": id,
        "query": query,
    })
}

a.GET("/example/:id", handler)

Organizing Handlers

For larger applications, organize handlers in separate files:

// handlers/users.go
package handlers

import (
    "net/http"
    "rivaas.dev/app"
)

func GetUser(c *app.Context) {
    id := c.Param("id")
    // Fetch user from database...
    
    c.JSON(http.StatusOK, map[string]any{
        "id": id,
        "name": "John Doe",
    })
}

func CreateUser(c *app.Context) {
    var req struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    
    if !c.MustBind(&req) {
        return // Error response already sent
    }
    
    // Create user in database...
    
    c.JSON(http.StatusCreated, map[string]any{
        "id": "123",
        "name": req.Name,
        "email": req.Email,
    })
}
// main.go
package main

import (
    "myapp/handlers"
    "rivaas.dev/app"
)

func main() {
    a := app.MustNew()
    
    a.GET("/users/:id", handlers.GetUser)
    a.POST("/users", handlers.CreateUser)
    
    // ...
}

Response Rendering

JSON Responses

Send JSON responses with c.JSON():

a.GET("/users", func(c *app.Context) {
    users := []map[string]string{
        {"id": "1", "name": "Alice"},
        {"id": "2", "name": "Bob"},
    }
    
    c.JSON(http.StatusOK, users)
})

Status Codes

Set status without body using c.Status():

a.DELETE("/users/:id", func(c *app.Context) {
    id := c.Param("id")
    // Delete user from database...
    
    c.Status(http.StatusNoContent)
})

String Responses

Send plain text responses:

a.GET("/health", func(c *app.Context) {
    c.String(http.StatusOK, "OK")
})

HTML Responses

Send HTML responses:

a.GET("/", func(c *app.Context) {
    html := `
    <!DOCTYPE html>
    <html>
    <head><title>Welcome</title></head>
    <body><h1>Welcome to My App</h1></body>
    </html>
    `
    
    c.HTML(http.StatusOK, html)
})

Running the Server

HTTP Server

Start the HTTP server with graceful shutdown:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    a := app.MustNew()
    
    // Register routes...
    a.GET("/", homeHandler)
    
    // Setup graceful shutdown
    ctx, cancel := signal.NotifyContext(
        context.Background(),
        os.Interrupt,
        syscall.SIGTERM,
    )
    defer cancel()
    
    // Start server
    log.Println("Server starting on :8080")
    if err := a.Start(ctx); err != nil {
        log.Fatalf("Server error: %v", err)
    }
}

Port Configuration

Configure the listen address via options when creating the app (default is :8080):

// Development (default port 8080)
a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithPort(8080),
)
// ...
a.Start(ctx)

// Production
a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithPort(80),
)
// ...
a.Start(ctx)

// Bind to specific interface
a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithHost("127.0.0.1"),
    app.WithPort(8080),
)
// ...
a.Start(ctx)

// Use environment variable
port := 8080
if p := os.Getenv("PORT"); p != "" {
    port, _ = strconv.Atoi(p)
}
a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithPort(port),
)
// ...
a.Start(ctx)

Complete Example

Here’s a complete working example:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    // Create app
    a, err := app.New(
        app.WithServiceName("hello-api"),
        app.WithServiceVersion("v1.0.0"),
    )
    if err != nil {
        log.Fatalf("Failed to create app: %v", err)
    }
    
    // Home route
    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Welcome to Hello API",
            "version": "v1.0.0",
        })
    })
    
    // Greet route with parameter
    a.GET("/greet/:name", func(c *app.Context) {
        name := c.Param("name")
        
        c.JSON(http.StatusOK, map[string]string{
            "greeting": "Hello, " + name + "!",
        })
    })
    
    // Echo route with request body
    a.POST("/echo", func(c *app.Context) {
        var req map[string]any
        
        if !c.MustBind(&req) {
            return
        }
        
        c.JSON(http.StatusOK, req)
    })
    
    // Setup graceful shutdown
    ctx, cancel := signal.NotifyContext(
        context.Background(),
        os.Interrupt,
        syscall.SIGTERM,
    )
    defer cancel()
    
    // Start server
    log.Println("Server starting on :8080")
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

Test the endpoints:

# Home route
curl http://localhost:8080/

# Greet route
curl http://localhost:8080/greet/Alice

# Echo route
curl -X POST http://localhost:8080/echo \
  -H "Content-Type: application/json" \
  -d '{"message": "Hello, World!"}'

Next Steps

  • Configuration - Configure service name, environment, and server settings
  • Context - Learn about request binding and validation
  • Routing - Organize routes with groups and middleware
  • Examples - Explore complete working examples

1.3 - Configuration

Configure your application with service metadata, environment modes, and server settings.

Service Configuration

Service Name

Set the service name used in observability metadata. This includes metrics, traces, and logs:

a, err := app.New(
    app.WithServiceName("orders-api"),
)

The service name must be non-empty or validation will fail. Default: "rivaas-app".

Service Version

Set the service version for observability and API documentation:

a, err := app.New(
    app.WithServiceVersion("v1.2.3"),
)

The service version must be non-empty or validation will fail. Default: "1.0.0".

Complete Service Metadata

Configure both service name and version:

a, err := app.New(
    app.WithServiceName("payments-api"),
    app.WithServiceVersion("v2.0.0"),
)

These values are automatically injected into:

  • Metrics - Service name and version labels on all metrics.
  • Tracing - Service name and version attributes on all spans.
  • Logging - Service name and version fields in all log entries.
  • OpenAPI - API title and version in the specification.

Environment Modes

Development Mode

Development mode enables verbose logging and developer-friendly features:

a, err := app.New(
    app.WithEnvironment("development"),
)

Development mode features:

  • Verbose access logging for all requests.
  • Route table displayed in startup banner.
  • More detailed error messages.
  • Terminal colors enabled.

Production Mode

Production mode optimizes for performance and security:

a, err := app.New(
    app.WithEnvironment("production"),
)

Production mode features:

  • Error-only access logging. Reduces log volume.
  • Minimal startup banner.
  • Sanitized error messages.
  • Terminal colors stripped for log aggregation.

Environment from Environment Variables

Use environment variables for configuration:

env := os.Getenv("ENVIRONMENT")
if env == "" {
    env = "development"
}

a, err := app.New(
    app.WithEnvironment(env),
)

Valid values: "development", "production". Invalid values cause validation to fail.

Server Configuration

Timeouts

Configure server timeouts for safety and performance:

a, err := app.New(
    app.WithServer(
        app.WithReadTimeout(10 * time.Second),
        app.WithWriteTimeout(15 * time.Second),
        app.WithIdleTimeout(60 * time.Second),
        app.WithReadHeaderTimeout(2 * time.Second),
    ),
)

Timeout descriptions:

  • ReadTimeout - Maximum time to read entire request. Includes body.
  • WriteTimeout - Maximum time to write response.
  • IdleTimeout - Maximum time to wait for next request on keep-alive connection.
  • ReadHeaderTimeout - Maximum time to read request headers.

Default values:

  • ReadTimeout: 10s
  • WriteTimeout: 10s
  • IdleTimeout: 60s
  • ReadHeaderTimeout: 2s

Header Size Limits

Configure maximum request header size:

a, err := app.New(
    app.WithServer(
        app.WithMaxHeaderBytes(2 << 20), // 2MB
    ),
)

Default: 1MB (1048576 bytes). Must be at least 1KB or validation fails.

Shutdown Timeout

Configure graceful shutdown timeout:

a, err := app.New(
    app.WithServer(
        app.WithShutdownTimeout(30 * time.Second),
    ),
)

Default: 30s. Must be at least 1s or validation fails.

The shutdown timeout controls how long the server waits for:

  1. In-flight requests to complete.
  2. OnShutdown hooks to execute.
  3. Observability components to flush.
  4. Connections to close gracefully.

Validation Rules

Server configuration is automatically validated:

Timeout validation:

  • All timeouts must be positive.
  • ReadTimeout should not exceed WriteTimeout. This is a common misconfiguration.
  • ShutdownTimeout must be at least 1 second.

Size validation:

  • MaxHeaderBytes must be at least 1KB (1024 bytes)

Invalid configuration example:

a, err := app.New(
    app.WithServer(
        app.WithReadTimeout(15 * time.Second),
        app.WithWriteTimeout(10 * time.Second), // ❌ Invalid: read > write
        app.WithShutdownTimeout(100 * time.Millisecond), // ❌ Invalid: too short
        app.WithMaxHeaderBytes(512), // ❌ Invalid: too small
    ),
)
// err contains all validation errors

Valid configuration example:

a, err := app.New(
    app.WithServer(
        app.WithReadTimeout(10 * time.Second),
        app.WithWriteTimeout(15 * time.Second), // ✅ Valid: write >= read
        app.WithShutdownTimeout(5 * time.Second), // ✅ Valid: >= 1s
        app.WithMaxHeaderBytes(2048), // ✅ Valid: >= 1KB
    ),
)

Partial Configuration

You can set only the options you need - unset fields use defaults:

// Only override read and write timeouts
a, err := app.New(
    app.WithServer(
        app.WithReadTimeout(15 * time.Second),
        app.WithWriteTimeout(15 * time.Second),
        // Other fields use defaults: IdleTimeout=60s, etc.
    ),
)

Configuration from Environment

Load configuration from environment variables:

package main

import (
    "log"
    "os"
    "strconv"
    "time"
    
    "rivaas.dev/app"
)

func main() {
    // Parse timeouts from environment
    readTimeout := parseDuration("READ_TIMEOUT", 10*time.Second)
    writeTimeout := parseDuration("WRITE_TIMEOUT", 10*time.Second)
    
    a, err := app.New(
        app.WithServiceName(getEnv("SERVICE_NAME", "my-api")),
        app.WithServiceVersion(getEnv("SERVICE_VERSION", "v1.0.0")),
        app.WithEnvironment(getEnv("ENVIRONMENT", "development")),
        app.WithServer(
            app.WithReadTimeout(readTimeout),
            app.WithWriteTimeout(writeTimeout),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // ...
}

func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

func parseDuration(key string, defaultValue time.Duration) time.Duration {
    if value := os.Getenv(key); value != "" {
        if d, err := time.ParseDuration(value); err == nil {
            return d
        }
    }
    return defaultValue
}

Configuration Validation

All configuration is validated when calling app.New():

a, err := app.New(
    app.WithServiceName(""),  // ❌ Empty service name
    app.WithEnvironment("staging"),  // ❌ Invalid environment
)
if err != nil {
    // Handle validation errors
    log.Fatalf("Configuration error: %v", err)
}

Validation errors are structured and include all issues:

validation errors (2):
  1. configuration error in serviceName: must not be empty
  2. configuration error in environment: must be "development" or "production", got "staging"

Complete Configuration Example

package main

import (
    "log"
    "os"
    "time"
    
    "rivaas.dev/app"
)

func main() {
    a, err := app.New(
        // Service metadata
        app.WithServiceName("orders-api"),
        app.WithServiceVersion("v2.1.0"),
        app.WithEnvironment("production"),
        
        // Server configuration
        app.WithServer(
            app.WithReadTimeout(10 * time.Second),
            app.WithWriteTimeout(15 * time.Second),
            app.WithIdleTimeout(120 * time.Second),
            app.WithReadHeaderTimeout(3 * time.Second),
            app.WithMaxHeaderBytes(2 << 20), // 2MB
            app.WithShutdownTimeout(30 * time.Second),
        ),
    )
    if err != nil {
        log.Fatalf("Failed to create app: %v", err)
    }
    
    // Register routes...
    
    // Start server...
}

Next Steps

  • Observability - Configure metrics, tracing, and logging
  • Server - Learn about HTTP, HTTPS, and mTLS servers
  • Lifecycle - Use lifecycle hooks for initialization and cleanup

1.4 - Observability

Integrate metrics, tracing, and logging for complete application observability.

Overview

The app package provides unified configuration for the three pillars of observability:

  • Metrics - Prometheus or OTLP metrics with automatic HTTP instrumentation.
  • Tracing - OpenTelemetry distributed tracing with context propagation.
  • Logging - Structured logging with slog that includes request-scoped fields.

All three pillars use the same functional options pattern. They automatically receive service metadata (name and version) from app-level configuration.

Environment Variable Configuration

You can configure observability using environment variables. This is useful for container deployments and following 12-factor app principles.

See Environment Variables for the complete guide.

Quick example:

export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=localhost:4317
export RIVAAS_LOG_LEVEL=info
export RIVAAS_LOG_FORMAT=json
app, err := app.New(
    app.WithServiceName("my-api"),
    app.WithEnv(), // Reads environment variables
)

Environment variables override code configuration, making it easy to deploy the same code to different environments.

Unified Observability Configuration

Configure all three pillars in one place.

a, err := app.New(
    app.WithServiceName("orders-api"),
    app.WithServiceVersion("v1.2.3"),
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
        app.WithMetrics(), // Prometheus is default
        app.WithTracing(tracing.WithOTLP("localhost:4317")),
    ),
)

Logging

Basic Logging

Enable structured logging with slog:

a, err := app.New(
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
    ),
)

Log Handlers

Choose from different log handlers.

// JSON handler (production)
app.WithLogging(logging.WithJSONHandler())

// Console handler (development)
app.WithLogging(logging.WithConsoleHandler())

// Text handler
app.WithLogging(logging.WithTextHandler())

Log Levels

Configure log level:

app.WithLogging(
    logging.WithJSONHandler(),
    logging.WithLevel(slog.LevelDebug),
)

Request-Scoped Logging

Pass the request context when you log so trace IDs are attached automatically:

a.GET("/orders/:id", func(c *app.Context) {
    orderID := c.Param("id")
    
    slog.InfoContext(c.RequestContext(), "processing order",
        slog.String("order.id", orderID),
    )
    
    slog.DebugContext(c.RequestContext(), "fetching from database")
    
    c.JSON(http.StatusOK, map[string]string{
        "order_id": orderID,
    })
})

Handler log lines stay lean: they include trace_id and span_id (when tracing is enabled) plus whatever attributes you add. HTTP details (method, route, client IP, etc.) are in the access log, not in every handler log.

Example handler log line:

{
  "time": "2024-01-18T10:30:00Z",
  "level": "INFO",
  "msg": "processing order",
  "trace_id": "abc...",
  "span_id": "def...",
  "order.id": "123"
}

Metrics

Prometheus Metrics (Default)

Enable Prometheus metrics on a separate server:

a, err := app.New(
    app.WithObservability(
        app.WithMetrics(), // Default: Prometheus on :9090/metrics
    ),
)

Custom Prometheus Configuration

Configure Prometheus address and path:

a, err := app.New(
    app.WithObservability(
        app.WithMetrics(metrics.WithPrometheus(":9091", "/custom-metrics")),
    ),
)

Mount Metrics on Main Router

Mount metrics endpoint on the main HTTP server:

a, err := app.New(
    app.WithObservability(
        app.WithMetricsOnMainRouter("/metrics"),
    ),
)
// Metrics available at http://localhost:8080/metrics

OTLP Metrics

Send metrics via OTLP to collectors like Prometheus, Grafana, or Datadog:

a, err := app.New(
    app.WithObservability(
        app.WithMetrics(metrics.WithOTLP("localhost:4317")),
    ),
)

Custom Metrics in Handlers

Record custom metrics in your handlers:

a.GET("/orders/:id", func(c *app.Context) {
    orderID := c.Param("id")
    
    // Increment counter
    c.IncrementCounter("order.lookups",
        attribute.String("order.id", orderID),
    )
    
    // Record histogram
    c.RecordHistogram("order.processing_time", 0.250,
        attribute.String("order.id", orderID),
    )
    
    c.JSON(http.StatusOK, order)
})

Tracing

OpenTelemetry Tracing

Enable OpenTelemetry tracing with OTLP exporter:

a, err := app.New(
    app.WithObservability(
        app.WithTracing(tracing.WithOTLP("localhost:4317")),
    ),
)

Stdout Tracing (Development)

Use stdout tracing for development:

a, err := app.New(
    app.WithObservability(
        app.WithTracing(tracing.WithStdout()),
    ),
)

Sample Rate

Configure trace sampling:

a, err := app.New(
    app.WithObservability(
        app.WithTracing(
            tracing.WithOTLP("localhost:4317"),
            tracing.WithSampleRate(0.1), // Sample 10% of requests
        ),
    ),
)

Span Attributes in Handlers

Add span attributes and events in your handlers:

a.GET("/orders/:id", func(c *app.Context) {
    orderID := c.Param("id")
    
    // Add span attribute
    c.SetSpanAttribute("order.id", orderID)
    
    // Add span event
    c.AddSpanEvent("order_lookup_started")
    
    // Fetch order...
    
    c.AddSpanEvent("order_lookup_completed")
    
    c.JSON(http.StatusOK, order)
})

Accessing Trace IDs

Get the current trace ID for correlation:

a.GET("/orders/:id", func(c *app.Context) {
    traceID := c.TraceID()
    
    c.JSON(http.StatusOK, map[string]string{
        "order_id": orderID,
        "trace_id": traceID,
    })
})

Service Metadata Injection

Service name and version are automatically injected into all observability components:

a, err := app.New(
    app.WithServiceName("orders-api"),
    app.WithServiceVersion("v1.2.3"),
    app.WithObservability(
        app.WithLogging(),   // Automatically gets service metadata
        app.WithMetrics(),   // Automatically gets service metadata
        app.WithTracing(),   // Automatically gets service metadata
    ),
)

You don’t need to pass service name/version explicitly - the app injects them automatically.

Overriding Service Metadata

If needed, you can override service metadata for specific components:

a, err := app.New(
    app.WithServiceName("orders-api"),
    app.WithServiceVersion("v1.2.3"),
    app.WithObservability(
        app.WithLogging(
            logging.WithServiceName("custom-logger"), // Overrides injected value
        ),
    ),
)

Path Filtering

Exclude specific paths from observability (metrics, tracing, logging):

Exclude Paths

Exclude exact paths:

a, err := app.New(
    app.WithObservability(
        app.WithLogging(),
        app.WithMetrics(),
        app.WithTracing(),
        app.WithExcludePaths("/livez", "/readyz", "/metrics"),
    ),
)

Exclude Prefixes

Exclude path prefixes:

a, err := app.New(
    app.WithObservability(
        app.WithExcludePrefixes("/internal/", "/admin/debug/"),
    ),
)

Exclude Patterns

Exclude paths matching regex patterns:

a, err := app.New(
    app.WithObservability(
        app.WithExcludePatterns(`^/api/v\d+/health$`, `^/debug/.*`),
    ),
)

Default Exclusions

By default, the following paths are excluded:

  • /health, /livez, /ready, /readyz
  • /ready, /readyz
  • /metrics
  • /debug/*

To disable default exclusions:

a, err := app.New(
    app.WithObservability(
        app.WithoutDefaultExclusions(),
        app.WithExcludePaths("/custom-health"), // Add your own
    ),
)

Access Logging

Enable/Disable Access Logging

Control access logging:

// Enable access logging (default)
a, err := app.New(
    app.WithObservability(
        app.WithAccessLogging(true),
    ),
)

// Disable access logging
a, err := app.New(
    app.WithObservability(
        app.WithAccessLogging(false),
    ),
)

Log Only Errors

Log only errors and slow requests (automatically enabled in production):

a, err := app.New(
    app.WithObservability(
        app.WithLogOnlyErrors(),
    ),
)

Slow Request Threshold

Mark requests as slow and log them:

a, err := app.New(
    app.WithObservability(
        app.WithLogOnlyErrors(),
        app.WithSlowThreshold(500 * time.Millisecond),
    ),
)

Complete Example

Production-ready observability configuration:

package main

import (
    "log"
    "time"
    
    "rivaas.dev/app"
    "rivaas.dev/logging"
    "rivaas.dev/metrics"
    "rivaas.dev/tracing"
)

func main() {
    a, err := app.New(
        // Service metadata (automatically injected into all components)
        app.WithServiceName("orders-api"),
        app.WithServiceVersion("v2.1.0"),
        app.WithEnvironment("production"),
        
        // Unified observability configuration
        app.WithObservability(
            // Logging: JSON handler for production
            app.WithLogging(
                logging.WithJSONHandler(),
                logging.WithLevel(slog.LevelInfo),
            ),
            
            // Metrics: Prometheus on separate server
            app.WithMetrics(
                metrics.WithPrometheus(":9090", "/metrics"),
            ),
            
            // Tracing: OTLP to Jaeger/Tempo
            app.WithTracing(
                tracing.WithOTLP("jaeger:4317"),
                tracing.WithSampleRate(0.1), // 10% sampling
            ),
            
            // Path filtering
            app.WithExcludePaths("/livez", "/readyz"),
            app.WithExcludePrefixes("/internal/"),
            
            // Access logging: errors and slow requests only
            app.WithLogOnlyErrors(),
            app.WithSlowThreshold(1 * time.Second),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Register routes...
    a.GET("/orders/:id", handleGetOrder)
    
    // Start server...
}

Next Steps

1.5 - Context

Use the app context for request binding, validation, error handling, and logging.

Overview

The app.Context wraps router.Context and provides app-level features:

  • Request Binding - Parse JSON, form, query, path, header, and cookie data automatically
  • Validation - Comprehensive validation with multiple strategies
  • Error Handling - Structured error responses with content negotiation
  • Logging - Request-scoped logger with automatic context

Request Binding

Binding and Validation

Bind() reads your request data and checks if it’s valid. It handles JSON, forms, query parameters, and more.

Use Bind() for most cases. It automatically validates your data:

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required,min=3"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"gte=18"`
}

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if err := c.Bind(&req); err != nil {
        c.Fail(err) // Handles binding and validation errors
        return
    }
    
    // req is valid and ready to use
})

The Bind() method does two things: it reads the request data and validates it. If either step fails, you get an error.

Binding from Multiple Sources

You can bind data from different places at once. Use struct tags to tell Rivaas where to look:

type GetUserRequest struct {
    ID      int    `path:"id"`           // From URL path
    Expand  string `query:"expand"`      // From query string
    APIKey  string `header:"X-API-Key"`  // From HTTP header
    Session string `cookie:"session"`    // From cookie
}

a.GET("/users/:id", func(c *app.Context) {
    var req GetUserRequest
    if err := c.Bind(&req); err != nil {
        c.Fail(err)
        return
    }
    
    // All fields are populated from their sources
})

Binding Without Validation

Sometimes you need to process data before validating it. Use BindOnly() for this:

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if err := c.BindOnly(&req); err != nil {
        c.Fail(err)
        return
    }
    
    // Clean up the data
    req.Email = strings.ToLower(req.Email)
    
    // Now validate
    if err := c.Validate(&req); err != nil {
        c.Fail(err)
        return
    }
})

Multi-Source Binding

Bind from multiple sources in one call. This is useful when your request needs data from different places:

type UpdateUserRequest struct {
    ID    int    `path:"id"`          // From URL path
    Name  string `json:"name"`        // From JSON body
    Token string `header:"X-Token"`   // From header
}

a.PUT("/users/:id", func(c *app.Context) {
    var req UpdateUserRequest
    if err := c.Bind(&req); err != nil {
        c.Fail(err)
        return
    }
    
    // All fields populated: ID from path, Name from JSON, Token from header
})

Multipart Forms with Files

For file uploads, use the *binding.File type. The context automatically detects and handles multipart form data:

type UploadRequest struct {
    File        *binding.File `form:"file"`
    Title       string        `form:"title"`
    Description string        `form:"description"`
    // JSON in form fields is automatically parsed
    Settings    struct {
        Quality int    `json:"quality"`
        Format  string `json:"format"`
    } `form:"settings"`
}

a.POST("/upload", func(c *app.Context) {
    var req UploadRequest
    if err := c.Bind(&req); err != nil {
        c.Fail(err)
        return
    }
    
    // Validate file type
    allowedTypes := []string{".jpg", ".png", ".gif"}
    if !slices.Contains(allowedTypes, req.File.Ext()) {
        c.BadRequest(fmt.Errorf("invalid file type"))
        return
    }
    
    // Save the file
    filename := fmt.Sprintf("/uploads/%d_%s", time.Now().Unix(), req.File.Name)
    if err := req.File.Save(filename); err != nil {
        c.InternalError(err)
        return
    }
    
    c.JSON(http.StatusCreated, map[string]interface{}{
        "filename": filepath.Base(filename),
        "size":     req.File.Size,
        "url":      "/uploads/" + filepath.Base(filename),
    })
})

Multiple file uploads:

type GalleryUpload struct {
    Photos []*binding.File `form:"photos"`
    Title  string          `form:"title"`
}

a.POST("/gallery", func(c *app.Context) {
    var req GalleryUpload
    if err := c.Bind(&req); err != nil {
        c.Fail(err)
        return
    }
    
    // Process each photo
    for i, photo := range req.Photos {
        filename := fmt.Sprintf("/uploads/%s_%d%s", req.Title, i, photo.Ext())
        if err := photo.Save(filename); err != nil {
            c.InternalError(err)
            return
        }
    }
    
    c.JSON(http.StatusCreated, map[string]int{
        "uploaded": len(req.Photos),
    })
})

File security best practices:

  • Always validate file types using file.Ext() or check magic bytes
  • Limit file sizes (check file.Size)
  • Generate safe filenames (don’t use user-provided names directly)
  • Store files outside your web root
  • Scan for malware in production environments

See Multipart Forms for detailed examples and security patterns.

Validation

The Must Pattern

The easiest way to handle requests is with MustBind(). It reads the data, validates it, and sends an error response if something is wrong:

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required,min=3,max=50"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"required,gte=18,lte=120"`
}

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if !c.MustBind(&req) {
        return // Error already sent to client
    }
    
    // req is valid, continue with your logic
})

This is the recommended approach. It keeps your code clean and handles errors automatically.

Type-Safe Binding with Generics

If you prefer working with return values instead of pointers, use the generic functions:

a.POST("/users", func(c *app.Context) {
    req, ok := app.MustBind[CreateUserRequest](c)
    if !ok {
        return // Error already sent
    }
    
    // req is type CreateUserRequest, not a pointer
})

This approach is more concise. You don’t need to declare the variable first.

Manual Error Handling

When you need more control over error handling, use Bind() directly:

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if err := c.Bind(&req); err != nil {
        slog.ErrorContext(c.RequestContext(), "binding failed", "error", err)
        c.Fail(err)
        return
    }
    
    // Continue processing
})

Or with generics:

a.POST("/users", func(c *app.Context) {
    req, err := app.Bind[CreateUserRequest](c)
    if err != nil {
        c.Fail(err)
        return
    }
    
    // Continue processing
})

Partial Validation for PATCH Requests

PATCH requests only update some fields. Use WithPartial() to validate only the fields that are present:

type UpdateUserRequest struct {
    Name  *string `json:"name" validate:"omitempty,min=3,max=50"`
    Email *string `json:"email" validate:"omitempty,email"`
}

a.PATCH("/users/:id", func(c *app.Context) {
    req, ok := app.MustBind[UpdateUserRequest](c, app.WithPartial())
    if !ok {
        return
    }
    
    // Only fields in the request are validated
})

You can also use the shortcut function BindPatch():

a.PATCH("/users/:id", func(c *app.Context) {
    req, ok := app.MustBindPatch[UpdateUserRequest](c)
    if !ok {
        return
    }
    
    // Same as above, but shorter
})

Strict Mode (Reject Unknown Fields)

Catch typos and API mismatches by rejecting unknown fields:

a.POST("/users", func(c *app.Context) {
    req, ok := app.MustBind[CreateUserRequest](c, app.WithStrict())
    if !ok {
        return // Error sent if client sends unknown fields
    }
})

Or use the shortcut:

a.POST("/users", func(c *app.Context) {
    req, ok := app.MustBindStrict[CreateUserRequest](c)
    if !ok {
        return
    }
})

This is helpful during development to catch mistakes early.

Binding Options

You can customize how binding and validation work:

OptionWhat it does
app.WithStrict()Reject unknown JSON fields
app.WithPartial()Only validate fields that are present
app.WithoutValidation()Skip validation (bind only)
app.WithBindingOptions(...)Advanced binding settings
app.WithValidationOptions(...)Advanced validation settings

Example with multiple options:

req, err := app.Bind[Request](c, 
    app.WithStrict(),
    app.WithValidationOptions(validation.WithMaxErrors(10)),
)

Validation Strategies

Choose how validation works:

// Tag validation (default, uses struct tags)
c.Bind(&req)

// Explicit strategy selection
c.Bind(&req, app.WithValidationOptions(
    validation.WithStrategy(validation.StrategyTags),
))

// JSON Schema validation
c.Bind(&req, app.WithValidationOptions(
    validation.WithStrategy(validation.StrategyJSONSchema),
))

Most apps use tag validation. It’s simple and works well.

Error Handling

Basic Error Handling

When something goes wrong in your handler, use Fail() to send an error response. This method formats the error, writes the HTTP response, and automatically stops the handler chain so no other handlers run after it:

a.GET("/users/:id", func(c *app.Context) {
    id := c.Param("id")
    
    user, err := db.GetUser(id)
    if err != nil {
        c.Fail(err)
        return
    }
    
    c.JSON(http.StatusOK, user)
})

Explicit Status Codes

When you need a specific HTTP status code for an error, use FailStatus():

a.GET("/users/:id", func(c *app.Context) {
    user, err := db.GetUser(id)
    if err != nil {
        c.FailStatus(http.StatusNotFound, err)
        return
    }
    
    c.JSON(http.StatusOK, user)
})

Convenience Error Methods

Use convenience methods for common HTTP error status codes. These methods automatically format and send the error response, then stop the handler chain:

// 404 Not Found
if user == nil {
    c.NotFound(fmt.Errorf("user not found"))
    return
}

// 400 Bad Request
if err := validateInput(input); err != nil {
    c.BadRequest(fmt.Errorf("invalid input"))
    return
}

// 401 Unauthorized
if !isAuthenticated {
    c.Unauthorized(fmt.Errorf("authentication required"))
    return
}

// 403 Forbidden
if !hasPermission {
    c.Forbidden(fmt.Errorf("insufficient permissions"))
    return
}

// 409 Conflict
if userExists {
    c.Conflict(fmt.Errorf("user already exists"))
    return
}

// 422 Unprocessable Entity
if validationErr != nil {
    c.UnprocessableEntity(validationErr)
    return
}

// 429 Too Many Requests
if rateLimitExceeded {
    c.TooManyRequests(fmt.Errorf("rate limit exceeded"))
    return
}

// 500 Internal Server Error
if err := processRequest(); err != nil {
    c.InternalError(err)
    return
}

// 503 Service Unavailable
if maintenanceMode {
    c.ServiceUnavailable(fmt.Errorf("maintenance mode"))
    return
}

You can also pass nil to use a generic default message:

c.NotFound(nil)  // Uses "Not Found" as the message
c.BadRequest(nil)  // Uses "Bad Request" as the message

Error Formatters

Configure error formatting at app level:

// Single formatter
a, err := app.New(
    app.WithErrorFormatter(&errors.RFC9457{
        BaseURL: "https://api.example.com/problems",
    }),
)

// Multiple formatters with content negotiation
a, err := app.New(
    app.WithErrorFormatters(map[string]errors.Formatter{
        "application/problem+json": &errors.RFC9457{},
        "application/json": &errors.Simple{},
    }),
    app.WithDefaultErrorFormat("application/problem+json"),
)

Request-Scoped Logging

Pass the request context when you log so trace IDs are attached automatically. Use the standard library’s context-aware logging:

import "log/slog"

a.GET("/orders/:id", func(c *app.Context) {
    orderID := c.Param("id")
    
    slog.InfoContext(c.RequestContext(), "processing order",
        slog.String("order.id", orderID),
    )
    
    c.JSON(http.StatusOK, order)
})

trace_id and span_id are injected automatically when tracing is enabled. HTTP details (method, route, client IP, etc.) live in the access log; handler logs stay lean with just your message and attributes plus trace correlation.

Structured Logging

Use key-value pairs with any slog.*Context call:

a.POST("/orders", func(c *app.Context) {
    req, ok := app.MustBind[CreateOrderRequest](c)
    if !ok {
        return
    }
    
    slog.InfoContext(c.RequestContext(), "creating order",
        slog.String("customer.id", req.CustomerID),
        slog.Int("item.count", len(req.Items)),
        slog.Float64("order.total", req.Total),
    )
    
    // Process order...
    
    slog.InfoContext(c.RequestContext(), "order created successfully",
        slog.String("order.id", orderID),
    )
})

Log Levels

Use the context-aware variants for each level:

slog.DebugContext(c.RequestContext(), "fetching from cache")
slog.InfoContext(c.RequestContext(), "request processed successfully")
slog.WarnContext(c.RequestContext(), "cache miss, fetching from database")
slog.ErrorContext(c.RequestContext(), "failed to save to database", "error", err)

What Appears in Handler Logs

Handler log lines include service metadata, trace correlation, and whatever attributes you add. They do not duplicate HTTP fields; those are in the access log:

{
  "time": "2024-01-18T10:30:00Z",
  "level": "INFO",
  "msg": "processing order",
  "service": "orders-api",
  "trace_id": "abc...",
  "span_id": "def...",
  "order.id": "123"
}

Router Context Features

The app context embeds router.Context, so all router features are available:

HTTP Methods

method := c.Request.Method
path := c.Request.URL.Path
headers := c.Request.Header

Response Handling

c.Status(http.StatusOK)
c.Header("Content-Type", "application/json")
c.JSON(http.StatusOK, data)
c.String(http.StatusOK, "text")
c.HTML(http.StatusOK, html)

Content Negotiation

accepts := c.Accepts("application/json", "text/html")

Complete Example

Here’s a complete example showing binding, validation, and logging:

package main

import (
    "log"
    "log/slog"
    "net/http"
    
    "rivaas.dev/app"
)

type CreateOrderRequest struct {
    CustomerID string   `json:"customer_id" validate:"required,uuid"`
    Items      []string `json:"items" validate:"required,min=1,dive,required"`
    Total      float64  `json:"total" validate:"required,gt=0"`
}

func main() {
    a := app.MustNew(
        app.WithServiceName("orders-api"),
    )
    
    a.POST("/orders", func(c *app.Context) {
        // Bind and validate in one step
        req, ok := app.MustBind[CreateOrderRequest](c)
        if !ok {
            return // Error already sent
        }
        
        slog.InfoContext(c.RequestContext(), "creating order",
            slog.String("customer.id", req.CustomerID),
            slog.Int("item.count", len(req.Items)),
            slog.Float64("order.total", req.Total),
        )
        
        // Your business logic here...
        orderID := "order-123"
        
        slog.InfoContext(c.RequestContext(), "order created",
            slog.String("order.id", orderID),
        )
        
        // Send response
        c.JSON(http.StatusCreated, map[string]string{
            "order_id": orderID,
        })
    })
    
    // Start server...
}

Next Steps

1.6 - Environment Variables

Configure your app using environment variables for easier deployment.

Overview

Want to configure your app without changing code? Use environment variables. This is helpful when you deploy to containers or cloud platforms.

The app package supports environment variables through the WithEnv() option. Just add it to your app setup, and you can control settings like port, logging, metrics, and tracing using environment variables.

This follows the 12-factor app approach, which means your code stays the same across different environments. You just change the environment variables.

Quick Start

Here’s a simple example. First, set some environment variables:

export RIVAAS_PORT=3000
export RIVAAS_LOG_LEVEL=debug
export RIVAAS_METRICS_EXPORTER=prometheus

Then create your app with WithEnv():

app, err := app.New(
    app.WithServiceName("my-api"),
    app.WithEnv(), // This reads environment variables
)
if err != nil {
    log.Fatal(err)
}

// Your app now runs on port 3000 with debug logging and Prometheus metrics

That’s it! No need to set these in code anymore.

Environment Variables Reference

All environment variables start with the RIVAAS_ prefix. You can also use a custom prefix with WithEnvPrefix().

Server Configuration

VariableDescriptionDefaultExample
RIVAAS_PORTPort number to listen on80803000
RIVAAS_HOSTHost address to bind to0.0.0.0127.0.0.1

Logging Configuration

VariableDescriptionDefaultExample
RIVAAS_LOG_LEVELLog level to useinfodebug, info, warn, error
RIVAAS_LOG_FORMATLog output formatjsonjson, text, console

Metrics Configuration

VariableDescriptionDefaultExample
RIVAAS_METRICS_EXPORTERType of metrics exporter-prometheus, otlp, stdout
RIVAAS_METRICS_ADDRPrometheus server address:9090:9000, 0.0.0.0:9090
RIVAAS_METRICS_PATHPrometheus metrics path/metrics/custom-metrics
RIVAAS_METRICS_ENDPOINTOTLP endpoint for metrics-http://localhost:4318

Tracing Configuration

VariableDescriptionDefaultExample
RIVAAS_TRACING_EXPORTERType of tracing exporter-otlp, otlp-http, stdout
RIVAAS_TRACING_ENDPOINTOTLP endpoint for traces-localhost:4317

Debug Configuration

VariableDescriptionDefaultExample
RIVAAS_PPROF_ENABLEDEnable pprof endpointsfalsetrue, false

Metrics Configuration

You can set up metrics using just environment variables. No need to write code for it.

Prometheus (Default)

The simplest way to get metrics:

export RIVAAS_METRICS_EXPORTER=prometheus

This starts a Prometheus server on :9090/metrics. Your app will expose metrics there.

Custom Prometheus Settings

Want to use a different port or path?

export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_METRICS_ADDR=:9000
export RIVAAS_METRICS_PATH=/custom-metrics

Now your metrics are at http://localhost:9000/custom-metrics.

OTLP Metrics

Need to send metrics to an OTLP collector (like Grafana, Datadog, or Prometheus)?

export RIVAAS_METRICS_EXPORTER=otlp
export RIVAAS_METRICS_ENDPOINT=http://localhost:4318

Make sure to set the endpoint. The app will fail to start if you forget it.

Stdout Metrics (Development)

For local development, you can print metrics to stdout:

export RIVAAS_METRICS_EXPORTER=stdout

This shows all metrics in your terminal. Good for debugging.

Tracing Configuration

Set up distributed tracing using environment variables.

OTLP Tracing (gRPC)

This is the most common way to send traces:

export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=localhost:4317

This works with Jaeger, Tempo, and other tracing backends that support OTLP over gRPC.

OTLP Tracing (HTTP)

Prefer HTTP instead of gRPC?

export RIVAAS_TRACING_EXPORTER=otlp-http
export RIVAAS_TRACING_ENDPOINT=http://localhost:4318

This is useful when your tracing backend only supports HTTP.

Stdout Tracing (Development)

For local development, print traces to your terminal:

export RIVAAS_TRACING_EXPORTER=stdout

You’ll see all traces in your console. Great for testing.

Logging Configuration

Control how your app logs messages.

Log Level

Set the minimum log level:

export RIVAAS_LOG_LEVEL=debug  # Show everything
export RIVAAS_LOG_LEVEL=info   # Normal logging (default)
export RIVAAS_LOG_LEVEL=warn   # Only warnings and errors
export RIVAAS_LOG_LEVEL=error  # Only errors

Log Format

Choose how logs look:

export RIVAAS_LOG_FORMAT=json     # JSON format (good for production)
export RIVAAS_LOG_FORMAT=text     # Simple text format
export RIVAAS_LOG_FORMAT=console  # Colored output (good for development)

Common Patterns

Here are some typical setups for different environments.

Development Setup

For local development, you want to see everything:

export RIVAAS_PORT=3000
export RIVAAS_LOG_LEVEL=debug
export RIVAAS_LOG_FORMAT=console
export RIVAAS_METRICS_EXPORTER=stdout
export RIVAAS_TRACING_EXPORTER=stdout

This gives you:

  • Port 3000 (so you can run multiple apps)
  • Debug logging with colors
  • Metrics and traces in your terminal

Production Setup

For production, you want structured logs and proper observability:

export RIVAAS_PORT=8080
export RIVAAS_LOG_LEVEL=info
export RIVAAS_LOG_FORMAT=json
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=jaeger:4317

This gives you:

  • Standard port 8080
  • JSON logs (easy to parse)
  • Prometheus metrics on :9090
  • Traces sent to Jaeger

Docker Setup

For Docker containers, you often need to bind to all addresses:

export RIVAAS_HOST=0.0.0.0
export RIVAAS_PORT=8080
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_METRICS_ADDR=0.0.0.0:9090

This makes sure your app is reachable from outside the container.

Custom Prefix

Don’t like the RIVAAS_ prefix? You can change it:

app, err := app.New(
    app.WithServiceName("my-api"),
    app.WithEnvPrefix("MYAPP_"), // Use MYAPP_ instead of RIVAAS_
)

Now you can use variables like:

export MYAPP_PORT=3000
export MYAPP_LOG_LEVEL=debug

Environment Variables Override Code

Environment variables always win. If you set something in code and in an environment variable, the environment variable is used.

app, err := app.New(
    app.WithPort(8080), // Set port in code
    app.WithEnv(),      // But environment variable overrides it
)

If you set RIVAAS_PORT=3000, your app uses port 3000, not 8080.

This is by design. It follows the 12-factor app principle where configuration comes from the environment.

Error Messages

The app checks your environment variables at startup. If something is wrong, it tells you clearly.

Missing Required Endpoint

If you set an OTLP exporter but forget the endpoint:

export RIVAAS_METRICS_EXPORTER=otlp
# Forgot to set RIVAAS_METRICS_ENDPOINT

You get this error:

RIVAAS_METRICS_EXPORTER=otlp requires RIVAAS_METRICS_ENDPOINT to be set

The app won’t start. This is good! It prevents wrong configurations in production.

Invalid Exporter Type

If you use a wrong exporter name:

export RIVAAS_METRICS_EXPORTER=datadog  # Not supported

You get:

RIVAAS_METRICS_EXPORTER must be one of: prometheus, otlp, stdout (got: datadog)

Invalid Port

If you set a bad port number:

export RIVAAS_PORT=99999  # Too high

You get:

invalid port: must be between 1 and 65535

These clear error messages help you fix problems quickly.

Complete Example

Here’s a full example showing everything together:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    
    "rivaas.dev/app"
)

func main() {
    // Create app with environment variable support
    a, err := app.New(
        app.WithServiceName("orders-api"),
        app.WithServiceVersion("v1.0.0"),
        app.WithEnv(), // Read RIVAAS_* environment variables
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Register routes
    a.GET("/orders/:id", func(c *app.Context) {
        c.JSON(200, map[string]string{
            "order_id": c.Param("id"),
        })
    })
    
    // Start server
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()
    
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

Now you can configure this app without changing the code:

# Development
export RIVAAS_PORT=3000
export RIVAAS_LOG_LEVEL=debug
export RIVAAS_LOG_FORMAT=console

# Production
export RIVAAS_PORT=8080
export RIVAAS_LOG_LEVEL=info
export RIVAAS_LOG_FORMAT=json
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=jaeger:4317

Next Steps

1.7 - Middleware

Add cross-cutting concerns with built-in and custom middleware.

Overview

Middleware functions execute before and after route handlers. They add cross-cutting concerns like logging, authentication, and rate limiting.

The app package provides access to high-quality middleware from the router/middleware subpackages.

Using Middleware

Global Middleware

Apply middleware to all routes:

a := app.MustNew()

a.Use(requestid.New())
a.Use(cors.New(cors.WithAllowAllOrigins(true)))

// All routes registered after Use() will have this middleware
a.GET("/users", handler)
a.POST("/orders", handler)

Middleware During Initialization

Add middleware when creating the app:

a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithMiddleware(
        requestid.New(),
        cors.New(cors.WithAllowAllOrigins(true)),
    ),
)

Default Middleware

The app package automatically includes recovery middleware by default in both development and production modes.

To disable default middleware:

a, err := app.New(
    app.WithoutDefaultMiddleware(),
    app.WithMiddleware(myCustomRecovery), // Add your own
)

Built-in Middleware

Request ID

Generate unique request IDs for tracing:

import "rivaas.dev/router/middleware/requestid"

a.Use(requestid.New())

// Access in handler
a.GET("/", func(c *app.Context) {
    reqID := c.Response.Header().Get("X-Request-ID")
    c.JSON(http.StatusOK, map[string]string{
        "request_id": reqID,
    })
})

Options:

requestid.New(
    requestid.WithRequestIDHeader("X-Correlation-ID"),
    requestid.WithGenerator(customGenerator),
)

CORS

Handle Cross-Origin Resource Sharing:

import "rivaas.dev/router/middleware/cors"

// Allow all origins (development)
a.Use(cors.New(cors.WithAllowAllOrigins(true)))

// Specific origins (production)
a.Use(cors.New(
    cors.WithAllowedOrigins([]string{"https://example.com"}),
    cors.WithAllowCredentials(true),
    cors.WithAllowedMethods([]string{"GET", "POST", "PUT", "DELETE"}),
    cors.WithAllowedHeaders([]string{"Content-Type", "Authorization"}),
))

Recovery

Recover from panics gracefully (included by default):

import "rivaas.dev/router/middleware/recovery"

a.Use(recovery.New(
    recovery.WithStackTrace(true),
))

Access Logging

Log HTTP requests (when not using app’s built-in observability):

import "rivaas.dev/router/middleware/accesslog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

a.Use(accesslog.New(
    accesslog.WithLogger(logger),
    accesslog.WithSkipPaths([]string{"/health", "/metrics"}),
))

Note: The app package automatically configures access logging through its unified observability when WithLogging() is used.

Timeout

Add request timeout handling:

import "rivaas.dev/router/middleware/timeout"

// Default timeout (30s)
a.Use(timeout.New())

// Custom timeout
a.Use(timeout.New(
    timeout.WithDuration(5 * time.Second),
    timeout.WithSkipPaths("/stream"),
    timeout.WithSkipPrefix("/admin"),
))

Rate Limiting

Rate limit requests (single-instance only):

import "rivaas.dev/router/middleware/ratelimit"

// 100 requests per minute
a.Use(ratelimit.New(100, time.Minute))

Note: This is in-memory rate limiting suitable for single-instance deployments only. For production with multiple instances, use a distributed rate limiting solution.

Compression

Compress responses with gzip or brotli:

import "rivaas.dev/router/middleware/compression"

a.Use(compression.New(
    compression.WithLevel(compression.BestSpeed),
    compression.WithMinSize(1024), // Only compress responses > 1KB
))

Body Limit

Limit request body size:

import "rivaas.dev/router/middleware/bodylimit"

a.Use(bodylimit.New(
    bodylimit.WithMaxBytes(5 << 20), // 5MB max
))

Security Headers

Add security headers (HSTS, CSP, etc.):

import "rivaas.dev/router/middleware/securityheaders"

a.Use(securityheaders.New(
    securityheaders.WithHSTS(true),
    securityheaders.WithContentSecurityPolicy("default-src 'self'"),
    securityheaders.WithXFrameOptions("DENY"),
))

Basic Auth

HTTP Basic Authentication:

import "rivaas.dev/router/middleware/basicauth"

a.Use(basicauth.New(
    basicauth.WithUsers(map[string]string{
        "admin": "password123",
    }),
    basicauth.WithRealm("Admin Area"),
))

Custom Middleware

Writing Custom Middleware

Create custom middleware as functions:

func AuthMiddleware() app.HandlerFunc {
    return func(c *app.Context) {
        token := c.Request.Header.Get("Authorization")
        
        if token == "" {
            c.Unauthorized(fmt.Errorf("missing authorization token"))
            return
        }
        
        // Validate token...
        if !isValid(token) {
            c.Unauthorized(fmt.Errorf("invalid token"))
            return
        }
        
        // Continue to next middleware/handler
        c.Next()
    }
}

// Use it
a.Use(AuthMiddleware())

Middleware with Configuration

Create configurable middleware:

type AuthConfig struct {
    TokenHeader string
    SkipPaths   []string
}

func AuthWithConfig(config AuthConfig) app.HandlerFunc {
    return func(c *app.Context) {
        // Skip authentication for certain paths
        for _, path := range config.SkipPaths {
            if c.Request.URL.Path == path {
                c.Next()
                return
            }
        }
        
        token := c.Request.Header.Get(config.TokenHeader)
        
        if token == "" || !isValid(token) {
            c.Unauthorized(fmt.Errorf("authentication failed"))
            return
        }
        
        c.Next()
    }
}

// Use it
a.Use(AuthWithConfig(AuthConfig{
    TokenHeader: "X-API-Key",
    SkipPaths:   []string{"/health", "/public"},
}))

Middleware with State

Share state across requests:

type RateLimiter struct {
    requests map[string]int
    mu       sync.Mutex
}

func NewRateLimiter() *RateLimiter {
    return &RateLimiter{
        requests: make(map[string]int),
    }
}

func (rl *RateLimiter) Middleware() app.HandlerFunc {
    return func(c *app.Context) {
        clientIP := c.ClientIP()
        
        rl.mu.Lock()
        count := rl.requests[clientIP]
        rl.requests[clientIP]++
        rl.mu.Unlock()
        
        if count > 100 {
            c.Status(http.StatusTooManyRequests)
            return
        }
        
        c.Next()
    }
}

// Use it
limiter := NewRateLimiter()
a.Use(limiter.Middleware())

Route-Specific Middleware

Per-Route Middleware

Apply middleware to specific routes:

// Using WithBefore option
a.GET("/admin", adminHandler,
    app.WithBefore(AuthMiddleware()),
)

// Multiple middleware
a.GET("/admin/users", handler,
    app.WithBefore(
        AuthMiddleware(),
        AdminOnlyMiddleware(),
    ),
)

After Middleware

Execute middleware after the handler:

a.GET("/orders/:id", handler,
    app.WithAfter(AuditLogMiddleware()),
)

Combined Middleware

Combine before and after middleware:

a.POST("/orders", handler,
    app.WithBefore(AuthMiddleware(), RateLimitMiddleware()),
    app.WithAfter(AuditLogMiddleware()),
)

Group Middleware

Apply middleware to route groups:

// Admin routes with auth middleware
admin := a.Group("/admin", AuthMiddleware(), AdminOnlyMiddleware())
admin.GET("/users", getUsersHandler)
admin.POST("/users", createUserHandler)

// API routes with rate limiting
api := a.Group("/api", RateLimitMiddleware())
api.GET("/status", statusHandler)
api.GET("/version", versionHandler)

Middleware Execution Order

Middleware executes in the order it’s registered:

a.Use(Middleware1())  // Executes first
a.Use(Middleware2())  // Executes second
a.Use(Middleware3())  // Executes third

a.GET("/", handler)   // Handler executes last

// Execution order:
// 1. Middleware1
// 2. Middleware2
// 3. Middleware3
// 4. handler
// 5. Middleware3 (after c.Next())
// 6. Middleware2 (after c.Next())
// 7. Middleware1 (after c.Next())

Complete Example

package main

import (
    "log/slog"
    "net/http"
    "time"
    
    "rivaas.dev/app"
    "rivaas.dev/router/middleware/requestid"
    "rivaas.dev/router/middleware/cors"
    "rivaas.dev/router/middleware/timeout"
)

func main() {
    a := app.MustNew(
        app.WithServiceName("api"),
        app.WithMiddleware(
            requestid.New(),
            cors.New(cors.WithAllowAllOrigins(true)),
            timeout.New(timeout.WithDuration(30 * time.Second)),
        ),
    )
    
    // Custom middleware
    a.Use(LoggingMiddleware())
    a.Use(AuthMiddleware())
    
    // Public routes (no auth)
    a.GET("/health", healthHandler)
    
    // Protected routes (with auth)
    a.GET("/users", usersHandler)
    
    // Admin routes (with auth + admin check)
    admin := a.Group("/admin", AdminOnlyMiddleware())
    admin.GET("/dashboard", dashboardHandler)
    
    // Start server...
}

func LoggingMiddleware() app.HandlerFunc {
    return func(c *app.Context) {
        start := time.Now()
        
        c.Next()
        
        duration := time.Since(start)
        slog.InfoContext(c.RequestContext(), "request completed",
            "method", c.Request.Method,
            "path", c.Request.URL.Path,
            "duration", duration,
        )
    }
}

func AuthMiddleware() app.HandlerFunc {
    return func(c *app.Context) {
        // Skip auth for health check
        if c.Request.URL.Path == "/health" {
            c.Next()
            return
        }
        
        token := c.Request.Header.Get("Authorization")
        if token == "" {
            c.Unauthorized(fmt.Errorf("missing authorization token"))
            return
        }
        
        c.Next()
    }
}

func AdminOnlyMiddleware() app.HandlerFunc {
    return func(c *app.Context) {
        // Check if user is admin...
        if !isAdmin() {
            c.Forbidden(fmt.Errorf("admin access required"))
            return
        }
        
        c.Next()
    }
}

Next Steps

  • Routing - Organize routes with groups and versioning
  • Context - Access request and response in middleware
  • Examples - See complete working examples

1.8 - Routing

Organize routes with groups, versioning, and static files.

Route Registration

HTTP Method Shortcuts

Register routes using HTTP method shortcuts:

a.GET("/users", handler)
a.POST("/users", handler)
a.PUT("/users/:id", handler)
a.PATCH("/users/:id", handler)
a.DELETE("/users/:id", handler)
a.HEAD("/users", handler)
a.OPTIONS("/users", handler)

Match All Methods

Register a route that matches all HTTP methods:

a.Any("/webhook", webhookHandler)
// Handles GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS

Route Groups

Basic Groups

Organize routes with shared prefixes:

api := a.Group("/api")
api.GET("/users", getUsersHandler)
api.POST("/users", createUserHandler)
// Routes: GET /api/users, POST /api/users

Nested Groups

Create hierarchical route structures:

api := a.Group("/api")
v1 := api.Group("/v1")
v1.GET("/users", getUsersHandler)
// Route: GET /api/v1/users

Groups with Middleware

Apply middleware to all routes in a group:

admin := a.Group("/admin", AuthMiddleware(), AdminOnlyMiddleware())
admin.GET("/users", getUsersHandler)
admin.POST("/users", createUserHandler)
// Both routes have auth and admin middleware

API Versioning

Version Groups

Create version-specific routes:

v1 := a.Version("v1")
v1.GET("/users", v1GetUsersHandler)

v2 := a.Version("v2")
v2.GET("/users", v2GetUsersHandler)

Version Detection

Configure how versions are detected. This requires router configuration:

a, err := app.New(
    app.WithRouter(
        router.WithVersioning(
            router.WithVersionHeader("API-Version"),
            router.WithVersionQuery("version"),
        ),
    ),
)

Static Files

Serve Directory

Serve static files from a directory:

a.Static("/static", "./public")
// Files in ./public served at /static/*

Serve Single File

Serve a single file at a specific path:

a.File("/favicon.ico", "./static/favicon.ico")
a.File("/robots.txt", "./static/robots.txt")

Serve from Filesystem

Serve files from an http.FileSystem:

//go:embed static
var staticFiles embed.FS

a.StaticFS("/assets", http.FS(staticFiles))

Route Naming

Named Routes

Name routes for URL generation:

a.GET("/users/:id", getUserHandler).Name("users.get")
a.POST("/users", createUserHandler).Name("users.create")

Generate URLs

Generate URLs from route names:

// After router is frozen (after a.Start())
url, err := a.URLFor("users.get", map[string]string{"id": "123"}, nil)
// Returns: "/users/123"

// With query parameters
url, err := a.URLFor("users.get", 
    map[string]string{"id": "123"},
    map[string][]string{"expand": {"profile"}},
)
// Returns: "/users/123?expand=profile"

Must Generate URLs

Generate URLs and panic on error:

url := a.MustURLFor("users.get", map[string]string{"id": "123"}, nil)

Route Constraints

Numeric Constraints

Constrain parameters to numeric values:

a.GET("/users/:id", handler).WhereInt("id")
// Only matches /users/123, not /users/abc

UUID Constraints

Constrain parameters to UUIDs:

a.GET("/orders/:id", handler).WhereUUID("id")
// Only matches valid UUIDs

Custom Constraints

Use regex patterns for custom constraints:

a.GET("/posts/:slug", handler).Where("slug", `[a-z\-]+`)
// Only matches lowercase letters and hyphens

Custom 404 Handler

Set NoRoute Handler

Handle routes that don’t match:

a.NoRoute(func(c *app.Context) {
    c.JSON(http.StatusNotFound, map[string]string{
        "error": "route not found",
        "path": c.Request.URL.Path,
    })
})

Complete Example

package main

import (
    "log"
    "net/http"
    
    "rivaas.dev/app"
)

func main() {
    a := app.MustNew(
        app.WithServiceName("api"),
    )
    
    // Root routes
    a.GET("/", homeHandler)
    a.GET("/health", healthHandler)
    
    // API v1
    v1 := a.Group("/api/v1")
    v1.GET("/status", statusHandler)
    
    // Users
    users := v1.Group("/users")
    users.GET("", getUsersHandler).Name("users.list")
    users.POST("", createUserHandler).Name("users.create")
    users.GET("/:id", getUserHandler).Name("users.get").WhereInt("id")
    users.PUT("/:id", updateUserHandler).Name("users.update").WhereInt("id")
    users.DELETE("/:id", deleteUserHandler).Name("users.delete").WhereInt("id")
    
    // Admin routes with authentication
    admin := a.Group("/admin", AuthMiddleware())
    admin.GET("/dashboard", dashboardHandler)
    admin.GET("/users", adminGetUsersHandler)
    
    // Static files
    a.Static("/assets", "./public")
    a.File("/favicon.ico", "./public/favicon.ico")
    
    // Custom 404
    a.NoRoute(func(c *app.Context) {
        c.NotFound(fmt.Errorf("route not found"))
    })
    
    // Start server...
}

Next Steps

  • Middleware - Add middleware to routes and groups
  • Context - Access route parameters and query strings
  • Examples - See complete working examples

1.9 - Lifecycle

Use lifecycle hooks for initialization, cleanup, reload, and event handling.

Overview

The app package provides lifecycle hooks for managing application state:

  • OnStart - Called before server starts. Runs sequentially. Stops on first error.
  • OnReady - Called when server is ready to accept connections. Runs async. Non-blocking.
  • OnReload - Called when SIGHUP is received or Reload() is called. Runs sequentially. Errors logged.
  • OnShutdown - Called during graceful shutdown. LIFO order.
  • OnStop - Called after shutdown completes. Best-effort.
  • OnRoute - Called when a route is registered. Synchronous.

OnStart Hook

Basic Usage

Initialize resources before the server starts:

a := app.MustNew()

a.OnStart(func(ctx context.Context) error {
    log.Println("Connecting to database...")
    return db.Connect(ctx)
})

a.OnStart(func(ctx context.Context) error {
    log.Println("Running migrations...")
    return db.Migrate(ctx)
})

// Start server - hooks execute before listening
a.Start(ctx)

Error Handling

OnStart hooks run sequentially and stop on first error:

a.OnStart(func(ctx context.Context) error {
    if err := db.Connect(ctx); err != nil {
        return fmt.Errorf("database connection failed: %w", err)
    }
    return nil
})

// If this hook fails, server won't start
if err := a.Start(ctx); err != nil {
    log.Fatalf("Startup failed: %v", err)
}

Common Use Cases

// Database connection
a.OnStart(func(ctx context.Context) error {
    return db.PingContext(ctx)
})

// Load configuration
a.OnStart(func(ctx context.Context) error {
    return config.Load("config.yaml")
})

// Initialize caches
a.OnStart(func(ctx context.Context) error {
    return cache.Warmup(ctx)
})

// Check external dependencies
a.OnStart(func(ctx context.Context) error {
    return checkExternalServices(ctx)
})

OnReady Hook

Basic Usage

Execute tasks after the server starts listening:

a.OnReady(func() {
    log.Println("Server is ready!")
    log.Printf("Listening on :8080")
})

a.OnReady(func() {
    // Register with service discovery
    consul.Register("my-service", ":8080")
})

Async Execution

OnReady hooks run asynchronously and don’t block startup:

a.OnReady(func() {
    // Long-running warmup task
    time.Sleep(5 * time.Second)
    cache.Preload()
})

// Server accepts connections immediately, warmup runs in background

Error Handling

Panics in OnReady hooks are caught and logged:

a.OnReady(func() {
    // If this panics, it's logged but doesn't crash the server
    doSomethingRisky()
})

OnReload Hook

What is it?

The OnReload hook lets you reload your app’s configuration without stopping the server. When you register this hook, your app automatically listens for SIGHUP signals on Unix systems (Linux, macOS). No extra setup needed!

Basic Usage

Here’s how to reload configuration when you get a SIGHUP signal:

a := app.MustNew(
    app.WithServiceName("my-api"),
)

// Register a reload hook - SIGHUP is now automatically enabled!
a.OnReload(func(ctx context.Context) error {
    log.Println("Reloading configuration...")
    
    // Load new config
    newConfig, err := loadConfig("config.yaml")
    if err != nil {
        return fmt.Errorf("failed to load config: %w", err)
    }
    
    // Apply new config
    applyConfig(newConfig)
    return nil
})

// Start server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
a.Start(ctx)

Now you can reload without restarting:

# Send SIGHUP to reload
kill -HUP <pid>

# Or use killall
killall -HUP my-api

How it works

When you register an OnReload hook:

  • On Unix/Linux/macOS: Your app automatically listens for SIGHUP signals
  • On Windows: SIGHUP doesn’t exist, but you can still call Reload() programmatically
  • All platforms: You can trigger reload from your code using app.Reload(ctx)

When no OnReload hooks are registered, SIGHUP is ignored on Unix so the process is not terminated (e.g. by kill -HUP or terminal disconnect).

Error Handling

If reload fails, your app keeps running with the old configuration:

a.OnReload(func(ctx context.Context) error {
    cfg, err := loadConfig("config.yaml")
    if err != nil {
        // Error is logged, but server keeps running
        return err
    }
    
    // Validate before applying
    if err := cfg.Validate(); err != nil {
        return fmt.Errorf("invalid config: %w", err)
    }
    
    applyConfig(cfg)
    return nil
})

The hooks run one at a time (sequentially) and stop on the first error. This means if you have multiple reload hooks and one fails, the rest won’t run.

Programmatic Reload

You can also trigger reload from your code - useful for admin endpoints:

// Create an admin endpoint to trigger reload
a.POST("/admin/reload", func(c *app.Context) {
    if err := a.Reload(c.Request.Context()); err != nil {
        c.InternalError(err)
        return
    }
    c.JSON(200, map[string]string{"status": "config reloaded"})
})

Multiple Reload Hooks

You can register multiple hooks for different parts of your config:

// Reload database pool settings
a.OnReload(func(ctx context.Context) error {
    log.Println("Reloading database config...")
    return db.ReconfigurePool(ctx)
})

// Reload cache settings
a.OnReload(func(ctx context.Context) error {
    log.Println("Reloading cache config...")
    return cache.Reload(ctx)
})

// Reload log level
a.OnReload(func(ctx context.Context) error {
    log.Println("Reloading log level...")
    return logger.SetLevel(newLevel)
})

Common Use Cases

// Reload TLS certificates
a.OnReload(func(ctx context.Context) error {
    return tlsManager.ReloadCertificates()
})

// Reload feature flags
a.OnReload(func(ctx context.Context) error {
    return features.Reload(ctx)
})

// Reload rate limits
a.OnReload(func(ctx context.Context) error {
    return rateLimiter.UpdateLimits(ctx)
})

// Flush caches
a.OnReload(func(ctx context.Context) error {
    cache.Clear()
    return nil
})

What can’t be reloaded?

Routes and middleware can’t be changed after the server starts - they’re frozen for safety. Only reload things like:

  • Configuration files
  • Database connection settings
  • TLS certificates
  • Cache contents
  • Log levels
  • Feature flags

Platform Differences

  • Unix/Linux/macOS: SIGHUP works automatically
  • Windows: SIGHUP isn’t available, use app.Reload(ctx) instead

Thread Safety

Don’t worry about multiple reload signals at the same time - the framework handles this automatically. If multiple SIGHUPs come in, they’ll run one at a time.

OnShutdown Hook

Basic Usage

Clean up resources during graceful shutdown:

a.OnShutdown(func(ctx context.Context) {
    log.Println("Shutting down gracefully...")
    db.Close()
})

a.OnShutdown(func(ctx context.Context) {
    log.Println("Flushing metrics...")
    metrics.Flush(ctx)
})

LIFO Execution Order

OnShutdown hooks execute in reverse order (Last In, First Out):

a.OnShutdown(func(ctx context.Context) {
    log.Println("1. First registered")
})

a.OnShutdown(func(ctx context.Context) {
    log.Println("2. Second registered")
})

// During shutdown, prints:
// "2. Second registered"
// "1. First registered"

This ensures cleanup happens in reverse dependency order.

Timeout Handling

OnShutdown hooks must complete within the shutdown timeout:

a, err := app.New(
    app.WithServer(
        app.WithShutdownTimeout(30 * time.Second),
    ),
)

a.OnShutdown(func(ctx context.Context) {
    // This context has a 30s deadline
    select {
    case <-flushComplete:
        log.Println("Flush completed")
    case <-ctx.Done():
        log.Println("Flush timed out")
    }
})

Common Use Cases

// Close database connections
a.OnShutdown(func(ctx context.Context) {
    db.Close()
})

// Flush metrics and traces
a.OnShutdown(func(ctx context.Context) {
    metrics.Shutdown(ctx)
    tracing.Shutdown(ctx)
})

// Deregister from service discovery
a.OnShutdown(func(ctx context.Context) {
    consul.Deregister("my-service")
})

// Close external connections
a.OnShutdown(func(ctx context.Context) {
    redis.Close()
    messageQueue.Close()
})

OnStop Hook

Basic Usage

Final cleanup after shutdown completes:

a.OnStop(func() {
    log.Println("Cleanup complete")
    cleanupTempFiles()
})

Best-Effort Execution

OnStop hooks run in best-effort mode - panics are caught and logged:

a.OnStop(func() {
    // Even if this panics, other hooks still run
    cleanupTempFiles()
})

No Timeout

OnStop hooks don’t have a timeout constraint:

a.OnStop(func() {
    // This can take as long as needed
    archiveLogs()
})

OnRoute Hook

Basic Usage

Execute code when routes are registered:

a.OnRoute(func(rt *route.Route) {
    log.Printf("Registered: %s %s", rt.Method(), rt.Path())
})

// Register routes - hook fires for each one
a.GET("/users", handler)
a.POST("/users", handler)

Route Validation

Validate routes during registration:

a.OnRoute(func(rt *route.Route) {
    // Ensure all routes have names
    if rt.Name() == "" {
        log.Printf("Warning: Route %s %s has no name", rt.Method(), rt.Path())
    }
})

Documentation Generation

Use for automatic documentation:

var routes []string

a.OnRoute(func(rt *route.Route) {
    routes = append(routes, fmt.Sprintf("%s %s", rt.Method(), rt.Path()))
})

// After all routes registered
a.OnReady(func() {
    log.Printf("Registered %d routes:", len(routes))
    for _, r := range routes {
        log.Println("  ", r)
    }
})

Complete Example

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

var db *Database

func main() {
    a := app.MustNew(
        app.WithServiceName("api"),
        app.WithServer(
            app.WithShutdownTimeout(30 * time.Second),
        ),
    )
    
    // OnStart: Initialize resources
    a.OnStart(func(ctx context.Context) error {
        log.Println("Connecting to database...")
        var err error
        db, err = ConnectDB(ctx)
        if err != nil {
            return fmt.Errorf("database connection failed: %w", err)
        }
        return nil
    })
    
    a.OnStart(func(ctx context.Context) error {
        log.Println("Running migrations...")
        return db.Migrate(ctx)
    })
    
    // OnRoute: Log route registration
    a.OnRoute(func(rt *route.Route) {
        log.Printf("Route registered: %s %s", rt.Method(), rt.Path())
    })
    
    // OnReady: Post-startup tasks
    a.OnReady(func() {
        log.Println("Server is ready!")
        log.Println("Registering with service discovery...")
        consul.Register("api", ":8080")
    })
    
    // OnShutdown: Graceful cleanup
    a.OnShutdown(func(ctx context.Context) {
        log.Println("Deregistering from service discovery...")
        consul.Deregister("api")
    })
    
    a.OnShutdown(func(ctx context.Context) {
        log.Println("Closing database connection...")
        if err := db.Close(); err != nil {
            log.Printf("Error closing database: %v", err)
        }
    })
    
    // OnStop: Final cleanup
    a.OnStop(func() {
        log.Println("Cleanup complete")
    })
    
    // Register routes
    a.GET("/", homeHandler)
    a.GET("/health", healthHandler)
    
    // Setup graceful shutdown
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()
    
    // Start server
    log.Println("Starting server...")
    if err := a.Start(ctx); err != nil {
        log.Fatalf("Server error: %v", err)
    }
}

Hook Execution Flow

1. app.Start(ctx) called
2. OnStart hooks execute (sequential, stop on error)
3. Server starts listening
4. OnReady hooks execute (async, non-blocking)
5. Server handles requests...
   → OnReload hooks execute when SIGHUP received (sequential, logged on error)
6. Context canceled (SIGTERM/SIGINT)
7. OnShutdown hooks execute (LIFO order, with timeout)
8. Server shutdown complete
9. OnStop hooks execute (best-effort, no timeout)
10. Process exits

Next Steps

1.10 - Health Endpoints

Configure Kubernetes-compatible liveness and readiness probes.

Overview

The app package provides standard health check endpoints. They work with Kubernetes and other orchestration platforms:

  • Liveness Probe (/livez) — Tells you if the process is alive. Restart the container if it fails.
  • Readiness Probe (/readyz) — Tells you if the service can accept traffic.

Basic Configuration

Enable Health Endpoints

Enable health endpoints with defaults.

a, err := app.New(
    app.WithHealthEndpoints(),
)

// Endpoints:
// GET /livez - Liveness probe
// GET /readyz - Readiness probe

Custom Paths

Configure custom health check paths:

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithLivezPath("/health/live"),
        app.WithReadyzPath("/health/ready"),
    ),
)

Path Prefix

Mount health endpoints under a prefix.

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithHealthPrefix("/_system"),
    ),
)

// Endpoints:
// GET /_system/livez
// GET /_system/readyz

Liveness Checks

Basic Liveness Check

Liveness checks should be dependency-free and fast:

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithLivenessCheck("process", func(ctx context.Context) error {
            // Process is alive if we can execute this
            return nil
        }),
    ),
)

Multiple Liveness Checks

Add multiple liveness checks.

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithLivenessCheck("process", func(ctx context.Context) error {
            return nil
        }),
        app.WithLivenessCheck("goroutines", func(ctx context.Context) error {
            if runtime.NumGoroutine() > 10000 {
                return fmt.Errorf("too many goroutines: %d", runtime.NumGoroutine())
            }
            return nil
        }),
    ),
)

Liveness Behavior

  • Returns 200 "ok" if all checks pass
  • Returns 503 if any check fails
  • If no checks configured, always returns 200

Readiness Checks

Basic Readiness Check

Readiness checks verify external dependencies:

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithReadinessCheck("database", func(ctx context.Context) error {
            return db.PingContext(ctx)
        }),
    ),
)

Multiple Readiness Checks

Check multiple dependencies:

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithReadinessCheck("database", func(ctx context.Context) error {
            return db.PingContext(ctx)
        }),
        app.WithReadinessCheck("cache", func(ctx context.Context) error {
            return redis.Ping(ctx).Err()
        }),
        app.WithReadinessCheck("api", func(ctx context.Context) error {
            return checkUpstreamAPI(ctx)
        }),
    ),
)

Readiness Behavior

  • Returns 204 if all checks pass
  • Returns 503 if any check fails
  • If no checks configured, always returns 204

Health Check Timeout

Configure timeout for individual checks:

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithHealthTimeout(800 * time.Millisecond),
        app.WithReadinessCheck("database", func(ctx context.Context) error {
            // This check has 800ms to complete
            return db.PingContext(ctx)
        }),
    ),
)

Default timeout: 1s

Runtime Readiness Gates

Readiness Manager

Dynamically manage readiness state at runtime:

type DatabaseGate struct {
    db *sql.DB
}

func (g *DatabaseGate) Ready() bool {
    return g.db.Ping() == nil
}

func (g *DatabaseGate) Name() string {
    return "database"
}

// Register gate at runtime
a.Readiness().Register("db", &DatabaseGate{db: db})

// Unregister during shutdown
a.OnShutdown(func(ctx context.Context) {
    a.Readiness().Unregister("db")
})

Use Cases

Runtime gates are useful for:

  • Connection pools that manage their own health
  • Circuit breakers that track upstream failures
  • Dynamic dependencies that come and go at runtime

Liveness vs Readiness

When to Use Liveness

Liveness checks answer: “Should the process be restarted?”

Use for:

  • Detecting deadlocks
  • Detecting infinite loops
  • Detecting corrupted state that requires restart

Don’t use for:

  • External dependency failures (use readiness instead)
  • Temporary errors that will resolve themselves
  • Network connectivity issues

When to Use Readiness

Readiness checks answer: “Can this instance handle traffic?”

Use for:

  • Database connectivity
  • Cache availability
  • Upstream service health
  • Initialization completion

Don’t use for:

  • Process-level health (use liveness instead)
  • Permanent failures that require restart

Kubernetes Configuration

Deployment YAML

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-api
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: api
        image: my-api:latest
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /livez
            port: 8080
          initialDelaySeconds: 10
          periodSeconds: 10
          timeoutSeconds: 1
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /readyz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
          timeoutSeconds: 1
          failureThreshold: 3

Complete Example

package main

import (
    "context"
    "database/sql"
    "log"
    "time"
    
    "rivaas.dev/app"
)

var db *sql.DB

func main() {
    a, err := app.New(
        app.WithServiceName("api"),
        
        // Health endpoints configuration
        app.WithHealthEndpoints(
            // Custom paths
            app.WithHealthPrefix("/_system"),
            
            // Timeout for checks
            app.WithHealthTimeout(800 * time.Millisecond),
            
            // Liveness: process-level health
            app.WithLivenessCheck("process", func(ctx context.Context) error {
                // Always healthy if we can execute this
                return nil
            }),
            
            // Readiness: dependency health
            app.WithReadinessCheck("database", func(ctx context.Context) error {
                return db.PingContext(ctx)
            }),
            
            app.WithReadinessCheck("cache", func(ctx context.Context) error {
                return checkCache(ctx)
            }),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Initialize database
    a.OnStart(func(ctx context.Context) error {
        var err error
        db, err = sql.Open("postgres", "...")
        return err
    })
    
    // Unregister readiness during shutdown
    a.OnShutdown(func(ctx context.Context) {
        // Mark as not ready before closing connections
        log.Println("Marking service as not ready")
        time.Sleep(100 * time.Millisecond) // Allow load balancer to notice
    })
    
    // Register routes...
    
    // Start server...
    // Endpoints available at:
    // GET /_system/livez - Liveness
    // GET /_system/readyz - Readiness
}

func checkCache(ctx context.Context) error {
    // Check cache connectivity
    return nil
}

Testing Health Endpoints

Test Liveness

curl http://localhost:8080/livez
# Expected: 200 OK
# Body: "ok"

Test Readiness

curl http://localhost:8080/readyz
# Expected: 204 No Content (healthy)
# Or: 503 Service Unavailable (unhealthy)

Test with Custom Prefix

curl http://localhost:8080/_system/livez
curl http://localhost:8080/_system/readyz

Next Steps

1.11 - Debug Endpoints

Enable pprof profiling endpoints for performance analysis and debugging.

Overview

The app package provides optional debug endpoints for profiling and diagnostics. It uses Go’s net/http/pprof package.

Security Warning: Debug endpoints expose sensitive runtime information. NEVER enable them in production without proper security measures.

Basic Configuration

Enable pprof Unconditionally

Enable pprof endpoints. Use for development only.

a, err := app.New(
    app.WithDebugEndpoints(
        app.WithPprof(),
    ),
)

Enable pprof Conditionally

Enable based on environment variable. This is recommended:

a, err := app.New(
    app.WithDebugEndpoints(
        app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
    ),
)

Custom Prefix

Mount debug endpoints under a custom prefix.

a, err := app.New(
    app.WithDebugEndpoints(
        app.WithDebugPrefix("/_internal/debug"),
        app.WithPprof(),
    ),
)

Available Endpoints

When pprof is enabled, the following endpoints are registered:

EndpointDescription
GET /debug/pprof/Main pprof index
GET /debug/pprof/cmdlineCommand line invocation
GET /debug/pprof/profileCPU profile (30s by default)
GET /debug/pprof/symbolSymbol lookup
POST /debug/pprof/symbolSymbol lookup (POST)
GET /debug/pprof/traceExecution trace
GET /debug/pprof/allocsMemory allocations profile
GET /debug/pprof/blockBlock profile
GET /debug/pprof/goroutineGoroutine profile
GET /debug/pprof/heapHeap profile
GET /debug/pprof/mutexMutex profile
GET /debug/pprof/threadcreateThread creation profile

Security Considerations

Development

Safe to enable unconditionally in development.

a, err := app.New(
    app.WithEnvironment("development"),
    app.WithDebugEndpoints(
        app.WithPprof(),
    ),
)

Staging

Enable behind VPN or IP allowlist:

a, err := app.New(
    app.WithEnvironment("staging"),
    app.WithDebugEndpoints(
        app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
    ),
)

// Use authentication middleware
a.Use(IPAllowlistMiddleware([]string{"10.0.0.0/8"}))

Production

Enable only with proper authentication:

a, err := app.New(
    app.WithEnvironment("production"),
    app.WithDebugEndpoints(
        app.WithDebugPrefix("/_internal/debug"),
        app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
    ),
)

// Protect debug endpoints with authentication
debugAuth := a.Group("/_internal", AdminAuthMiddleware())
// pprof endpoints are automatically under this group

Using pprof

CPU Profile

Capture a 30-second CPU profile:

curl http://localhost:8080/debug/pprof/profile > cpu.prof
go tool pprof cpu.prof

Heap Profile

Capture current heap allocations:

curl http://localhost:8080/debug/pprof/heap > heap.prof
go tool pprof heap.prof

Goroutine Profile

View current goroutines:

curl http://localhost:8080/debug/pprof/goroutine > goroutine.prof
go tool pprof goroutine.prof

Interactive Analysis

Analyze profiles interactively:

# CPU profile
go tool pprof http://localhost:8080/debug/pprof/profile

# Heap profile
go tool pprof http://localhost:8080/debug/pprof/heap

# Goroutine profile
go tool pprof http://localhost:8080/debug/pprof/goroutine

Web UI

View profiles in a web browser:

go tool pprof -http=:8081 http://localhost:8080/debug/pprof/profile

Complete Example

package main

import (
    "log"
    "os"
    
    "rivaas.dev/app"
)

func main() {
    env := os.Getenv("ENVIRONMENT")
    if env == "" {
        env = "development"
    }
    
    a, err := app.New(
        app.WithServiceName("api"),
        app.WithEnvironment(env),
        
        // Debug endpoints with conditional pprof
        app.WithDebugEndpoints(
            app.WithDebugPrefix("/_internal/debug"),
            app.WithPprofIf(env == "development" || os.Getenv("PPROF_ENABLED") == "true"),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // In production, protect debug endpoints
    if env == "production" {
        // Add authentication middleware to /_internal/* routes
        a.Use(func(c *app.Context) {
            if strings.HasPrefix(c.Request.URL.Path, "/_internal/") {
                // Verify admin token
                if !isAdmin(c) {
                    c.Forbidden(fmt.Errorf("admin access required"))
                    return
                }
            }
            c.Next()
        })
    }
    
    // Register routes...
    
    // Start server...
}

Best Practices

  1. Never enable in production without authentication
  2. Use environment variables for conditional enablement
  3. Mount under non-obvious path prefix
  4. Log when pprof is enabled
  5. Document security requirements in deployment docs
  6. Consider using separate admin port

Next Steps

1.12 - Server

Start HTTP, HTTPS, and mTLS servers with graceful shutdown.

HTTP Server

Basic HTTP Server

Start an HTTP server:

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

if err := a.Start(ctx); err != nil {
    log.Fatal(err)
}

Custom Address

Configure the listen address via options when creating the app (default is :8080):

// Localhost only
a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithHost("127.0.0.1"),
    app.WithPort(8080),
)
// ...
a.Start(ctx)

// All interfaces (default)
a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithPort(8080),
)
// ...
a.Start(ctx)

HTTPS Server

Start HTTPS Server

Start with TLS certificates:

ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

if err := a.StartTLS(ctx, "server.crt", "server.key"); err != nil {
    log.Fatal(err)
}

Generate Self-Signed Certificate

For development:

openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes

mTLS Server

Start mTLS Server

Mutual TLS with client certificate verification:

// Load server certificate
serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
    log.Fatal(err)
}

// Load CA certificate for client validation
caCert, err := os.ReadFile("ca.crt")
if err != nil {
    log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)

// Start mTLS server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()

err = a.StartMTLS(ctx, serverCert,
    app.WithClientCAs(caCertPool),
    app.WithMinVersion(tls.VersionTLS13),
)

Client Authorization

Authorize clients based on certificate:

err = a.StartMTLS(ctx, serverCert,
    app.WithClientCAs(caCertPool),
    app.WithAuthorize(func(cert *x509.Certificate) (string, bool) {
        // Extract principal from certificate
        principal := cert.Subject.CommonName
        
        // Check if authorized
        if principal == "" {
            return "", false
        }
        
        return principal, true
    }),
)

Graceful Shutdown

Signal-Based Shutdown

Use signal.NotifyContext for graceful shutdown:

ctx, cancel := signal.NotifyContext(
    context.Background(),
    os.Interrupt,
    syscall.SIGTERM,
)
defer cancel()

if err := a.Start(ctx); err != nil {
    log.Fatal(err)
}

Shutdown Process

When context is canceled:

  1. Server stops accepting new connections
  2. OnShutdown hooks execute (LIFO order)
  3. Server waits for in-flight requests (up to shutdown timeout)
  4. Observability components shut down (metrics, tracing)
  5. OnStop hooks execute (best-effort)
  6. Process exits

Shutdown Timeout

Configure shutdown timeout:

a, err := app.New(
    app.WithServer(
        app.WithShutdownTimeout(30 * time.Second),
    ),
)

Default: 30 seconds

Complete Examples

HTTP with Graceful Shutdown

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    a := app.MustNew(
        app.WithServiceName("api"),
    )
    
    a.GET("/", homeHandler)
    
    ctx, cancel := signal.NotifyContext(
        context.Background(),
        os.Interrupt,
        syscall.SIGTERM,
    )
    defer cancel()
    
    log.Println("Server starting on :8080")
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

HTTPS with mTLS

package main

import (
    "context"
    "crypto/tls"
    "crypto/x509"
    "log"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    a := app.MustNew(app.WithServiceName("secure-api"))
    
    // Load certificates
    serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        log.Fatal(err)
    }
    
    caCert, err := os.ReadFile("ca.crt")
    if err != nil {
        log.Fatal(err)
    }
    
    caCertPool := x509.NewCertPool()
    caCertPool.AppendCertsFromPEM(caCert)
    
    // Register routes
    a.GET("/", homeHandler)
    
    // Start mTLS server
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()
    
    log.Println("mTLS server starting on :8443")
    err = a.StartMTLS(ctx, serverCert,
        app.WithClientCAs(caCertPool),
        app.WithMinVersion(tls.VersionTLS13),
    )
    if err != nil {
        log.Fatal(err)
    }
}

Next Steps

1.13 - OpenAPI

Automatically generate OpenAPI specifications and Swagger UI.

Overview

The app package integrates with the rivaas.dev/openapi package. It automatically generates OpenAPI specifications with Swagger UI.

Basic Configuration

Enable OpenAPI

Enable OpenAPI with default configuration:

a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithServiceVersion("v1.0.0"),
    app.WithOpenAPI(
        openapi.WithSwaggerUI(true, "/docs"),
    ),
)

Service name and version are automatically injected into the OpenAPI spec.

Configure OpenAPI

API Information

Configure API metadata:

a, err := app.New(
    app.WithOpenAPI(
        openapi.WithTitle("My API", "1.0.0"),
        openapi.WithDescription("API for managing resources"),
        openapi.WithContact("API Support", "https://example.com/support", "support@example.com"),
        openapi.WithLicense("Apache 2.0", "https://www.apache.org/licenses/LICENSE-2.0"),
    ),
)

Servers

Add server URLs:

a, err := app.New(
    app.WithOpenAPI(
        openapi.WithServer("http://localhost:8080", "Local development"),
        openapi.WithServer("https://api.example.com", "Production"),
    ),
)

Security

Configure security schemes:

a, err := app.New(
    app.WithOpenAPI(
        openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
        openapi.WithAPIKeyAuth("apiKey", "header", "X-API-Key", "API key authentication"),
    ),
)

Document Routes

WithDoc Option

Document routes inline:

a.GET("/users/:id", getUserHandler,
    app.WithDoc(
        openapi.WithSummary("Get user by ID"),
        openapi.WithDescription("Retrieves a user by their unique identifier"),
        openapi.WithResponse(200, UserResponse{}),
        openapi.WithResponse(404, ErrorResponse{}),
        openapi.WithTags("users"),
    ),
)

Request Bodies

Document request bodies:

a.POST("/users", createUserHandler,
    app.WithDoc(
        openapi.WithSummary("Create user"),
        openapi.WithRequest(CreateUserRequest{}),
        openapi.WithResponse(201, UserResponse{}),
    ),
)

Parameters

Document path and query parameters:

a.GET("/users", listUsersHandler,
    app.WithDoc(
        openapi.WithSummary("List users"),
        openapi.WithQueryParam("page", "integer", "Page number"),
        openapi.WithQueryParam("limit", "integer", "Items per page"),
        openapi.WithResponse(200, UserListResponse{}),
    ),
)

Swagger UI

Enable Swagger UI

Enable Swagger UI at a specific path:

a, err := app.New(
    app.WithOpenAPI(
        openapi.WithSwaggerUI(true, "/docs"),
    ),
)

// Access Swagger UI at: http://localhost:8080/docs

Configure Swagger UI

Customize Swagger UI appearance:

a, err := app.New(
    app.WithOpenAPI(
        openapi.WithSwaggerUI(true, "/docs"),
        openapi.WithUIDocExpansion(openapi.DocExpansionList),
        openapi.WithUISyntaxTheme(openapi.SyntaxThemeMonokai),
        openapi.WithUIDeepLinking(true),
    ),
)

OpenAPI Endpoints

When OpenAPI is enabled, two endpoints are registered:

  • GET /openapi.json - OpenAPI specification (JSON)
  • GET /docs - Swagger UI (if enabled)

Custom Spec Path

Configure custom spec path:

a, err := app.New(
    app.WithOpenAPI(
        openapi.WithSpecPath("/api/spec.json"),
        openapi.WithSwaggerUI(true, "/api/docs"),
    ),
)

Complete Example

package main

import (
    "log"
    "net/http"
    
    "rivaas.dev/app"
    "rivaas.dev/openapi"
)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
}

func main() {
    a, err := app.New(
        app.WithServiceName("users-api"),
        app.WithServiceVersion("v1.0.0"),
        
        app.WithOpenAPI(
            openapi.WithDescription("API for managing users"),
            openapi.WithServer("http://localhost:8080", "Development"),
            openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
            openapi.WithSwaggerUI(true, "/docs"),
            openapi.WithTags(
                openapi.Tag("users", "User management"),
            ),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // List users
    a.GET("/users", listUsersHandler,
        app.WithDoc(
            openapi.WithSummary("List users"),
            openapi.WithDescription("Returns a list of all users"),
            openapi.WithResponse(200, []User{}),
            openapi.WithTags("users"),
        ),
    )
    
    // Create user
    a.POST("/users", createUserHandler,
        app.WithDoc(
            openapi.WithSummary("Create user"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(201, User{}),
            openapi.WithResponse(400, map[string]string{}),
            openapi.WithTags("users"),
            openapi.WithSecurity("bearerAuth"),
        ),
    )
    
    // Get user
    a.GET("/users/:id", getUserHandler,
        app.WithDoc(
            openapi.WithSummary("Get user by ID"),
            openapi.WithResponse(200, User{}),
            openapi.WithResponse(404, map[string]string{}),
            openapi.WithTags("users"),
        ),
    )
    
    // Start server
    // OpenAPI spec: http://localhost:8080/openapi.json
    // Swagger UI: http://localhost:8080/docs
}

Next Steps

1.14 - Testing

Test routes and handlers without starting a server.

Overview

The app package provides built-in testing utilities. Test routes and handlers without starting an HTTP server.

Test Method

Basic Testing

Test routes using app.Test():

func TestHome(t *testing.T) {
    a := app.MustNew()
    a.GET("/", homeHandler)
    
    req := httptest.NewRequest("GET", "/", nil)
    resp, err := a.Test(req)
    if err != nil {
        t.Fatal(err)
    }
    
    if resp.StatusCode != 200 {
        t.Errorf("expected 200, got %d", resp.StatusCode)
    }
}

With Timeout

Configure test timeout:

req := httptest.NewRequest("GET", "/slow", nil)
resp, err := a.Test(req, app.WithTimeout(5*time.Second))

With Context

Pass custom context:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req, app.WithContext(ctx))

TestJSON Method

Basic JSON Testing

Test JSON endpoints easily:

func TestCreateUser(t *testing.T) {
    a := app.MustNew()
    a.POST("/users", createUserHandler)
    
    body := map[string]string{
        "name": "Alice",
        "email": "alice@example.com",
    }
    
    resp, err := a.TestJSON("POST", "/users", body)
    if err != nil {
        t.Fatal(err)
    }
    
    if resp.StatusCode != 201 {
        t.Errorf("expected 201, got %d", resp.StatusCode)
    }
}

ExpectJSON Helper

Assert JSON Responses

Use ExpectJSON for easy JSON assertions:

func TestGetUser(t *testing.T) {
    a := app.MustNew()
    a.GET("/users/:id", getUserHandler)
    
    req := httptest.NewRequest("GET", "/users/123", nil)
    resp, err := a.Test(req)
    if err != nil {
        t.Fatal(err)
    }
    
    var user User
    app.ExpectJSON(t, resp, 200, &user)
    
    if user.ID != "123" {
        t.Errorf("expected ID 123, got %s", user.ID)
    }
}

Complete Test Examples

Testing Routes

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
    
    "rivaas.dev/app"
)

func TestHomeRoute(t *testing.T) {
    a := app.MustNew()
    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Hello, World!",
        })
    })
    
    req := httptest.NewRequest("GET", "/", nil)
    resp, err := a.Test(req)
    if err != nil {
        t.Fatal(err)
    }
    
    if resp.StatusCode != 200 {
        t.Errorf("expected 200, got %d", resp.StatusCode)
    }
    
    var result map[string]string
    app.ExpectJSON(t, resp, 200, &result)
    
    if result["message"] != "Hello, World!" {
        t.Errorf("unexpected message: %s", result["message"])
    }
}

Testing with Dependencies

func TestWithDatabase(t *testing.T) {
    // Setup test database
    db := setupTestDB(t)
    defer db.Close()
    
    a := app.MustNew()
    
    a.GET("/users/:id", func(c *app.Context) {
        id := c.Param("id")
        user, err := db.GetUser(id)
        if err != nil {
            c.NotFound(fmt.Errorf("user not found"))
            return
        }
        c.JSON(http.StatusOK, user)
    })
    
    req := httptest.NewRequest("GET", "/users/123", nil)
    resp, err := a.Test(req)
    if err != nil {
        t.Fatal(err)
    }
    
    var user User
    app.ExpectJSON(t, resp, 200, &user)
}

Table-Driven Tests

func TestUserRoutes(t *testing.T) {
    a := app.MustNew()
    a.GET("/users/:id", getUserHandler)
    
    tests := []struct {
        name       string
        id         string
        wantStatus int
    }{
        {"valid ID", "123", 200},
        {"invalid ID", "abc", 400},
        {"not found", "999", 404},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest("GET", "/users/"+tt.id, nil)
            resp, err := a.Test(req)
            if err != nil {
                t.Fatal(err)
            }
            
            if resp.StatusCode != tt.wantStatus {
                t.Errorf("expected %d, got %d", tt.wantStatus, resp.StatusCode)
            }
        })
    }
}

Testing Middleware

func TestAuthMiddleware(t *testing.T) {
    a := app.MustNew()
    
    a.Use(AuthMiddleware())
    a.GET("/protected", protectedHandler)
    
    // Test without token
    req := httptest.NewRequest("GET", "/protected", nil)
    resp, _ := a.Test(req)
    if resp.StatusCode != 401 {
        t.Errorf("expected 401, got %d", resp.StatusCode)
    }
    
    // Test with token
    req = httptest.NewRequest("GET", "/protected", nil)
    req.Header.Set("Authorization", "Bearer valid-token")
    resp, _ = a.Test(req)
    if resp.StatusCode != 200 {
        t.Errorf("expected 200, got %d", resp.StatusCode)
    }
}

Next Steps

1.15 - Migration

Migrate from the router package to the app package.

When to Migrate

Consider migrating from router to app when you need:

  • Integrated observability - Built-in metrics, tracing, and logging.
  • Lifecycle management - OnStart, OnReady, OnShutdown, OnStop hooks.
  • Graceful shutdown - Automatic shutdown handling with context.
  • Health endpoints - Kubernetes-compatible liveness and readiness probes.
  • Sensible defaults - Pre-configured with production-ready settings.

Key Differences

Constructor Returns Error

Router:

r := router.New()  // No error returned

App:

a, err := app.New()  // Returns (*App, error)
if err != nil {
    log.Fatal(err)
}

// Or use MustNew() for panic on error
a := app.MustNew()

Context Type

Router:

r.GET("/", func(c *router.Context) {
    c.JSON(http.StatusOK, data)
})

App:

a.GET("/", func(c *app.Context) {  // Different context type
    c.JSON(http.StatusOK, data)
})

app.Context embeds router.Context. It adds binding, validation, and error handling methods.

Server Startup

Router:

r := router.New()
http.ListenAndServe(":8080", r)

App:

a := app.MustNew()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
a.Start(ctx)  // Includes graceful shutdown

Migration Steps

1. Update Imports

// Before
import "rivaas.dev/router"

// After
import "rivaas.dev/app"

2. Change Constructor

// Before
r := router.New(
    router.WithMetrics(),
    router.WithTracing(),
)

// After
a, err := app.New(
    app.WithServiceName("my-service"),
    app.WithObservability(
        app.WithMetrics(),
        app.WithTracing(),
    ),
)
if err != nil {
    log.Fatal(err)
}

3. Update Handler Signatures

// Before
func handler(c *router.Context) {
    c.JSON(http.StatusOK, data)
}

// After
func handler(c *app.Context) {  // Change context type
    c.JSON(http.StatusOK, data)
}

4. Update Server Startup

// Before
http.ListenAndServe(":8080", r)

// After
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
a.Start(ctx)

Complete Migration Example

Before (Router)

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.New(
        router.WithMetrics(),
        router.WithTracing(),
    )
    
    r.GET("/", func(c *router.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Hello!",
        })
    })
    
    http.ListenAndServe(":8080", r)
}

After (App)

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    a, err := app.New(
        app.WithServiceName("my-service"),
        app.WithServiceVersion("v1.0.0"),
        app.WithObservability(
            app.WithMetrics(),
            app.WithTracing(),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Hello!",
        })
    })
    
    ctx, cancel := signal.NotifyContext(
        context.Background(),
        os.Interrupt,
        syscall.SIGTERM,
    )
    defer cancel()
    
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

Accessing Router

If you need router-specific features, access the underlying router:

a := app.MustNew()

// Access router for advanced features
router := a.Router()
router.Freeze()  // Manually freeze router

Gradual Migration

You can migrate gradually:

  1. Start with app constructor - Change router.New() to app.New()
  2. Update handlers incrementally - Change handler signatures one at a time
  3. Add app features - Add observability, health checks, lifecycle hooks
  4. Update server startup - Add graceful shutdown last

Next Steps

1.16 - Examples

Complete working examples of Rivaas applications.

Quick Start Example

Minimal application to get started.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
)

func main() {
    a, err := app.New()
    if err != nil {
        log.Fatal(err)
    }

    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Hello from Rivaas!",
        })
    })

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()

    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

Complete application with all features.

package main

import (
    "context"
    "database/sql"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
    
    "rivaas.dev/app"
    "rivaas.dev/logging"
    "rivaas.dev/metrics"
    "rivaas.dev/tracing"
)

var db *sql.DB

func main() {
    a, err := app.New(
        // Service metadata
        app.WithServiceName("orders-api"),
        app.WithServiceVersion("v2.0.0"),
        app.WithEnvironment("production"),
        
        // Observability: all three pillars
        app.WithObservability(
            app.WithLogging(logging.WithJSONHandler()),
            app.WithMetrics(),
            app.WithTracing(tracing.WithOTLP("localhost:4317")),
            app.WithExcludePaths("/livez", "/readyz", "/metrics"),
            app.WithLogOnlyErrors(),
            app.WithSlowThreshold(1 * time.Second),
        ),
        
        // Health endpoints
        app.WithHealthEndpoints(
            app.WithHealthTimeout(800 * time.Millisecond),
            app.WithReadinessCheck("database", func(ctx context.Context) error {
                return db.PingContext(ctx)
            }),
        ),
        
        // Server configuration
        app.WithServer(
            app.WithReadTimeout(10 * time.Second),
            app.WithWriteTimeout(15 * time.Second),
            app.WithShutdownTimeout(30 * time.Second),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Lifecycle hooks
    a.OnStart(func(ctx context.Context) error {
        log.Println("Connecting to database...")
        var err error
        db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
        return err
    })
    
    a.OnShutdown(func(ctx context.Context) {
        log.Println("Closing database connection...")
        db.Close()
    })
    
    // Register routes
    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "service": "orders-api",
            "version": "v2.0.0",
        })
    })
    
    a.GET("/orders/:id", func(c *app.Context) {
        orderID := c.Param("id")
        
        c.Logger().Info("fetching order", "order_id", orderID)
        
        c.JSON(http.StatusOK, map[string]string{
            "order_id": orderID,
            "status":   "completed",
        })
    })
    
    // Start server
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()
    
    log.Println("Server starting on :8080")
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

REST API Example

Complete REST API with CRUD operations:

package main

import (
    "log"
    "net/http"
    
    "rivaas.dev/app"
)

type User struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required,min=3"`
    Email string `json:"email" validate:"required,email"`
}

func main() {
    a := app.MustNew(app.WithServiceName("users-api"))
    
    // List users
    a.GET("/users", func(c *app.Context) {
        users := []User{
            {ID: "1", Name: "Alice", Email: "alice@example.com"},
            {ID: "2", Name: "Bob", Email: "bob@example.com"},
        }
        c.JSON(http.StatusOK, users)
    })
    
    // Create user
    a.POST("/users", func(c *app.Context) {
        req, ok := app.MustBind[CreateUserRequest](c)
        if !ok {
            return
        }
        
        user := User{
            ID:    "123",
            Name:  req.Name,
            Email: req.Email,
        }
        
        c.JSON(http.StatusCreated, user)
    })
    
    // Get user
    a.GET("/users/:id", func(c *app.Context) {
        id := c.Param("id")
        user := User{ID: id, Name: "Alice", Email: "alice@example.com"}
        c.JSON(http.StatusOK, user)
    })
    
    // Update user
    a.PUT("/users/:id", func(c *app.Context) {
        id := c.Param("id")
        
        req, ok := app.MustBind[CreateUserRequest](c)
        if !ok {
            return
        }
        
        user := User{ID: id, Name: req.Name, Email: req.Email}
        c.JSON(http.StatusOK, user)
    })
    
    // Delete user
    a.DELETE("/users/:id", func(c *app.Context) {
        c.Status(http.StatusNoContent)
    })
    
    // Start server...
}

More Examples

See the examples/ directory in the repository for additional examples:

  • 01-quick-start/ - Minimal setup (~20 lines)
  • 02-blog/ - Complete blog API with database, validation, and testing

Next Steps

2 - HTTP Router

An HTTP router for Go. Built for cloud-native applications with complete routing, middleware, and observability features.

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.
  • Route Constraints: Numeric, UUID, Alpha, Alphanumeric, Custom regex validation.
  • Concurrent Safe: Thread-safe for use by multiple goroutines.

Request Binding

Automatically bind request data to structs:

  • Binding Package: Full binding with binding.Query(), binding.JSON(), binding.Form(), binding.Headers(), binding.Cookies().
  • App Package: Integrated binding + validation with app.Bind[T](), app.BindStrict[T]().
  • 15+ Type Categories: Primitives, Time, Network types like net.IP and net.IPNet, Maps, Nested Structs, Slices.
  • Advanced Features: Maps with dot or bracket notation, nested structs in query strings, enum validation, default values.

Request Validation

  • Multiple Strategies: Interface validation, Tag validation with go-playground/validator, JSON Schema.
  • Partial Validation: PATCH request support. Validate only present fields.
  • Structured Errors: Machine-readable error codes and field paths.
  • Context-Aware: Request-scoped validation rules.

Response Rendering

  • JSON Variants: Standard, Indented, Pure, Secure, ASCII, JSONP.
  • Alternative Formats: YAML, String, HTML.
  • Binary & Streaming: Zero-copy streaming from io.Reader, file serving.

Content Negotiation - RFC 7231 Compliant

  • Media type negotiation with quality values.
  • Character set, encoding, and language negotiation.
  • Wildcard support and specificity matching.

API Versioning - Built-in

  • Header-based: API-Version: v1
  • Query-based: ?version=v1
  • Custom detection: Flexible version strategies
  • Version-specific routes: r.Version("v1").GET(...)
  • Lock-free implementation: Atomic operations

Middleware (Built-in)

  • AccessLog - Structured HTTP access logging
  • Recovery - Panic recovery with graceful errors
  • CORS - Cross-Origin Resource Sharing
  • Basic Auth - HTTP Basic Authentication
  • Compression - Gzip/Brotli response compression
  • Request ID - X-Request-ID generation
  • Security Headers - HSTS, CSP, X-Frame-Options
  • Timeout - Request timeout handling
  • Rate Limit - Token bucket rate limiting
  • Body Limit - Request body size limiting

Observability - OpenTelemetry Native

  • Metrics: Custom histograms, counters, gauges, automatic request metrics
  • Tracing: Native OpenTelemetry support with zero overhead when disabled
  • Diagnostics: Optional diagnostic events for security concerns

Performance

  • Sub-microsecond routing: 119ns per operation
  • High throughput: 8.4M+ requests/second
  • Memory efficient: 16 bytes per request, 1 allocation
  • Context pooling: Automatic context reuse
  • Lock-free operations: Atomic operations for concurrent access

Quick Start

Get up and running in minutes with this complete example:

package main

import (
    "fmt"
    "net/http"
    "time"
    
    "rivaas.dev/router"
)

func main() {
    r := router.MustNew()  // Panics on invalid config (use at startup)
    
    // Global middleware
    r.Use(Logger(), Recovery())
    
    // Simple route
    r.GET("/", func(c *router.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Hello Rivaas!",
            "version": "1.0.0",
        })
    })
    
    // Parameter route
    r.GET("/users/:id", func(c *router.Context) {
        userID := c.Param("id")
        c.JSON(http.StatusOK, map[string]string{
            "user_id": userID,
        })
    })
    
    // POST with JSON binding
    r.POST("/users", func(c *router.Context) {
        var req struct {
            Name  string `json:"name"`
            Email string `json:"email"`
        }
        
        if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
            c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
            return
        }
        
        c.JSON(http.StatusCreated, req)
    })
    
    http.ListenAndServe(":8080", r)
}

// Middleware examples
func Logger() router.HandlerFunc {
    return func(c *router.Context) {
        start := time.Now()
        c.Next()
        duration := time.Since(start)
        fmt.Printf("[%s] %s - %v\n", c.Request.Method, c.Request.URL.Path, duration)
    }
}

func Recovery() router.HandlerFunc {
    return func(c *router.Context) {
        defer func() {
            if err := recover(); err != nil {
                c.JSON(http.StatusInternalServerError, map[string]string{
                    "error": "Internal server error",
                })
            }
        }()
        c.Next()
    }
}

Learning Path

Follow this structured path to master the Rivaas Router:

1. Getting Started

Start with the basics:

2. Core Features

Build upon the fundamentals:

  • Route Groups - Organize routes with groups and prefixes
  • Middleware - Add cross-cutting concerns like logging and auth
  • Context - Understand the request context and memory safety

3. Request Handling

Handle requests effectively:

4. Advanced Features

Use advanced capabilities:

5. Production Readiness

Prepare for production:

  • Observability - Integrate OpenTelemetry tracing and diagnostics
  • Testing - Test your routes and middleware
  • Migration - Migrate from Gin, Echo, or http.ServeMux

6. Examples & Patterns

Learn from real-world examples:

  • Examples - Complete working examples and use cases

Common Use Cases

The Rivaas Router excels in these scenarios:

  • REST APIs - JSON APIs with comprehensive request/response handling
  • Web Applications - HTML rendering, forms, sessions, static files
  • Microservices - OpenTelemetry integration, API versioning, health checks
  • High-Performance Services - Sub-microsecond routing, 8.4M+ req/s throughput

Next Steps

Need Help?

2.1 - Installation

Install the Rivaas Router in your Go project.

Requirements

  • Go 1.25 or higher.
  • 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:

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    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 middleware
go get rivaas.dev/metrics

OpenTelemetry Tracing

For OpenTelemetry tracing support:

# Core OpenTelemetry libraries
go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/trace
go get go.opentelemetry.io/otel/sdk

# Example: Jaeger exporter
go 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:

myapp/
├── main.go                 # Application entry point
├── routes/
│   ├── routes.go          # Route registration
│   ├── users.go           # User routes
│   └── posts.go           # Post routes
├── handlers/
│   ├── users.go           # User handlers
│   └── posts.go           # Post handlers
├── middleware/
│   ├── auth.go            # Authentication middleware
│   └── logging.go         # Custom logging
└── go.mod

Next Steps

2.2 - Basic Usage

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:

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    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:

  1. router.MustNew() creates a new router instance. Panics on invalid config.
  2. r.GET("/", handler) registers a handler for GET requests to /.
  3. The handler function receives a *router.Context with request and response information.
  4. c.JSON() sends a JSON response.
  5. http.ListenAndServe() starts the HTTP server.

Test it:

curl http://localhost:8080/
# Output: {"message":"Hello, Rivaas Router!"}

Adding Routes with Parameters

Routes can capture dynamic segments from the URL path:

func main() {
    r := router.MustNew()
    
    // Static route
    r.GET("/", func(c *router.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "message": "Welcome to Rivaas Router",
        })
    })
    
    // Single parameter
    r.GET("/users/:id", func(c *router.Context) {
        userID := c.Param("id")
        c.JSON(http.StatusOK, map[string]string{
            "user_id": userID,
            "message": "User found",
        })
    })
    
    // Multiple parameters
    r.GET("/users/:id/posts/:post_id", func(c *router.Context) {
        userID := c.Param("id")
        postID := c.Param("post_id")
        c.JSON(http.StatusOK, map[string]string{
            "user_id": userID,
            "post_id": postID,
        })
    })
    
    http.ListenAndServe(":8080", r)
}

Parameter syntax:

  • :name - Captures a path segment and stores it under the given name.
  • Access with c.Param("name").
  • Parameters match any non-slash characters.

Test it:

curl http://localhost:8080/users/123
# Output: {"user_id":"123","message":"User found"}

curl http://localhost:8080/users/123/posts/456
# Output: {"user_id":"123","post_id":"456"}

HTTP Methods

The router supports all standard HTTP methods:

func main() {
    r := router.MustNew()
    
    r.GET("/users", listUsers)          // List all users
    r.POST("/users", createUser)        // Create a new user
    r.GET("/users/:id", getUser)        // Get a specific user
    r.PUT("/users/:id", updateUser)     // Update a user (full replacement)
    r.PATCH("/users/:id", patchUser)    // Partial update
    r.DELETE("/users/:id", deleteUser)  // Delete a user
    r.HEAD("/users/:id", headUser)      // Check if user exists
    r.OPTIONS("/users", optionsUsers)   // Get available methods
    
    http.ListenAndServe(":8080", r)
}

func listUsers(c *router.Context) {
    c.JSON(200, []string{"user1", "user2"})
}

func createUser(c *router.Context) {
    c.JSON(201, map[string]string{"message": "User created"})
}

func getUser(c *router.Context) {
    c.JSON(200, map[string]string{"user_id": c.Param("id")})
}

func updateUser(c *router.Context) {
    c.JSON(200, map[string]string{"message": "User updated"})
}

func patchUser(c *router.Context) {
    c.JSON(200, map[string]string{"message": "User patched"})
}

func deleteUser(c *router.Context) {
    c.Status(204) // No Content
}

func headUser(c *router.Context) {
    c.Status(200) // OK, no body
}

func optionsUsers(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=10
r.GET("/search", func(c *router.Context) {
    query := c.Query("q")
    limit := c.Query("limit")
    
    c.JSON(200, map[string]string{
        "query": query,
        "limit": limit,
    })
})

Test it:

curl "http://localhost:8080/search?q=golang&limit=10"
# Output: {"query":"golang","limit":"10"}

Form Data

Access POST form data with c.FormValue():

// POST /login with form data
r.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",
    })
})

Test it:

curl -X POST http://localhost:8080/login \
  -d "username=john" \
  -d "password=secret"
# Output: {"username":"john","status":"logged in"}

JSON Request Body

Parse JSON request bodies:

r.POST("/users", func(c *router.Context) {
    var req struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    
    if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
        c.JSON(400, map[string]string{"error": "Invalid JSON"})
        return
    }
    
    c.JSON(201, map[string]interface{}{
        "id":    "123",
        "name":  req.Name,
        "email": req.Email,
    })
})

Test it:

curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"John Doe","email":"john@example.com"}'
# Output: {"id":"123","name":"John Doe","email":"john@example.com"}

Error Handling

Always handle errors and provide meaningful responses:

r.GET("/users/:id", func(c *router.Context) {
    userID := c.Param("id")
    
    // Validate user ID
    if userID == "" {
        c.JSON(400, map[string]string{
            "error": "User ID is required",
        })
        return
    }
    
    // Simulate user lookup
    user, err := findUser(userID)
    if err != nil {
        if err == 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 JSON
r.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 formatting
r.GET("/text-formatted", func(c *router.Context) {
    c.Stringf(200, "Hello, %s!", "World")
})

HTML

r.GET("/html", func(c *router.Context) {
    c.HTML(200, "<h1>Hello, World!</h1>")
})

Status Only

r.DELETE("/users/:id", func(c *router.Context) {
    // Delete user...
    c.Status(204) // No Content
})

Working with Headers

Reading Headers

r.GET("/headers", func(c *router.Context) {
    userAgent := c.Request.Header.Get("User-Agent")
    contentType := c.Request.Header.Get("Content-Type")
    
    c.JSON(200, map[string]string{
        "user_agent":   userAgent,
        "content_type": contentType,
    })
})

Setting Headers

r.GET("/custom-headers", func(c *router.Context) {
    c.Header("X-Custom-Header", "CustomValue")
    c.Header("Cache-Control", "no-cache")
    c.JSON(200, map[string]string{"message": "Headers set"})
})

Redirects

Redirect to another URL:

r.GET("/old-url", func(c *router.Context) {
    c.Redirect(301, "/new-url") // 301 Permanent Redirect
})

r.GET("/temporary", func(c *router.Context) {
    c.Redirect(302, "/elsewhere") // 302 Temporary Redirect
})

Complete Example

Here’s a complete example combining all the concepts:

package main

import (
    "encoding/json"
    "net/http"
    "rivaas.dev/router"
)

type User struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

var users = map[string]User{
    "1": {ID: "1", Name: "Alice", Email: "alice@example.com"},
    "2": {ID: "2", Name: "Bob", Email: "bob@example.com"},
}

func main() {
    r := router.MustNew()
    
    // List all users
    r.GET("/users", func(c *router.Context) {
        userList := make([]User, 0, len(users))
        for _, user := range users {
            userList = append(userList, user)
        }
        c.JSON(200, userList)
    })
    
    // Get a specific user
    r.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 user
    r.POST("/users", func(c *router.Context) {
        var req User
        if err := 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] = req
        
        c.JSON(201, req)
    })
    
    // Update a user
    r.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
        }
        
        var req User
        if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
            c.JSON(400, map[string]string{
                "error": "Invalid JSON",
            })
            return
        }
        
        req.ID = id
        users[id] = req
        c.JSON(200, req)
    })
    
    // Delete a user
    r.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:

2.3 - Route Patterns

Learn about static routes, parameter routes, wildcards, and route matching priority.

The Rivaas Router supports multiple route patterns, from simple static routes to dynamic parameters and wildcards.

Static Routes

Static routes match exact path strings and have the best performance:

r.GET("/", homeHandler)
r.GET("/about", aboutHandler)
r.GET("/api/health", healthHandler)
r.GET("/admin/dashboard", dashboardHandler)

Characteristics:

  • Exact string match required.
  • Fastest route type. Sub-microsecond lookups.
  • Uses hash table lookups with bloom filters.
  • No pattern matching overhead.
curl http://localhost:8080/about
# Matches: /about
# Does NOT match: /about/, /about/us

Parameter Routes

Routes can capture dynamic segments using the :param syntax:

Single Parameter

// Capture user ID
r.GET("/users/:id", func(c *router.Context) {
    userID := c.Param("id")
    c.JSON(200, map[string]string{"user_id": userID})
})

Matches:

  • /users/123id="123"
  • /users/abcid="abc"
  • /users/uuid-hereid="uuid-here"

Does NOT match:

  • /users - missing parameter
  • /users/ - empty parameter
  • /users/123/posts - too many segments

Multiple Parameters

r.GET("/users/:id/posts/:post_id", func(c *router.Context) {
    userID := c.Param("id")
    postID := c.Param("post_id")
    c.JSON(200, map[string]string{
        "user_id": userID,
        "post_id": postID,
    })
})

Matches:

  • /users/123/posts/456id="123", post_id="456"
  • /users/alice/posts/hello-worldid="alice", post_id="hello-world"

Mixed Static and Parameter Segments

r.GET("/api/v1/users/:id/profile", userProfileHandler)
r.GET("/organizations/:org/teams/:team/members", membersHandler)

Example:

  • /api/v1/users/123/profileid="123"
  • /organizations/acme/teams/engineering/membersorg="acme", team="engineering"

Wildcard Routes

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

Matches:

  • /files/images/logo.pngfilepath="images/logo.png"
  • /files/docs/api/v1/index.htmlfilepath="docs/api/v1/index.html"
  • /files/a/b/c/d/e/f.txtfilepath="a/b/c/d/e/f.txt"

Important:

  • Wildcards match everything after their position, including slashes
  • Only one wildcard per route
  • Wildcard must be the last segment
// ✅ Valid
r.GET("/static/*filepath", handler)
r.GET("/api/v1/files/*path", handler)

// ❌ Invalid - wildcard must be last
r.GET("/files/*path/metadata", handler) // Won't work

// ❌ Invalid - only one wildcard
r.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:

1. Static Routes (Highest Priority)

Exact matches are evaluated first:

r.GET("/users/me", currentUserHandler)      // Static
r.GET("/users/:id", getUserHandler)         // Parameter

Request: GET /users/me

  • ✅ Matches /users/me (static) - Selected
  • ❌ Could match /users/:id but static wins

2. Parameter Routes

After static routes, parameter routes are checked:

r.GET("/posts/:id", getPostHandler)
r.GET("/posts/*filepath", catchAllHandler)

Request: GET /posts/123

  • ❌ No static match
  • ✅ Matches /posts/:id - Selected
  • ❌ Could match /posts/*filepath but parameter wins

3. Wildcard Routes (Lowest Priority)

Wildcards are the catch-all:

r.GET("/files/*filepath", serveFileHandler)

Request: GET /files/images/logo.png

  • ❌ No static match
  • ❌ No parameter match
  • ✅ Matches /files/*filepath - Selected

Priority Examples

func main() {
    r := router.MustNew()
    
    // Priority 1: Static
    r.GET("/users/me", func(c *router.Context) {
        c.String(200, "Current user")
    })
    
    // Priority 2: Parameter
    r.GET("/users/:id", func(c *router.Context) {
        c.String(200, "User: "+c.Param("id"))
    })
    
    // Priority 3: Wildcard
    r.GET("/users/*path", func(c *router.Context) {
        c.String(200, "Catch-all: "+c.Param("path"))
    })
    
    http.ListenAndServe(":8080", r)
}

Tests:

curl http://localhost:8080/users/me
# Output: "Current user" (static route)

curl http://localhost:8080/users/123
# Output: "User: 123" (parameter route)

curl http://localhost:8080/users/123/posts
# Output: "Catch-all: 123/posts" (wildcard route)

Parameter Design Best Practices

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)

Best Practices

1. Keep Parameter Count ≤8

// ✅ GOOD: 2 parameters
r.GET("/users/:id/posts/:post_id", handler)

// ✅ GOOD: 4 parameters
r.GET("/api/:version/users/:id/posts/:post_id/comments/:comment_id", handler)

// ⚠️ WARNING: 9 parameters (requires map allocation)
r.GET("/a/:p1/b/:p2/c/:p3/d/:p4/e/:p5/f/:p6/g/:p7/h/:p8/i/:p9", handler)

2. Use Query Parameters for Additional Data

Instead of many path parameters, use query parameters:

// ❌ BAD: Too many path parameters
r.GET("/search/:category/:subcategory/:type/:status/:sort/:order/:page/:limit", handler)

// ✅ GOOD: Use query parameters for filters
r.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 parameters
r.POST("/api/:version/:resource/:action/:target/:scope/:context/:mode/:format", handler)

// ✅ GOOD: Use request body
r.POST("/api/v1/operations", handler)
// Body: {"resource": "...", "action": "...", "target": "...", ...}

4. Restructure Routes

Flatten hierarchies or consolidate parameters:

// ❌ BAD: 10 parameters in path
r.GET("/:a/:b/:c/:d/:e/:f/:g/:h/:i/:j", handler)

// ✅ GOOD: Flatten hierarchy or use query parameters
r.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)

Route Constraints

Add validation to parameters with constraints:

// Integer constraint
r.GET("/users/:id", getUserHandler).WhereInt("id")

// UUID constraint
r.GET("/entities/:uuid", getEntityHandler).WhereUUID("uuid")

// Custom regex
r.GET("/files/:filename", getFileHandler).WhereRegex("filename", `[a-zA-Z0-9.-]+`)

// Enum constraint
r.GET("/status/:state", getStatusHandler).WhereEnum("state", "active", "pending", "deleted")

Learn more: See the Route Constraints reference for all available constraints.

Common Patterns

RESTful Resources

// Standard REST endpoints
r.GET("/users", listUsers)              // List all
r.POST("/users", createUser)            // Create new
r.GET("/users/:id", getUser)            // Get one
r.PUT("/users/:id", updateUser)         // Update (full)
r.PATCH("/users/:id", patchUser)        // Update (partial)
r.DELETE("/users/:id", deleteUser)      // Delete

Nested Resources

// Comments belong to posts
r.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 resources
r.POST("/users/:id/activate", activateUser)
r.POST("/users/:id/deactivate", deactivateUser)
r.POST("/posts/:id/publish", publishPost)
r.POST("/orders/:id/cancel", cancelOrder)

File Serving

// Static file serving
r.GET("/assets/*filepath", serveAssets)
r.GET("/downloads/*filepath", serveDownloads)

func serveAssets(c *router.Context) {
    filepath := c.Param("filepath")
    c.ServeFile("./public/" + filepath)
}

Anti-Patterns

Avoid Ambiguous Routes

// ❌ BAD: Ambiguous - which route matches /users/delete?
r.GET("/users/:id", getUser)
r.DELETE("/users/:action", performAction)

// ✅ GOOD: Clear distinction
r.GET("/users/:id", getUser)
r.POST("/users/:id/actions/:action", performAction)

Avoid Overly Deep Hierarchies

// ❌ BAD: Too deep
r.GET("/api/v1/organizations/:org/teams/:team/projects/:proj/tasks/:task/comments/:id", handler)

// ✅ GOOD: Flatten or use query parameters
r.GET("/api/v1/comments/:id", handler) // Include org/team/proj/task in query or auth context

Next Steps

2.4 - Route Groups

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:

func main() {
    r := router.MustNew()
    r.Use(Logger()) // Global middleware
    
    // API v1 group
    v1 := 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:

func main() {
    r := router.MustNew()
    r.Use(Logger()) // Global - applies to all routes
    
    // Public API - no auth required
    public := r.Group("/api/public")
    public.GET("/health", healthHandler)
    public.GET("/version", versionHandler)
    
    // Private API - auth required
    private := r.Group("/api/private")
    private.Use(AuthRequired()) // Group middleware
    private.GET("/profile", profileHandler)
    private.POST("/settings", updateSettingsHandler)
    
    http.ListenAndServe(":8080", r)
}

func AuthRequired() router.HandlerFunc {
    return func(c *router.Context) {
        token := c.Request.Header.Get("Authorization")
        if token == "" {
            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:

func main() {
    r := router.MustNew()
    r.Use(Logger())
    
    api := r.Group("/api")
    {
        v1 := api.Group("/v1")
        v1.Use(RateLimitV1()) // V1-specific rate limiting
        {
            // User endpoints
            users := 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/:id
                users.PUT("/:id", updateUser)      // PUT /api/v1/users/:id
                users.DELETE("/:id", deleteUser)   // DELETE /api/v1/users/:id
            }
            
            // Admin endpoints
            admin := v1.Group("/admin")
            admin.Use(AdminAuth())
            {
                admin.GET("/stats", getStats)                    // GET /api/v1/admin/stats
                admin.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:

r.Use(GlobalMiddleware())                   // 1st
api := r.Group("/api", APIMiddleware())     // 2nd
v1 := api.Group("/v1", V1Middleware())      // 3rd
users := v1.Group("/users", UsersMiddleware()) // 4th
users.GET("/:id", RouteMiddleware(), handler)  // 5th → handler

// Execution order:
// GlobalMiddleware → APIMiddleware → V1Middleware → UsersMiddleware → RouteMiddleware → handler

Example with logging:

func main() {
    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 bundles
func PublicAPI() []router.HandlerFunc {
    return []router.HandlerFunc{
        CORS(),
        RateLimit(1000),
    }
}

func AuthenticatedAPI() []router.HandlerFunc {
    return []router.HandlerFunc{
        CORS(),
        RateLimit(100),
        AuthRequired(),
    }
}

func AdminAPI() []router.HandlerFunc {
    return []router.HandlerFunc{
        CORS(),
        RateLimit(50),
        AuthRequired(),
        AdminOnly(),
    }
}

func main() {
    r := router.MustNew()
    r.Use(Logger(), Recovery())
    
    // Public endpoints
    public := r.Group("/api/public")
    public.Use(PublicAPI()...)
    public.GET("/status", statusHandler)
    
    // User endpoints
    user := r.Group("/api/user")
    user.Use(AuthenticatedAPI()...)
    user.GET("/profile", profileHandler)
    
    // Admin endpoints
    admin := r.Group("/api/admin")
    admin.Use(AdminAPI()...)
    admin.GET("/users", listUsersAdmin)
    
    http.ListenAndServe(":8080", r)
}

Organizing by Resource

Structure your API around resources:

func main() {
    r := router.MustNew()
    r.Use(Logger(), Recovery())
    
    // Setup route groups
    setupUserRoutes(r)
    setupPostRoutes(r)
    setupCommentRoutes(r)
    
    http.ListenAndServe(":8080", r)
}

func setupUserRoutes(r *router.Router) {
    users := r.Group("/api/users")
    users.Use(JSONContentType())
    
    users.GET("/", listUsers)
    users.POST("/", createUser)
    users.GET("/:id", getUser)
    users.PUT("/:id", updateUser)
    users.DELETE("/:id", deleteUser)
}

func setupPostRoutes(r *router.Router) {
    posts := r.Group("/api/posts")
    posts.Use(JSONContentType())
    
    posts.GET("/", listPosts)
    posts.POST("/", AuthRequired(), createPost)
    posts.GET("/:id", getPost)
    posts.PUT("/:id", AuthRequired(), updatePost)
    posts.DELETE("/:id", AuthRequired(), deletePost)
}

func setupCommentRoutes(r *router.Router) {
    comments := r.Group("/api/comments")
    comments.Use(JSONContentType())
    
    comments.GET("/", listComments)
    comments.POST("/", AuthRequired(), createComment)
    comments.GET("/:id", getComment)
    comments.PUT("/:id", AuthRequired(), updateComment)
    comments.DELETE("/:id", AuthRequired(), deleteComment)
}

Versioning with Groups

Organize API versions:

func main() {
    r := router.MustNew()
    r.Use(Logger())
    
    // Version 1 - Stable API
    v1 := r.Group("/api/v1")
    v1.Use(JSONContentType())
    {
        v1.GET("/users", listUsersV1)
        v1.GET("/users/:id", getUserV1)
        v1.GET("/posts", listPostsV1)
    }
    
    // Version 2 - New features
    v2 := r.Group("/api/v2")
    v2.Use(JSONContentType())
    {
        v2.GET("/users", listUsersV2)        // Enhanced user list
        v2.GET("/users/:id", getUserV2)      // Additional fields
        v2.GET("/posts", listPostsV2)        // Pagination support
        v2.GET("/posts/:id/likes", getPostLikesV2) // New endpoint
    }
    
    // Beta features
    beta := r.Group("/api/beta")
    beta.Use(JSONContentType(), BetaWarning())
    {
        beta.GET("/experimental", experimentalFeature)
    }
    
    http.ListenAndServe(":8080", r)
}

Group Configuration Patterns

Pattern 1: Inline Configuration

api := r.Group("/api")
api.Use(Logger(), Auth())
api.GET("/users", handler)
api.POST("/users", handler)

Pattern 2: Block Scope

api := r.Group("/api")
{
    api.Use(Logger(), Auth())
    api.GET("/users", handler)
    api.POST("/users", handler)
}

Pattern 3: Function-Based Setup

setupAPIRoutes := func(parent *router.Group) {
    api := parent.Group("/api")
    api.Use(Logger(), Auth())
    api.GET("/users", handler)
    api.POST("/users", handler)
}

setupAPIRoutes(r)

Best Practices

// ✅ GOOD: Related routes grouped
users := r.Group("/api/users")
users.GET("/", listUsers)
users.POST("/", createUser)
users.GET("/:id", getUser)

// ❌ BAD: Scattered registration
r.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 needed
public := r.Group("/api/public")
public.GET("/status", statusHandler)

private := r.Group("/api/private")
private.Use(AuthRequired())
private.GET("/profile", profileHandler)

// ❌ BAD: Auth on everything
r.Use(AuthRequired()) // Public endpoints won't work!
r.GET("/api/status", statusHandler)

3. Use Descriptive Names

// ✅ GOOD: Clear purpose
adminAPI := r.Group("/admin")
userAPI := r.Group("/user")
publicAPI := r.Group("/public")

// ❌ BAD: Unclear
g1 := r.Group("/api")
g2 := r.Group("/routes")
group := r.Group("/stuff")

4. Keep Nesting Shallow

// ✅ GOOD: 2-3 levels
api := 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

package main

import (
    "fmt"
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.MustNew()
    
    // Global middleware
    r.Use(Logger(), Recovery())
    
    // Public routes (no auth)
    public := r.Group("/api/public")
    public.Use(CORS())
    {
        public.GET("/health", healthHandler)
        public.GET("/version", versionHandler)
    }
    
    // API v1
    v1 := 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)
}

// Middleware
func Logger() router.HandlerFunc {
    return func(c *router.Context) {
        fmt.Printf("[%s] %s\n", c.Request.Method, c.Request.URL.Path)
        c.Next()
    }
}

func Recovery() router.HandlerFunc {
    return func(c *router.Context) {
        defer func() {
            if err := recover(); err != nil {
                c.JSON(500, map[string]string{"error": "Internal server error"})
            }
        }()
        c.Next()
    }
}

func CORS() router.HandlerFunc {
    return func(c *router.Context) {
        c.Header("Access-Control-Allow-Origin", "*")
        c.Next()
    }
}

func JSONContentType() router.HandlerFunc {
    return func(c *router.Context) {
        c.Header("Content-Type", "application/json")
        c.Next()
    }
}

func AuthRequired() router.HandlerFunc {
    return func(c *router.Context) {
        token := c.Request.Header.Get("Authorization")
        if token == "" {
            c.JSON(401, map[string]string{"error": "Unauthorized"})
            return
        }
        c.Next()
    }
}

func AdminOnly() router.HandlerFunc {
    return func(c *router.Context) {
        // Check if user is admin...
        c.Next()
    }
}

// Handlers (simplified)
func healthHandler(c *router.Context) { c.String(200, "OK") }
func versionHandler(c *router.Context) { c.String(200, "v1.0.0") }
func listUsers(c *router.Context) { c.JSON(200, []string{"user1", "user2"}) }
func createUser(c *router.Context) { c.JSON(201, map[string]string{"id": "1"}) }
func getUser(c *router.Context) { c.JSON(200, map[string]string{"id": c.Param("id")}) }
func updateUser(c *router.Context) { c.JSON(200, map[string]string{"id": c.Param("id")}) }
func deleteUser(c *router.Context) { c.Status(204) }
func adminStats(c *router.Context) { c.JSON(200, map[string]int{"users": 100}) }
func adminListUsers(c *router.Context) { c.JSON(200, []string{"all", "users"}) }

Next Steps

2.5 - Middleware

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:

func Logger() router.HandlerFunc {
    return func(c *router.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        
        c.Next() // Continue to next handler
        
        duration := time.Since(start)
        fmt.Printf("[%s] %s - %v\n", c.Request.Method, path, duration)
    }
}

func main() {
    r := router.MustNew()
    
    // Apply middleware globally
    r.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 routes
r.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 auth
public := r.Group("/api/public")
public.GET("/status", statusHandler)

// Private routes - auth required
private := r.Group("/api/private")
private.Use(AuthRequired()) // Group-level
private.GET("/profile", profileHandler)

Route-Specific Middleware

Applied to individual routes:

r := router.MustNew()
r.Use(Logger()) // Global

// Auth only for this route
r.GET("/admin", AdminAuth(), adminHandler)

// Multiple middleware for one route
r.POST("/upload", RateLimit(), ValidateFile(), uploadHandler)

Built-in Middleware

The router includes production-ready middleware in sub-packages. See the Middleware Reference for complete options.

Security

Security Headers

import "rivaas.dev/router/middleware/security"

r.Use(security.New(
    security.WithHSTS(true),
    security.WithFrameDeny(true),
    security.WithContentTypeNosniff(true),
))

CORS

import "rivaas.dev/router/middleware/cors"

r.Use(cors.New(
    cors.WithAllowedOrigins("https://example.com"),
    cors.WithAllowedMethods("GET", "POST", "PUT", "DELETE"),
    cors.WithAllowedHeaders("Content-Type", "Authorization"),
    cors.WithAllowCredentials(true),
))

Basic Auth

import "rivaas.dev/router/middleware/basicauth"

admin := r.Group("/admin")
admin.Use(basicauth.New(
    basicauth.WithCredentials("admin", "secret"),
))

Observability

Access Log

import (
    "log/slog"
    "rivaas.dev/router/middleware/accesslog"
)

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
r.Use(accesslog.New(
    accesslog.WithLogger(logger),
    accesslog.WithExcludePaths("/health", "/metrics"),
    accesslog.WithSlowThreshold(500 * time.Millisecond),
))

Request ID

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 name
r.Use(requestid.New(requestid.WithHeader("X-Correlation-ID")))

// Later in handlers:
func handler(c *router.Context) {
    id := requestid.Get(c)
    fmt.Println("Request ID:", id)
}

Reliability

Recovery

import "rivaas.dev/router/middleware/recovery"

r.Use(recovery.New(
    recovery.WithPrintStack(true),
    recovery.WithLogger(logger),
))

Timeout

import "rivaas.dev/router/middleware/timeout"

r.Use(timeout.New(
    timeout.WithDuration(30 * time.Second),
    timeout.WithMessage("Request timeout"),
))

Rate Limit

import "rivaas.dev/router/middleware/ratelimit"

r.Use(ratelimit.New(
    ratelimit.WithRequestsPerSecond(1000),
    ratelimit.WithBurst(100),
    ratelimit.WithKeyFunc(func(c *router.Context) string {
        return c.ClientIP() // Rate limit by IP
    }),
))

Body Limit

import "rivaas.dev/router/middleware/bodylimit"

r.Use(bodylimit.New(
    bodylimit.WithLimit(10 * 1024 * 1024), // 10MB
))

Performance

Compression

import "rivaas.dev/router/middleware/compression"

r.Use(compression.New(
    compression.WithLevel(compression.DefaultCompression),
    compression.WithMinSize(1024), // Don't compress <1KB
))

Middleware Ordering

The order in which middleware is applied matters. Recommended order:

r := router.MustNew()

// 1. Request ID - Generate early for logging/tracing
r.Use(requestid.New())

// 2. AccessLog - Log all requests including failed ones
r.Use(accesslog.New())

// 3. Recovery - Catch panics from all other middleware
r.Use(recovery.New())

// 4. Security/CORS - Set security headers early
r.Use(security.New())
r.Use(cors.New())

// 5. Body Limit - Reject large requests before processing
r.Use(bodylimit.New())

// 6. Rate Limit - Reject excessive requests before processing
r.Use(ratelimit.New())

// 7. Timeout - Set time limits for downstream processing
r.Use(timeout.New())

// 8. Authentication - Verify identity after rate limiting
r.Use(auth.New())

// 9. Compression - Compress responses (last)
r.Use(compression.New())

// 10. Your application routes
r.GET("/", handler)

Why this order?

  1. RequestID first - Generates a unique ID that other middleware can use
  2. Logger early - Captures all activity including errors
  3. Recovery early - Catches panics to prevent crashes
  4. Security/CORS - Applies security policies before business logic
  5. BodyLimit - Prevents reading excessive request bodies (DoS protection)
  6. RateLimit - Blocks excessive requests before expensive operations
  7. Timeout - Sets deadlines for request processing
  8. Auth - Authenticates after rate limiting but before business logic
  9. Compression - Compresses response bodies (should be last)

Writing Custom Middleware

Basic Middleware Pattern

func MyMiddleware() router.HandlerFunc {
    return func(c *router.Context) {
        // Before request processing
        fmt.Println("Before handler")
        
        c.Next() // Execute next middleware/handler
        
        // After request processing
        fmt.Println("After handler")
    }
}

Middleware with Configuration

func RateLimit(requestsPerSecond int) router.HandlerFunc {
    // Setup (runs once when middleware is created)
    limiter := rate.NewLimiter(rate.Limit(requestsPerSecond), requestsPerSecond)
    
    return func(c *router.Context) {
        // Per-request logic
        if !limiter.Allow() {
            c.JSON(429, map[string]string{
                "error": "Too many requests",
            })
            return // Don't call c.Next() - stop the chain
        }
        c.Next()
    }
}

// Usage
r.Use(RateLimit(100)) // 100 requests per second

Middleware with Dependencies

func Auth(db *Database) router.HandlerFunc {
    return func(c *router.Context) {
        token := c.Request.Header.Get("Authorization")
        
        user, err := db.ValidateToken(token)
        if err != nil {
            c.JSON(401, map[string]string{
                "error": "Unauthorized",
            })
            return
        }
        
        // Store user in request context for handlers
        ctx := context.WithValue(c.Request.Context(), "user", user)
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}

// Usage
db := NewDatabase()
r.Use(Auth(db))

Conditional Middleware

func ConditionalAuth() router.HandlerFunc {
    return func(c *router.Context) {
        // Skip auth for public endpoints
        if c.Request.URL.Path == "/public" {
            c.Next()
            return
        }
        
        // Require auth for other endpoints
        token := c.Request.Header.Get("Authorization")
        if token == "" {
            c.JSON(401, map[string]string{
                "error": "Unauthorized",
            })
            return
        }
        
        c.Next()
    }
}

Middleware Patterns

Pattern: Error Handling Middleware

func ErrorHandler() router.HandlerFunc {
    return func(c *router.Context) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic: %v", err)
                c.JSON(500, map[string]string{
                    "error": "Internal server error",
                })
            }
        }()
        
        c.Next()
    }
}

Pattern: Logging Middleware

func Logger() router.HandlerFunc {
    return func(c *router.Context) {
        start := time.Now()
        path := c.Request.URL.Path
        method := c.Request.Method
        
        c.Next()
        
        duration := time.Since(start)
        status := c.Writer.Status()
        
        log.Printf("[%s] %s %s - %d (%v)",
            method,
            path,
            c.ClientIP(),
            status,
            duration,
        )
    }
}

Pattern: Authentication Middleware

func JWTAuth(secret string) router.HandlerFunc {
    return func(c *router.Context) {
        authHeader := c.Request.Header.Get("Authorization")
        if authHeader == "" {
            c.JSON(401, map[string]string{
                "error": "Missing authorization header",
            })
            return
        }
        
        // Extract token (Bearer <token>)
        parts := strings.SplitN(authHeader, " ", 2)
        if len(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)
        if err != nil {
            c.JSON(401, map[string]string{
                "error": "Invalid token",
            })
            return
        }
        
        // Store claims in request context
        ctx := 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 chars
r.Use(requestid.New())

// ULID - shorter, 26 chars
r.Use(requestid.New(requestid.WithULID()))

// Access in handlers
func handler(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:

func RequestID() router.HandlerFunc {
    return func(c *router.Context) {
        // Check for existing request ID
        requestID := c.Request.Header.Get("X-Request-ID")
        if requestID == "" {
            // Generate new UUID v7
            requestID = uuid.Must(uuid.NewV7()).String()
        }
        
        // Store in request context and response header
        ctx := 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 continue
func Logger() router.HandlerFunc {
    return func(c *router.Context) {
        start := time.Now()
        c.Next() // Continue to handler
        duration := time.Since(start)
        log.Printf("Duration: %v", duration)
    }
}

// ✅ GOOD: Doesn't call c.Next() to stop chain
func Auth() router.HandlerFunc {
    return func(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 responsibility
func Logger() router.HandlerFunc { ... }
func Auth() router.HandlerFunc { ... }
func RateLimit() router.HandlerFunc { ... }

// ❌ BAD: Does too much
func SuperMiddleware() router.HandlerFunc {
    return func(c *router.Context) {
        // Logging
        // Auth
        // Rate limiting
        // ...
        c.Next()
    }
}

3. Use Functional Options for Configuration

type Config struct {
    Limit int
    Burst int
}

type Option func(*Config)

func WithLimit(limit int) Option {
    return func(c *Config) {
        c.Limit = limit
    }
}

func WithBurst(burst int) Option {
    return func(c *Config) {
        c.Burst = burst
    }
}

func RateLimit(opts ...Option) router.HandlerFunc {
    config := &Config{
        Limit: 100,
        Burst: 10,
    }
    for _, opt := range opts {
        opt(config)
    }
    
    limiter := rate.NewLimiter(rate.Limit(config.Limit), config.Burst)
    
    return func(c *router.Context) {
        if !limiter.Allow() {
            c.JSON(429, map[string]string{"error": "Too many requests"})
            return
        }
        c.Next()
    }
}

// Usage
r.Use(RateLimit(
    WithLimit(1000),
    WithBurst(100),
))

4. Handle Errors Gracefully

func Middleware() router.HandlerFunc {
    return func(c *router.Context) {
        if err := doSomething(c); err != nil {
            // Log error
            log.Printf("Middleware error: %v", err)
            
            // Return error response
            c.JSON(500, map[string]string{
                "error": "Internal server error",
            })
            return // Don't call c.Next()
        }
        c.Next()
    }
}

Complete Example

package main

import (
    "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"
)

func main() {
    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 routes
    r.GET("/health", healthHandler)
    r.GET("/public", publicHandler)
    
    // API routes with auth
    api := r.Group("/api")
    api.Use(JWTAuth("your-secret-key"))
    {
        api.GET("/profile", profileHandler)
        api.POST("/posts", createPostHandler)
        
        // Admin routes with additional middleware
        admin := api.Group("/admin")
        admin.Use(RequireAdmin())
        {
            admin.GET("/users", listUsersHandler)
            admin.DELETE("/users/:id", deleteUserHandler)
        }
    }
    
    log.Fatal(http.ListenAndServe(":8080", r))
}

// Custom middleware
func JWTAuth(secret string) router.HandlerFunc {
    return func(c *router.Context) {
        token := c.Request.Header.Get("Authorization")
        if token == "" {
            c.JSON(401, map[string]string{"error": "Unauthorized"})
            return
        }
        // Validate token...
        c.Next()
    }
}

func RequireAdmin() router.HandlerFunc {
    return func(c *router.Context) {
        // Check if user is admin...
        c.Next()
    }
}

// Handlers
func healthHandler(c *router.Context) {
    c.JSON(200, map[string]string{"status": "OK"})
}

func publicHandler(c *router.Context) {
    c.JSON(200, map[string]string{"message": "Public endpoint"})
}

func profileHandler(c *router.Context) {
    c.JSON(200, map[string]string{"user": "john@example.com"})
}

func createPostHandler(c *router.Context) {
    c.JSON(201, map[string]string{"message": "Post created"})
}

func listUsersHandler(c *router.Context) {
    c.JSON(200, []string{"user1", "user2"})
}

func deleteUserHandler(c *router.Context) {
    c.Status(204)
}

Next Steps

2.6 - Context

Understand the Context API, request/response methods, and critical memory safety rules.

The router.Context provides access to the request/response and various utility methods. Understanding its lifecycle is critical for memory safety.

⚠️ Memory Safety - CRITICAL

Context objects are pooled and reused across requests. You must understand context lifecycle for memory safety.

CRITICAL RULES

  1. DO NOT retain references to Context objects beyond the request handler lifetime.
  2. For async operations, copy needed data from Context before starting goroutines.
  3. The router automatically returns contexts to the pool after request completion.
  4. DO NOT access Context concurrently. It is NOT thread-safe.

Why This Matters

  • Memory leaks: Retaining references prevents contexts from being garbage collected.
  • Data corruption: Contexts are reused. Old data may appear in new requests.
  • Security issues: Sensitive request data may leak to other requests.
  • Undefined behavior: Use-after-release causes unpredictable bugs.

Correct Usage

// ✅ CORRECT: Normal handler - context used within handler
func handler(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 data
func handler(c *router.Context) {
    // Copy needed data before starting goroutine
    userID := c.Param("id")
    go func(id string) {
        // Process async work with copied data...
        processAsync(id)
    }(userID)
}

Incorrect Usage

// ❌ WRONG: Retaining context reference
var globalContext *router.Context

func handler(c *router.Context) {
    globalContext = c // BAD! Memory leak and data corruption
}

// ❌ WRONG: Passing context to goroutine
func handler(c *router.Context) {
    go func(ctx *router.Context) {
        // BAD! Context may be reused by another request
        processAsync(ctx.Param("id"))
    }(c)
}

// ❌ WRONG: Storing context in struct
type Service struct {
    ctx *router.Context // BAD! Never do this
}

Request Information

Basic Request Data

func handler(c *router.Context) {
    // HTTP method
    method := c.Request.Method // "GET", "POST", etc.
    
    // URL path
    path := c.Request.URL.Path // "/users/123"
    
    // Headers
    userAgent := c.Request.Header.Get("User-Agent")
    contentType := c.Request.Header.Get("Content-Type")
    
    // Remote address
    remoteAddr := c.Request.RemoteAddr // "192.168.1.1:12345"
}

Path Parameters

Extract parameters from the URL path:

// Route: /users/:id/posts/:post_id
r.GET("/users/:id/posts/:post_id", func(c *router.Context) {
    userID := c.Param("id")
    postID := c.Param("post_id")
    
    c.JSON(200, map[string]string{
        "user_id": userID,
        "post_id": postID,
    })
})

Query Parameters

// GET /search?q=golang&limit=10&page=2
r.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 data
r.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

// YAML
c.YAML(200, config)

// Plain text
c.String(200, "Hello, World!")
c.Stringf(200, "Hello, %s!", name)

// HTML
c.HTML(200, "<h1>Welcome</h1>")

// Binary data
c.Data(200, "image/png", imageBytes)

// Stream from reader (zero-copy!)
c.DataFromReader(200, size, "video/mp4", file, nil)

// Status only
c.Status(204) // No Content

File Serving

// Serve file
c.ServeFile("/path/to/file.pdf")

// Force download
c.Download("/path/to/file.pdf", "custom-name.pdf")

Request Headers

Reading Headers

func handler(c *router.Context) {
    userAgent := c.Request.Header.Get("User-Agent")
    auth := c.Request.Header.Get("Authorization")
    contentType := c.Request.Header.Get("Content-Type")
}

Setting Response Headers

func handler(c *router.Context) {
    c.Header("Cache-Control", "no-cache")
    c.Header("X-Custom-Header", "value")
    c.JSON(200, data)
}

Helper Methods

Content Type Detection

func handler(c *router.Context) {
    if c.IsJSON() {
        // Request has JSON content-type
    }
    
    if c.AcceptsJSON() {
        c.JSON(200, data)
    } else if c.AcceptsHTML() {
        c.HTML(200, htmlContent)
    }
}

Client Information

func handler(c *router.Context) {
    clientIP := c.ClientIP()       // Real IP (considers X-Forwarded-For)
    isSecure := c.IsHTTPS()       // HTTPS check
}

Redirects

func handler(c *router.Context) {
    c.Redirect(301, "/new-url") // Permanent redirect
    c.Redirect(302, "/temp")    // Temporary redirect
}

Cookies

// Set cookie
c.SetCookie(
    "session_id",    // name
    "abc123",        // value
    3600,            // max age (seconds)
    "/",             // path
    "",              // domain
    false,           // secure
    true,            // httpOnly
)

// Get cookie
sessionID, err := c.GetCookie("session_id")

Passing Values Between Middleware

Use context.WithValue() to pass values between middleware and handlers:

// Define context keys to avoid collisions
type contextKey string
const userKey contextKey = "user"

// In middleware - create new request with value
func AuthMiddleware() router.HandlerFunc {
    return func(c *router.Context) {
        user := authenticateUser(c)
        
        // Create new context with value
        ctx := context.WithValue(c.Request.Context(), userKey, user)
        c.Request = c.Request.WithContext(ctx)
        
        c.Next()
    }
}

// In handler - retrieve value from request context
func handler(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)
}

File Uploads

r.POST("/upload", func(c *router.Context) {
    // Single file
    file, err := c.File("avatar")
    if err != nil {
        c.JSON(400, map[string]string{"error": "avatar required"})
        return
    }
    
    // File info
    fmt.Printf("Name: %s, Size: %d, Type: %s\n", 
        file.Name, file.Size, file.ContentType)
    
    // Save file
    if err := 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 files
r.POST("/upload-many", func(c *router.Context) {
    files, err := c.Files("documents")
    if err != nil {
        c.JSON(400, map[string]string{"error": "documents required"})
        return
    }
    
    for _, f := range files {
        f.Save("./uploads/" + f.Name)
    }
    
    c.JSON(200, map[string]int{"count": len(files)})
})

Performance Tips

Extract Data Immediately

// ✅ GOOD: Extract data early
func handler(c *router.Context) {
    userID := c.Param("id")
    query := c.Query("q")
    
    // Use extracted data
    result := processData(userID, query)
    c.JSON(200, result)
}

// ❌ BAD: Don't store context reference
var globalContext *router.Context
func handler(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

package main

import (
    "encoding/json"
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.MustNew()
    
    // Request parameters
    r.GET("/users/:id", func(c *router.Context) {
        id := c.Param("id")
        c.JSON(200, map[string]string{"id": id})
    })
    
    // Query parameters
    r.GET("/search", func(c *router.Context) {
        q := c.Query("q")
        c.JSON(200, map[string]string{"query": q})
    })
    
    // Form data
    r.POST("/login", func(c *router.Context) {
        username := c.FormValue("username")
        c.JSON(200, map[string]string{"username": username})
    })
    
    // JSON request body
    r.POST("/users", func(c *router.Context) {
        var req struct {
            Name  string `json:"name"`
            Email string `json:"email"`
        }
        
        if err := 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 cookies
    r.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)
}

Next Steps

2.7 - Request Binding

Parse and bind request data to Go structs.

Request binding parses request data (query parameters, URL parameters, form data, JSON) into Go structs.

Router Context Methods

The router Context provides basic data access methods and streaming capabilities.

Simple JSON Binding

For simple JSON binding in router-only code, use the standard library:

import "encoding/json"

r.POST("/users", func(c *router.Context) {
    var req CreateUserRequest
    if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
        c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
        return
    }
    c.JSON(201, req)
})

This works well for simple cases. For more features, use the binding package.

Manual Parameter Access

For simple cases, access parameters directly:

// Query parameters
r.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 parameters
r.GET("/users/:id", func(c *router.Context) {
    userID := c.Param("id")
    c.JSON(200, map[string]string{"user_id": userID})
})

// Form data
r.POST("/login", func(c *router.Context) {
    username := c.FormValue("username")
    password := c.FormValue("password")
    // ...
})

Content Type Validation

Check the content type before processing the body:

r.POST("/users", func(c *router.Context) {
    if !c.RequireContentTypeJSON() {
        return // 415 Unsupported Media Type already sent
    }
    
    var req CreateUserRequest
    if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
        c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
        return
    }
    c.JSON(201, req)
})

Streaming Large Payloads

For large arrays, stream instead of loading into memory:

// Stream JSON array items
r.POST("/bulk/users", func(c *router.Context) {
    err := router.StreamJSONArray(c, func(user User) error {
        return processUser(user)
    }, 10000) // Max 10k items
    
    if err != nil {
        return
    }
    c.NoContent()
})

// Stream NDJSON (newline-delimited JSON)
r.POST("/import", func(c *router.Context) {
    err := router.StreamNDJSON(c, func(item Record) error {
        return importRecord(item)
    })
    
    if err != nil {
        return
    }
    c.NoContent()
})

Binding Package (Full Features)

For comprehensive binding with struct tags, use the binding package:

import "rivaas.dev/binding"

// Bind query parameters to struct
type SearchRequest struct {
    Query string `query:"q"`
    Limit int    `query:"limit" default:"10"`
    Page  int    `query:"page" default:"1"`
}

r.GET("/search", func(c *router.Context) {
    var req SearchRequest
    if err := binding.Query(c.Request, &req); err != nil {
        c.JSON(400, map[string]string{"error": err.Error()})
        return
    }
    c.JSON(200, req)
})

Binding Methods (binding package)

binding.Query(r *http.Request, dst any) error    // Query parameters
binding.Params(params map[string]string, dst any) error  // URL parameters
binding.JSON(r *http.Request, dst any) error     // JSON body
binding.Form(r *http.Request, dst any) error     // Form data
binding.Headers(r *http.Request, dst any) error  // Request headers
binding.Cookies(r *http.Request, dst any) error  // Cookies

Supported Types

Primitives:

type Example struct {
    String  string  `query:"string"`
    Int     int     `query:"int"`
    Int64   int64   `query:"int64"`
    Float64 float64 `query:"float64"`
    Bool    bool    `query:"bool"`
}

Time and Duration:

type Example struct {
    Time     time.Time     `query:"time"`      // RFC3339, ISO8601, etc.
    Duration time.Duration `query:"duration"`  // "5m", "1h30m", etc.
}

Network Types:

type Example struct {
    IP     net.IP     `query:"ip"`      // "192.168.1.1"
    IPNet  net.IPNet  `query:"ipnet"`   // "192.168.1.0/24"
    URL    url.URL    `query:"url"`     // "https://example.com"
}

Slices:

type Example struct {
    Tags  []string `query:"tags"`   // ?tags=a&tags=b&tags=c
    IDs   []int    `query:"ids"`    // ?ids=1&ids=2&ids=3
}

Maps:

type Example struct {
    // Dot notation: ?metadata.key1=value1&metadata.key2=value2
    Metadata map[string]string `query:"metadata"`
    
    // Bracket notation: ?filters[status]=active&filters[type]=post
    Filters map[string]string `query:"filters"`
}

Struct Tags

enum - Enum Validation:

type Request struct {
    Status string `query:"status" enum:"active,inactive,pending"`
}

default - Default Values:

type Request struct {
    Limit int    `query:"limit" default:"10"`
    Sort  string `query:"sort" default:"desc"`
}

Combined:

type Request struct {
    Status string `query:"status" enum:"active,inactive" default:"active"`
    Limit  int    `query:"limit" default:"10"`
}

Complete Example

package main

import (
    "net/http"
    "rivaas.dev/router"
    "rivaas.dev/binding"
)

type CreateUserRequest struct {
    Name     string            `json:"name"`
    Email    string            `json:"email"`
    Age      int               `json:"age"`
    Tags     []string          `json:"tags"`
    Metadata map[string]string `json:"metadata"`
}

type SearchRequest struct {
    Query  string `query:"q"`
    Limit  int    `query:"limit" default:"10"`
    Status string `query:"status" enum:"active,inactive,all" default:"all"`
}

func main() {
    r := router.MustNew()
    
    // Simple JSON binding
    r.POST("/users", func(c *router.Context) {
        var req CreateUserRequest
        if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
            c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
            return
        }
        c.JSON(201, req)
    })
    
    // Query binding (using binding package)
    r.GET("/search", func(c *router.Context) {
        var req SearchRequest
        if err := binding.Query(c.Request, &req); err != nil {
            c.JSON(400, map[string]string{"error": err.Error()})
            return
        }
        c.JSON(200, req)
    })
    
    // Simple parameter access
    r.GET("/users/:id", func(c *router.Context) {
        userID := c.Param("id")
        includeDeleted := c.QueryDefault("include_deleted", "false")
        
        c.JSON(200, map[string]string{
            "user_id":         userID,
            "include_deleted": includeDeleted,
        })
    })
    
    http.ListenAndServe(":8080", r)
}

Next Steps

2.8 - Validation

Validate requests with multiple strategies: interface methods, struct tags, or JSON Schema.

Request validation ensures incoming data meets your requirements before processing.

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

type TransferRequest struct {
    FromAccount string  `json:"from_account"`
    ToAccount   string  `json:"to_account"`
    Amount      float64 `json:"amount"`
}

func (t *TransferRequest) Validate() error {
    if t.FromAccount == t.ToAccount {
        return errors.New("cannot transfer to same account")
    }
    if t.Amount > 10000 {
        return errors.New("amount exceeds daily limit")
    }
    return nil
}

Context-Aware Validation

type CreatePostRequest struct {
    Title string   `json:"title"`
    Tags  []string `json:"tags"`
}

func (p *CreatePostRequest) ValidateContext(ctx context.Context) error {
    // Get user tier from context
    tier := ctx.Value("user_tier")
    if tier == "free" && len(p.Tags) > 3 {
        return errors.New("free users can only use 3 tags")
    }
    return nil
}

Handler Integration

func createTransfer(c *router.Context) {
    var req TransferRequest
    if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
        c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
        return
    }
    
    // Call interface validation method
    if err := req.Validate(); err != nil {
        c.JSON(400, map[string]string{"error": err.Error()})
        return
    }
    
    // Process validated request
    c.JSON(200, map[string]string{"status": "success"})
}

Tag Validation with Binding Package

Use the binding package with struct tags for declarative validation:

import (
    "rivaas.dev/binding"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Username string `json:"username" validate:"required,min=3,max=20"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
}

func createUser(c *router.Context) {
    var req CreateUserRequest
    
    // Bind JSON using binding package
    if err := binding.JSON(c.Request, &req); err != nil {
        c.JSON(400, map[string]string{"error": err.Error()})
        return
    }
    
    // Validate with struct tags
    if err := validation.Validate(&req); err != nil {
        c.JSON(400, map[string]string{"error": err.Error()})
        return
    }
    
    c.JSON(201, req)
}

Common Validation Tags

type Example struct {
    Required string  `validate:"required"`           // Must be present
    Email    string  `validate:"email"`              // Valid email format
    URL      string  `validate:"url"`                // Valid URL
    Min      int     `validate:"min=10"`             // Minimum value
    Max      int     `validate:"max=100"`            // Maximum value
    Range    int     `validate:"min=10,max=100"`     // Range
    Length   string  `validate:"min=3,max=50"`       // String length
    OneOf    string  `validate:"oneof=active pending"` // Enum
    Optional string  `validate:"omitempty,email"`    // Optional but validates if present
}

JSON Schema Validation

Implement the JSONSchemaProvider interface for contract-based validation:

type ProductRequest struct {
    Name  string  `json:"name"`
    Price float64 `json:"price"`
    SKU   string  `json:"sku"`
}

func (p *ProductRequest) JSONSchema() (id string, schema string) {
    return "product-v1", `{
        "type": "object",
        "properties": {
            "name": {"type": "string", "minLength": 3},
            "price": {"type": "number", "minimum": 0},
            "sku": {"type": "string", "pattern": "^[A-Z]{3}-[0-9]{6}$"}
        },
        "required": ["name", "price", "sku"]
    }`
}

Combining Binding and Validation

For a complete solution, combine strict binding with interface validation:

type CreateOrderRequest struct {
    CustomerID string       `json:"customer_id"`
    Items      []OrderItem  `json:"items"`
    Notes      string       `json:"notes"`
}

func (r *CreateOrderRequest) Validate() error {
    if len(r.Items) == 0 {
        return errors.New("order must have at least one item")
    }
    for i, item := range r.Items {
        if item.Quantity <= 0 {
            return fmt.Errorf("item %d: quantity must be positive", i)
        }
    }
    return nil
}

func createOrder(c *router.Context) {
    var req CreateOrderRequest
    
    // Simple JSON binding
    if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
        c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
        return
    }
    
    // Business logic validation
    if err := 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:

type UpdateUserRequest struct {
    Email    *string `json:"email,omitempty"`
    Username *string `json:"username,omitempty"`
    Bio      *string `json:"bio,omitempty"`
}

func (r *UpdateUserRequest) Validate() error {
    if r.Email != nil && *r.Email == "" {
        return errors.New("email cannot be empty if provided")
    }
    if r.Username != nil && len(*r.Username) < 3 {
        return errors.New("username must be at least 3 characters")
    }
    if r.Bio != nil && len(*r.Bio) > 500 {
        return errors.New("bio cannot exceed 500 characters")
    }
    return nil
}

func updateUser(c *router.Context) {
    var req UpdateUserRequest
    
    if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
        c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
        return
    }
    
    if err := req.Validate(); err != nil {
        c.JSON(400, map[string]string{"error": err.Error()})
        return
    }
    
    // Update only non-nil fields
    if req.Email != nil {
        // Update email
    }
    
    c.JSON(200, map[string]string{"status": "updated"})
}

Structured Validation Errors

Return detailed errors for better API usability:

type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

type ValidationErrors struct {
    Errors []ValidationError `json:"errors"`
}

func (r *CreateUserRequest) Validate() *ValidationErrors {
    var errs []ValidationError
    
    if r.Email == "" {
        errs = append(errs, ValidationError{
            Field:   "email",
            Message: "email is required",
        })
    }
    
    if len(r.Username) < 3 {
        errs = append(errs, ValidationError{
            Field:   "username",
            Message: "username must be at least 3 characters",
        })
    }
    
    if len(errs) > 0 {
        return &ValidationErrors{Errors: errs}
    }
    return nil
}

func createUser(c *router.Context) {
    var req CreateUserRequest
    
    if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
        c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
        return
    }
    }
    
    if verrs := 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 the binding or app package for full binding capabilities

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

package main

import (
    "errors"
    "net/http"
    "rivaas.dev/router"
)

type CreateUserRequest struct {
    Email    string `json:"email"`
    Username string `json:"username"`
    Age      int    `json:"age"`
}

func (r *CreateUserRequest) Validate() error {
    if r.Email == "" {
        return errors.New("email is required")
    }
    if len(r.Username) < 3 {
        return errors.New("username must be at least 3 characters")
    }
    if r.Age < 18 || r.Age > 120 {
        return errors.New("age must be between 18 and 120")
    }
    return nil
}

func main() {
    r := router.MustNew()
    
    r.POST("/users", func(c *router.Context) {
        var req CreateUserRequest
        
        // Bind JSON
        if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
            c.WriteErrorResponse(http.StatusBadRequest, "Invalid JSON")
            return
        }
        
        // Run business validation
        if err := req.Validate(); err != nil {
            c.JSON(400, map[string]string{"error": err.Error()})
            return
        }
        
        c.JSON(201, req)
    })
    
    http.ListenAndServe(":8080", r)
}

Next Steps

2.9 - Response Rendering

Render JSON, YAML, HTML, binary responses with performance optimizations.

The router provides multiple response rendering methods optimized for different use cases.

JSON Variants

Standard JSON

HTML-escaped JSON (default):

c.JSON(200, map[string]string{
    "message": "Hello, <script>alert('xss')</script>",
})
// Output: {"message":"Hello, \u003cscript\u003ealert('xss')\u003c/script\u003e"}

Indented JSON

Pretty-printed for debugging:

c.IndentedJSON(200, data) // Pretty-printed with indentation

Pure JSON

No HTML escaping. 35% faster:

c.PureJSON(200, data) // Best for HTML/markdown content

Secure JSON

Anti-hijacking prefix for compliance:

c.SecureJSON(200, data) // Adds ")]}',\n" prefix

ASCII JSON

Pure ASCII with Unicode escaping:

c.AsciiJSON(200, data) // All Unicode as \uXXXX

JSONP

JSONP with callback:

c.JSONP(200, data, "callback") // callback({...})

Alternative Formats

YAML

c.YAML(200, config) // YAML rendering for config/DevOps APIs

Plain Text

c.String(200, "Hello, World!")
c.Stringf(200, "Hello, %s!", name)

HTML

c.HTML(200, "<h1>Welcome</h1>")

Binary & Streaming

Binary Data

c.Data(200, "image/png", imageBytes) // 98% faster than JSON!

Zero-Copy Streaming

file, _ := os.Open("video.mp4")
defer file.Close()
fileInfo, _ := file.Stat()

c.DataFromReader(200, fileInfo.Size(), "video/mp4", file, nil)

File Serving

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

Methodns/opOverhead vs JSONUse Case
JSON4,189-Production APIs
PureJSON2,725-35%HTML/markdown content
SecureJSON4,835+15%Compliance/old browsers
IndentedJSON8,111+94%Debug/development
AsciiJSON1,593-62%Legacy compatibility
YAML36,700+776%Config/admin APIs
Data90-98%Binary/custom formats

Complete Example

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.MustNew()
    
    // Standard JSON
    r.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>",
        })
    })
    
    // YAML
    r.GET("/yaml", func(c *router.Context) {
        c.YAML(200, map[string]interface{}{
            "server": map[string]interface{}{
                "port": 8080,
                "host": "localhost",
            },
        })
    })
    
    // Binary data
    r.GET("/image", func(c *router.Context) {
        imageData := loadImage()
        c.Data(200, "image/png", imageData)
    })
    
    // File download
    r.GET("/download", func(c *router.Context) {
        c.Download("/path/to/report.pdf", "report-2024.pdf")
    })
    
    http.ListenAndServe(":8080", r)
}

Next Steps

2.10 - Content Negotiation

RFC 7231 compliant content negotiation for media types, charsets, encodings, and languages.

The router provides RFC 7231 compliant content negotiation through Accepts* methods.

Media Type Negotiation

r.GET("/data", func(c *router.Context) {
    if c.AcceptsJSON() {
        c.JSON(200, data)
    } else if c.Accepts("xml") != "" {
        c.XML(200, data)
    } else if c.AcceptsHTML() {
        c.HTML(200, htmlTemplate)
    } else {
        c.String(200, fmt.Sprintf("%v", data))
    }
})

Quality Values

// Request: Accept: application/json;q=0.8, text/html;q=1.0
r.GET("/content", func(c *router.Context) {
    accepted := c.Accepts("application/json", "text/html")
    // Returns "text/html" (higher quality value)
})

Wildcard Support

// Request: Accept: */*
c.Accepts("application/json") // true

// Request: Accept: text/*
c.Accepts("text/html", "text/plain") // Returns "text/html"

Character Set Negotiation

r.GET("/data", func(c *router.Context) {
    charset := c.AcceptsCharsets("utf-8", "iso-8859-1")
    // Set response charset based on preference
    c.Header("Content-Type", "text/html; charset="+charset)
})

Encoding Negotiation

r.GET("/data", func(c *router.Context) {
    encoding := c.AcceptsEncodings("gzip", "br", "deflate")
    if encoding == "gzip" {
        // Compress response with gzip
    } else if encoding == "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 language
    content := getContentInLanguage(lang)
    c.String(200, content)
})

Complete Example

package main

import (
    "net/http"
    "rivaas.dev/router"
)

type User struct {
    ID    string `json:"id" xml:"id"`
    Name  string `json:"name" xml:"name"`
    Email string `json:"email" xml:"email"`
}

func main() {
    r := router.MustNew()
    
    r.GET("/user/:id", func(c *router.Context) {
        user := User{
            ID:    c.Param("id"),
            Name:  "John Doe",
            Email: "john@example.com",
        }
        
        // Content negotiation
        if c.AcceptsJSON() {
            c.JSON(200, user)
        } else if c.Accepts("xml") != "" {
            c.XML(200, user)
        } else if c.AcceptsHTML() {
            html := fmt.Sprintf(`
                <div>
                    <h1>%s</h1>
                    <p>Email: %s</p>
                </div>
            `, user.Name, user.Email)
            c.HTML(200, html)
        } else {
            c.String(200, fmt.Sprintf("User: %s (%s)", user.Name, user.Email))
        }
    })
    
    http.ListenAndServe(":8080", r)
}

Next Steps

2.11 - API Versioning

How to version your API with Rivaas Router

This guide explains how to add versioning to your API. Versioning lets you change your API without breaking existing clients.

Why Version APIs?

You need API versioning when:

  • You remove or change fields — Breaks existing clients
  • You add required fields — Old clients don’t send them
  • You change behavior — Clients expect the old way
  • You want to test new features — Test with some users first
  • Clients upgrade slowly — Different clients use different versions

Versioning Methods

Rivaas Router supports four ways to detect versions:

The version goes in an HTTP header:

curl -H 'API-Version: v2' https://api.example.com/users

Good for:

  • Public APIs
  • RESTful services
  • Modern web applications

Why it’s good:

  • URLs stay clean
  • Works with CDN caching
  • Easy to route
  • Standard practice

2. Query Parameter Versioning

The version goes in the URL query:

curl 'https://api.example.com/users?version=v2'

Good for:

  • Developer testing
  • Internal APIs
  • Simple clients

Why it’s good:

  • Easy to test in browsers
  • Simple to document
  • No header handling needed

3. Path-Based Versioning

The version goes in the URL path:

curl https://api.example.com/v2/users

Good for:

  • Very different API versions
  • Simple routing
  • When you want version visible

Why it’s good:

  • Most visible
  • Works with all HTTP clients
  • Easy infrastructure routing

4. Accept Header Versioning

The version goes in the Accept header (content negotiation):

curl -H 'Accept: application/vnd.myapi.v2+json' https://api.example.com/users

Good for:

  • Hypermedia APIs
  • Multiple content types
  • Strict REST compliance

Why it’s good:

  • Follows HTTP standards
  • Supports content negotiation
  • Used by major APIs

Getting Started

Basic Setup

Here’s how to set up versioning:

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.New(
        router.WithVersioning(
            // Choose your version detection method
            router.WithHeaderVersioning("API-Version"),
            
            // Set default version (when client doesn't specify)
            router.WithDefaultVersion("v2"),
            
            // Optional: Only allow these versions
            router.WithValidVersions("v1", "v2", "v3"),
        ),
    )
    
    // Create version 1 routes
    v1 := r.Version("v1")
    v1.GET("/users", listUsersV1)
    
    // Create version 2 routes
    v2 := 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"),       // Primary
        router.WithQueryVersioning("version"),           // For testing
        router.WithPathVersioning("/v{version}/"),       // Legacy support
        router.WithAcceptVersioning("application/vnd.myapi.v{version}+json"),
        router.WithDefaultVersion("v2"),
    ),
)

Check order (first match wins):

  1. Custom detector (if you made one)
  2. Accept header
  3. Path parameter
  4. HTTP header
  5. Query parameter
  6. Default version

Version Detection Methods

Header-Based

Configure:

router.WithHeaderVersioning("API-Version")

Clients use:

curl -H 'API-Version: v2' https://api.example.com/users

Query Parameter

Configure:

router.WithQueryVersioning("version")

Clients use:

curl 'https://api.example.com/users?version=v2'

Path-Based

Configure:

router.WithPathVersioning("/v{version}/")

Routes work with or without path version:

// Accessed as /v2/users or /users (with header/query)
r.Version("v2").GET("/users", handler)

Clients use:

curl https://api.example.com/v2/users

Accept Header

Configure:

router.WithAcceptVersioning("application/vnd.myapi.v{version}+json")

Clients use:

curl -H 'Accept: application/vnd.myapi.v2+json' https://api.example.com/users

Custom Detector

For complex logic, make your own detector:

router.WithCustomVersionDetector(func(req *http.Request) string {
    // Your custom logic
    if isLegacyClient(req) {
        return "v1"
    }
    return extractVersionSomehow(req)
})

Migration Patterns

Share Business Logic

Keep business logic the same, change only the response format:

// Business logic (shared between versions)
func getUserByID(id string) (*User, error) {
    // Database query, business rules, etc.
    return &User{ID: id, Name: "Alice"}, nil
}

// Version 1 handler
func listUsersV1(c *router.Context) {
    users, _ := getUsersFromDB()
    
    // V1 format: flat structure
    c.JSON(200, map[string]any{
        "users": users,
    })
}

// Version 2 handler
func listUsersV2(c *router.Context) {
    users, _ := getUsersFromDB()
    
    // V2 format: with metadata
    c.JSON(200, map[string]any{
        "data": users,
        "meta": map[string]any{
            "total": len(users),
            "version": "v2",
        },
    })
}

Handle Breaking Changes

Example: Making email field required

Version 1 (original):

type UserV1 struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email,omitempty"` // Optional
}

Version 2 (breaking change):

type UserV2 struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"` // Required now
}

func createUserV2(c *router.Context) {
    var user UserV2
    if err := 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 method
v2.GET("/users", listUsersV2)

Change Data Structure

Example: Flat to nested structure

// V1: Flat structure
type UserV1 struct {
    ID      int    `json:"id"`
    Name    string `json:"name"`
    City    string `json:"city"`
    Country string `json:"country"`
}

// V2: Nested structure
type UserV2 struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Address struct {
        City    string `json:"city"`
        Country string `json:"country"`
    } `json:"address"`
}

// Helper to convert
func convertV1ToV2(v1 UserV1) UserV2 {
    v2 := UserV2{
        ID:   v1.ID,
        Name: v1.Name,
    }
    v2.Address.City = v1.City
    v2.Address.Country = v1.Country
    return v2
}

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 date
        router.WithDeprecatedVersion(
            "v1",
            time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC),
        ),
        
        // Track version usage
        router.WithVersionObserver(
            router.WithOnDetected(func(version, method string) {
                // Record metrics
                metrics.RecordVersionUsage(version, method)
            }),
            router.WithOnMissing(func() {
                // Client didn't specify version
                log.Warn("client using default version")
            }),
            router.WithOnInvalid(func(attempted string) {
                // Client used invalid version
                metrics.RecordInvalidVersion(attempted)
            }),
        ),
    ),
)

Deprecation Headers

The router automatically adds headers for deprecated versions:

Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Deprecation: true
Link: <https://api.example.com/docs/migration>; rel="deprecation"

These tell clients when the version will stop working.

Deprecation Timeline

6 months before end:

  1. Announce in release notes
  2. Add deprecation header
  3. Write migration guide
  4. Contact major users

3 months before end:

  1. Add sunset header with date
  2. Email active users
  3. Monitor usage (should go down)
  4. Offer help with migration

1 month before end:

  1. Send final warnings
  2. Return 410 Gone for deprecated endpoints
  3. Link to migration guide

After end date:

  1. Remove old version code
  2. Always return 410 Gone
  3. 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 field
type UserV2 struct {
    ID    int     `json:"id"`
    Name  string  `json:"name"`
    Email string  `json:"email,omitempty"` // New, optional
}

// Bad: Remove field (breaks clients)
type UserV2 struct {
    ID   int    `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(attempted string) {
            log.Warn("invalid API version", "version", attempted)
        }),
    ),
)

7. Test All Versions

func TestAPIVersions(t *testing.T) {
    r := setupRouter()
    
    tests := []struct{
        version string
        path    string
        want    int
    }{
        {"v1", "/users", 200},
        {"v2", "/users", 200},
        {"v3", "/users", 200},
        {"v99", "/users", 404}, // Invalid
    }
    
    for _, tt := range tests {
        req := httptest.NewRequest("GET", tt.path, nil)
        req.Header.Set("API-Version", tt.version)
        
        w := httptest.NewRecorder()
        r.ServeHTTP(w, req)
        
        assert.Equal(t, tt.want, w.Code)
    }
}

Real-World Examples

Stripe-Style (Date-Based Versions)

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 date
v20241120 := r.Version("2024-11-20")
v20241120.GET("/charges", listCharges)

GitHub-Style (Accept Header)

r := router.New(
    router.WithVersioning(
        router.WithAcceptVersioning("application/vnd.github.v{version}+json"),
        router.WithDefaultVersion("v3"),
    ),
)

// Usage: Accept: application/vnd.github.v3+json

Further Reading

Summary

API versioning helps you:

  • Make changes without breaking clients
  • Support old and new clients at the same time
  • Control when to remove old versions
  • Track which versions clients use

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.

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

OpenTelemetry Tracing

Enable Tracing

r := router.New(router.WithTracing())

Configuration Options

r := router.New(
    router.WithTracing(),
    router.WithTracingServiceName("my-api"),
    router.WithTracingServiceVersion("v1.2.3"),
    router.WithTracingSampleRate(0.1), // 10% sampling
    router.WithTracingExcludePaths("/health", "/metrics"),
)

Context Tracing Methods

func handler(c *router.Context) {
    // Get trace/span IDs
    traceID := c.TraceID()
    spanID := c.SpanID()
    
    // Add custom attributes
    c.SetSpanAttribute("user.id", "123")
    c.SetSpanAttribute("operation.type", "database_query")
    
    // Add events
    c.AddSpanEvent("processing_started")
    c.AddSpanEvent("cache_miss", 
        attribute.String("cache.key", "user:123"),
    )
}

Complete Tracing Example

package main

import (
    "context"
    "log"
    "net/http"
    
    "rivaas.dev/router"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/jaeger"
    "go.opentelemetry.io/otel/sdk/trace"
)

func main() {
    // Initialize Jaeger exporter
    exp, err := jaeger.New(jaeger.WithCollectorEndpoint(
        jaeger.WithEndpoint("http://localhost:14268/api/traces"),
    ))
    if err != nil {
        log.Fatal(err)
    }

    tp := trace.NewTracerProvider(
        trace.WithBatcher(exp),
        trace.WithSampler(trace.TraceIDRatioBased(0.1)),
    )
    otel.SetTracerProvider(tp)

    // Create router with tracing
    r := router.New(
        router.WithTracing(),
        router.WithTracingServiceName("my-service"),
    )
    
    r.GET("/", func(c *router.Context) {
        c.SetSpanAttribute("handler", "home")
        c.JSON(200, map[string]string{"message": "Hello"})
    })
    
    defer tp.Shutdown(context.Background())
    log.Fatal(http.ListenAndServe(":8080", r))
}

Diagnostics

Enable diagnostic events for security concerns and configuration issues:

import "log/slog"

handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    slog.Warn(e.Message, "kind", e.Kind, "fields", e.Fields)
})

r := router.New(router.WithDiagnostics(handler))

Diagnostic Event Types

  • DiagXFFSuspicious - Suspicious X-Forwarded-For chain detected
  • DiagHeaderInjection - Header injection attempt blocked
  • DiagInvalidProto - Invalid X-Forwarded-Proto value
  • DiagHighParamCount - Route has >8 parameters (uses map storage)
  • DiagH2CEnabled - H2C enabled (development warning)

Example with Metrics

handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    metrics.Increment("router.diagnostics", "kind", string(e.Kind))
})

r := router.New(router.WithDiagnostics(handler))

Best Practices

  1. Use path exclusion for high-frequency endpoints:

    router.WithTracingExcludePaths("/health", "/metrics", "/ping")
    
  2. Set appropriate sampling rates in production:

    router.WithTracingSampleRate(0.01) // 1% sampling
    
  3. Add meaningful attributes in handlers:

    c.SetSpanAttribute("user.id", userID)
    c.SetSpanAttribute("operation.type", "database_query")
    
  4. Disable parameter recording for sensitive data:

    router.WithTracingDisableParams()
    

Next Steps

2.13 - Static Files

Serve static files and directories efficiently.

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

Example:

./public/
├── css/
│   └── style.css
├── js/
│   └── app.js
└── images/
    └── logo.png

Access:

  • http://localhost:8080/assets/css/style.css
  • http://localhost:8080/assets/js/app.js
  • http://localhost:8080/assets/images/logo.png

Single File Serving

Serve specific files:

r.StaticFile("/favicon.ico", "./static/favicon.ico")
r.StaticFile("/robots.txt", "./static/robots.txt")

Custom File System

Use a custom filesystem.

import "net/http"

r.StaticFS("/files", http.Dir("./files"))

Embedded Files

Go 1.16 added embed.FS which lets you put files inside your binary. This is great for single-file deployments — no need to copy static files around.

The router has a helper method that makes this easy:

import "embed"

//go:embed web/dist/*
var webAssets embed.FS

r := router.MustNew()

// Serve web/dist/* at /assets/*
r.StaticEmbed("/assets", webAssets, "web/dist")

The third parameter ("web/dist") tells the router which folder inside the embed to use. This strips that prefix from the URLs.

Why use embedded files?

  • One binary — Deploy a single file, no folders to manage
  • Fast startup — Files are already in memory
  • Safe — Nobody can change your static files at runtime

Example project layout:

myapp/
├── main.go
└── web/
    └── dist/
        ├── index.html
        ├── css/
        │   └── style.css
        └── js/
            └── app.js

Serving a frontend app:

package main

import (
    "embed"
    "net/http"
    "rivaas.dev/router"
)

//go:embed web/dist/*
var webAssets embed.FS

func main() {
    r := router.MustNew()
    
    // Serve your frontend at the root
    r.StaticEmbed("/", webAssets, "web/dist")
    
    // API routes
    r.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.

File Serving in Handlers

Serve File

r.GET("/download/:filename", func(c *router.Context) {
    filename := c.Param("filename")
    filepath := "./uploads/" + filename
    c.ServeFile(filepath)
})

Force Download

r.GET("/download/:filename", func(c *router.Context) {
    filename := c.Param("filename")
    filepath := "./reports/" + filename
    c.Download(filepath, "report-2024.pdf")
})

Wildcard Routes for File Serving

r.GET("/files/*filepath", func(c *router.Context) {
    filepath := c.Param("filepath")
    fullPath := "./public/" + filepath
    c.ServeFile(fullPath)
})

Complete Example

Here’s a full example with all the ways to serve static files:

package main

import (
    "embed"
    "net/http"
    "rivaas.dev/router"
)

//go:embed static/*
var staticAssets embed.FS

func main() {
    r := router.MustNew()
    
    // Option 1: Serve from filesystem
    r.Static("/assets", "./public")
    
    // Option 2: Serve embedded files
    r.StaticEmbed("/static", staticAssets, "static")
    
    // Serve specific files
    r.StaticFile("/favicon.ico", "./static/favicon.ico")
    r.StaticFile("/robots.txt", "./static/robots.txt")
    
    // Custom file serving with download
    r.GET("/downloads/:filename", func(c *router.Context) {
        filename := c.Param("filename")
        c.Download("./files/"+filename, filename)
    })
    
    // API routes
    r.GET("/api/status", func(c *router.Context) {
        c.JSON(200, map[string]string{"status": "OK"})
    })
    
    http.ListenAndServe(":8080", r)
}

Security Considerations

Path Traversal Prevention

// ❌ BAD: Vulnerable to path traversal
r.GET("/files/*filepath", func(c *router.Context) {
    filepath := c.Param("filepath")
    c.ServeFile(filepath) // Can access ../../../etc/passwd
})

// ✅ GOOD: Validate and sanitize paths
r.GET("/files/*filepath", func(c *router.Context) {
    filepath := c.Param("filepath")
    
    // Validate path
    if strings.Contains(filepath, "..") {
        c.Status(400)
        return
    }
    
    // Serve from safe directory
    c.ServeFile("./public/" + filepath)
})

Best Practices

  1. Use absolute paths for static directories
  2. Validate file paths to prevent traversal attacks
  3. Set appropriate cache headers for static assets
  4. Use CDN for production static assets
  5. Serve from dedicated file server for large files
  6. Use embed.FS for single-binary deployments (great for containers and CLI tools)

Next Steps

2.14 - Testing

Test your routes and middleware with httptest.

Testing router-based applications is straightforward using Go’s httptest package.

Testing Routes

Basic Route Test

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
    
    "rivaas.dev/router"
)

func TestGetUser(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)
    
    if w.Code != http.StatusOK {
        t.Errorf("Expected status 200, got %d", w.Code)
    }
}

Testing JSON Responses

func TestCreateUser(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)
    
    if w.Code != 201 {
        t.Errorf("Expected status 201, got %d", w.Code)
    }
    
    var response map[string]string
    if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
        t.Fatal(err)
    }
    
    if response["id"] != "123" {
        t.Errorf("Expected id '123', got %v", response["id"])
    }
}

Testing Middleware

func TestAuthMiddleware(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 header
    req := httptest.NewRequest("GET", "/protected", nil)
    w := httptest.NewRecorder()
    r.ServeHTTP(w, req)
    
    if w.Code != 401 {
        t.Errorf("Expected status 401, got %d", w.Code)
    }
    
    // Test with auth header
    req = httptest.NewRequest("GET", "/protected", nil)
    req.Header.Set("Authorization", "Bearer valid-token")
    w = httptest.NewRecorder()
    r.ServeHTTP(w, req)
    
    if w.Code != 200 {
        t.Errorf("Expected status 200, got %d", w.Code)
    }
}

Table-Driven Tests

func TestRoutes(t *testing.T) {
    r := setupRouter()
    
    tests := []struct {
        name           string
        method         string
        path           string
        expectedStatus int
    }{
        {"Home", "GET", "/", 200},
        {"Users", "GET", "/users", 200},
        {"Not Found", "GET", "/invalid", 404},
        {"Method Not Allowed", "POST", "/", 405},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(tt.method, tt.path, nil)
            w := httptest.NewRecorder()
            
            r.ServeHTTP(w, req)
            
            if w.Code != tt.expectedStatus {
                t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
            }
        })
    }
}

Helper Functions

func setupRouter() *router.Router {
    r := router.MustNew()
    r.GET("/users", listUsers)
    r.POST("/users", createUser)
    r.GET("/users/:id", getUser)
    return r
}

func makeRequest(r *router.Router, method, path string, body io.Reader) *httptest.ResponseRecorder {
    req := httptest.NewRequest(method, path, body)
    w := httptest.NewRecorder()
    r.ServeHTTP(w, req)
    return w
}

Complete Test Example

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
    
    "rivaas.dev/router"
)

func setupRouter() *router.Router {
    r := router.MustNew()
    r.GET("/users", listUsers)
    r.POST("/users", createUser)
    r.GET("/users/:id", getUser)
    return r
}

func TestListUsers(t *testing.T) {
    r := setupRouter()
    
    req := httptest.NewRequest("GET", "/users", nil)
    w := httptest.NewRecorder()
    
    r.ServeHTTP(w, req)
    
    if w.Code != 200 {
        t.Errorf("Expected 200, got %d", w.Code)
    }
}

func TestCreateUser(t *testing.T) {
    r := setupRouter()
    
    userData := map[string]string{
        "name":  "John Doe",
        "email": "john@example.com",
    }
    
    body, _ := json.Marshal(userData)
    req := httptest.NewRequest("POST", "/users", bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    
    w := httptest.NewRecorder()
    r.ServeHTTP(w, req)
    
    if w.Code != 201 {
        t.Errorf("Expected 201, got %d", w.Code)
    }
}

func TestGetUser(t *testing.T) {
    r := setupRouter()
    
    req := httptest.NewRequest("GET", "/users/123", nil)
    w := httptest.NewRecorder()
    
    r.ServeHTTP(w, req)
    
    if w.Code != 200 {
        t.Errorf("Expected 200, got %d", w.Code)
    }
}

// Handlers (simplified for testing)
func listUsers(c *router.Context) {
    c.JSON(200, []string{"user1", "user2"})
}

func createUser(c *router.Context) {
    c.JSON(201, map[string]string{"id": "123"})
}

func getUser(c *router.Context) {
    c.JSON(200, map[string]string{"id": c.Param("id")})
}

Best Practices

  1. Use table-driven tests for multiple scenarios
  2. Test error cases not just success paths
  3. Mock dependencies for unit tests
  4. Use test helpers to reduce boilerplate
  5. Test middleware independently from routes

Next Steps

2.15 - Migration

Migrate from Gin, Echo, or http.ServeMux to Rivaas Router.

This guide helps you migrate from other popular Go routers.

Route Registration

gin := gin.Default()
gin.GET("/users/:id", getUserHandler)
gin.POST("/users", createUserHandler)
e := echo.New()
e.GET("/users/:id", getUserHandler)
e.POST("/users", createUserHandler)
mux := http.NewServeMux()
mux.HandleFunc("/users/", usersHandler)
mux.HandleFunc("/posts/", postsHandler)
r := router.MustNew()
r.GET("/users/:id", getUserHandler)
r.POST("/users", createUserHandler)

Context Usage

func ginHandler(c *gin.Context) {
    id := c.Param("id")
    c.JSON(200, gin.H{"user_id": id})
}
func echoHandler(c echo.Context) error {
    id := c.Param("id")
    return c.JSON(200, map[string]string{"user_id": id})
}
func usersHandler(w http.ResponseWriter, r *http.Request) {
    path := strings.TrimPrefix(r.URL.Path, "/users/")
    userID := strings.Split(path, "/")[0]
    // Manual parameter extraction
}
func rivaasHandler(c *router.Context) {
    id := c.Param("id")
    c.JSON(200, map[string]string{"user_id": id})
}

Middleware

gin.Use(gin.Logger(), gin.Recovery())
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Manual middleware chaining
handler := Logger(Recovery(mux))
http.ListenAndServe(":8080", handler)
r.Use(Logger(), Recovery())

Key Differences

Response Methods

FeatureGinEchoRivaas
JSONc.JSON()c.JSON()c.JSON()
Stringc.String()c.String()c.String()
HTMLc.HTML()c.HTML()c.HTML()
Pure JSON
Secure JSON
YAML

Request Binding

FeatureGinEchoRivaas
Query binding
JSON binding
Form binding
Maps (dot notation)
Maps (bracket notation)
Nested structs in query
Enum validation
Default values

Next Steps

2.16 - Examples

Complete working examples and common use cases.

This guide provides complete, working examples for common use cases.

REST API Server

Complete REST API with CRUD operations:

package main

import (
    "encoding/json"
    "net/http"
    "rivaas.dev/router"
)

type User struct {
    ID    string `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

var users = map[string]User{
    "1": {ID: "1", Name: "Alice", Email: "alice@example.com"},
    "2": {ID: "2", Name: "Bob", Email: "bob@example.com"},
}

func main() {
    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)
}

func listUsers(c *router.Context) {
    userList := make([]User, 0, len(users))
    for _, user := range users {
        userList = append(userList, user)
    }
    c.JSON(200, userList)
}

func getUser(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)
}

func createUser(c *router.Context) {
    var user User
    if err := json.NewDecoder(c.Request.Body).Decode(&user); err != nil {
        c.JSON(400, map[string]string{"error": "Invalid JSON"})
        return
    }
    users[user.ID] = user
    c.JSON(201, user)
}

func updateUser(c *router.Context) {
    id := c.Param("id")
    if _, exists := users[id]; !exists {
        c.JSON(404, map[string]string{"error": "User not found"})
        return
    }
    
    var user User
    if err := json.NewDecoder(c.Request.Body).Decode(&user); err != nil {
        c.JSON(400, map[string]string{"error": "Invalid JSON"})
        return
    }
    
    user.ID = id
    users[id] = user
    c.JSON(200, user)
}

func deleteUser(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:

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.MustNew()
    r.Use(Logger(), RateLimit(), Tracing())
    
    // Service discovery and routing
    r.GET("/users/*path", proxyToUserService)
    r.GET("/orders/*path", proxyToOrderService)
    r.GET("/payments/*path", proxyToPaymentService)
    
    // Health checks
    r.GET("/health", healthCheck)
    r.GET("/metrics", metricsHandler)
    
    http.ListenAndServe(":8080", r)
}

func proxyToUserService(c *router.Context) {
    path := c.Param("path")
    // Proxy to user service...
    c.JSON(200, map[string]string{"service": "users", "path": path})
}

func proxyToOrderService(c *router.Context) {
    path := c.Param("path")
    // Proxy to order service...
    c.JSON(200, map[string]string{"service": "orders", "path": path})
}

func proxyToPaymentService(c *router.Context) {
    path := c.Param("path")
    // Proxy to payment service...
    c.JSON(200, map[string]string{"service": "payments", "path": path})
}

func healthCheck(c *router.Context) {
    c.JSON(200, map[string]string{"status": "OK"})
}

func metricsHandler(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:

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.MustNew()
    
    // Serve static files
    r.Static("/assets", "./public")
    r.StaticFile("/favicon.ico", "./static/favicon.ico")
    
    // API routes
    api := r.Group("/api")
    {
        api.GET("/status", statusHandler)
        api.GET("/users", listUsersHandler)
    }
    
    http.ListenAndServe(":8080", r)
}

func statusHandler(c *router.Context) {
    c.JSON(200, map[string]string{"status": "OK"})
}

func listUsersHandler(c *router.Context) {
    c.JSON(200, []string{"user1", "user2"})
}

Authentication & Authorization

Complete auth example with JWT:

package main

import (
    "net/http"
    "rivaas.dev/router"
)

func main() {
    r := router.MustNew()
    r.Use(Logger(), Recovery())
    
    // Public routes
    r.POST("/login", loginHandler)
    r.POST("/register", registerHandler)
    
    // Protected routes
    api := r.Group("/api")
    api.Use(JWTAuth())
    {
        api.GET("/profile", profileHandler)
        api.PUT("/profile", updateProfileHandler)
        
        // Admin routes
        admin := api.Group("/admin")
        admin.Use(RequireAdmin())
        {
            admin.GET("/users", listUsersHandler)
            admin.DELETE("/users/:id", deleteUserHandler)
        }
    }
    
    http.ListenAndServe(":8080", r)
}

func loginHandler(c *router.Context) {
    // Authenticate user and generate JWT...
    c.JSON(200, map[string]string{"token": "jwt-token-here"})
}

func registerHandler(c *router.Context) {
    // Create new user...
    c.JSON(201, map[string]string{"message": "User created"})
}

func profileHandler(c *router.Context) {
    // Get user from context (set by JWT middleware)
    c.JSON(200, map[string]string{"user": "john@example.com"})
}

func updateProfileHandler(c *router.Context) {
    c.JSON(200, map[string]string{"message": "Profile updated"})
}

func listUsersHandler(c *router.Context) {
    c.JSON(200, []string{"user1", "user2"})
}

func deleteUserHandler(c *router.Context) {
    c.Status(204)
}

func JWTAuth() router.HandlerFunc {
    return func(c *router.Context) {
        token := c.Request.Header.Get("Authorization")
        if token == "" {
            c.JSON(401, map[string]string{"error": "Unauthorized"})
            return
        }
        // Validate JWT...
        c.Next()
    }
}

func RequireAdmin() router.HandlerFunc {
    return func(c *router.Context) {
        // Check if user is admin...
        c.Next()
    }
}

Next Steps

3 - Request Data Binding

Learn how to bind HTTP request data to Go structs with type safety and performance

The Rivaas Binding package provides high-performance request data binding for Go web applications. It maps values from various sources (query parameters, form data, JSON bodies, headers, cookies, path parameters) into Go structs using struct tags.

Features

  • Multiple Sources - Query, path, form, multipart, header, cookie, JSON, XML, YAML, TOML, MessagePack, Protocol Buffers
  • File Uploads - Handle file uploads with multipart forms seamlessly
  • Type Safe - Generic API for compile-time type safety
  • Zero Allocation - Struct reflection info cached for performance
  • Flexible - Nested structs, slices, maps, pointers, custom types
  • Error Context - Detailed field-level error information
  • Extensible - Custom type converters and value getters

Quick Start

import "rivaas.dev/binding"

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

// Generic API (preferred)
user, err := binding.JSON[CreateUserRequest](body)
if err != nil {
    // Handle error
}
import "rivaas.dev/binding"

type ListParams struct {
    Page   int      `query:"page" default:"1"`
    Limit  int      `query:"limit" default:"20"`
    Tags   []string `query:"tags"`
    SortBy string   `query:"sort_by"`
}

params, err := binding.Query[ListParams](r.URL.Query())
import "rivaas.dev/binding"

type CreateOrderRequest struct {
    // From path parameters
    UserID int `path:"user_id"`
    
    // From query string
    Coupon string `query:"coupon"`
    
    // From headers
    Auth string `header:"Authorization"`
    
    // From JSON body
    Items []OrderItem `json:"items"`
    Total float64     `json:"total"`
}

req, err := binding.Bind[CreateOrderRequest](
    binding.FromPath(pathParams),
    binding.FromQuery(r.URL.Query()),
    binding.FromHeader(r.Header),
    binding.FromJSON(body),
)

Learning Path

Follow these guides to master request data binding with Rivaas:

  1. Installation - Get started with the binding package
  2. Basic Usage - Learn the fundamentals of binding data
  3. Query Parameters - Work with URL query strings
  4. JSON Binding - Handle JSON request bodies
  5. Multipart Forms - Handle file uploads with form data
  6. Multi-Source - Combine data from multiple sources
  7. Struct Tags - Master struct tag syntax and options
  8. Type Support - Built-in and custom type conversion
  9. Error Handling - Handle binding errors gracefully
  10. Advanced Usage - Custom getters, streaming, and more
  11. Examples - Real-world integration patterns

Supported Sources

SourceFunctionDescription
QueryQuery[T]()URL query parameters (?name=value)
PathPath[T]()URL path parameters (/users/:id)
FormForm[T]()Form data (application/x-www-form-urlencoded)
MultipartMultipart[T]()Multipart form data with file uploads
HeaderHeader[T]()HTTP headers
CookieCookie[T]()HTTP cookies
JSONJSON[T]()JSON body
XMLXML[T]()XML body
YAMLyaml.YAML[T]()YAML body (sub-package)
TOMLtoml.TOML[T]()TOML body (sub-package)
MessagePackmsgpack.MsgPack[T]()MessagePack body (sub-package)
Protocol Buffersproto.Proto[T]()Protobuf body (sub-package)

Why Generic API?

The binding package uses Go generics for compile-time type safety:

// Generic API (preferred) - Type-safe at compile time
user, err := binding.JSON[CreateUserRequest](body)

// Non-generic API - When type comes from variable
var user CreateUserRequest
err := binding.JSONTo(body, &user)

Benefits:

  • ✅ Compile-time type checking
  • ✅ No reflection overhead for type instantiation
  • ✅ Better IDE autocomplete
  • ✅ Cleaner, more readable code

Performance

  • First binding of a type: ~500ns overhead for reflection
  • Subsequent bindings: ~50ns overhead (cache lookup)
  • Query/Path/Form: Zero allocations for primitive types
  • Struct reflection info cached automatically

Next Steps

For integration with rivaas/app, the Context provides a convenient Bind() method that handles all the complexity automatically.

3.1 - Installation

Install and set up the Rivaas binding package for your Go application

Get started with the Rivaas binding package by installing it in your Go project.

Prerequisites

  • Go 1.25 or higher - The binding package requires Go 1.25+
  • Basic familiarity with Go generics

Installation

Install the binding package using go get:

go get rivaas.dev/binding

This will add the package to your go.mod file and download the dependencies.

Sub-Packages

The binding package includes optional sub-packages for additional format support:

YAML

go get rivaas.dev/binding/yaml

TOML

go get rivaas.dev/binding/toml

MessagePack

go get rivaas.dev/binding/msgpack

Protocol Buffers

go get rivaas.dev/binding/proto

Verify Installation

Create a simple test to verify the installation is working:

package main

import (
    "fmt"
    "net/url"
    "rivaas.dev/binding"
)

type TestParams struct {
    Name string `query:"name"`
    Age  int    `query:"age"`
}

func main() {
    values := url.Values{
        "name": []string{"Alice"},
        "age":  []string{"30"},
    }
    
    params, err := binding.Query[TestParams](values)
    if err != nil {
        panic(err)
    }
    
    fmt.Printf("Binding package installed successfully!\n")
    fmt.Printf("Name: %s, Age: %d\n", params.Name, params.Age)
}

Save this as main.go and run:

go run main.go

If you see the success message with parsed values, the installation is complete!

Import Paths

Always import the binding package using:

import "rivaas.dev/binding"

For sub-packages:

import (
    "rivaas.dev/binding"
    "rivaas.dev/binding/yaml"
    "rivaas.dev/binding/toml"
)

Common Issues

Go Version Too Old

If you get an error about Go version:

go: rivaas.dev/binding requires go >= 1.25

Update your Go installation to version 1.25 or higher:

go version  # Check current version

Visit golang.org/dl/ to download the latest version.

Module Not Found

If you get a “module not found” error:

go clean -modcache
go get rivaas.dev/binding

Dependency Conflicts

If you experience dependency conflicts, ensure your go.mod is up to date:

go mod tidy

Next Steps

Now that you have the binding package installed:

For complete API documentation, visit the API Reference.

3.2 - Basic Usage

Learn the fundamentals of binding request data to Go structs

This guide covers the essential operations for working with the binding package. Learn how to bind from different sources, understand the API variants, and handle errors.

Generic API vs Non-Generic API

The binding package provides two API styles:

Use the generic API when you know the type at compile time:

// Type is specified as a type parameter
user, err := binding.JSON[CreateUserRequest](body)
params, err := binding.Query[ListParams](r.URL.Query())

Benefits:

  • Compile-time type safety.
  • Cleaner syntax.
  • Better IDE support.
  • No need to pre-allocate the struct.

Non-Generic API

Use the non-generic API when the type comes from a variable or when working with interfaces:

var user CreateUserRequest
err := binding.JSONTo(body, &user)

var params ListParams
err := binding.QueryTo(r.URL.Query(), &params)

Use when:

  • Type is determined at runtime.
  • Working with reflection.
  • Integrating with older codebases.

Binding from Different Sources

JSON Body

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

// Read body from request
body, err := io.ReadAll(r.Body)
if err != nil {
    // Handle error
}
defer r.Body.Close()

// Bind JSON to struct
user, err := binding.JSON[CreateUserRequest](body)
if err != nil {
    // Handle binding error
}

Query Parameters

type ListParams struct {
    Page   int      `query:"page" default:"1"`
    Limit  int      `query:"limit" default:"20"`
    Search string   `query:"search"`
    Tags   []string `query:"tags"`
}

params, err := binding.Query[ListParams](r.URL.Query())

Path Parameters

type UserIDParam struct {
    UserID int `path:"user_id"`
}

// Path params typically come from your router
// Example with common router pattern:
pathParams := map[string]string{
    "user_id": "123",
}

params, err := binding.Path[UserIDParam](pathParams)

Form Data

type LoginForm struct {
    Username string `form:"username"`
    Password string `form:"password"`
    Remember bool   `form:"remember"`
}

// Parse form first
if err := r.ParseForm(); err != nil {
    // Handle parse error
}

form, err := binding.Form[LoginForm](r.Form)

Headers

type RequestHeaders struct {
    Auth        string `header:"Authorization"`
    ContentType string `header:"Content-Type"`
    UserAgent   string `header:"User-Agent"`
}

headers, err := binding.Header[RequestHeaders](r.Header)

Cookies

type SessionCookies struct {
    SessionID string `cookie:"session_id"`
    CSRF      string `cookie:"csrf_token"`
}

cookies, err := binding.Cookie[SessionCookies](r.Cookies())

Multipart Forms

For file uploads with form data, use multipart forms:

type UploadRequest struct {
    File        *binding.File `form:"file"`
    Title       string        `form:"title"`
    Description string        `form:"description"`
}

// Parse multipart form first (32MB max)
if err := r.ParseMultipartForm(32 << 20); err != nil {
    // Handle parse error
}

// Bind form and files
req, err := binding.Multipart[UploadRequest](r.MultipartForm)
if err != nil {
    // Handle binding error
}

// Work with the uploaded file
if err := req.File.Save("/uploads/" + req.File.Name); err != nil {
    // Handle save error
}

The binding.File type provides methods to work with uploaded files:

  • Save(path) - Save file to disk
  • Bytes() - Read file contents into memory
  • Open() - Open file for streaming
  • Ext() - Get file extension

See Multipart Forms for detailed examples and security considerations.

Error Handling Basics

All binding functions return an error that provides context about what went wrong:

user, err := binding.JSON[CreateUserRequest](body)
if err != nil {
    // Check for specific error types
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        fmt.Printf("Field %s: %v\n", bindErr.Field, bindErr.Err)
    }
    
    // Or just use the error message
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
}

Common error types:

  • BindError - Field-level binding error with context
  • UnknownFieldError - Unknown fields in strict mode
  • MultiError - Multiple errors when using WithAllErrors()

See Error Handling for detailed information.

Default Values

Use the default tag to specify fallback values:

type Config struct {
    Port    int    `query:"port" default:"8080"`
    Host    string `query:"host" default:"localhost"`
    Debug   bool   `query:"debug" default:"false"`
    Timeout string `query:"timeout" default:"30s"`
}

// If query params don't include these values, defaults are used
cfg, err := binding.Query[Config](r.URL.Query())

Working with Pointers

Use pointers to distinguish between “not set” and “set to zero value”:

type UpdateUserRequest struct {
    Name  *string `json:"name"`   // nil = not updating, "" = clear value
    Email *string `json:"email"`
    Age   *int    `json:"age"`    // nil = not updating, 0 = set to zero
}

user, err := binding.JSON[UpdateUserRequest](body)

// Check if field was provided
if user.Name != nil {
    // Update name to *user.Name
}
if user.Age != nil {
    // Update age to *user.Age
}

Common Patterns

API Handler Pattern

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    // Read body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()
    
    // Bind request
    req, err := binding.JSON[CreateUserRequest](body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Process request
    user := createUser(req)
    
    // Send response
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

Query + Path Parameters

type GetUserRequest struct {
    UserID int    `path:"user_id"`
    Format string `query:"format" default:"json"`
}

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    req, err := binding.Bind[GetUserRequest](
        binding.FromPath(pathParams),  // From router
        binding.FromQuery(r.URL.Query()),
    )
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    user := getUserByID(req.UserID)
    // Format response according to req.Format
}

Form with CSRF Token

type EditForm struct {
    Title   string `form:"title"`
    Content string `form:"content"`
    CSRF    string `form:"csrf_token"`
}

func EditHandler(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseForm(); err != nil {
        http.Error(w, "Invalid form", http.StatusBadRequest)
        return
    }
    
    form, err := binding.Form[EditForm](r.Form)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Verify CSRF token
    if !verifyCRSF(form.CSRF) {
        http.Error(w, "Invalid CSRF token", http.StatusForbidden)
        return
    }
    
    // Process form
}

Type Conversion

The binding package automatically converts string values to appropriate types:

type Request struct {
    // String to int
    Page int `query:"page"`           // "123" -> 123
    
    // String to bool
    Active bool `query:"active"`      // "true" -> true
    
    // String to float
    Price float64 `query:"price"`     // "19.99" -> 19.99
    
    // String to time.Duration
    Timeout time.Duration `query:"timeout"`  // "30s" -> 30 * time.Second
    
    // String to time.Time
    CreatedAt time.Time `query:"created"`   // "2025-01-01" -> time.Time
    
    // String to slice
    Tags []string `query:"tags"`      // "go,rust,python" -> []string
}

See Type Support for complete type conversion details.

Performance Tips

  1. Reuse request bodies: Binding consumes the body, so read it once and reuse
  2. Use defaults: Struct tags with defaults avoid unnecessary error checking
  3. Cache reflection: Happens automatically, but avoid dynamic struct generation
  4. Stream large payloads: Use JSONReader for bodies > 1MB

Next Steps

For complete API documentation, see API Reference.

3.3 - Query Parameters

Master URL query string binding with slices, defaults, and type conversion

Learn how to bind URL query parameters to Go structs with automatic type conversion, default values, and slice handling.

Basic Query Binding

Query parameters are parsed from the URL query string:

// URL: /users?page=2&limit=50&search=john
type ListParams struct {
    Page   int    `query:"page"`
    Limit  int    `query:"limit"`
    Search string `query:"search"`
}

params, err := binding.Query[ListParams](r.URL.Query())
// Result: {Page: 2, Limit: 50, Search: "john"}

Default Values

Use the default tag to provide fallback values:

type PaginationParams struct {
    Page  int `query:"page" default:"1"`
    Limit int `query:"limit" default:"20"`
}

// URL: /items (no query params)
params, err := binding.Query[PaginationParams](r.URL.Query())
// Result: {Page: 1, Limit: 20}

// URL: /items?page=3
params, err := binding.Query[PaginationParams](r.URL.Query())
// Result: {Page: 3, Limit: 20}

Slice Handling

The binding package supports two modes for parsing slices:

Repeated Parameters (Default)

type FilterParams struct {
    Tags []string `query:"tags"`
}

// URL: /items?tags=go&tags=rust&tags=python
params, err := binding.Query[FilterParams](r.URL.Query())
// Result: {Tags: ["go", "rust", "python"]}

CSV Mode

Use WithSliceMode for comma-separated values:

// URL: /items?tags=go,rust,python
params, err := binding.Query[FilterParams](
    r.URL.Query(),
    binding.WithSliceMode(binding.SliceCSV),
)
// Result: {Tags: ["go", "rust", "python"]}

Type Conversion

Query parameters are automatically converted to appropriate types:

type QueryParams struct {
    // String to integer
    Age int `query:"age"`                    // "30" -> 30
    
    // String to boolean
    Active bool `query:"active"`             // "true" -> true
    
    // String to float
    Price float64 `query:"price"`            // "19.99" -> 19.99
    
    // String to time.Duration
    Timeout time.Duration `query:"timeout"`  // "30s" -> 30 * time.Second
    
    // String to time.Time
    Since time.Time `query:"since"`          // "2025-01-01" -> time.Time
    
    // String slice
    IDs []int `query:"ids"`                  // "1&2&3" -> [1, 2, 3]
}

Nested Structures

Use dot notation for nested structs:

type SearchParams struct {
    Query string `query:"q"`
    Filter struct {
        Category string `query:"category"`
        MinPrice int    `query:"min_price"`
        MaxPrice int    `query:"max_price"`
    } `query:"filter"`  // Prefix tag on parent struct
}

// URL: /search?q=laptop&filter.category=electronics&filter.min_price=500
params, err := binding.Query[SearchParams](r.URL.Query())

Tag Aliases

Support multiple parameter names for the same field:

type UserParams struct {
    UserID int `query:"user_id,id,uid"`  // Accepts any of these names
}

// All of these work:
// /users?user_id=123
// /users?id=123
// /users?uid=123

Optional Fields with Pointers

Use pointers to distinguish between “not provided” and “zero value”:

type OptionalParams struct {
    Limit  *int    `query:"limit"`   // nil if not provided
    Offset *int    `query:"offset"`  // nil if not provided
    Filter *string `query:"filter"`  // nil if not provided
}

// URL: /items?limit=10
params, err := binding.Query[OptionalParams](r.URL.Query())
// Result: {Limit: &10, Offset: nil, Filter: nil}

if params.Limit != nil {
    // Use *params.Limit
}

Complex Example

type ComplexSearchParams struct {
    // Basic fields
    Query string `query:"q"`
    Page  int    `query:"page" default:"1"`
    Limit int    `query:"limit" default:"20"`
    
    // Sorting
    SortBy    string `query:"sort_by" default:"created_at"`
    SortOrder string `query:"sort_order" default:"desc"`
    
    // Filters
    Tags       []string  `query:"tags"`
    Categories []string  `query:"categories"`
    MinPrice   *float64  `query:"min_price"`
    MaxPrice   *float64  `query:"max_price"`
    
    // Date range
    Since *time.Time `query:"since"`
    Until *time.Time `query:"until"`
    
    // Flags
    IncludeArchived bool `query:"include_archived"`
    IncludeDrafts   bool `query:"include_drafts"`
}

// URL: /search?q=laptop&tags=electronics&tags=sale&min_price=500&page=2
params, err := binding.Query[ComplexSearchParams](r.URL.Query())

Boolean Parsing

Boolean values accept multiple formats:

type Flags struct {
    Debug bool `query:"debug"`
}

// All of these parse to true:
// ?debug=true
// ?debug=1
// ?debug=yes
// ?debug=on

// All of these parse to false:
// ?debug=false
// ?debug=0
// ?debug=no
// ?debug=off
// (parameter not present)

Common Patterns

Pagination

type PaginationParams struct {
    Page     int `query:"page" default:"1"`
    PageSize int `query:"page_size" default:"20"`
}

func ListHandler(w http.ResponseWriter, r *http.Request) {
    params, err := binding.Query[PaginationParams](r.URL.Query())
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    offset := (params.Page - 1) * params.PageSize
    items := getItems(offset, params.PageSize)
    
    json.NewEncoder(w).Encode(items)
}

Search and Filter

type SearchParams struct {
    Q          string   `query:"q"`
    Categories []string `query:"category"`
    Tags       []string `query:"tag"`
    Sort       string   `query:"sort" default:"relevance"`
}

func SearchHandler(w http.ResponseWriter, r *http.Request) {
    params, err := binding.Query[SearchParams](r.URL.Query())
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    results := search(params.Q, params.Categories, params.Tags, params.Sort)
    json.NewEncoder(w).Encode(results)
}

Date Range Filtering

type DateRangeParams struct {
    StartDate time.Time `query:"start_date"`
    EndDate   time.Time `query:"end_date"`
}

// URL: /reports?start_date=2025-01-01&end_date=2025-12-31
params, err := binding.Query[DateRangeParams](r.URL.Query())

Error Handling

params, err := binding.Query[SearchParams](r.URL.Query())
if err != nil {
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        // Field-specific error
        log.Printf("Invalid query param %s: %v", bindErr.Field, bindErr.Err)
    }
    
    http.Error(w, "Invalid query parameters", http.StatusBadRequest)
    return
}

Validation

params, err := binding.Query[SearchParams](r.URL.Query())
if err != nil {
    return err
}

// Validate after binding
if err := validation.Validate(params); err != nil {
    return err
}

Performance Tips

  1. Use defaults: Avoids checking for zero values
  2. Avoid reflection: Struct info is cached automatically
  3. Reuse structs: Define parameter structs once
  4. Primitive types: Zero allocation for basic types

Troubleshooting

Query Parameter Not Binding

Check that:

  • Tag name matches query parameter name
  • Field is exported (starts with uppercase)
  • Type conversion is supported
// Wrong - unexported field
type Params struct {
    page int `query:"page"`  // Won't bind
}

// Correct
type Params struct {
    Page int `query:"page"`
}

Slice Not Parsing

Ensure you’re using the correct slice mode:

// For repeated params: ?tags=go&tags=rust
params, err := binding.Query[Params](values)  // Default mode

// For CSV: ?tags=go,rust,python
params, err := binding.Query[Params](
    values,
    binding.WithSliceMode(binding.SliceCSV),
)

Next Steps

For complete API details, see API Reference.

3.4 - JSON Binding

Bind and parse JSON request bodies with automatic type conversion and validation

Learn how to bind JSON request bodies to Go structs with proper error handling, nested objects, and integration with validators.

Basic JSON Binding

Bind JSON request bodies directly to structs:

type CreateUserRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    Age      int    `json:"age"`
}

req, err := binding.JSON[CreateUserRequest](r.Body)
if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
}

// Use req.Username, req.Email, req.Age

JSON Tags

The binding package respects standard json tags:

type Product struct {
    ID          int       `json:"id"`
    Name        string    `json:"name"`
    Price       float64   `json:"price"`
    CreatedAt   time.Time `json:"created_at"`
    
    // Omit if empty
    Description string    `json:"description,omitempty"`
    
    // Ignore this field
    Internal    string    `json:"-"`
}

Nested Structures

Handle complex nested JSON:

type Order struct {
    ID       string    `json:"id"`
    Customer struct {
        Name    string `json:"name"`
        Email   string `json:"email"`
        Address struct {
            Street  string `json:"street"`
            City    string `json:"city"`
            Country string `json:"country"`
            ZipCode string `json:"zip_code"`
        } `json:"address"`
    } `json:"customer"`
    Items []struct {
        ProductID string  `json:"product_id"`
        Quantity  int     `json:"quantity"`
        Price     float64 `json:"price"`
    } `json:"items"`
    Total float64 `json:"total"`
}

// POST /orders
// {
//   "id": "ORD-12345",
//   "customer": {
//     "name": "John Doe",
//     "email": "john@example.com",
//     "address": {
//       "street": "123 Main St",
//       "city": "New York",
//       "country": "USA",
//       "zip_code": "10001"
//     }
//   },
//   "items": [
//     {"product_id": "PROD-1", "quantity": 2, "price": 29.99}
//   ],
//   "total": 59.98
// }

order, err := binding.JSON[Order](r.Body)

Type Support

JSON binding supports rich type conversion:

type ComplexTypes struct {
    // Basic types
    String  string  `json:"string"`
    Int     int     `json:"int"`
    Float   float64 `json:"float"`
    Bool    bool    `json:"bool"`
    
    // Time types
    Timestamp time.Time     `json:"timestamp"`
    Duration  time.Duration `json:"duration"`
    
    // Slices
    Tags      []string `json:"tags"`
    Numbers   []int    `json:"numbers"`
    
    // Maps
    Metadata  map[string]string      `json:"metadata"`
    Settings  map[string]interface{} `json:"settings"`
    
    // Pointers (nullable)
    Optional  *string `json:"optional"`
    Nullable  *int    `json:"nullable"`
}

// Example JSON:
// {
//   "string": "hello",
//   "int": 42,
//   "float": 3.14,
//   "bool": true,
//   "timestamp": "2025-01-01T00:00:00Z",
//   "duration": "30s",
//   "tags": ["go", "rust"],
//   "numbers": [1, 2, 3],
//   "metadata": {"key": "value"},
//   "optional": null,
//   "nullable": 10
// }

Reading Limits

Protect against large payloads with WithMaxBytes:

// Limit to 1MB
req, err := binding.JSON[CreateUserRequest](
    r.Body,
    binding.WithMaxBytes(1024 * 1024),
)
if err != nil {
    http.Error(w, "Request too large", http.StatusRequestEntityTooLarge)
    return
}

Strict JSON Parsing

Reject unknown fields with WithDisallowUnknownFields:

type StrictRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

// This will error if JSON contains fields not in the struct
req, err := binding.JSON[StrictRequest](
    r.Body,
    binding.WithDisallowUnknownFields(),
)

Optional Fields

Use pointers to distinguish between “not provided” and “zero value”:

type UpdateUserRequest struct {
    Username *string `json:"username,omitempty"`
    Email    *string `json:"email,omitempty"`
    Age      *int    `json:"age,omitempty"`
}

// JSON: {"email": "new@example.com"}
req, err := binding.JSON[UpdateUserRequest](r.Body)
// Result: {Username: nil, Email: &"new@example.com", Age: nil}

if req.Email != nil {
    // Update email to *req.Email
}

Array Bodies

Bind arrays directly:

type BatchRequest []struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

// JSON: [{"id": "1", "name": "A"}, {"id": "2", "name": "B"}]
batch, err := binding.JSON[BatchRequest](r.Body)

Complete HTTP Handler Example

func CreateProductHandler(w http.ResponseWriter, r *http.Request) {
    type CreateProductRequest struct {
        Name        string   `json:"name"`
        Description string   `json:"description"`
        Price       float64  `json:"price"`
        Categories  []string `json:"categories"`
        Stock       int      `json:"stock"`
    }
    
    // 1. Bind JSON
    req, err := binding.JSON[CreateProductRequest](
        r.Body,
        binding.WithMaxBytes(1024*1024),
        binding.WithDisallowUnknownFields(),
    )
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // 2. Validate (using rivaas.dev/validation)
    if err := validation.Validate(req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // 3. Business logic
    product := createProduct(req)
    
    // 4. Response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(product)
}

Error Handling

The binding package provides detailed error information:

req, err := binding.JSON[CreateUserRequest](r.Body)
if err != nil {
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        // Field-specific error
        log.Printf("Failed to bind field %s: %v", bindErr.Field, bindErr.Err)
        http.Error(w, 
            fmt.Sprintf("Invalid field: %s", bindErr.Field),
            http.StatusBadRequest)
        return
    }
    
    // Generic error (malformed JSON, etc.)
    http.Error(w, "Invalid JSON", http.StatusBadRequest)
    return
}

Common Error Types

// Syntax errors
// {"name": "test"  <- missing closing brace
// Error: "unexpected end of JSON input"

// Type mismatch
// {"age": "not a number"}  <- age is int
// Error: "cannot unmarshal string into field age of type int"

// Unknown fields (with WithDisallowUnknownFields)
// {"name": "test", "unknown": "value"}
// Error: "json: unknown field \"unknown\""

// Request too large (with WithMaxBytes)
// Payload > limit
// Error: "http: request body too large"

Integration with Validation

Combine with rivaas.dev/validation for comprehensive validation:

import (
    "rivaas.dev/binding"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `json:"username" validate:"required,min=3,max=32"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
}

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    // Step 1: Bind JSON structure
    req, err := binding.JSON[CreateUserRequest](r.Body)
    if err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Step 2: Validate business rules
    if err := validation.Validate(req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Proceed with valid data
    createUser(req)
}

Custom JSON Parsing

For special cases, implement json.Unmarshaler:

type Duration time.Duration

func (d *Duration) UnmarshalJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil {
        return err
    }
    
    parsed, err := time.ParseDuration(s)
    if err != nil {
        return err
    }
    
    *d = Duration(parsed)
    return nil
}

type Config struct {
    Timeout Duration `json:"timeout"`
}

// JSON: {"timeout": "30s"}
cfg, err := binding.JSON[Config](r.Body)

Handling Multiple Content Types

Use binding.Auto() to handle both JSON and form data:

// Works with both:
// Content-Type: application/json
// Content-Type: application/x-www-form-urlencoded

req, err := binding.Auto[CreateUserRequest](r)
if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
}

Performance Considerations

  1. Use io.LimitReader: Always set max bytes for untrusted input
  2. Avoid reflection: Type info is cached automatically
  3. Reuse structs: Define request types once
  4. Pointer fields: Only when you need to distinguish nil from zero

Best Practices

1. Separate Request/Response Types

// Request
type CreateUserRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
}

// Response
type CreateUserResponse struct {
    ID       string `json:"id"`
    Username string `json:"username"`
    Email    string `json:"email"`
    Created  time.Time `json:"created"`
}

2. Use Validation Tags

type CreateUserRequest struct {
    Username string `json:"username" validate:"required,alphanum,min=3,max=32"`
    Email    string `json:"email" validate:"required,email"`
}

3. Document with Examples

// CreateUserRequest represents a new user creation request.
//
// Example JSON:
//
//	{
//	  "username": "johndoe",
//	  "email": "john@example.com",
//	  "age": 30
//	}
type CreateUserRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    Age      int    `json:"age"`
}

4. Set Limits

const maxRequestSize = 1024 * 1024 // 1MB

req, err := binding.JSON[CreateUserRequest](
    r.Body,
    binding.WithMaxBytes(maxRequestSize),
)

Testing

func TestCreateUserHandler(t *testing.T) {
    payload := `{"username": "test", "email": "test@example.com", "age": 25}`
    
    req := httptest.NewRequest("POST", "/users", strings.NewReader(payload))
    req.Header.Set("Content-Type", "application/json")
    
    rec := httptest.NewRecorder()
    CreateUserHandler(rec, req)
    
    if rec.Code != http.StatusCreated {
        t.Errorf("expected status 201, got %d", rec.Code)
    }
}

Next Steps

For complete API details, see API Reference.

3.5 - Multipart Forms

Handle file uploads with form data using multipart form binding

This guide shows you how to handle file uploads and form data together using multipart form binding. You’ll learn how to bind files, work with the File type, and handle complex scenarios like JSON in form fields.

What Are Multipart Forms?

Multipart forms let you send files and regular form data in the same HTTP request. This is useful when you need to upload files along with metadata, like uploading a profile picture with user information.

Common use cases:

  • Uploading images with titles and descriptions
  • Importing CSV files with configuration options
  • Submitting documents with form metadata

Basic File Upload

Let’s start with a simple example. You want to upload a file with some metadata:

import "rivaas.dev/binding"

type UploadRequest struct {
    File        *binding.File `form:"file"`
    Title       string        `form:"title"`
    Description string        `form:"description"`
}

// Parse the multipart form
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
    // Handle error
}

// Bind the form data
req, err := binding.Multipart[UploadRequest](r.MultipartForm)
if err != nil {
    // Handle binding error
}

// Now you have:
// - req.File - the uploaded file
// - req.Title - the title from form
// - req.Description - the description from form

Working with Files

The binding.File type gives you easy access to uploaded files. Here’s what you can do:

File Properties

file := req.File

fmt.Println(file.Name)        // "photo.jpg" - sanitized filename
fmt.Println(file.Size)        // 1024 - file size in bytes
fmt.Println(file.ContentType) // "image/jpeg" - MIME type

Save to Disk

The easiest way to handle uploads is to save them directly:

// Save to a specific path
err := file.Save("/uploads/photo.jpg")
if err != nil {
    // Handle save error
}

// Save with original filename
err := file.Save("/uploads/" + file.Name)

The Save() method automatically creates parent directories if they don’t exist.

Read File Contents

You can read the file into memory:

// Get all bytes
data, err := file.Bytes()
if err != nil {
    // Handle error
}

// Process the data
processImage(data)

Stream File Contents

For larger files, you can stream the content:

// Open the file for reading
reader, err := file.Open()
if err != nil {
    // Handle error
}
defer reader.Close()

// Stream to another location
io.Copy(destination, reader)

Get File Extension

ext := file.Ext() // ".jpg" for "photo.jpg"

// Useful for validation
if ext != ".jpg" && ext != ".png" {
    return errors.New("only JPG and PNG files allowed")
}

Multiple File Uploads

You can handle multiple files using a slice:

type GalleryUpload struct {
    Photos []*binding.File `form:"photos"`
    Title  string          `form:"title"`
}

req, err := binding.Multipart[GalleryUpload](r.MultipartForm)
if err != nil {
    // Handle error
}

// Process each file
for i, photo := range req.Photos {
    filename := fmt.Sprintf("/uploads/photo_%d%s", i, photo.Ext())
    if err := photo.Save(filename); err != nil {
        // Handle error
    }
}

JSON in Form Fields

Here’s a powerful feature: Rivaas automatically parses JSON from form fields into nested structs.

type Settings struct {
    Theme         string `json:"theme"`
    Notifications bool   `json:"notifications"`
}

type ProfileUpdate struct {
    Avatar   *binding.File `form:"avatar"`
    Username string        `form:"username"`
    Settings Settings      `form:"settings"` // JSON automatically parsed!
}

// In your HTML form:
// <input type="file" name="avatar">
// <input type="text" name="username">
// <input type="hidden" name="settings" value='{"theme":"dark","notifications":true}'>

req, err := binding.Multipart[ProfileUpdate](r.MultipartForm)
if err != nil {
    // Handle error
}

// req.Settings is now populated from the JSON string
fmt.Println(req.Settings.Theme)         // "dark"
fmt.Println(req.Settings.Notifications) // true

This works with deeply nested structures too:

type ImportOptions struct {
    Format   string `json:"format"`
    Encoding string `json:"encoding"`
    Options  struct {
        SkipHeader bool `json:"skip_header"`
        Delimiter  string `json:"delimiter"`
    } `json:"options"`
}

type ImportRequest struct {
    File    *binding.File   `form:"file"`
    Options ImportOptions   `form:"options"` // Complex JSON parsed automatically
}

Complete Example

Here’s a realistic file upload handler:

package main

import (
    "fmt"
    "net/http"
    "rivaas.dev/binding"
    "rivaas.dev/validation"
)

type UploadRequest struct {
    File        *binding.File `form:"file" validate:"required"`
    Title       string        `form:"title" validate:"required,min=3,max=100"`
    Description string        `form:"description"`
    Tags        []string      `form:"tags"`
    IsPublic    bool          `form:"is_public"`
}

func UploadHandler(w http.ResponseWriter, r *http.Request) {
    // Step 1: Parse multipart form (32MB limit)
    if err := r.ParseMultipartForm(32 << 20); err != nil {
        http.Error(w, "Failed to parse form", http.StatusBadRequest)
        return
    }
    
    // Step 2: Bind form data
    req, err := binding.Multipart[UploadRequest](r.MultipartForm)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Step 3: Validate
    if err := validation.Validate(req); err != nil {
        http.Error(w, err.Error(), http.StatusUnprocessableEntity)
        return
    }
    
    // Step 4: Validate file type
    allowedTypes := []string{".jpg", ".jpeg", ".png", ".gif"}
    ext := req.File.Ext()
    if !contains(allowedTypes, ext) {
        http.Error(w, "Invalid file type", http.StatusBadRequest)
        return
    }
    
    // Step 5: Validate file size
    if req.File.Size > 10*1024*1024 { // 10MB
        http.Error(w, "File too large", http.StatusBadRequest)
        return
    }
    
    // Step 6: Generate safe filename
    filename := fmt.Sprintf("%s_%d%s", 
        sanitizeFilename(req.Title),
        time.Now().Unix(),
        ext,
    )
    
    // Step 7: Save file
    uploadPath := "/var/uploads/" + filename
    if err := req.File.Save(uploadPath); err != nil {
        http.Error(w, "Failed to save file", http.StatusInternalServerError)
        return
    }
    
    // Step 8: Save metadata to database
    file := &FileRecord{
        Filename:    filename,
        Title:       req.Title,
        Description: req.Description,
        Tags:        req.Tags,
        IsPublic:    req.IsPublic,
        Size:        req.File.Size,
        ContentType: req.File.ContentType,
    }
    
    if err := db.Create(file); err != nil {
        http.Error(w, "Failed to save metadata", http.StatusInternalServerError)
        return
    }
    
    // Step 9: Return success
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "id":       file.ID,
        "filename": filename,
        "url":      "/uploads/" + filename,
    })
}

func contains(slice []string, item string) bool {
    for _, s := range slice {
        if s == item {
            return true
        }
    }
    return false
}

File Security

Always validate uploaded files to protect your application:

1. Validate File Type

Don’t trust the Content-Type header alone. Check the file extension:

allowedExtensions := []string{".jpg", ".jpeg", ".png", ".gif"}
ext := strings.ToLower(file.Ext())

if !slices.Contains(allowedExtensions, ext) {
    return errors.New("file type not allowed")
}

For better security, check the file’s magic bytes:

data, err := file.Bytes()
if err != nil {
    return err
}

// Check magic bytes for JPEG
if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 {
    return errors.New("not a valid JPEG file")
}

2. Validate File Size

maxSize := int64(10 * 1024 * 1024) // 10MB
if file.Size > maxSize {
    return errors.New("file too large")
}

3. Sanitize Filenames

The File type automatically sanitizes filenames by:

  • Using only the base filename (removes paths)
  • Replacing dangerous characters

But you should also generate unique names:

import (
    "crypto/rand"
    "encoding/hex"
    "path/filepath"
)

func generateSafeFilename(originalName string) string {
    ext := filepath.Ext(originalName)
    
    // Generate random name
    b := make([]byte, 16)
    rand.Read(b)
    name := hex.EncodeToString(b)
    
    return name + ext
}

// Use it
safeName := generateSafeFilename(file.Name)
file.Save("/uploads/" + safeName)

4. Store Outside Web Root

Never save uploads directly in your web server’s document root:

// Bad - files accessible directly via URL
file.Save("/var/www/html/uploads/file.jpg")

// Good - files outside web root
file.Save("/var/app/uploads/file.jpg")

// Serve files through a handler that checks permissions

5. Scan for Malware

For production applications, scan uploaded files:

// Example with ClamAV
if infected, err := scanFile(uploadPath); err != nil {
    return err
} else if infected {
    os.Remove(uploadPath)
    return errors.New("file contains malware")
}

Integration with Rivaas App

When using rivaas.dev/app, the Context.Bind() method handles multipart forms automatically:

import "rivaas.dev/app"

type UploadRequest struct {
    File  *binding.File `form:"file"`
    Title string        `form:"title"`
}

a.POST("/upload", func(c *app.Context) {
    var req UploadRequest
    if err := c.Bind(&req); err != nil {
        c.Fail(err)
        return
    }
    
    // req.File is ready to use
    if err := req.File.Save("/uploads/" + req.File.Name); err != nil {
        c.InternalError(err)
        return
    }
    
    c.JSON(http.StatusOK, map[string]string{
        "message": "File uploaded successfully",
    })
})

The app context automatically:

  • Parses the multipart form
  • Binds files and form fields
  • Handles errors appropriately

Common Patterns

Image Processing Pipeline

type ImageUpload struct {
    Image   *binding.File `form:"image"`
    Width   int           `form:"width" default:"800"`
    Height  int           `form:"height" default:"600"`
    Quality int           `form:"quality" default:"85"`
}

func ProcessImageHandler(w http.ResponseWriter, r *http.Request) {
    r.ParseMultipartForm(32 << 20)
    
    req, err := binding.Multipart[ImageUpload](r.MultipartForm)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Read image data
    data, err := req.Image.Bytes()
    if err != nil {
        http.Error(w, "Failed to read image", http.StatusInternalServerError)
        return
    }
    
    // Process image
    processed, err := resizeImage(data, req.Width, req.Height, req.Quality)
    if err != nil {
        http.Error(w, "Failed to process image", http.StatusInternalServerError)
        return
    }
    
    // Save processed image
    outputPath := "/uploads/processed_" + req.Image.Name
    if err := os.WriteFile(outputPath, processed, 0644); err != nil {
        http.Error(w, "Failed to save image", http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{
        "url": "/uploads/" + filepath.Base(outputPath),
    })
}

CSV Import with Options

type CSVImportRequest struct {
    File       *binding.File `form:"file"`
    Options    struct {
        SkipHeader bool   `json:"skip_header"`
        Delimiter  string `json:"delimiter"`
        Encoding   string `json:"encoding"`
    } `form:"options"` // JSON from form field
}

func ImportCSVHandler(w http.ResponseWriter, r *http.Request) {
    r.ParseMultipartForm(32 << 20)
    
    req, err := binding.Multipart[CSVImportRequest](r.MultipartForm)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Validate CSV file
    if req.File.Ext() != ".csv" {
        http.Error(w, "Only CSV files allowed", http.StatusBadRequest)
        return
    }
    
    // Open file for streaming
    reader, err := req.File.Open()
    if err != nil {
        http.Error(w, "Failed to open file", http.StatusInternalServerError)
        return
    }
    defer reader.Close()
    
    // Parse CSV with options
    csvReader := csv.NewReader(reader)
    csvReader.Comma = rune(req.Options.Delimiter[0])
    
    if req.Options.SkipHeader {
        csvReader.Read() // Skip first row
    }
    
    // Process records
    records, err := csvReader.ReadAll()
    if err != nil {
        http.Error(w, "Failed to parse CSV", http.StatusBadRequest)
        return
    }
    
    // Import into database
    for _, record := range records {
        // Process each record
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "imported": len(records),
    })
}

Performance Tips

  1. Set appropriate size limits - Don’t let users upload huge files:

    r.ParseMultipartForm(10 << 20) // 10MB limit
    
  2. Stream large files - Don’t load everything into memory:

    reader, err := file.Open()
    defer reader.Close()
    io.Copy(destination, reader)
    
  3. Process asynchronously - For heavy processing, use background jobs:

    // Save file first
    file.Save(tempPath)
    
    // Queue processing job
    queue.Enqueue(ProcessFileJob{Path: tempPath})
    
    // Return immediately
    c.JSON(http.StatusAccepted, "Processing started")
    
  4. Clean up temporary files - Remove uploaded files after processing:

    defer os.Remove(tempPath)
    

Error Handling

The binding package provides specific errors for file operations:

req, err := binding.Multipart[UploadRequest](r.MultipartForm)
if err != nil {
    // Check for specific errors
    if errors.Is(err, binding.ErrFileNotFound) {
        http.Error(w, "No file uploaded", http.StatusBadRequest)
        return
    }
    
    if errors.Is(err, binding.ErrNoFilesFound) {
        http.Error(w, "Multiple files required", http.StatusBadRequest)
        return
    }
    
    // Generic binding error
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        http.Error(w, fmt.Sprintf("Field %s: %v", bindErr.Field, bindErr.Err), 
            http.StatusBadRequest)
        return
    }
    
    // Unknown error
    http.Error(w, "Failed to bind form data", http.StatusBadRequest)
    return
}

Next Steps

For complete API documentation, see API Reference.

3.6 - Multi-Source Binding

Combine multiple data sources with precedence rules for flexible request handling

Learn how to bind data from multiple sources. This includes query parameters, JSON body, and headers. Configure precedence rules for flexible request handling.

Concept Overview

Multi-source binding allows you to populate a single struct from multiple request sources. It uses clear precedence rules:

graph LR
    A[HTTP Request] --> B[Query Params]
    A --> C[JSON Body]
    A --> D[Headers]
    A --> E[Path Params]
    
    B --> F[Multi-Source Binder]:::info
    C --> F
    D --> F
    E --> F
    
    F --> G[Merged Struct]:::success
    
    classDef default fill:#F8FAF9,stroke:#1E6F5C,color:#1F2A27
    classDef info fill:#D1ECF1,stroke:#17A2B8,color:#1F2A27
    classDef success fill:#D4EDDA,stroke:#28A745,color:#1F2A27

Basic Multi-Source Binding

Use binding.Auto() to bind from query, body, and headers automatically:

type UserRequest struct {
    // From query or JSON body
    Username string `json:"username" query:"username"`
    Email    string `json:"email" query:"email"`
    
    // From header
    APIKey   string `header:"X-API-Key"`
}

// Works with:
// - POST /users?username=john with JSON body
// - GET /users?username=john&email=john@example.com
// - Headers: X-API-Key: secret123

req, err := binding.Auto[UserRequest](r)

Custom Multi-Source

Build custom multi-source binding with explicit precedence:

type SearchRequest struct {
    Query    string   `query:"q" json:"query"`
    Page     int      `query:"page" default:"1"`
    PageSize int      `query:"page_size" default:"20"`
    Filters  []string `json:"filters"`
    SortBy   string   `header:"X-Sort-By" default:"created_at"`
}

// Bind from multiple sources
req, err := binding.Multi[SearchRequest](
    binding.WithQuery(r.URL.Query()),
    binding.WithJSON(r.Body),
    binding.WithHeaders(r.Header),
)

Precedence Rules

By default, sources are applied in order (last wins):

// Example: User ID from multiple sources
type Request struct {
    UserID int `query:"user_id" json:"user_id" header:"X-User-ID"`
}

// Query: ?user_id=1
// JSON:  {"user_id": 2}
// Header: X-User-ID: 3

// Default precedence (last wins):
req, err := binding.Multi[Request](
    binding.WithQuery(r.URL.Query()),      // user_id = 1
    binding.WithJSON(r.Body),              // user_id = 2 (overwrites)
    binding.WithHeaders(r.Header),         // user_id = 3 (overwrites)
)
// Result: user_id = 3

First-Wins Precedence

Use WithMergeStrategy to prefer first non-empty value:

req, err := binding.Multi[Request](
    binding.WithMergeStrategy(binding.MergeFirstWins),
    binding.WithQuery(r.URL.Query()),      // user_id = 1
    binding.WithJSON(r.Body),              // user_id = 2 (ignored)
    binding.WithHeaders(r.Header),         // user_id = 3 (ignored)
)
// Result: user_id = 1

Partial Binding

Different fields can come from different sources:

type CompleteRequest struct {
    // Pagination from query
    Page     int `query:"page" default:"1"`
    PageSize int `query:"page_size" default:"20"`
    
    // Search criteria from JSON body
    Filters struct {
        Category string   `json:"category"`
        Tags     []string `json:"tags"`
        MinPrice float64  `json:"min_price"`
        MaxPrice float64  `json:"max_price"`
    } `json:"filters"`
    
    // Auth from headers
    APIKey    string `header:"X-API-Key"`
    RequestID string `header:"X-Request-ID"`
}

// POST /search?page=2&page_size=50
// Headers: X-API-Key: secret, X-Request-ID: req-123
// Body: {"filters": {"category": "electronics", "tags": ["sale"]}}

req, err := binding.Multi[CompleteRequest](
    binding.WithQuery(r.URL.Query()),
    binding.WithJSON(r.Body),
    binding.WithHeaders(r.Header),
)

Path Parameters

Combine with router path parameters:

type UserUpdateRequest struct {
    // From path: /users/:id
    UserID int `path:"id"`
    
    // From JSON body
    Username string `json:"username"`
    Email    string `json:"email"`
    
    // From header
    APIKey string `header:"X-API-Key"`
}

// With gorilla/mux or chi
req, err := binding.Multi[UserUpdateRequest](
    binding.WithPath(mux.Vars(r)),  // or chi.URLParams(r)
    binding.WithJSON(r.Body),
    binding.WithHeaders(r.Header),
)

Form Data and JSON

Handle both form and JSON submissions:

type LoginRequest struct {
    Username string `json:"username" form:"username"`
    Password string `json:"password" form:"password"`
}

// Works with both:
// Content-Type: application/json
// Content-Type: application/x-www-form-urlencoded

req, err := binding.Auto[LoginRequest](r)

Source-Specific Options

Apply options to specific sources:

req, err := binding.Multi[Request](
    binding.WithQuery(r.URL.Query()),
    
    binding.WithJSON(r.Body,
        binding.WithMaxBytes(1024*1024),
        binding.WithDisallowUnknownFields(),
    ),
    
    binding.WithHeaders(r.Header),
)

Conditional Sources

Bind from sources based on conditions:

func BindRequest[T any](r *http.Request) (T, error) {
    sources := []binding.Source{
        binding.WithQuery(r.URL.Query()),
    }
    
    // Add JSON source only for POST/PUT/PATCH
    if r.Method != "GET" && r.Method != "DELETE" {
        sources = append(sources, binding.WithJSON(r.Body))
    }
    
    // Add auth header if present
    if r.Header.Get("Authorization") != "" {
        sources = append(sources, binding.WithHeaders(r.Header))
    }
    
    return binding.Multi[T](sources...)
}

Complex Example

Real-world multi-source scenario:

type ProductSearchRequest struct {
    // Query parameters (user input)
    Query    string   `query:"q"`
    Page     int      `query:"page" default:"1"`
    PageSize int      `query:"page_size" default:"20"`
    SortBy   string   `query:"sort_by" default:"relevance"`
    
    // Advanced filters (JSON body)
    Filters struct {
        Categories []string `json:"categories"`
        Brands     []string `json:"brands"`
        MinPrice   float64  `json:"min_price"`
        MaxPrice   float64  `json:"max_price"`
        InStock    *bool    `json:"in_stock"`
        Rating     *int     `json:"min_rating"`
    } `json:"filters"`
    
    // Request metadata (headers)
    Locale      string `header:"Accept-Language" default:"en-US"`
    Currency    string `header:"X-Currency" default:"USD"`
    UserAgent   string `header:"User-Agent"`
    RequestID   string `header:"X-Request-ID"`
    
    // Internal fields (not from request)
    UserID      int    `binding:"-"`  // Set after auth
    RequestedAt time.Time `binding:"-"`
}

func SearchProducts(w http.ResponseWriter, r *http.Request) {
    // Bind from multiple sources
    req, err := binding.Multi[ProductSearchRequest](
        binding.WithQuery(r.URL.Query()),
        binding.WithJSON(r.Body, binding.WithMaxBytes(1024*1024)),
        binding.WithHeaders(r.Header),
    )
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Set internal fields
    req.UserID = getUserID(r)
    req.RequestedAt = time.Now()
    
    // Execute search
    results := executeSearch(req)
    json.NewEncoder(w).Encode(results)
}

Error Handling

Multi-source errors include source information:

req, err := binding.Multi[Request](
    binding.WithQuery(r.URL.Query()),
    binding.WithJSON(r.Body),
)
if err != nil {
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        log.Printf("Source: %s, Field: %s, Error: %v",
            bindErr.Source, bindErr.Field, bindErr.Err)
    }
    
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
}

Source Priority Pattern

Common pattern for API versioning and backward compatibility:

type VersionedRequest struct {
    // Prefer header, fallback to query
    APIVersion string `header:"X-API-Version" query:"api_version" default:"v1"`
    
    // Prefer body, fallback to query
    UserID int `json:"user_id" query:"user_id"`
}

// With first-wins strategy:
req, err := binding.Multi[VersionedRequest](
    binding.WithMergeStrategy(binding.MergeFirstWins),
    binding.WithHeaders(r.Header),   // Highest priority
    binding.WithQuery(r.URL.Query()), // Fallback
    binding.WithJSON(r.Body),         // Lowest priority
)

Middleware Pattern

Create reusable binding middleware:

func BindMiddleware[T any](next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        req, err := binding.Multi[T](
            binding.WithQuery(r.URL.Query()),
            binding.WithJSON(r.Body),
            binding.WithHeaders(r.Header),
        )
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        
        // Store in context
        ctx := context.WithValue(r.Context(), "request", req)
        next(w, r.WithContext(ctx))
    }
}

// Usage
http.HandleFunc("/users", BindMiddleware[CreateUserRequest](CreateUserHandler))

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    req := r.Context().Value("request").(CreateUserRequest)
    // Use req
}

Integration with Rivaas Router

Seamless integration with rivaas.dev/router:

import (
    "rivaas.dev/binding"
    "rivaas.dev/router"
)

type CreateUserRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    APIKey   string `header:"X-API-Key"`
}

r := router.New()

r.POST("/users", func(c *router.Context) error {
    req, err := binding.Multi[CreateUserRequest](
        binding.WithJSON(c.Request().Body),
        binding.WithHeaders(c.Request().Header),
    )
    if err != nil {
        return c.JSON(http.StatusBadRequest, err)
    }
    
    // Use req
    return c.JSON(http.StatusCreated, createUser(req))
})

Performance Considerations

  1. Source order: Most specific first (headers before query)
  2. Lazy evaluation: Sources are processed in order
  3. Caching: Struct info is cached across requests
  4. Zero allocation: Primitive types use no extra memory

Best Practices

1. Document Source Expectations

// SearchRequest accepts search parameters from multiple sources:
//   - Query: pagination (page, page_size)
//   - JSON body: filters (categories, price range)
//   - Headers: locale, currency
type SearchRequest struct {
    // ...
}

2. Use Defaults Wisely

type Request struct {
    Page int `query:"page" default:"1"`     // Good
    Sort string `header:"X-Sort" query:"sort" default:"created_at"`  // Good
}

3. Validate After Binding

req, err := binding.Multi[Request](...)
if err != nil {
    return err
}

// Validate business rules
if err := validation.Validate(req); err != nil {
    return err
}

Troubleshooting

Values Not Merging

Check tag names match across sources:

// Wrong - different tag names
type Request struct {
    ID int `query:"id" json:"user_id"`  // Won't merge
}

// Correct - same semantic field
type Request struct {
    ID int `query:"id" json:"id"`
}

Unexpected Overwrites

Use first-wins strategy or check source order:

// Last wins (default)
binding.Multi[T](
    binding.WithQuery(...),  // Applied first
    binding.WithJSON(...),   // May overwrite query
)

// First wins (explicit)
binding.Multi[T](
    binding.WithMergeStrategy(binding.MergeFirstWins),
    binding.WithHeaders(...),  // Highest priority
    binding.WithQuery(...),
)

Next Steps

For complete API details, see API Reference.

3.7 - Struct Tags

Master struct tag syntax for precise control over data binding

Comprehensive guide to struct tag syntax, options, and conventions for the binding package.

Overview

Struct tags control how fields are bound from different sources. The binding package supports multiple tag types:

type Example struct {
    Field string `json:"field" query:"field" header:"X-Field" default:"value"`
}

Tag Types

Source Tags

TagSourceExample
jsonJSON bodyjson:"field_name"
queryURL query paramsquery:"field_name"
formForm dataform:"field_name"
headerHTTP headersheader:"X-Field-Name"
pathURL path paramspath:"param_name"
cookieHTTP cookiescookie:"cookie_name"

Special Tags

TagPurposeExample
defaultDefault valuedefault:"value"
validateValidation rulesvalidate:"required,email"
bindingControl bindingbinding:"-" or binding:"required"

Basic Syntax

Simple Field

type User struct {
    Name string `json:"name"`
}

Multiple Sources

Same field can bind from multiple sources:

type Request struct {
    UserID int `query:"user_id" json:"user_id" header:"X-User-ID"`
}

Field Name Mapping

Map different source names to same field:

type Request struct {
    UserID int `query:"uid" json:"user_id" header:"X-User-ID"`
}

JSON Tags

Standard encoding/json tag syntax:

type Product struct {
    // Basic field
    ID int `json:"id"`
    
    // Custom name
    Name string `json:"product_name"`
    
    // Omit if empty
    Description string `json:"description,omitempty"`
    
    // Ignore field
    Internal string `json:"-"`
    
    // Use field name as-is (case-sensitive)
    SKU string `json:"SKU"`
}

JSON Tag Options

type Example struct {
    // Omit if empty/zero value
    Optional string `json:"optional,omitempty"`
    
    // Omit if empty AND keep format
    Field string `json:"field,omitempty,string"`
    
    // Treat as string (for numbers)
    ID int64 `json:"id,string"`
}

Query Tags

URL query parameter binding:

type QueryParams struct {
    // Basic parameter
    Search string `query:"q"`
    
    // With default
    Page int `query:"page" default:"1"`
    
    // Array/slice
    Tags []string `query:"tags"`
    
    // Optional with pointer
    Filter *string `query:"filter"`
}

Query Tag Aliases

Support multiple parameter names:

type Request struct {
    // Accepts any of: user_id, id, uid
    UserID int `query:"user_id,id,uid"`
}

Header Tags

HTTP header binding:

type HeaderParams struct {
    // Standard header
    ContentType string `header:"Content-Type"`
    
    // Custom header
    APIKey string `header:"X-API-Key"`
    
    // Case-insensitive
    UserAgent string `header:"user-agent"`  // Matches User-Agent
    
    // Authorization
    AuthToken string `header:"Authorization"`
}

Header Naming Conventions

Headers are case-insensitive:

type Example struct {
    // All match "X-API-Key", "x-api-key", "X-Api-Key"
    APIKey string `header:"X-API-Key"`
}

Path Tags

URL path parameter binding:

// Route: /users/:id
type PathParams struct {
    UserID int `path:"id"`
}

// Route: /posts/:category/:slug
type PostParams struct {
    Category string `path:"category"`
    Slug     string `path:"slug"`
}

Form Tags

Form data binding:

type FormData struct {
    Username string `form:"username"`
    Email    string `form:"email"`
    Age      int    `form:"age"`
}

HTTP cookie binding:

type CookieParams struct {
    SessionID string `cookie:"session_id"`
    Theme     string `cookie:"theme" default:"light"`
}

Default Tag

Specify default values for fields:

type Config struct {
    // String default
    Host string `query:"host" default:"localhost"`
    
    // Integer default
    Port int `query:"port" default:"8080"`
    
    // Boolean default
    Debug bool `query:"debug" default:"false"`
    
    // Duration default
    Timeout time.Duration `query:"timeout" default:"30s"`
}

Default Value Types

type Defaults struct {
    String   string        `default:"text"`
    Int      int           `default:"42"`
    Float    float64       `default:"3.14"`
    Bool     bool          `default:"true"`
    Duration time.Duration `default:"1h30m"`
    Time     time.Time     `default:"2025-01-01T00:00:00Z"`
}

Binding Tag

Control binding behavior:

type Request struct {
    // Skip binding entirely
    Internal string `binding:"-"`
    
    // Required field
    UserID int `binding:"required"`
    
    // Optional field (explicit)
    Email string `binding:"optional"`
}

Validation Tag

Integration with rivaas.dev/validation:

type CreateUserRequest struct {
    Username string `json:"username" validate:"required,alphanum,min=3,max=32"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
    Website  string `json:"website" validate:"omitempty,url"`
}

Common Validation Rules

type ValidationExamples struct {
    // Required
    Required string `validate:"required"`
    
    // Length constraints
    Username string `validate:"min=3,max=32"`
    
    // Format validation
    Email    string `validate:"email"`
    URL      string `validate:"url"`
    UUID     string `validate:"uuid"`
    
    // Numeric constraints
    Age      int     `validate:"min=18,max=120"`
    Price    float64 `validate:"gt=0"`
    
    // Pattern matching
    Phone    string `validate:"regexp=^[0-9]{10}$"`
    
    // Conditional
    Optional string `validate:"omitempty,email"`  // Validate only if present
}

Tag Combinations

Complete Example

type CompleteRequest struct {
    // Multi-source with default and validation
    UserID int `query:"user_id" json:"user_id" header:"X-User-ID" default:"0" validate:"min=1"`
    
    // Optional with validation
    Email string `json:"email" validate:"omitempty,email"`
    
    // Required with custom name
    APIKey string `header:"X-API-Key" binding:"required"`
    
    // Array with default
    Tags []string `query:"tags" default:"general"`
    
    // Nested struct
    Filters struct {
        Category string `json:"category" validate:"required"`
        MinPrice int    `json:"min_price" validate:"min=0"`
    } `json:"filters"`
}

Embedded Structs

Tags on embedded structs:

type Pagination struct {
    Page     int `query:"page" default:"1"`
    PageSize int `query:"page_size" default:"20"`
}

type SearchRequest struct {
    Query string `query:"q"`
    Pagination  // Embedded - inherits tags
}

// Usage
req, err := binding.Query[SearchRequest](values)
// Can access req.Page, req.PageSize

Embedded with Prefix

type SearchRequest struct {
    Query      string `query:"q"`
    Pagination `query:"pagination"`  // Adds prefix
}

// URL: ?q=test&pagination.page=2&pagination.page_size=50

Pointer Fields

Pointers distinguish “not provided” from “zero value”:

type UpdateRequest struct {
    // nil = not provided, &0 = set to zero
    Age *int `json:"age"`
    
    // nil = not provided, &"" = set to empty string
    Bio *string `json:"bio"`
    
    // nil = not provided, &false = set to false
    Active *bool `json:"active"`
}

Tag Naming Conventions

JSON (snake_case)

type User struct {
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    EmailAddr string `json:"email_address"`
}

Query (snake_case or kebab-case)

type Params struct {
    UserID   int `query:"user_id"`
    SortBy   string `query:"sort_by"`
    SortOrder string `query:"sort-order"`  // kebab-case also fine
}

Headers (Title-Case)

type Headers struct {
    ContentType string `header:"Content-Type"`
    APIKey      string `header:"X-API-Key"`
    RequestID   string `header:"X-Request-ID"`
}

Ignored Fields

Multiple ways to ignore fields:

type Example struct {
    // Unexported - automatically ignored
    internal string
    
    // Explicitly ignored with json tag
    Debug string `json:"-"`
    
    // Explicitly ignored with binding tag
    Temporary string `binding:"-"`
    
    // Exported but not bound
    Computed int  // No tags
}

Complex Types

Time Fields

type TimeFields struct {
    // RFC3339 format
    CreatedAt time.Time `json:"created_at"`
    
    // Unix timestamp (as integer)
    UpdatedAt time.Time `json:"updated_at,unix"`
    
    // Duration
    Timeout time.Duration `json:"timeout"`  // "30s", "1h", etc.
}

Map Fields

type Config struct {
    // String map
    Metadata map[string]string `json:"metadata"`
    
    // Nested map
    Settings map[string]interface{} `json:"settings"`
    
    // Typed map
    Counters map[string]int `json:"counters"`
}

Interface Fields

type Flexible struct {
    // Any JSON value
    Data interface{} `json:"data"`
    
    // Strongly typed when possible
    Config map[string]interface{} `json:"config"`
}

Tag Best Practices

1. Be Consistent

// Good - consistent naming
type User struct {
    UserID    int    `json:"user_id"`
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
}

// Bad - inconsistent naming
type User struct {
    UserID    int    `json:"userId"`
    FirstName string `json:"first_name"`
    LastName  string `json:"LastName"`
}

2. Use Defaults for Common Values

type Pagination struct {
    Page     int `query:"page" default:"1"`
    PageSize int `query:"page_size" default:"20"`
}

3. Validate After Binding

// Separate binding from validation
type Request struct {
    Email string `json:"email" validate:"required,email"`
}

// Bind first
req, err := binding.JSON[Request](r.Body)
// Then validate
err = validation.Validate(req)

4. Document Complex Tags

// UserRequest represents a user creation request.
// The user_id can come from query, JSON, or X-User-ID header.
// If not provided, defaults to 0 (anonymous user).
type UserRequest struct {
    UserID int `query:"user_id" json:"user_id" header:"X-User-ID" default:"0"`
}

Tag Parsing Rules

  1. Tag precedence: Last source wins (unless using first-wins strategy)
  2. Case sensitivity:
    • JSON: case-sensitive
    • Query: case-sensitive
    • Headers: case-insensitive
  3. Empty values: Use omitempty to skip
  4. Type conversion: Automatic for supported types
  5. Validation: Applied after binding

Common Patterns

API Versioning

type VersionedRequest struct {
    APIVersion string `header:"X-API-Version" query:"api_version" default:"v1"`
    Data       interface{} `json:"data"`
}

Tenant Isolation

type TenantRequest struct {
    TenantID string `header:"X-Tenant-ID" binding:"required"`
    Data     interface{} `json:"data"`
}

Audit Fields

type AuditableRequest struct {
    RequestID string    `header:"X-Request-ID"`
    UserAgent string    `header:"User-Agent"`
    ClientIP  string    `header:"X-Forwarded-For"`
    Timestamp time.Time `binding:"-"`  // Set by server
}

Troubleshooting

Field Not Binding

Check that:

  1. Field is exported (starts with uppercase)
  2. Tag name matches source key
  3. Tag type matches source (e.g., query for query params)
// Wrong
type Bad struct {
    name string `json:"name"`  // Unexported
}

// Correct
type Good struct {
    Name string `json:"name"`
}

Type Conversion Failing

Ensure source data matches field type:

// URL: ?age=twenty
type Params struct {
    Age int `query:"age"`  // Will error - can't convert "twenty" to int
}

Default Not Applied

Defaults only apply when field is missing, not for zero values:

type Params struct {
    Page int `query:"page" default:"1"`
}

// ?page=0 -> Page = 0 (not 1, zero was provided)
// (no page param) -> Page = 1 (default applied)

Next Steps

For complete API details, see API Reference.

3.8 - Type Support

Complete reference for all supported data types and conversions

Comprehensive guide to type support in the binding package. This includes automatic conversions, custom types, and edge cases.

Supported Types

The binding package supports a wide range of Go types with automatic conversion:

Basic Types

type BasicTypes struct {
    // String
    Name string `json:"name"`
    
    // Integers
    Int    int    `json:"int"`
    Int8   int8   `json:"int8"`
    Int16  int16  `json:"int16"`
    Int32  int32  `json:"int32"`
    Int64  int64  `json:"int64"`
    
    // Unsigned integers
    Uint   uint   `json:"uint"`
    Uint8  uint8  `json:"uint8"`
    Uint16 uint16 `json:"uint16"`
    Uint32 uint32 `json:"uint32"`
    Uint64 uint64 `json:"uint64"`
    
    // Floats
    Float32 float32 `json:"float32"`
    Float64 float64 `json:"float64"`
    
    // Boolean
    Active bool `json:"active"`
    
    // Byte (alias for uint8)
    Byte byte `json:"byte"`
    
    // Rune (alias for int32)
    Rune rune `json:"rune"`
}

String Conversions

From Query/Header

type StringParams struct {
    Name  string `query:"name"`
    Value string `header:"X-Value"`
}

// URL: ?name=John+Doe
// Header: X-Value: hello world
// Result: {Name: "John Doe", Value: "hello world"}

From JSON

type JSONStrings struct {
    Text string `json:"text"`
}

// JSON: {"text": "hello"}
// Result: {Text: "hello"}

Empty Strings

type Optional struct {
    // Empty string is valid
    Name string `json:"name"`  // "" is kept
    
    // Use pointer for "not provided"
    Bio *string `json:"bio"`  // nil if not in JSON
}

Integer Conversions

From Strings

type Numbers struct {
    Age   int   `query:"age"`
    Count int64 `header:"X-Count"`
}

// URL: ?age=30
// Header: X-Count: 1000000
// Result: {Age: 30, Count: 1000000}

From JSON

type JSONNumbers struct {
    ID    int   `json:"id"`
    Count int64 `json:"count"`
}

// JSON: {"id": 42, "count": 9223372036854775807}

Overflow Handling

type SmallInt struct {
    Value int8 `json:"value"`
}

// JSON: {"value": 200}
// Error: value 200 overflows int8 (max 127)

Float Conversions

From Strings

type Floats struct {
    Price  float64 `query:"price"`
    Rating float32 `query:"rating"`
}

// URL: ?price=19.99&rating=4.5
// Result: {Price: 19.99, Rating: 4.5}

Scientific Notation

// URL: ?value=1.23e10
// Result: Value = 12300000000.0

Special Values

type SpecialFloats struct {
    Value float64 `query:"value"`
}

// URL: ?value=inf  -> +Inf
// URL: ?value=-inf -> -Inf
// URL: ?value=nan  -> NaN

Boolean Conversions

True Values

type Flags struct {
    Debug bool `query:"debug"`
}

// All parse to true:
// ?debug=true
// ?debug=1
// ?debug=yes
// ?debug=on
// ?debug=t
// ?debug=y

False Values

// All parse to false:
// ?debug=false
// ?debug=0
// ?debug=no
// ?debug=off
// ?debug=f
// ?debug=n
// (parameter not present)

Case Insensitive

// All parse to true:
// ?debug=TRUE
// ?debug=True
// ?debug=tRuE

Time Types

time.Time

type TimeFields struct {
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `query:"updated_at"`
}

// Supported formats:
// - RFC3339: "2025-01-01T00:00:00Z"
// - RFC3339Nano: "2025-01-01T00:00:00.123456789Z"
// - Date only: "2025-01-01"
// - Unix timestamp: "1735689600"

time.Duration

type Timeouts struct {
    Timeout   time.Duration `json:"timeout"`
    RetryAfter time.Duration `query:"retry_after"`
}

// Supported formats:
// - "300ms" -> 300 milliseconds
// - "1.5s" -> 1.5 seconds
// - "2m30s" -> 2 minutes 30 seconds
// - "1h30m" -> 1 hour 30 minutes
// - "24h" -> 24 hours

// URL: ?retry_after=30s
// JSON: {"timeout": "5m"}
// Result: {Timeout: 5*time.Minute, RetryAfter: 30*time.Second}

Slices and Arrays

String Slices

type Lists struct {
    Tags []string `query:"tags"`
}

// Repeated parameters (default):
// ?tags=go&tags=rust&tags=python
// Result: {Tags: ["go", "rust", "python"]}

// CSV mode:
// ?tags=go,rust,python
params, err := binding.Query[Lists](
    values,
    binding.WithSliceMode(binding.SliceCSV),
)
// Result: {Tags: ["go", "rust", "python"]}

Integer Slices

type IDList struct {
    IDs []int `query:"ids"`
}

// URL: ?ids=1&ids=2&ids=3
// Result: {IDs: [1, 2, 3]}

Float Slices

type Prices struct {
    Values []float64 `json:"values"`
}

// JSON: {"values": [19.99, 29.99, 39.99]}

Arrays (Fixed Size)

type FixedArray struct {
    RGB [3]int `json:"rgb"`
}

// JSON: {"rgb": [255, 128, 0]}
// Result: {RGB: [255, 128, 0]}

// JSON: {"rgb": [255, 128]}
// Error: array length mismatch

Nested Slices

type Matrix struct {
    Grid [][]int `json:"grid"`
}

// JSON: {"grid": [[1,2,3], [4,5,6], [7,8,9]]}

Maps

String Maps

type StringMaps struct {
    Metadata map[string]string `json:"metadata"`
    Labels   map[string]string `json:"labels"`
}

// JSON: {"metadata": {"key1": "value1", "key2": "value2"}}

Typed Maps

type TypedMaps struct {
    Counters map[string]int     `json:"counters"`
    Prices   map[string]float64 `json:"prices"`
    Flags    map[string]bool    `json:"flags"`
}

// JSON: {
//   "counters": {"views": 100, "likes": 50},
//   "prices": {"basic": 9.99, "premium": 29.99},
//   "flags": {"enabled": true, "public": false}
// }

Interface Maps

type FlexibleMap struct {
    Settings map[string]interface{} `json:"settings"`
}

// JSON: {
//   "settings": {
//     "name": "app",
//     "port": 8080,
//     "debug": true,
//     "features": ["a", "b", "c"]
//   }
// }

Nested Maps

type NestedMaps struct {
    Config map[string]map[string]string `json:"config"`
}

// JSON: {
//   "config": {
//     "database": {"host": "localhost", "port": "5432"},
//     "cache": {"host": "localhost", "port": "6379"}
//   }
// }

Pointers

Basic Pointers

type Pointers struct {
    // nil = not provided, &0 = explicitly zero
    Age *int `json:"age"`
    
    // nil = not provided, &"" = explicitly empty
    Bio *string `json:"bio"`
    
    // nil = not provided, &false = explicitly false
    Active *bool `json:"active"`
}

// JSON: {"age": 0, "bio": "", "active": false}
// Result: {Age: &0, Bio: &"", Active: &false}

// JSON: {}
// Result: {Age: nil, Bio: nil, Active: nil}

Pointer Semantics

type Update struct {
    Name *string `json:"name"`
}

// Distinguish between:
// 1. Not updating: {"other_field": "value"}
//    -> Name = nil (don't update)
// 
// 2. Setting to empty: {"name": ""}
//    -> Name = &"" (update to empty)
// 
// 3. Setting value: {"name": "John"}
//    -> Name = &"John" (update to John)

Double Pointers

type DoublePointer struct {
    Value **int `json:"value"`
}

// Supported but rarely needed

Structs

Nested Structs

type Order struct {
    ID string `json:"id"`
    Customer struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    } `json:"customer"`
    Items []struct {
        ID    string  `json:"id"`
        Price float64 `json:"price"`
    } `json:"items"`
}

Embedded Structs

type Base struct {
    ID        int       `json:"id"`
    CreatedAt time.Time `json:"created_at"`
}

type User struct {
    Base  // Embedded - fields promoted
    Name  string `json:"name"`
    Email string `json:"email"`
}

// JSON: {"id": 1, "created_at": "2025-01-01T00:00:00Z", "name": "John"}

Anonymous Structs

type Response struct {
    Data struct {
        Message string `json:"message"`
        Code    int    `json:"code"`
    } `json:"data"`
}

Interfaces

Empty Interface

type Flexible struct {
    Data interface{} `json:"data"`
}

// JSON: {"data": "string"}  -> Data = "string"
// JSON: {"data": 42}        -> Data = float64(42)
// JSON: {"data": true}      -> Data = true
// JSON: {"data": [1,2,3]}   -> Data = []interface{}{1,2,3}
// JSON: {"data": {"k":"v"}} -> Data = map[string]interface{}{"k":"v"}

Type Assertions

func handleData(d interface{}) {
    switch v := d.(type) {
    case string:
        fmt.Println("String:", v)
    case float64:
        fmt.Println("Number:", v)
    case bool:
        fmt.Println("Boolean:", v)
    case []interface{}:
        fmt.Println("Array:", v)
    case map[string]interface{}:
        fmt.Println("Object:", v)
    }
}

Custom Types

Type Aliases

type UserID int
type Email string

type User struct {
    ID    UserID `json:"id"`
    Email Email  `json:"email"`
}

// Binds like underlying type
// JSON: {"id": 123, "email": "test@example.com"}

Custom Unmarshalers

Implement json.Unmarshaler for custom parsing:

type CustomDuration time.Duration

func (cd *CustomDuration) UnmarshalJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil {
        return err
    }
    
    d, err := time.ParseDuration(s)
    if err != nil {
        return err
    }
    
    *cd = CustomDuration(d)
    return nil
}

type Config struct {
    Timeout CustomDuration `json:"timeout"`
}

TextUnmarshaler

For query/header parsing:

type Status string

const (
    StatusActive   Status = "active"
    StatusInactive Status = "inactive"
)

func (s *Status) UnmarshalText(text []byte) error {
    str := string(text)
    switch str {
    case "active", "inactive":
        *s = Status(str)
        return nil
    default:
        return fmt.Errorf("invalid status: %s", str)
    }
}

type Params struct {
    Status Status `query:"status"`
}

// URL: ?status=active

Using Converter Factories

The binding package provides ready-made converter factories that make it easier to handle common custom type patterns.

Time Parsing with Custom Formats

import "rivaas.dev/binding"

binder := binding.MustNew(
    binding.WithConverter(binding.TimeConverter(
        "01/02/2006",      // US format
        "2006-01-02",      // ISO format
        "02-Jan-2006",     // Short month
    )),
)

type Event struct {
    Date time.Time `query:"date"`
}

// Works with any of these formats:
// ?date=01/28/2026
// ?date=2026-01-28
// ?date=28-Jan-2026
event, err := binder.Query[Event](values)

Duration with Friendly Aliases

binder := binding.MustNew(
    binding.WithConverter(binding.DurationConverter(map[string]time.Duration{
        "quick":  5 * time.Minute,
        "normal": 30 * time.Minute,
        "long":   2 * time.Hour,
    })),
)

type Config struct {
    Timeout time.Duration `query:"timeout"`
}

// All of these work:
// ?timeout=quick   → 5 minutes
// ?timeout=30m     → 30 minutes (standard Go format)
// ?timeout=2h30m   → 2 hours 30 minutes
config, err := binder.Query[Config](values)

String Enums with Validation

type Priority string

const (
    PriorityLow    Priority = "low"
    PriorityMedium Priority = "medium"
    PriorityHigh   Priority = "high"
)

binder := binding.MustNew(
    binding.WithConverter(binding.EnumConverter(
        PriorityLow,
        PriorityMedium,
        PriorityHigh,
    )),
)

type Task struct {
    Priority Priority `query:"priority"`
}

// ?priority=high    ✓ Works
// ?priority=HIGH    ✓ Works (case-insensitive)
// ?priority=urgent  ✗ Error: must be one of: low, medium, high
task, err := binder.Query[Task](values)

Custom Boolean Values

binder := binding.MustNew(
    binding.WithConverter(binding.BoolConverter(
        []string{"yes", "on", "enabled"},    // truthy values
        []string{"no", "off", "disabled"},   // falsy values
    )),
)

type Settings struct {
    Feature bool `query:"feature"`
}

// ?feature=yes      → true
// ?feature=enabled  → true
// ?feature=OFF      → false (case-insensitive)
// ?feature=no       → false
settings, err := binder.Query[Settings](values)

Third-Party Types

import "github.com/google/uuid"

binder := binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
)

type User struct {
    ID uuid.UUID `query:"id"`
}

// ?id=550e8400-e29b-41d4-a716-446655440000
user, err := binder.Query[User](values)

Type Conversion Matrix

Source TypeTarget TypeConversionExample
stringintParse"42"42
stringfloat64Parse"3.14"3.14
stringboolParse"true"true
stringtime.DurationParse"30s"30*time.Second
stringtime.TimeParse"2025-01-01"time.Time
numberintCast42.042
numberstringFormat42"42"
boolstringFormattrue"true"
array[]TElement-wise[1,2,3][]int{1,2,3}
objectstructField-wise{"a":1}struct{A int}
objectmapKey-value{"a":1}map[string]int

Edge Cases

Null vs Zero

type Nullable struct {
    // Pointer distinguishes null from zero
    Count *int `json:"count"`
}

// JSON: {"count": null} -> Count = nil
// JSON: {"count": 0}    -> Count = &0
// JSON: {}              -> Count = nil

Empty vs Missing

type Optional struct {
    Name  string  `json:"name"`
    Email *string `json:"email"`
}

// JSON: {"name": "", "email": ""}
// Result: {Name: "", Email: &""}

// JSON: {"name": ""}
// Result: {Name: "", Email: nil}

Overflow Protection

// Protects against overflow
type SafeInt struct {
    Value int8 `json:"value"`
}

// JSON: {"value": 200}
// Error: value overflows int8

Type Mismatches

type Typed struct {
    Age int `json:"age"`
}

// JSON: {"age": "not a number"}
// Error: cannot unmarshal string into int

Performance Characteristics

TypeAllocationSpeedNotes
PrimitivesZeroFastDirect assignment
StringsOneFastImmutable
SlicesOneFastPre-allocated when possible
MapsOneMediumHash allocation
StructsZeroFastStack allocation
PointersOneFastHeap allocation
InterfacesOneMediumType assertion overhead

Unsupported Types

The following types are not supported:

type Unsupported struct {
    // Channel
    Ch chan int  // Not supported
    
    // Function
    Fn func()  // Not supported
    
    // Complex numbers
    C complex128  // Not supported
    
    // Unsafe pointer
    Ptr unsafe.Pointer  // Not supported
}

Best Practices

1. Use Appropriate Types

// Good - specific types
type Good struct {
    Age      int       `json:"age"`
    Price    float64   `json:"price"`
    Created  time.Time `json:"created"`
}

// Bad - generic types
type Bad struct {
    Age     interface{} `json:"age"`
    Price   interface{} `json:"price"`
    Created interface{} `json:"created"`
}

2. Use Pointers for Optional Fields

type Update struct {
    Name *string `json:"name"`  // Can be null
    Age  *int    `json:"age"`   // Can be null
}

3. Use Slices for Variable-Length Data

// Good - slice
type Good struct {
    Tags []string `json:"tags"`
}

// Bad - fixed array
type Bad struct {
    Tags [10]string `json:"tags"`  // Rigid
}

4. Document Custom Types

// UserID represents a unique user identifier.
// It must be a positive integer.
type UserID int

// Validate ensures the UserID is valid.
func (id UserID) Validate() error {
    if id <= 0 {
        return errors.New("invalid user ID")
    }
    return nil
}

Troubleshooting

Type Conversion Errors

// Error: cannot unmarshal string into int
// Solution: Check source data matches target type

// Error: value overflows int8
// Solution: Use larger type (int16, int32, int64)

// Error: parsing time "invalid" as "2006-01-02"
// Solution: Use correct time format

Unexpected Nil Values

// Problem: field is nil when expected
// Solution: Check if source provided the value

// Problem: can't distinguish nil from zero
// Solution: Use pointer type

Next Steps

For complete API documentation, see API Reference.

3.9 - Error Handling

Master error handling patterns for robust request validation and debugging

Comprehensive guide to error handling in the binding package. This includes error types, validation patterns, and debugging strategies.

Error Types

The binding package provides structured error types for detailed error handling:

// BindError represents a field-specific binding error
type BindError struct {
    Field  string // Field name that failed.
    Source string // Source like "query", "json", "header".
    Err    error  // Underlying error.
}

// ValidationError represents a validation failure
type ValidationError struct {
    Field   string // Field name that failed validation.
    Value   interface{} // The invalid value.
    Rule    string // Validation rule that failed.
    Message string // Human-readable message.
}

Enhanced Error Messages

The binding package now provides helpful hints when type conversion fails. These hints suggest what might have gone wrong and how to fix it.

Example error messages with hints:

type Request struct {
    Age   int       `query:"age"`
    Price float64   `query:"price"`
    When  time.Time `query:"when"`
    Active bool     `query:"active"`
}

// URL: ?age=10.5
// Error: cannot bind field "Age" from query: strconv.ParseInt: parsing "10.5": invalid syntax
//        Hint: value looks like a floating-point number; use float32 or float64 instead

// URL: ?price=twenty
// Error: cannot bind field "Price" from query: strconv.ParseFloat: parsing "twenty": invalid syntax
//        Hint: value "twenty" doesn't look like a number

// URL: ?when=yesterday
// Error: cannot bind field "When" from query: unable to parse time "yesterday" (tried 8 layouts)
//        Hint: common formats: "2006-01-02T15:04:05Z07:00", "2006-01-02", "01/02/2006"

// URL: ?active=maybe
// Error: cannot bind field "Active" from query: strconv.ParseBool: parsing "maybe": invalid syntax
//        Hint: use one of: true, false, 1, 0, t, f, yes, no, y, n

These contextual hints make it easier to understand what went wrong and fix the issue quickly.

Basic Error Handling

Simple Pattern

user, err := binding.JSON[CreateUserRequest](r.Body)
if err != nil {
    http.Error(w, err.Error(), http.StatusBadRequest)
    return
}

Detailed Pattern

user, err := binding.JSON[CreateUserRequest](r.Body)
if err != nil {
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        // Field-specific error
        log.Printf("Failed to bind field %s from %s: %v",
            bindErr.Field, bindErr.Source, bindErr.Err)
    }
    
    http.Error(w, "Invalid request", http.StatusBadRequest)
    return
}

Common Error Patterns

Type Conversion Errors

type Params struct {
    Age int `query:"age"`
}

// URL: ?age=invalid
// Error: BindError{
//   Field: "Age",
//   Source: "query",
//   Err: strconv.NumError{...}
// }

Missing Required Fields

type Request struct {
    APIKey string `header:"X-API-Key" binding:"required"`
}

// Missing header
// Error: BindError{
//   Field: "APIKey",
//   Source: "header",
//   Err: errors.New("required field missing")
// }

JSON Syntax Errors

// Malformed JSON: {"name": "test"
// Error: json.SyntaxError{...}

// Unknown field (with WithDisallowUnknownFields)
// Error: json.UnmarshalTypeError{...}

Size Limit Errors

req, err := binding.JSON[Request](
    r.Body,
    binding.WithMaxBytes(1024*1024),
)

// Request > 1MB
// Error: http.MaxBytesError{...}

Error Response Patterns

Basic JSON Error

func handleError(w http.ResponseWriter, err error) {
    type ErrorResponse struct {
        Error string `json:"error"`
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusBadRequest)
    json.NewEncoder(w).Encode(ErrorResponse{
        Error: err.Error(),
    })
}

// Usage
req, err := binding.JSON[Request](r.Body)
if err != nil {
    handleError(w, err)
    return
}

Detailed Error Response

type DetailedErrorResponse struct {
    Error   string                 `json:"error"`
    Details []FieldError           `json:"details,omitempty"`
}

type FieldError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
    Code    string `json:"code,omitempty"`
}

func handleBindError(w http.ResponseWriter, err error) {
    response := DetailedErrorResponse{
        Error: "Invalid request",
    }
    
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        response.Details = []FieldError{
            {
                Field:   bindErr.Field,
                Message: bindErr.Err.Error(),
                Code:    "BIND_ERROR",
            },
        }
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusBadRequest)
    json.NewEncoder(w).Encode(response)
}

RFC 7807 Problem Details

type ProblemDetail struct {
    Type     string                 `json:"type"`
    Title    string                 `json:"title"`
    Status   int                    `json:"status"`
    Detail   string                 `json:"detail,omitempty"`
    Instance string                 `json:"instance,omitempty"`
    Errors   map[string]interface{} `json:"errors,omitempty"`
}

func problemDetail(r *http.Request, err error) ProblemDetail {
    pd := ProblemDetail{
        Type:     "https://api.example.com/problems/invalid-request",
        Title:    "Invalid Request",
        Status:   http.StatusBadRequest,
        Instance: r.URL.Path,
        Errors:   make(map[string]interface{}),
    }
    
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        pd.Errors[bindErr.Field] = bindErr.Err.Error()
        pd.Detail = fmt.Sprintf("Field '%s' is invalid", bindErr.Field)
    } else {
        pd.Detail = err.Error()
    }
    
    return pd
}

// Usage
req, err := binding.JSON[Request](r.Body)
if err != nil {
    pd := problemDetail(r, err)
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(pd.Status)
    json.NewEncoder(w).Encode(pd)
    return
}

Validation Integration

Combine binding with validation:

import (
    "rivaas.dev/binding"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `json:"username" validate:"required,alphanum,min=3,max=32"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
}

func CreateUser(w http.ResponseWriter, r *http.Request) {
    // Step 1: Bind request
    req, err := binding.JSON[CreateUserRequest](r.Body)
    if err != nil {
        handleBindError(w, err)
        return
    }
    
    // Step 2: Validate
    if err := validation.Validate(req); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Process valid request
    user := createUser(req)
    json.NewEncoder(w).Encode(user)
}

func handleValidationError(w http.ResponseWriter, err error) {
    var valErrs validation.Errors
    if errors.As(err, &valErrs) {
        response := DetailedErrorResponse{
            Error: "Validation failed",
        }
        
        for _, valErr := range valErrs {
            response.Details = append(response.Details, FieldError{
                Field:   valErr.Field,
                Message: valErr.Message,
                Code:    valErr.Rule,
            })
        }
        
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusUnprocessableEntity)
        json.NewEncoder(w).Encode(response)
        return
    }
    
    http.Error(w, err.Error(), http.StatusBadRequest)
}

Error Context

Add context to errors for better debugging:

func bindRequest[T any](r *http.Request) (T, error) {
    req, err := binding.JSON[T](r.Body)
    if err != nil {
        return req, fmt.Errorf("binding request from %s: %w", r.RemoteAddr, err)
    }
    return req, nil
}

Error Logging

Structured Logging

import "log/slog"

func handleRequest(w http.ResponseWriter, r *http.Request) {
    req, err := binding.JSON[Request](r.Body)
    if err != nil {
        var bindErr *binding.BindError
        if errors.As(err, &bindErr) {
            slog.Error("Binding error",
                "field", bindErr.Field,
                "source", bindErr.Source,
                "error", bindErr.Err,
                "path", r.URL.Path,
                "method", r.Method,
                "remote", r.RemoteAddr,
            )
        } else {
            slog.Error("Request binding failed",
                "error", err,
                "path", r.URL.Path,
                "method", r.Method,
            )
        }
        
        http.Error(w, "Invalid request", http.StatusBadRequest)
        return
    }
    
    // Process request
}

Error Metrics

import "rivaas.dev/metrics"

var (
    bindErrorsCounter = metrics.NewCounter(
        "binding_errors_total",
        "Total number of binding errors",
        "field", "source", "error_type",
    )
)

func handleBindError(err error) {
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        bindErrorsCounter.Inc(
            bindErr.Field,
            bindErr.Source,
            fmt.Sprintf("%T", bindErr.Err),
        )
    }
}

Multi-Error Handling

Handle multiple errors from multi-source binding:

type MultiError []error

func (me MultiError) Error() string {
    var msgs []string
    for _, err := range me {
        msgs = append(msgs, err.Error())
    }
    return strings.Join(msgs, "; ")
}

func handleMultiError(w http.ResponseWriter, err error) {
    var multiErr MultiError
    if errors.As(err, &multiErr) {
        response := DetailedErrorResponse{
            Error: "Multiple validation errors",
        }
        
        for _, e := range multiErr {
            var bindErr *binding.BindError
            if errors.As(e, &bindErr) {
                response.Details = append(response.Details, FieldError{
                    Field:   bindErr.Field,
                    Message: bindErr.Err.Error(),
                })
            }
        }
        
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(response)
        return
    }
    
    http.Error(w, err.Error(), http.StatusBadRequest)
}

Error Recovery

Graceful Degradation

func loadConfig(r *http.Request) Config {
    cfg, err := binding.Query[Config](r.URL.Query())
    if err != nil {
        // Log error but use defaults
        slog.Warn("Failed to bind config, using defaults", "error", err)
        return DefaultConfig()
    }
    return cfg
}

Partial Success

func processBatch(items []Item) ([]Result, []error) {
    var results []Result
    var errors []error
    
    for _, item := range items {
        result, err := binding.Unmarshal[ProcessedItem](item.Data)
        if err != nil {
            errors = append(errors, fmt.Errorf("item %s: %w", item.ID, err))
            continue
        }
        results = append(results, Result{ID: item.ID, Data: result})
    }
    
    return results, errors
}

Error Testing

Unit Tests

func TestBindingError(t *testing.T) {
    type Request struct {
        Age int `json:"age"`
    }
    
    // Test invalid type
    body := strings.NewReader(`{"age": "not a number"}`)
    _, err := binding.JSON[Request](body)
    
    if err == nil {
        t.Fatal("expected error, got nil")
    }
    
    var bindErr *binding.BindError
    if !errors.As(err, &bindErr) {
        t.Fatalf("expected BindError, got %T", err)
    }
    
    if bindErr.Field != "Age" {
        t.Errorf("expected field Age, got %s", bindErr.Field)
    }
}

Integration Tests

func TestErrorResponse(t *testing.T) {
    payload := `{"age": "invalid"}`
    req := httptest.NewRequest("POST", "/users", strings.NewReader(payload))
    req.Header.Set("Content-Type", "application/json")
    
    rec := httptest.NewRecorder()
    CreateUserHandler(rec, req)
    
    if rec.Code != http.StatusBadRequest {
        t.Errorf("expected status 400, got %d", rec.Code)
    }
    
    var response ErrorResponse
    if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
        t.Fatal(err)
    }
    
    if response.Error == "" {
        t.Error("expected error message")
    }
}

Best Practices

1. Always Check Errors

// Good
req, err := binding.JSON[Request](r.Body)
if err != nil {
    handleError(w, err)
    return
}

// Bad - ignoring errors
req, _ := binding.JSON[Request](r.Body)

2. Use Specific Error Types

// Good - check specific error types
var bindErr *binding.BindError
if errors.As(err, &bindErr) {
    // Handle binding error specifically
}

// Bad - generic error handling
if err != nil {
    http.Error(w, "error", 500)
}

3. Log for Debugging

// Good - structured logging
slog.Error("Binding failed",
    "error", err,
    "path", r.URL.Path,
    "user", getUserID(r),
)

// Bad - no logging
if err != nil {
    http.Error(w, "error", 400)
    return
}

4. Return Helpful Messages

// Good - specific error message
type ErrorResponse struct {
    Error  string       `json:"error"`
    Field  string       `json:"field,omitempty"`
    Detail string       `json:"detail,omitempty"`
}

// Bad - generic message
http.Error(w, "bad request", 400)

5. Separate Binding from Validation

// Good - clear separation
req, err := binding.JSON[Request](r.Body)
if err != nil {
    return handleBindError(err)
}

if err := validation.Validate(req); err != nil {
    return handleValidationError(err)
}

// Bad - mixing concerns
if err := bindAndValidate(r.Body); err != nil {
    // Can't tell binding from validation errors
}

Error Middleware

Create reusable error handling middleware:

type ErrorHandler func(http.ResponseWriter, *http.Request) error

func (fn ErrorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    if err := fn(w, r); err != nil {
        handleError(w, r, err)
    }
}

func handleError(w http.ResponseWriter, r *http.Request, err error) {
    // Log error
    slog.Error("Request error",
        "error", err,
        "path", r.URL.Path,
        "method", r.Method,
    )
    
    // Determine status code
    status := http.StatusInternalServerError
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        status = http.StatusBadRequest
    }
    
    // Send response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]string{
        "error": err.Error(),
    })
}

// Usage
http.Handle("/users", ErrorHandler(func(w http.ResponseWriter, r *http.Request) error {
    req, err := binding.JSON[CreateUserRequest](r.Body)
    if err != nil {
        return err
    }
    
    // Process request
    return nil
}))

Common Error Scenarios

Scenario 1: Type Mismatch

// Request: {"age": "twenty"}
// Expected: {"age": 20}
// Error: cannot unmarshal string into int

Solution: Validate input format, provide clear error message

Scenario 2: Missing Required Field

// Request: {}
// Expected: {"api_key": "secret"}
// Error: required field 'api_key' missing

Solution: Use binding:"required" tag or validation

Scenario 3: Invalid JSON

// Request: {"name": "test"
// Error: unexpected EOF

Solution: Check Content-Type header, validate JSON syntax

Scenario 4: Request Too Large

// Request: 10MB payload
// Limit: 1MB
// Error: http: request body too large

Solution: Set appropriate WithMaxBytes() limit

Debugging Tips

1. Enable Debug Logging

import "log/slog"

slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug,
})))

2. Inspect Raw Request

// Save body for debugging
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(body))

slog.Debug("Raw request body", "body", string(body))

req, err := binding.JSON[Request](r.Body)

3. Check Headers

slog.Debug("Request headers",
    "content-type", r.Header.Get("Content-Type"),
    "content-length", r.Header.Get("Content-Length"),
)

4. Use Error Wrapping

if err != nil {
    return fmt.Errorf("processing request from %s: %w", r.RemoteAddr, err)
}

Next Steps

For complete error type documentation, see API Reference.

3.10 - Advanced Usage

Advanced techniques including custom converters, binders, and extension patterns

Explore advanced binding techniques for custom types, sources, and integration patterns.

Custom Type Converters

Register converters for types not natively supported.

import (
    "github.com/google/uuid"
    "github.com/shopspring/decimal"
    "rivaas.dev/binding"
)

binder := binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
    binding.WithConverter[decimal.Decimal](decimal.NewFromString),
)

type Product struct {
    ID    uuid.UUID       `query:"id"`
    Price decimal.Decimal `query:"price"`
}

// URL: ?id=550e8400-e29b-41d4-a716-446655440000&price=19.99
product, err := binder.Query[Product](values)

Converter Function Signature

type ConverterFunc[T any] func(string) (T, error)

// Example: Custom email type
type Email string

func ParseEmail(s string) (Email, error) {
    if !strings.Contains(s, "@") {
        return "", errors.New("invalid email format")
    }
    return Email(s), nil
}

binder := binding.MustNew(
    binding.WithConverter[Email](ParseEmail),
)

Built-in Converter Factories

The binding package provides ready-to-use converter factories for common patterns. These make it easier to handle dates, durations, enums, and custom boolean values.

TimeConverter

Parse time strings with custom date formats.

binder := binding.MustNew(
    // US date format: 01/15/2026
    binding.WithConverter(binding.TimeConverter("01/02/2006")),
)

type Event struct {
    Date time.Time `query:"date"`
}

// URL: ?date=01/15/2026
event, err := binder.Query[Event](values)

You can also provide multiple formats as fallbacks:

binder := binding.MustNew(
    binding.WithConverter(binding.TimeConverter(
        "2006-01-02",           // ISO date
        "01/02/2006",           // US format
        "02-Jan-2006",          // Short month
        "2006-01-02 15:04:05",  // DateTime
    )),
)

DurationConverter

Parse duration strings with friendly aliases.

binder := binding.MustNew(
    binding.WithConverter(binding.DurationConverter(map[string]time.Duration{
        "short":  5 * time.Minute,
        "medium": 30 * time.Minute,
        "long":   2 * time.Hour,
    })),
)

type CacheConfig struct {
    TTL time.Duration `query:"ttl"`
}

// URL: ?ttl=short  → 5 minutes
// URL: ?ttl=30m    → 30 minutes (standard Go duration)
// URL: ?ttl=2h30m  → 2 hours 30 minutes
config, err := binder.Query[CacheConfig](values)

EnumConverter

Validate string values against a set of allowed options.

type Status string

const (
    StatusActive   Status = "active"
    StatusPending  Status = "pending"
    StatusDisabled Status = "disabled"
)

binder := binding.MustNew(
    binding.WithConverter(binding.EnumConverter(
        StatusActive,
        StatusPending,
        StatusDisabled,
    )),
)

type User struct {
    Status Status `query:"status"`
}

// URL: ?status=active  ✓ OK
// URL: ?status=ACTIVE  ✓ OK (case-insensitive)
// URL: ?status=invalid ✗ Error: must be one of: active, pending, disabled
user, err := binder.Query[User](values)

BoolConverter

Parse boolean values with custom truthy/falsy strings.

binder := binding.MustNew(
    binding.WithConverter(binding.BoolConverter(
        []string{"yes", "on", "enabled", "1"},   // truthy values
        []string{"no", "off", "disabled", "0"},  // falsy values
    )),
)

type Settings struct {
    Notifications bool `query:"notifications"`
}

// URL: ?notifications=yes      → true
// URL: ?notifications=enabled  → true
// URL: ?notifications=no       → false
// URL: ?notifications=OFF      → false (case-insensitive)
settings, err := binder.Query[Settings](values)

Combining Converter Factories

You can use multiple converter factories together:

binder := binding.MustNew(
    // Custom time formats
    binding.WithConverter(binding.TimeConverter("01/02/2006")),
    
    // Duration with aliases
    binding.WithConverter(binding.DurationConverter(map[string]time.Duration{
        "quick": 5 * time.Minute,
        "slow":  1 * time.Hour,
    })),
    
    // Status enum
    binding.WithConverter(binding.EnumConverter("active", "pending", "disabled")),
    
    // Boolean with custom values
    binding.WithConverter(binding.BoolConverter(
        []string{"yes", "on"},
        []string{"no", "off"},
    )),
    
    // Third-party types
    binding.WithConverter[uuid.UUID](uuid.Parse),
)

Custom ValueGetter

Implement custom data sources.

// ValueGetter interface
type ValueGetter interface {
    Get(key string) string
    GetAll(key string) []string
    Has(key string) bool
}

// Example: Environment variables getter
type EnvGetter struct{}

func (g *EnvGetter) Get(key string) string {
    return os.Getenv(key)
}

func (g *EnvGetter) GetAll(key string) []string {
    if val := os.Getenv(key); val != "" {
        return []string{val}
    }
    return nil
}

func (g *EnvGetter) Has(key string) bool {
    _, exists := os.LookupEnv(key)
    return exists
}

// Usage
type Config struct {
    APIKey string `env:"API_KEY"`
    Port   int    `env:"PORT" default:"8080"`
}

getter := &EnvGetter{}
config, err := binding.RawInto[Config](getter, "env")

GetterFunc Adapter

Use a function as a ValueGetter:

getter := binding.GetterFunc(func(key string) ([]string, bool) {
    // Custom lookup logic
    if val, ok := customSource[key]; ok {
        return []string{val}, true
    }
    return nil, false
})

result, err := binding.Raw[MyStruct](getter, "custom")

Map-Based Getters

Convenience helpers for simple sources:

// Single-value map
data := map[string]string{"name": "Alice", "age": "30"}
getter := binding.MapGetter(data)
result, err := binding.RawInto[User](getter, "custom")

// Multi-value map (for slices)
multi := map[string][]string{
    "tags": {"go", "rust", "python"},
    "name": {"Alice"},
}
getter := binding.MultiMapGetter(multi)
result, err := binding.RawInto[User](getter, "custom")

Reusable Binders

Create configured binders for shared settings:

var AppBinder = binding.MustNew(
    // Type converters
    binding.WithConverter[uuid.UUID](uuid.Parse),
    binding.WithConverter[decimal.Decimal](decimal.NewFromString),
    
    // Time formats
    binding.WithTimeLayouts("2006-01-02", "01/02/2006"),
    
    // Security limits
    binding.WithMaxDepth(16),
    binding.WithMaxSliceLen(1000),
    binding.WithMaxMapSize(500),
    
    // Error handling
    binding.WithAllErrors(),
    
    // Observability
    binding.WithEvents(binding.Events{
        FieldBound: logFieldBound,
        UnknownField: logUnknownField,
        Done: logBindingStats,
    }),
)

// Use across handlers
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    user, err := AppBinder.JSON[CreateUserRequest](r.Body)
    if err != nil {
        handleError(w, err)
        return
    }
    // ...
}

Observability Hooks

Monitor binding operations:

binder := binding.MustNew(
    binding.WithEvents(binding.Events{
        // Called when a field is successfully bound
        FieldBound: func(name, tag string) {
            metrics.Increment("binding.field.bound", "field:"+name, "source:"+tag)
        },
        
        // Called when an unknown field is encountered
        UnknownField: func(name string) {
            slog.Warn("Unknown field in request", "field", name)
            metrics.Increment("binding.field.unknown", "field:"+name)
        },
        
        // Called after binding completes
        Done: func(stats binding.Stats) {
            slog.Info("Binding completed",
                "fields_bound", stats.FieldsBound,
                "errors", stats.ErrorCount,
                "duration", stats.Duration,
            )
            
            metrics.Histogram("binding.duration", stats.Duration.Milliseconds())
            metrics.Gauge("binding.fields.bound", stats.FieldsBound)
        },
    }),
)

Binding Stats

type Stats struct {
    FieldsBound int           // Number of fields successfully bound
    ErrorCount  int           // Number of errors encountered
    Duration    time.Duration // Time taken for binding
}

Custom Struct Tags

Extend binding with custom tag behavior:

// Example: Custom "env" tag handler
type EnvTagHandler struct {
    prefix string
}

func (h *EnvTagHandler) Get(fieldName, tagValue string) (string, bool) {
    envKey := h.prefix + tagValue
    val, exists := os.LookupEnv(envKey)
    return val, exists
}

// Register custom tag handler
binder := binding.MustNew(
    binding.WithTagHandler("env", &EnvTagHandler{prefix: "APP_"}),
)

type Config struct {
    APIKey string `env:"API_KEY"`  // Looks up APP_API_KEY
    Port   int    `env:"PORT"`     // Looks up APP_PORT
}

Streaming for Large Payloads

Use Reader variants for efficient memory usage:

// Instead of reading entire body into memory:
// body, _ := io.ReadAll(r.Body)  // Bad for large payloads
// user, err := binding.JSON[User](body)

// Stream directly from reader:
user, err := binding.JSONReader[User](r.Body)  // Memory-efficient

// Also available for XML, YAML:
doc, err := binding.XMLReader[Document](r.Body)
config, err := yaml.YAMLReader[Config](r.Body)

Nested Struct Binding

Dot Notation for Query Parameters

type SearchRequest struct {
    Query string `query:"q"`
    Filter struct {
        Category string `query:"filter.category"`
        MinPrice int    `query:"filter.min_price"`
        MaxPrice int    `query:"filter.max_price"`
        Tags     []string `query:"filter.tags"`
    }
}

// URL: ?q=laptop&filter.category=electronics&filter.min_price=100
params, err := binding.Query[SearchRequest](values)

Embedded Structs

type Timestamps struct {
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Timestamps  // Embedded - fields promoted
}

// JSON: {
//   "id": 1,
//   "name": "Alice",
//   "created_at": "2025-01-01T00:00:00Z",
//   "updated_at": "2025-01-01T12:00:00Z"
// }

Multi-Source with Priority

Control precedence of multiple sources:

type Request struct {
    UserID int    `query:"user_id" json:"user_id" header:"X-User-ID"`
    Token  string `header:"Authorization" query:"token"`
}

// Last source wins (default)
req, err := binding.Bind[Request](
    binding.FromQuery(r.URL.Query()),  // Lowest priority
    binding.FromJSON(r.Body),          // Medium priority
    binding.FromHeader(r.Header),      // Highest priority
)

// First source wins (explicit)
req, err := binding.Bind[Request](
    binding.WithMergeStrategy(binding.MergeFirstWins),
    binding.FromHeader(r.Header),      // Highest priority
    binding.FromJSON(r.Body),          // Medium priority
    binding.FromQuery(r.URL.Query()),  // Lowest priority
)

Conditional Binding

Bind based on request properties:

func BindRequest[T any](r *http.Request) (T, error) {
    sources := []binding.Source{}
    
    // Always include query params
    sources = append(sources, binding.FromQuery(r.URL.Query()))
    
    // Include body only for certain methods
    if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" {
        contentType := r.Header.Get("Content-Type")
        
        switch {
        case strings.Contains(contentType, "application/json"):
            sources = append(sources, binding.FromJSON(r.Body))
        case strings.Contains(contentType, "application/x-www-form-urlencoded"):
            sources = append(sources, binding.FromForm(r.Body))
        case strings.Contains(contentType, "application/xml"):
            sources = append(sources, binding.FromXML(r.Body))
        }
    }
    
    // Always include headers
    sources = append(sources, binding.FromHeader(r.Header))
    
    return binding.Bind[T](sources...)
}

Partial Updates

Handle PATCH requests with optional fields:

type UpdateUserRequest struct {
    Name     *string `json:"name"`      // nil = don't update
    Email    *string `json:"email"`     // nil = don't update
    Age      *int    `json:"age"`       // nil = don't update
    Active   *bool   `json:"active"`    // nil = don't update
}

func UpdateUser(w http.ResponseWriter, r *http.Request) {
    update, err := binding.JSON[UpdateUserRequest](r.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Only update fields that were provided
    if update.Name != nil {
        user.Name = *update.Name
    }
    if update.Email != nil {
        user.Email = *update.Email
    }
    if update.Age != nil {
        user.Age = *update.Age
    }
    if update.Active != nil {
        user.Active = *update.Active
    }
    
    saveUser(user)
}

Middleware Integration

Generic Binding Middleware

func BindMiddleware[T any](next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        req, err := binding.JSON[T](r.Body)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        
        // Store in context
        ctx := context.WithValue(r.Context(), "request", req)
        next(w, r.WithContext(ctx))
    }
}

// Usage
http.HandleFunc("/users",
    BindMiddleware[CreateUserRequest](CreateUserHandler))

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    req := r.Context().Value("request").(CreateUserRequest)
    // Use req...
}

With Validation

func BindAndValidate[T any](next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        req, err := binding.JSON[T](r.Body)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        
        // Validate
        if err := validation.Validate(req); err != nil {
            http.Error(w, err.Error(), http.StatusUnprocessableEntity)
            return
        }
        
        ctx := context.WithValue(r.Context(), "request", req)
        next(w, r.WithContext(ctx))
    }
}

Batch Binding

Process multiple items with error collection:

type BatchRequest []CreateUserRequest

func ProcessBatch(w http.ResponseWriter, r *http.Request) {
    batch, err := binding.JSON[BatchRequest](r.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    results := make([]Result, len(batch))
    errors := make([]error, 0)
    
    for i, item := range batch {
        user, err := createUser(item)
        if err != nil {
            errors = append(errors, fmt.Errorf("item %d: %w", i, err))
            continue
        }
        results[i] = Result{Success: true, User: user}
    }
    
    response := BatchResponse{
        Results: results,
        Errors:  errors,
    }
    
    json.NewEncoder(w).Encode(response)
}

TextUnmarshaler Integration

Implement custom text unmarshaling:

type Status string

const (
    StatusActive   Status = "active"
    StatusInactive Status = "inactive"
    StatusPending  Status = "pending"
)

func (s *Status) UnmarshalText(text []byte) error {
    str := string(text)
    switch str {
    case "active", "inactive", "pending":
        *s = Status(str)
        return nil
    default:
        return fmt.Errorf("invalid status: %s", str)
    }
}

type User struct {
    ID     int    `json:"id"`
    Name   string `json:"name"`
    Status Status `json:"status"` // Automatically uses UnmarshalText
}

Performance Optimization

Pre-allocate Slices

type Response struct {
    Items []Item `json:"items"`
}

// With capacity hint
items := make([]Item, 0, expectedSize)
// Bind into pre-allocated slice

Reuse Buffers

var bufferPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func bindWithPool(r io.Reader) (User, error) {
    buf := bufferPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufferPool.Put(buf)
    }()
    
    io.Copy(buf, r)
    return binding.JSON[User](buf.Bytes())
}

Avoid Reflection in Hot Paths

// Cache binder instance
var binder = binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
)

// Struct info is cached automatically after first use
// Subsequent bindings have minimal overhead

Testing Helpers

Mock Requests

func TestBindingJSON(t *testing.T) {
    payload := `{"name": "Alice", "age": 30}`
    body := io.NopCloser(strings.NewReader(payload))
    
    user, err := binding.JSON[User](body)
    if err != nil {
        t.Fatal(err)
    }
    
    if user.Name != "Alice" {
        t.Errorf("expected Alice, got %s", user.Name)
    }
}

Test Different Sources

func TestMultiSource(t *testing.T) {
    req, err := binding.Bind[Request](
        binding.FromQuery(url.Values{
            "page": []string{"1"},
        }),
        binding.FromJSON([]byte(`{"name":"test"}`)),
        binding.FromHeader(http.Header{
            "X-API-Key": []string{"secret"},
        }),
    )
    
    if err != nil {
        t.Fatal(err)
    }
    
    // Assertions...
}

Integration Patterns

With Rivaas Router

import "rivaas.dev/router"

r := router.New()

r.POST("/users", func(c *router.Context) error {
    user, err := binding.JSON[CreateUserRequest](c.Request().Body)
    if err != nil {
        return c.JSON(http.StatusBadRequest, err)
    }
    
    created := createUser(user)
    return c.JSON(http.StatusCreated, created)
})

With Rivaas App

import "rivaas.dev/app"

a := app.MustNew()

a.POST("/users", func(c *app.Context) error {
    var user CreateUserRequest
    if err := c.Bind(&user); err != nil {
        return err  // Automatically handled
    }
    
    created := createUser(user)
    return c.JSON(http.StatusCreated, created)
})

Next Steps

For complete API documentation, see API Reference.

3.11 - Examples

Real-world examples and integration patterns for common use cases

Complete, production-ready examples demonstrating common binding patterns and integrations.

Basic REST API

Complete CRUD handlers with proper error handling.

package main

import (
    "encoding/json"
    "net/http"
    "rivaas.dev/binding"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `json:"username" validate:"required,alphanum,min=3,max=32"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
}

type UpdateUserRequest struct {
    Username *string `json:"username,omitempty" validate:"omitempty,alphanum,min=3,max=32"`
    Email    *string `json:"email,omitempty" validate:"omitempty,email"`
    Age      *int    `json:"age,omitempty" validate:"omitempty,min=18,max=120"`
}

type ListUsersParams struct {
    Page     int      `query:"page" default:"1"`
    PageSize int      `query:"page_size" default:"20"`
    SortBy   string   `query:"sort_by" default:"created_at"`
    Search   string   `query:"search"`
    Tags     []string `query:"tags"`
}

func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
    // Bind JSON request
    req, err := binding.JSON[CreateUserRequest](r.Body)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid request body", err)
        return
    }
    
    // Validate
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Create user
    user := &User{
        Username: req.Username,
        Email:    req.Email,
        Age:      req.Age,
    }
    if err := db.Create(user); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to create user", err)
        return
    }
    
    respondJSON(w, http.StatusCreated, user)
}

func UpdateUserHandler(w http.ResponseWriter, r *http.Request) {
    // Get user ID from path
    userID := chi.URLParam(r, "id")
    
    // Bind partial update
    req, err := binding.JSON[UpdateUserRequest](r.Body)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid request body", err)
        return
    }
    
    // Validate
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Fetch existing user
    user, err := db.GetUser(userID)
    if err != nil {
        respondError(w, http.StatusNotFound, "User not found", err)
        return
    }
    
    // Apply updates (only non-nil fields)
    if req.Username != nil {
        user.Username = *req.Username
    }
    if req.Email != nil {
        user.Email = *req.Email
    }
    if req.Age != nil {
        user.Age = *req.Age
    }
    
    if err := db.Update(user); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to update user", err)
        return
    }
    
    respondJSON(w, http.StatusOK, user)
}

func ListUsersHandler(w http.ResponseWriter, r *http.Request) {
    // Bind query parameters
    params, err := binding.Query[ListUsersParams](r.URL.Query())
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid query parameters", err)
        return
    }
    
    // Fetch users with pagination
    users, total, err := db.ListUsers(params)
    if err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to list users", err)
        return
    }
    
    // Response with pagination metadata
    response := map[string]interface{}{
        "data":       users,
        "total":      total,
        "page":       params.Page,
        "page_size":  params.PageSize,
        "total_pages": (total + params.PageSize - 1) / params.PageSize,
    }
    
    respondJSON(w, http.StatusOK, response)
}

// Helper functions
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func respondError(w http.ResponseWriter, status int, message string, err error) {
    response := map[string]interface{}{
        "error":   message,
        "details": err.Error(),
    }
    respondJSON(w, status, response)
}

Search API with Complex Filtering

Advanced search with multiple filter types:

type ProductSearchRequest struct {
    // Basic search
    Query string `query:"q"`
    
    // Pagination
    Page     int `query:"page" default:"1"`
    PageSize int `query:"page_size" default:"20"`
    
    // Sorting
    SortBy    string `query:"sort_by" default:"relevance"`
    SortOrder string `query:"sort_order" default:"desc"`
    
    // Filters (from JSON body for complex queries)
    Filters struct {
        Categories []string  `json:"categories"`
        Brands     []string  `json:"brands"`
        MinPrice   *float64  `json:"min_price"`
        MaxPrice   *float64  `json:"max_price"`
        InStock    *bool     `json:"in_stock"`
        MinRating  *int      `json:"min_rating"`
        Tags       []string  `json:"tags"`
        DateRange  *struct {
            From time.Time `json:"from"`
            To   time.Time `json:"to"`
        } `json:"date_range"`
    } `json:"filters"`
    
    // Metadata from headers
    Locale    string `header:"Accept-Language" default:"en-US"`
    Currency  string `header:"X-Currency" default:"USD"`
    UserAgent string `header:"User-Agent"`
}

func SearchProductsHandler(w http.ResponseWriter, r *http.Request) {
    // Multi-source binding: query + JSON + headers
    req, err := binding.Bind[ProductSearchRequest](
        binding.FromQuery(r.URL.Query()),
        binding.FromJSON(r.Body),
        binding.FromHeader(r.Header),
    )
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid request", err)
        return
    }
    
    // Build search query
    query := db.NewQuery().
        Search(req.Query).
        Page(req.Page, req.PageSize).
        Sort(req.SortBy, req.SortOrder)
    
    // Apply filters
    if len(req.Filters.Categories) > 0 {
        query = query.FilterCategories(req.Filters.Categories)
    }
    if len(req.Filters.Brands) > 0 {
        query = query.FilterBrands(req.Filters.Brands)
    }
    if req.Filters.MinPrice != nil {
        query = query.MinPrice(*req.Filters.MinPrice)
    }
    if req.Filters.MaxPrice != nil {
        query = query.MaxPrice(*req.Filters.MaxPrice)
    }
    if req.Filters.InStock != nil && *req.Filters.InStock {
        query = query.InStockOnly()
    }
    if req.Filters.MinRating != nil {
        query = query.MinRating(*req.Filters.MinRating)
    }
    if req.Filters.DateRange != nil {
        query = query.DateRange(req.Filters.DateRange.From, req.Filters.DateRange.To)
    }
    
    // Execute search
    results, total, err := query.Execute(r.Context())
    if err != nil {
        respondError(w, http.StatusInternalServerError, "Search failed", err)
        return
    }
    
    // Apply currency conversion if needed
    if req.Currency != "USD" {
        results = convertCurrency(results, req.Currency)
    }
    
    response := map[string]interface{}{
        "results":     results,
        "total":       total,
        "page":        req.Page,
        "page_size":   req.PageSize,
        "total_pages": (total + req.PageSize - 1) / req.PageSize,
    }
    
    respondJSON(w, http.StatusOK, response)
}

Multi-Tenant API

Handle tenant context from headers:

type TenantRequest struct {
    TenantID string `header:"X-Tenant-ID" validate:"required,uuid"`
    APIKey   string `header:"X-API-Key" validate:"required"`
}

type CreateResourceRequest struct {
    TenantRequest
    Name        string `json:"name" validate:"required"`
    Description string `json:"description"`
    Type        string `json:"type" validate:"required,oneof=typeA typeB typeC"`
}

func CreateResourceHandler(w http.ResponseWriter, r *http.Request) {
    // Bind headers + JSON
    req, err := binding.Bind[CreateResourceRequest](
        binding.FromHeader(r.Header),
        binding.FromJSON(r.Body),
    )
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid request", err)
        return
    }
    
    // Validate
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Verify tenant and API key
    tenant, err := auth.VerifyTenant(req.TenantID, req.APIKey)
    if err != nil {
        respondError(w, http.StatusUnauthorized, "Invalid tenant credentials", err)
        return
    }
    
    // Create resource in tenant context
    resource := &Resource{
        TenantID:    tenant.ID,
        Name:        req.Name,
        Description: req.Description,
        Type:        req.Type,
    }
    
    if err := db.Create(resource); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to create resource", err)
        return
    }
    
    respondJSON(w, http.StatusCreated, resource)
}

File Upload with Metadata

Handle file uploads with form data using the new multipart binding:

type FileUploadRequest struct {
    File        *binding.File `form:"file" validate:"required"`
    Title       string        `form:"title" validate:"required"`
    Description string        `form:"description"`
    Tags        []string      `form:"tags"`
    Public      bool          `form:"public"`
    // JSON settings in form field (automatically parsed)
    Settings    struct {
        Quality     int    `json:"quality"`
        Compression string `json:"compression"`
    } `form:"settings"`
}

func UploadFileHandler(w http.ResponseWriter, r *http.Request) {
    // Parse multipart form (32MB max)
    if err := r.ParseMultipartForm(32 << 20); err != nil {
        respondError(w, http.StatusBadRequest, "Failed to parse form", err)
        return
    }
    
    // Bind form fields and file
    req, err := binding.Multipart[FileUploadRequest](r.MultipartForm)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid form data", err)
        return
    }
    
    // Validate
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Validate file type
    allowedTypes := []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}
    ext := req.File.Ext()
    if !contains(allowedTypes, ext) {
        respondError(w, http.StatusBadRequest, "Invalid file type", nil)
        return
    }
    
    // Validate file size (10MB max)
    if req.File.Size > 10*1024*1024 {
        respondError(w, http.StatusBadRequest, "File too large (max 10MB)", nil)
        return
    }
    
    // Generate safe filename
    filename := fmt.Sprintf("%s_%d%s", 
        sanitizeFilename(req.Title),
        time.Now().Unix(),
        ext,
    )
    
    // Save file
    uploadPath := "/var/uploads/" + filename
    if err := req.File.Save(uploadPath); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to save file", err)
        return
    }
    
    // Create database record
    record := &FileRecord{
        Filename:    filename,
        Title:       req.Title,
        Description: req.Description,
        Tags:        req.Tags,
        Public:      req.Public,
        Size:        req.File.Size,
        ContentType: req.File.ContentType,
        Settings:    req.Settings,
    }
    
    if err := db.Create(record); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to create record", err)
        return
    }
    
    respondJSON(w, http.StatusCreated, map[string]interface{}{
        "id":       record.ID,
        "filename": filename,
        "url":      "/uploads/" + filename,
    })
}

func sanitizeFilename(name string) string {
    // Remove special characters
    re := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
    return re.ReplaceAllString(name, "_")
}

func contains(slice []string, item string) bool {
    for _, s := range slice {
        if s == item {
            return true
        }
    }
    return false
}

Multiple file uploads:

type GalleryUpload struct {
    Photos      []*binding.File `form:"photos" validate:"required,min=1,max=10"`
    AlbumTitle  string          `form:"album_title" validate:"required"`
    Description string          `form:"description"`
}

func UploadGalleryHandler(w http.ResponseWriter, r *http.Request) {
    if err := r.ParseMultipartForm(100 << 20); err != nil { // 100MB for multiple files
        respondError(w, http.StatusBadRequest, "Failed to parse form", err)
        return
    }
    
    req, err := binding.Multipart[GalleryUpload](r.MultipartForm)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid form data", err)
        return
    }
    
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Process each photo
    uploadedFiles := make([]string, 0, len(req.Photos))
    for i, photo := range req.Photos {
        // Validate each file
        if photo.Size > 10*1024*1024 {
            respondError(w, http.StatusBadRequest, 
                fmt.Sprintf("Photo %d too large", i+1), nil)
            return
        }
        
        // Generate filename
        filename := fmt.Sprintf("%s_%d_%d%s",
            sanitizeFilename(req.AlbumTitle),
            time.Now().Unix(),
            i,
            photo.Ext(),
        )
        
        // Save file
        if err := photo.Save("/var/uploads/" + filename); err != nil {
            respondError(w, http.StatusInternalServerError, "Failed to save photo", err)
            return
        }
        
        uploadedFiles = append(uploadedFiles, filename)
    }
    
    // Create album record
    album := &Album{
        Title:       req.AlbumTitle,
        Description: req.Description,
        Photos:      uploadedFiles,
    }
    
    if err := db.Create(album); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to create album", err)
        return
    }
    
    respondJSON(w, http.StatusCreated, album)
}

API with Converter Factories

Using built-in converter factories for common patterns:

package main

import (
    "net/http"
    "time"
    "github.com/google/uuid"
    "rivaas.dev/binding"
)

type TaskStatus string

const (
    TaskPending   TaskStatus = "pending"
    TaskActive    TaskStatus = "active"
    TaskCompleted TaskStatus = "completed"
)

type Priority string

const (
    PriorityLow    Priority = "low"
    PriorityMedium Priority = "medium"
    PriorityHigh   Priority = "high"
)

// Global binder with converter factories
var TaskBinder = binding.MustNew(
    // UUID for task IDs
    binding.WithConverter[uuid.UUID](uuid.Parse),
    
    // Status enum with validation
    binding.WithConverter(binding.EnumConverter(
        TaskPending,
        TaskActive,
        TaskCompleted,
    )),
    
    // Priority enum with validation
    binding.WithConverter(binding.EnumConverter(
        PriorityLow,
        PriorityMedium,
        PriorityHigh,
    )),
    
    // Friendly duration aliases
    binding.WithConverter(binding.DurationConverter(map[string]time.Duration{
        "urgent":   1 * time.Hour,
        "today":    8 * time.Hours,
        "thisweek": 5 * 24 * time.Hour,
        "nextweek": 14 * 24 * time.Hour,
    })),
    
    // US date format for deadlines
    binding.WithConverter(binding.TimeConverter("01/02/2006", "2006-01-02")),
    
    // Boolean with friendly values
    binding.WithConverter(binding.BoolConverter(
        []string{"yes", "on", "enabled"},
        []string{"no", "off", "disabled"},
    )),
)

type CreateTaskRequest struct {
    Title       string     `json:"title" validate:"required,min=3,max=100"`
    Description string     `json:"description"`
    Priority    Priority   `json:"priority" validate:"required"`
    Deadline    time.Time  `json:"deadline"`
    Estimate    time.Duration `json:"estimate"`
    Assignee    uuid.UUID  `json:"assignee"`
}

type UpdateTaskRequest struct {
    Title       *string        `json:"title,omitempty"`
    Description *string        `json:"description,omitempty"`
    Status      *TaskStatus    `json:"status,omitempty"`
    Priority    *Priority      `json:"priority,omitempty"`
    Deadline    *time.Time     `json:"deadline,omitempty"`
    Completed   *bool          `json:"completed,omitempty"`
}

type ListTasksParams struct {
    Status    TaskStatus `query:"status"`
    Priority  Priority   `query:"priority"`
    Assignee  uuid.UUID  `query:"assignee"`
    DueIn     time.Duration `query:"due_in"`
    Page      int        `query:"page" default:"1"`
    PageSize  int        `query:"page_size" default:"20"`
    ShowDone  bool       `query:"show_done"`
}

func CreateTaskHandler(w http.ResponseWriter, r *http.Request) {
    // Bind and validate
    req, err := TaskBinder.JSON[CreateTaskRequest](r.Body)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid request", err)
        return
    }
    
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Create task
    task := &Task{
        ID:          uuid.New(),
        Title:       req.Title,
        Description: req.Description,
        Priority:    req.Priority,
        Status:      TaskPending,
        Deadline:    req.Deadline,
        Estimate:    req.Estimate,
        Assignee:    req.Assignee,
        CreatedAt:   time.Now(),
    }
    
    if err := db.Create(task); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to create task", err)
        return
    }
    
    respondJSON(w, http.StatusCreated, task)
}

func UpdateTaskHandler(w http.ResponseWriter, r *http.Request) {
    // Get task ID from path
    taskID, err := uuid.Parse(chi.URLParam(r, "id"))
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid task ID", err)
        return
    }
    
    // Bind partial update
    req, err := TaskBinder.JSON[UpdateTaskRequest](r.Body)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid request", err)
        return
    }
    
    // Fetch existing task
    task, err := db.GetTask(taskID)
    if err != nil {
        respondError(w, http.StatusNotFound, "Task not found", err)
        return
    }
    
    // Apply updates (only non-nil fields)
    if req.Title != nil {
        task.Title = *req.Title
    }
    if req.Description != nil {
        task.Description = *req.Description
    }
    if req.Status != nil {
        task.Status = *req.Status
    }
    if req.Priority != nil {
        task.Priority = *req.Priority
    }
    if req.Deadline != nil {
        task.Deadline = *req.Deadline
    }
    if req.Completed != nil && *req.Completed {
        task.Status = TaskCompleted
        task.CompletedAt = time.Now()
    }
    
    task.UpdatedAt = time.Now()
    
    if err := db.Update(task); err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to update task", err)
        return
    }
    
    respondJSON(w, http.StatusOK, task)
}

func ListTasksHandler(w http.ResponseWriter, r *http.Request) {
    // Bind query parameters with enum/duration validation
    params, err := TaskBinder.Query[ListTasksParams](r.URL.Query())
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid query parameters", err)
        return
    }
    
    // Build query
    query := db.NewQuery()
    
    if params.Status != "" {
        query = query.Where("status = ?", params.Status)
    }
    if params.Priority != "" {
        query = query.Where("priority = ?", params.Priority)
    }
    if params.Assignee != uuid.Nil {
        query = query.Where("assignee = ?", params.Assignee)
    }
    if params.DueIn > 0 {
        dueDate := time.Now().Add(params.DueIn)
        query = query.Where("deadline <= ?", dueDate)
    }
    if !params.ShowDone {
        query = query.Where("status != ?", TaskCompleted)
    }
    
    // Execute with pagination
    tasks, total, err := query.Paginate(params.Page, params.PageSize).Execute()
    if err != nil {
        respondError(w, http.StatusInternalServerError, "Failed to list tasks", err)
        return
    }
    
    response := map[string]interface{}{
        "data":        tasks,
        "total":       total,
        "page":        params.Page,
        "page_size":   params.PageSize,
        "total_pages": (total + params.PageSize - 1) / params.PageSize,
    }
    
    respondJSON(w, http.StatusOK, response)
}

// Example requests that work with the converter factories:
// POST /tasks
// {
//   "title": "Fix bug #123",
//   "priority": "high",
//   "deadline": "01/31/2026",
//   "estimate": "urgent",
//   "assignee": "550e8400-e29b-41d4-a716-446655440000"
// }
//
// GET /tasks?status=active&priority=HIGH&due_in=today&show_done=yes
// Note: enums are case-insensitive, duration uses friendly aliases, bool uses "yes"

Webhook Handler with Signature Verification

Process webhooks with headers:

type WebhookRequest struct {
    Signature string    `header:"X-Webhook-Signature" validate:"required"`
    Timestamp time.Time `header:"X-Webhook-Timestamp" validate:"required"`
    Event     string    `header:"X-Webhook-Event" validate:"required"`
    
    Payload json.RawMessage `json:"-"`
}

func WebhookHandler(w http.ResponseWriter, r *http.Request) {
    // Read body for signature verification
    body, err := io.ReadAll(r.Body)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Failed to read body", err)
        return
    }
    r.Body = io.NopCloser(bytes.NewReader(body))
    
    // Bind headers
    req, err := binding.Header[WebhookRequest](r.Header)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid headers", err)
        return
    }
    
    // Validate
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Verify signature
    if !verifyWebhookSignature(body, req.Signature, webhookSecret) {
        respondError(w, http.StatusUnauthorized, "Invalid signature", nil)
        return
    }
    
    // Check timestamp (prevent replay attacks)
    if time.Since(req.Timestamp) > 5*time.Minute {
        respondError(w, http.StatusBadRequest, "Request too old", nil)
        return
    }
    
    // Store raw payload
    req.Payload = body
    
    // Process event
    switch req.Event {
    case "payment.success":
        var payment PaymentEvent
        if err := json.Unmarshal(body, &payment); err != nil {
            respondError(w, http.StatusBadRequest, "Invalid payment payload", err)
            return
        }
        handlePaymentSuccess(payment)
        
    case "payment.failed":
        var payment PaymentEvent
        if err := json.Unmarshal(body, &payment); err != nil {
            respondError(w, http.StatusBadRequest, "Invalid payment payload", err)
            return
        }
        handlePaymentFailed(payment)
        
    default:
        respondError(w, http.StatusBadRequest, "Unknown event type", nil)
        return
    }
    
    w.WriteHeader(http.StatusNoContent)
}

GraphQL-style Nested Queries

Handle complex nested structures:

type GraphQLRequest struct {
    Query     string                 `json:"query" validate:"required"`
    Variables map[string]interface{} `json:"variables"`
    OperationName string              `json:"operationName"`
}

type GraphQLResponse struct {
    Data   interface{}            `json:"data,omitempty"`
    Errors []GraphQLError         `json:"errors,omitempty"`
}

type GraphQLError struct {
    Message string `json:"message"`
    Path    []string `json:"path,omitempty"`
}

func GraphQLHandler(w http.ResponseWriter, r *http.Request) {
    req, err := binding.JSON[GraphQLRequest](r.Body)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid GraphQL request", err)
        return
    }
    
    // Validate
    if err := validation.Validate(req); err != nil {
        respondError(w, http.StatusUnprocessableEntity, "Validation failed", err)
        return
    }
    
    // Execute GraphQL query
    result := executeGraphQL(r.Context(), req.Query, req.Variables, req.OperationName)
    
    respondJSON(w, http.StatusOK, result)
}

Batch Operations

Process multiple items in one request:

type BatchCreateRequest []CreateUserRequest

type BatchResponse struct {
    Success []User       `json:"success"`
    Failed  []BatchError `json:"failed"`
}

type BatchError struct {
    Index int    `json:"index"`
    Item  interface{} `json:"item"`
    Error string `json:"error"`
}

func BatchCreateUsersHandler(w http.ResponseWriter, r *http.Request) {
    // Bind array of requests
    batch, err := binding.JSON[BatchCreateRequest](r.Body)
    if err != nil {
        respondError(w, http.StatusBadRequest, "Invalid batch request", err)
        return
    }
    
    // Validate batch size
    if len(batch) == 0 {
        respondError(w, http.StatusBadRequest, "Empty batch", nil)
        return
    }
    if len(batch) > 100 {
        respondError(w, http.StatusBadRequest, "Batch too large (max 100)", nil)
        return
    }
    
    response := BatchResponse{
        Success: make([]User, 0),
        Failed:  make([]BatchError, 0),
    }
    
    // Process each item
    for i, req := range batch {
        // Validate item
        if err := validation.Validate(req); err != nil {
            response.Failed = append(response.Failed, BatchError{
                Index: i,
                Item:  req,
                Error: err.Error(),
            })
            continue
        }
        
        // Create user
        user := &User{
            Username: req.Username,
            Email:    req.Email,
            Age:      req.Age,
        }
        
        if err := db.Create(user); err != nil {
            response.Failed = append(response.Failed, BatchError{
                Index: i,
                Item:  req,
                Error: err.Error(),
            })
            continue
        }
        
        response.Success = append(response.Success, *user)
    }
    
    // Return 207 Multi-Status if there were any failures
    status := http.StatusCreated
    if len(response.Failed) > 0 {
        status = http.StatusMultiStatus
    }
    
    respondJSON(w, status, response)
}

Integration with Rivaas App

Complete application setup:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "rivaas.dev/app"
    "rivaas.dev/binding"
    "rivaas.dev/router"
)

func main() {
    // Create app
    a := app.MustNew(
        app.WithServiceName("api-server"),
        app.WithServiceVersion("1.0.0"),
    )
    
    // Setup routes
    setupRoutes(a)
    
    // Graceful shutdown
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
    defer stop()
    
    // Start server
    addr := ":8080"
    log.Printf("Server starting on %s", addr)
    
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

func setupRoutes(a *app.App) {
    // Users
    a.POST("/users", CreateUserHandler)
    a.GET("/users", ListUsersHandler)
    a.GET("/users/:id", GetUserHandler)
    a.PATCH("/users/:id", UpdateUserHandler)
    a.DELETE("/users/:id", DeleteUserHandler)
    
    // Search
    a.POST("/search", SearchProductsHandler)
}

func CreateUserHandler(c *router.Context) error {
    req, err := binding.JSON[CreateUserRequest](c.Request().Body)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": err.Error(),
        })
    }
    
    user := createUser(req)
    return c.JSON(http.StatusCreated, user)
}

Next Steps

For complete API documentation, see API Reference.

4 - Request Validation

Flexible, multi-strategy validation for Go structs. Supports struct tags, JSON Schema, and custom interfaces

The Rivaas Validation package provides flexible, multi-strategy validation for Go structs. Supports struct tags, JSON Schema, and custom interfaces. Includes detailed error messages and built-in security features.

Features

  • Multiple Validation Strategies
    • Struct tags via go-playground/validator
    • JSON Schema (RFC-compliant)
    • Custom interfaces (Validate() / ValidateContext())
  • Partial Validation - For PATCH requests where only provided fields should be validated
  • Thread-Safe - Safe for concurrent use by multiple goroutines
  • Security - Built-in protections against deep nesting, memory exhaustion, and sensitive data exposure
  • Standalone - Can be used independently without the full Rivaas framework
  • Custom Validators - Easy registration of custom validation tags

Quick Start

Basic Validation

The simplest way to use this package is with the package-level Validate function:

import "rivaas.dev/validation"

type User struct {
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"min=18"`
}

user := User{Email: "invalid", Age: 15}
if err := validation.Validate(ctx, &user); err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        for _, fieldErr := range verr.Fields {
            fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
        }
    }
}

Custom Validator Instance

For more control, create a Validator instance with custom options:

validator := validation.MustNew(
    validation.WithRedactor(sensitiveFieldRedactor),
    validation.WithMaxErrors(10),
    validation.WithCustomTag("phone", phoneValidator),
)

if err := validator.Validate(ctx, &user); err != nil {
    // Handle validation errors
}

Partial Validation (PATCH Requests)

For PATCH requests where only provided fields should be validated:

// Compute which fields are present in the JSON
presence, _ := validation.ComputePresence(rawJSON)

// Validate only the present fields
err := validator.ValidatePartial(ctx, &user, presence)

Learning Path

Follow these guides to master validation with Rivaas:

  1. Installation - Get started with the validation package
  2. Basic Usage - Learn the fundamentals of validation
  3. Struct Tags - Use go-playground/validator struct tags
  4. JSON Schema - Validate with JSON Schema
  5. Custom Interfaces - Implement Validate() methods
  6. Partial Validation - Handle PATCH requests correctly
  7. Error Handling - Work with structured errors
  8. Custom Validators - Register custom tags and functions
  9. Security - Protect sensitive data and prevent attacks
  10. Examples - Real-world integration patterns

Validation Strategies

The package supports three validation strategies that can be used individually or combined:

1. Struct Tags (go-playground/validator)

Use struct tags with go-playground/validator syntax:

type User struct {
    Email string `validate:"required,email"`
    Age   int    `validate:"min=18,max=120"`
    Name  string `validate:"required,min=2,max=100"`
}

2. JSON Schema

Implement the JSONSchemaProvider interface:

type User struct {
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func (u User) JSONSchema() (id, schema string) {
    return "user-schema", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "age": {"type": "integer", "minimum": 18}
        },
        "required": ["email"]
    }`
}

3. Custom Validation Interface

Implement ValidatorInterface for simple validation:

type User struct {
    Email string
}

func (u *User) Validate() error {
    if !strings.Contains(u.Email, "@") {
        return errors.New("email must contain @")
    }
    return nil
}

// validation.Validate will automatically call u.Validate()
err := validation.Validate(ctx, &user)

Or implement ValidatorWithContext for context-aware validation:

func (u *User) ValidateContext(ctx context.Context) error {
    // Access request-scoped data from context
    tenant := ctx.Value("tenant").(string)
    // Apply tenant-specific validation rules
    return nil
}

Strategy Priority

The package automatically selects the best strategy based on the type:

Priority Order:

  1. Interface methods (Validate() / ValidateContext())
  2. Struct tags (validate:"...")
  3. JSON Schema (JSONSchemaProvider)

You can explicitly choose a strategy:

err := validator.Validate(ctx, &user, validation.WithStrategy(validation.StrategyTags))

Or run all applicable strategies:

err := validator.Validate(ctx, &user, validation.WithRunAll(true))

Comparison with Other Libraries

Featurerivaas.dev/validationgo-playground/validatorJSON Schema validators
Struct tags
JSON Schema
Custom interfaces
Partial validation
Multi-strategy
Context supportVaries
Built-in redaction
Thread-safeVaries

Next Steps

  • Start with Installation to set up the validation package
  • Explore the API Reference for complete technical details
  • Check out examples for real-world integration patterns

For integration with rivaas/app, the Context provides convenient methods that handle validation automatically.

4.1 - Installation

Install and set up the validation package

Get started with the Rivaas validation package by installing it in your Go project.

Requirements

  • Go 1.25 or later
  • A Go module-enabled project (with go.mod)

Installation

Install the validation package using go get:

go get rivaas.dev/validation

This will add the package to your go.mod file and download all necessary dependencies.

Dependencies

The validation package depends on:

All dependencies are managed automatically by Go modules.

Verify Installation

Create a simple test file to verify the installation:

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type User struct {
    Email string `validate:"required,email"`
    Age   int    `validate:"min=18"`
}

func main() {
    ctx := context.Background()
    user := User{Email: "test@example.com", Age: 25}
    
    if err := validation.Validate(ctx, &user); err != nil {
        fmt.Println("Validation failed:", err)
        return
    }
    
    fmt.Println("Validation passed!")
}

Run the test:

go run main.go
# Output: Validation passed!

Import Paths

The validation package uses a simple import path:

import "rivaas.dev/validation"

There are no sub-packages to import - all functionality is in the main package.

Version Management

The validation package follows semantic versioning. To use a specific version:

# Install latest version
go get rivaas.dev/validation@latest

# Install specific version
go get rivaas.dev/validation@v1.2.3

# Install specific commit
go get rivaas.dev/validation@abc123

Upgrading

To upgrade to the latest version:

go get -u rivaas.dev/validation

To upgrade all dependencies:

go get -u ./...

Workspace Setup

If using Go workspaces, ensure the validation module is in your workspace:

# Add to workspace
go work use /path/to/rivaas/validation

# Verify workspace
go work sync

Next Steps

Now that the package is installed, learn how to use it:

Troubleshooting

Cannot find module

If you see:

go: finding module for package rivaas.dev/validation

Ensure you have a valid go.mod file and run:

go mod tidy

Version conflicts

If you encounter version conflicts with dependencies:

# Update go.mod
go mod tidy

# Verify dependencies
go mod verify

Build errors

If you encounter build errors after installation:

# Clean module cache
go clean -modcache

# Re-download dependencies
go mod download

For more help, see the Troubleshooting reference.

4.2 - Basic Usage

Learn the fundamentals of validating structs

Learn how to validate structs using the validation package. This guide starts from simple package-level functions and progresses to customized validator instances.

Package-Level Validation

The simplest way to validate is using the package-level Validate function:

import (
    "context"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"min=18"`
}

func Handler(ctx context.Context, req CreateUserRequest) error {
    if err := validation.Validate(ctx, &req); err != nil {
        return err
    }
    // Process valid request
    return nil
}

Handling Validation Errors

Validation errors are returned as structured *validation.Error values:

err := validation.Validate(ctx, &req)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        // Access structured field errors
        for _, fieldErr := range verr.Fields {
            fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
        }
    }
}

Creating a Validator Instance

For more control, create a Validator instance with custom configuration:

validator := validation.MustNew(
    validation.WithMaxErrors(10),
    validation.WithRedactor(sensitiveFieldRedactor),
)

// Use in handlers
if err := validator.Validate(ctx, &req); err != nil {
    // Handle validation errors
}

New vs MustNew

There are two constructors:

// New returns an error if configuration is invalid
validator, err := validation.New(
    validation.WithMaxErrors(-1), // Invalid
)
if err != nil {
    return fmt.Errorf("failed to create validator: %w", err)
}

// MustNew panics if configuration is invalid (use in main/init)
validator := validation.MustNew(
    validation.WithMaxErrors(10),
)

Use MustNew in main() or init() where panic on startup is acceptable. Use New when you need to handle initialization errors gracefully.

Per-Call Options

Override validator configuration on a per-call basis:

validator := validation.MustNew(
    validation.WithMaxErrors(10),
)

// Override max errors for this call
err := validator.Validate(ctx, &req,
    validation.WithMaxErrors(5),
    validation.WithStrategy(validation.StrategyTags),
)

Per-call options don’t modify the validator instance - they create a temporary config for that call only.

Validating Different Types

Structs

The most common use case:

type User struct {
    Name  string `validate:"required,min=2"`
    Email string `validate:"required,email"`
}

user := User{Name: "A", Email: "invalid"}
err := validation.Validate(ctx, &user)

Pointers

Pass pointers to structs:

user := &User{Name: "Alice", Email: "alice@example.com"}
err := validation.Validate(ctx, user)

Nil Values

Validating nil values returns an error:

var user *User
err := validation.Validate(ctx, user)
// Returns: *validation.Error with code "nil_pointer"

Context Usage

The context is passed to ValidatorWithContext implementations:

type User struct {
    Email string
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Access request-scoped data
    tenant := ctx.Value("tenant").(string)
    // Apply tenant-specific validation
    return nil
}

// Context is passed to ValidateContext
err := validation.Validate(ctx, &user)

For struct tags and JSON Schema validation, the context is not used (but must be provided for consistency).

Common Options

Limit Error Count

Stop validation after N errors:

err := validation.Validate(ctx, &req,
    validation.WithMaxErrors(5),
)

Choose Strategy

Explicitly select a validation strategy:

// Use only struct tags
err := validation.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyTags),
)

// Use only JSON Schema
err := validation.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyJSONSchema),
)

// Use only interface methods
err := validation.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyInterface),
)

Run All Strategies

Run all applicable strategies and aggregate errors:

err := validation.Validate(ctx, &req,
    validation.WithRunAll(true),
)

Thread Safety

Both package-level functions and Validator instances are safe for concurrent use:

validator := validation.MustNew(
    validation.WithMaxErrors(10),
)

// Safe to use from multiple goroutines
go func() {
    validator.Validate(ctx, &user1)
}()

go func() {
    validator.Validate(ctx, &user2)
}()

Default Validator

Package-level functions use a shared default validator:

// These both use the same default validator
validation.Validate(ctx, &req1)
validation.Validate(ctx, &req2)

The default validator is created with zero configuration. If you need custom options, create your own Validator instance.

Working Example

Here’s a complete example showing basic usage:

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `validate:"required,min=3,max=20"`
    Email    string `validate:"required,email"`
    Age      int    `validate:"min=18,max=120"`
}

func main() {
    ctx := context.Background()
    
    // Invalid request
    req := CreateUserRequest{
        Username: "ab",           // Too short
        Email:    "not-an-email", // Invalid format
        Age:      15,             // Too young
    }
    
    err := validation.Validate(ctx, &req)
    if err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            fmt.Println("Validation errors:")
            for _, fieldErr := range verr.Fields {
                fmt.Printf("  %s: %s\n", fieldErr.Path, fieldErr.Message)
            }
        }
    }
}

Output:

Validation errors:
  Username: min constraint failed
  Email: must be a valid email address
  Age: min constraint failed

Next Steps

4.3 - Struct Tags

Validate structs using go-playground/validator tags

Use struct tags with go-playground/validator syntax to validate your structs. This is the most common validation strategy in the Rivaas validation package.

Basic Syntax

Add validate tags to struct fields:

type User struct {
    Email    string `validate:"required,email"`
    Age      int    `validate:"min=18,max=120"`
    Username string `validate:"required,min=3,max=20"`
}

Tags are comma-separated constraints. Each constraint is evaluated, and all must pass for validation to succeed.

Common Validation Tags

Required Fields

type User struct {
    Email string `validate:"required"`      // Must be non-zero value
    Name  string `validate:"required"`      // Must be non-empty string
    Age   int    `validate:"required"`      // Must be non-zero number
}

String Constraints

type User struct {
    // Length constraints
    Username string `validate:"min=3,max=20"`
    Bio      string `validate:"max=500"`
    
    // Format constraints
    Email    string `validate:"email"`
    URL      string `validate:"url"`
    UUID     string `validate:"uuid"`
    
    // Character constraints
    AlphaOnly string `validate:"alpha"`
    AlphaNum  string `validate:"alphanum"`
    Numeric   string `validate:"numeric"`
}

Number Constraints

type Product struct {
    Price    float64 `validate:"min=0"`
    Quantity int     `validate:"min=1,max=1000"`
    Rating   float64 `validate:"gte=0,lte=5"`  // Greater/less than or equal
}

Comparison Operators

TagDescription
min=NMinimum value (numbers) or length (strings/slices)
max=NMaximum value (numbers) or length (strings/slices)
eq=NEqual to N
ne=NNot equal to N
gt=NGreater than N
gte=NGreater than or equal to N
lt=NLess than N
lte=NLess than or equal to N

Enum Values

type Order struct {
    Status string `validate:"oneof=pending confirmed shipped delivered"`
}

Multiple values separated by spaces.

Collection Constraints

type Request struct {
    Tags   []string `validate:"min=1,max=10,dive,min=2,max=20"`
    //                         ^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^
    //                         Array rules   Element rules
    
    Emails []string `validate:"required,dive,email"`
}

Use dive to validate elements inside slices/arrays/maps.

Nested Structs

type Address struct {
    Street string `validate:"required"`
    City   string `validate:"required"`
    Zip    string `validate:"required,numeric,len=5"`
}

type User struct {
    Name    string  `validate:"required"`
    Address Address `validate:"required"` // Validates nested struct
}

Pointer Fields

type User struct {
    Email *string `validate:"omitempty,email"`
    //                      ^^^^^^^^^ Skip validation if nil
}

Use omitempty to skip validation when the field is nil or zero-value.

Format Validation Tags

Email and Web

type Contact struct {
    Email     string `validate:"email"`
    Website   string `validate:"url"`
    Hostname  string `validate:"hostname"`
    IPAddress string `validate:"ip"`
    IPv4      string `validate:"ipv4"`
    IPv6      string `validate:"ipv6"`
}

File Paths

type Config struct {
    DataFile string `validate:"file"`      // Must be existing file
    DataDir  string `validate:"dir"`       // Must be existing directory
    FilePath string `validate:"filepath"`  // Valid file path syntax
}

Identifiers

type Resource struct {
    ID       string `validate:"uuid"`
    UUID4    string `validate:"uuid4"`
    ISBN     string `validate:"isbn"`
    CreditCard string `validate:"credit_card"`
}

Cross-Field Validation

Field Comparison

type Registration struct {
    Password        string `validate:"required,min=8"`
    ConfirmPassword string `validate:"required,eqfield=Password"`
    //                                         ^^^^^^^^^^^^^^^^
    //                                         Must equal Password field
}

Conditional Validation

type User struct {
    Type  string `validate:"oneof=personal business"`
    TaxID string `validate:"required_if=Type business"`
    //                      ^^^^^^^^^^^^^^^^^^^^^^^^
    //                      Required when Type is "business"
}

Cross-field tags:

TagDescription
eqfield=FieldMust equal another field
nefield=FieldMust not equal another field
gtfield=FieldMust be greater than another field
ltfield=FieldMust be less than another field
required_if=Field ValueRequired when field equals value
required_unless=Field ValueRequired unless field equals value
required_with=FieldRequired when field is present
required_without=FieldRequired when field is absent

Advanced Tags

Regular Expressions

type User struct {
    Phone string `validate:"required,e164"`           // E.164 phone format
    Slug  string `validate:"required,alphanum,min=3"` // URL-safe slug
}

Boolean Logic

type Product struct {
    // Must be numeric OR alpha
    Code string `validate:"numeric|alpha"`
}

Use | (OR) to allow multiple constraint sets.

Custom Formats

type Data struct {
    Datetime string `validate:"datetime=2006-01-02"`
    Date     string `validate:"datetime=2006-01-02 15:04:05"`
}

Tag Naming with JSON

By default, validation uses JSON field names in error messages:

type User struct {
    Email string `json:"email_address" validate:"required,email"`
    //            ^^^^^^^^^^^^^^^^^^^ Used in error message
}

Error message will reference email_address, not Email.

Validation Example

Complete example with various constraints:

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    // Required string with length constraints
    Username string `json:"username" validate:"required,min=3,max=20,alphanum"`
    
    // Valid email address
    Email string `json:"email" validate:"required,email"`
    
    // Age range
    Age int `json:"age" validate:"required,min=18,max=120"`
    
    // Password with confirmation
    Password        string `json:"password" validate:"required,min=8"`
    ConfirmPassword string `json:"confirm_password" validate:"required,eqfield=Password"`
    
    // Optional phone (validated if provided)
    Phone string `json:"phone" validate:"omitempty,e164"`
    
    // Enum value
    Role string `json:"role" validate:"required,oneof=user admin moderator"`
    
    // Nested struct
    Address Address `json:"address" validate:"required"`
    
    // Array with constraints
    Tags []string `json:"tags" validate:"min=1,max=10,dive,min=2,max=20"`
}

type Address struct {
    Street  string `json:"street" validate:"required"`
    City    string `json:"city" validate:"required"`
    State   string `json:"state" validate:"required,len=2,alpha"`
    ZipCode string `json:"zip_code" validate:"required,numeric,len=5"`
}

func main() {
    ctx := context.Background()
    
    req := CreateUserRequest{
        Username:        "ab",                // Too short
        Email:           "invalid",           // Invalid email
        Age:             15,                  // Too young
        Password:        "pass",              // Too short
        ConfirmPassword: "different",         // Doesn't match
        Phone:           "123",               // Invalid format
        Role:            "superuser",         // Not in enum
        Address:         Address{},           // Missing required fields
        Tags:            []string{"a", "bb"}, // First tag too short
    }
    
    err := validation.Validate(ctx, &req)
    if err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            for _, fieldErr := range verr.Fields {
                fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
            }
        }
    }
}

Tag Reference

For a complete list of available tags, see the go-playground/validator documentation.

Common categories:

  • Comparison: eq, ne, gt, gte, lt, lte, min, max, len
  • Strings: alpha, alphanum, numeric, email, url, uuid, contains, startswith, endswith
  • Numbers: Range validation, divisibility
  • Network: ip, ipv4, ipv6, hostname, mac
  • Files: file, dir, filepath
  • Cross-field: eqfield, nefield, gtfield, ltfield
  • Conditional: required_if, required_unless, required_with, required_without

Performance Considerations

  • Struct validation tags are cached after first use (fast)
  • Tag validator is initialized lazily (only when needed)
  • Thread-safe for concurrent validation
  • No runtime overhead for unused tags

Next Steps

4.4 - JSON Schema Validation

Validate structs using JSON Schema

Validate structs using JSON Schema. Implement the JSONSchemaProvider interface to use this feature. This provides RFC-compliant JSON Schema validation as an alternative to struct tags.

JSONSchemaProvider Interface

Implement the JSONSchemaProvider interface on your struct:

type JSONSchemaProvider interface {
    JSONSchema() (id, schema string)
}

The method returns:

  • id: Unique schema identifier for caching.
  • schema: JSON Schema as a string in JSON format.

Basic Example

type User struct {
    Email string `json:"email"`
    Age   int    `json:"age"`
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "age": {"type": "integer", "minimum": 18}
        },
        "required": ["email"]
    }`
}

// Validation automatically uses the schema
err := validation.Validate(ctx, &user)

JSON Schema Syntax

Basic Types

func (p Product) JSONSchema() (id, schema string) {
    return "product-v1", `{
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "price": {"type": "number"},
            "inStock": {"type": "boolean"},
            "tags": {"type": "array", "items": {"type": "string"}},
            "metadata": {"type": "object"}
        }
    }`
}

String Constraints

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "username": {
                "type": "string",
                "minLength": 3,
                "maxLength": 20,
                "pattern": "^[a-zA-Z0-9_]+$"
            },
            "email": {
                "type": "string",
                "format": "email"
            },
            "website": {
                "type": "string",
                "format": "uri"
            }
        }
    }`
}

Number Constraints

func (p Product) JSONSchema() (id, schema string) {
    return "product-v1", `{
        "type": "object",
        "properties": {
            "price": {
                "type": "number",
                "minimum": 0,
                "exclusiveMinimum": true
            },
            "quantity": {
                "type": "integer",
                "minimum": 0,
                "maximum": 1000
            },
            "rating": {
                "type": "number",
                "minimum": 0,
                "maximum": 5,
                "multipleOf": 0.5
            }
        }
    }`
}

Array Constraints

func (r Request) JSONSchema() (id, schema string) {
    return "request-v1", `{
        "type": "object",
        "properties": {
            "tags": {
                "type": "array",
                "items": {"type": "string"},
                "minItems": 1,
                "maxItems": 10,
                "uniqueItems": true
            }
        }
    }`
}

Enum Values

func (o Order) JSONSchema() (id, schema string) {
    return "order-v1", `{
        "type": "object",
        "properties": {
            "status": {
                "type": "string",
                "enum": ["pending", "confirmed", "shipped", "delivered"]
            }
        }
    }`
}

Nested Objects

type User struct {
    Name    string  `json:"name"`
    Address Address `json:"address"`
}

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
    Zip    string `json:"zip"`
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "name": {"type": "string"},
            "address": {
                "type": "object",
                "properties": {
                    "street": {"type": "string"},
                    "city": {"type": "string"},
                    "zip": {"type": "string", "pattern": "^[0-9]{5}$"}
                },
                "required": ["street", "city", "zip"]
            }
        },
        "required": ["name", "address"]
    }`
}

Format Validation

JSON Schema supports various format validators:

func (c Contact) JSONSchema() (id, schema string) {
    return "contact-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "website": {"type": "string", "format": "uri"},
            "ipAddress": {"type": "string", "format": "ipv4"},
            "createdAt": {"type": "string", "format": "date-time"},
            "birthDate": {"type": "string", "format": "date"}
        }
    }`
}

Supported formats:

  • email - Email address
  • uri / url - URL
  • hostname - DNS hostname
  • ipv4 / ipv6 - IP addresses
  • date - Date (YYYY-MM-DD)
  • date-time - RFC3339 date-time
  • uuid - UUID

Schema Caching

Schemas are cached by ID for performance:

func (u User) JSONSchema() (id, schema string) {
    // ID is used as cache key
    return "user-v1", `{...}`
    //     ^^^^^^^^ Cached after first validation
}

Cache is LRU with configurable size:

validator := validation.MustNew(
    validation.WithMaxCachedSchemas(2048), // Default: 1024
)

Override Schema Per-Call

Provide a custom schema for a specific validation:

customSchema := `{
    "type": "object",
    "properties": {
        "email": {"type": "string", "format": "email"}
    },
    "required": ["email"]
}`

err := validator.Validate(ctx, &user,
    validation.WithCustomSchema("custom-user", customSchema),
)

This overrides the JSONSchemaProvider for this call only.

Strategy Selection

By default, JSON Schema has lower priority than struct tags and interface methods. Explicitly select it:

err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyJSONSchema),
)

Or use automatic strategy selection (default behavior):

// Automatically uses JSON Schema if:
// 1. Type implements JSONSchemaProvider
// 2. No Validate() or ValidateContext() method
// 3. No struct tags present
err := validation.Validate(ctx, &user)

Combining with Other Strategies

Run all strategies and aggregate errors:

type User struct {
    Email string `json:"email" validate:"required,email"` // Struct tag
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"}
        }
    }`
}

// Run both struct tag and JSON Schema validation
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

Schema Validation Errors

JSON Schema errors are returned as FieldError values:

err := validation.Validate(ctx, &user)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        for _, fieldErr := range verr.Fields {
            fmt.Printf("Path: %s\n", fieldErr.Path)
            fmt.Printf("Code: %s\n", fieldErr.Code)       // e.g., "schema.type"
            fmt.Printf("Message: %s\n", fieldErr.Message)
        }
    }
}

Error codes follow the pattern schema.<constraint>:

  • schema.type - Type mismatch
  • schema.required - Missing required field
  • schema.minimum - Below minimum value
  • schema.pattern - Pattern mismatch
  • schema.format - Format validation failed

Complete Example

package main

import (
    "context"
    "fmt"
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    Age      int    `json:"age"`
}

func (r CreateUserRequest) JSONSchema() (id, schema string) {
    return "create-user-v1", `{
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "properties": {
            "username": {
                "type": "string",
                "minLength": 3,
                "maxLength": 20,
                "pattern": "^[a-zA-Z0-9_]+$"
            },
            "email": {
                "type": "string",
                "format": "email"
            },
            "age": {
                "type": "integer",
                "minimum": 18,
                "maximum": 120
            }
        },
        "required": ["username", "email", "age"],
        "additionalProperties": false
    }`
}

func main() {
    ctx := context.Background()
    
    req := CreateUserRequest{
        Username: "ab",           // Too short
        Email:    "not-an-email", // Invalid format
        Age:      15,             // Below minimum
    }
    
    // Explicitly use JSON Schema strategy
    err := validation.Validate(ctx, &req,
        validation.WithStrategy(validation.StrategyJSONSchema),
    )
    
    if err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            for _, fieldErr := range verr.Fields {
                fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
            }
        }
    }
}

Advantages of JSON Schema

  • Standard: RFC-compliant, widely supported format
  • Portable: Schema can be shared with frontend/documentation
  • Flexible: Complex validation logic without code
  • Versioned: Easy to version schemas with ID

Disadvantages

  • Verbose: More code than struct tags
  • Runtime: Schema parsing has overhead (mitigated by caching)
  • Complexity: Learning curve for JSON Schema syntax

When to Use JSON Schema

Use JSON Schema when:

  • You need to share validation rules with frontend
  • You have complex validation logic
  • You want portable, language-independent validation
  • You need to version validation rules

Use struct tags when:

  • You prefer concise, declarative validation
  • You only validate server-side
  • You want minimal overhead

JSON Schema Resources

Next Steps

4.5 - Custom Validation Interfaces

Implement custom validation methods with Validate() and ValidateContext()

Implement custom validation logic by adding Validate() or ValidateContext() methods to your structs. This provides the most flexible validation approach for complex business rules.

ValidatorInterface

Implement the ValidatorInterface for simple custom validation:

type ValidatorInterface interface {
    Validate() error
}

Basic Example

type User struct {
    Email string
    Name  string
}

func (u *User) Validate() error {
    if !strings.Contains(u.Email, "@") {
        return errors.New("email must contain @")
    }
    if len(u.Name) < 2 {
        return errors.New("name too short")
    }
    return nil
}

// Validation automatically calls u.Validate()
err := validation.Validate(ctx, &user)

Returning Structured Errors

Return *validation.Error for detailed field-level errors:

func (u *User) Validate() error {
    var verr validation.Error
    
    if !strings.Contains(u.Email, "@") {
        verr.Add("email", "format", "must contain @", nil)
    }
    
    if len(u.Name) < 2 {
        verr.Add("name", "length", "must be at least 2 characters", nil)
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
}

ValidatorWithContext

Implement ValidatorWithContext for context-aware validation:

type ValidatorWithContext interface {
    ValidateContext(context.Context) error
}

This is preferred when you need access to request-scoped data.

Context-Aware Validation

type User struct {
    Email    string
    TenantID string
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Access context values
    tenant := ctx.Value("tenant").(string)
    
    // Tenant-specific validation
    if u.TenantID != tenant {
        return errors.New("user does not belong to this tenant")
    }
    
    // Additional validation
    if !strings.HasSuffix(u.Email, "@"+tenant+".com") {
        return fmt.Errorf("email must be from %s.com domain", tenant)
    }
    
    return nil
}

Database Validation

type User struct {
    Username string
    Email    string
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Get database from context
    db := ctx.Value("db").(*sql.DB)
    
    // Check username uniqueness
    var exists bool
    err := db.QueryRowContext(ctx,
        "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)",
        u.Username,
    ).Scan(&exists)
    
    if err != nil {
        return fmt.Errorf("failed to check username: %w", err)
    }
    
    if exists {
        return errors.New("username already taken")
    }
    
    return nil
}

Interface Priority

When a type implements ValidatorInterface or ValidatorWithContext, those methods have the highest priority:

Priority Order:

  1. ValidateContext(ctx) or Validate() (highest)
  2. Struct tags (validate:"...")
  3. JSON Schema (JSONSchemaProvider)
type User struct {
    Email string `validate:"required,email"` // Lower priority
}

func (u *User) Validate() error {
    // This runs instead of struct tags
    return customEmailValidation(u.Email)
}

Override this behavior by explicitly selecting a strategy:

// Skip interface method, use struct tags
err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

Combining with Other Strategies

Run interface validation along with other strategies:

type User struct {
    Email string `validate:"required,email"`
}

func (u *User) Validate() error {
    // Custom business logic
    if isBlacklisted(u.Email) {
        return errors.New("email is blacklisted")
    }
    return nil
}

// Run both interface method AND struct tag validation
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

All errors are aggregated into a single *validation.Error.

Pointer vs Value Receivers

The validation package works with both pointer and value receivers:

func (u *User) Validate() error {
    // Can modify the struct if needed
    u.Email = strings.ToLower(u.Email)
    return nil
}

Value Receiver

func (u User) Validate() error {
    // Read-only validation
    if u.Email == "" {
        return errors.New("email required")
    }
    return nil
}

Use pointer receivers when you need to modify the struct during validation (normalization, etc.).

Complex Validation Example

type CreateOrderRequest struct {
    UserID    int
    Items     []OrderItem
    CouponCode string
    Total     float64
}

type OrderItem struct {
    ProductID int
    Quantity  int
    Price     float64
}

func (r *CreateOrderRequest) ValidateContext(ctx context.Context) error {
    var verr validation.Error
    
    // Validate user exists
    if !userExists(ctx, r.UserID) {
        verr.Add("user_id", "not_found", "user does not exist", nil)
    }
    
    // Validate items
    if len(r.Items) == 0 {
        verr.Add("items", "required", "at least one item required", nil)
    }
    
    var calculatedTotal float64
    for i, item := range r.Items {
        // Validate product exists and price matches
        product, err := getProduct(ctx, item.ProductID)
        if err != nil {
            verr.Add(
                fmt.Sprintf("items.%d.product_id", i),
                "not_found",
                "product does not exist",
                nil,
            )
            continue
        }
        
        if item.Price != product.Price {
            verr.Add(
                fmt.Sprintf("items.%d.price", i),
                "mismatch",
                "price does not match current product price",
                map[string]any{
                    "expected": product.Price,
                    "actual":   item.Price,
                },
            )
        }
        
        if item.Quantity < 1 {
            verr.Add(
                fmt.Sprintf("items.%d.quantity", i),
                "invalid",
                "quantity must be at least 1",
                nil,
            )
        }
        
        calculatedTotal += item.Price * float64(item.Quantity)
    }
    
    // Validate coupon if provided
    if r.CouponCode != "" {
        discount, err := validateCoupon(ctx, r.CouponCode)
        if err != nil {
            verr.Add("coupon_code", "invalid", err.Error(), nil)
        } else {
            calculatedTotal -= discount
        }
    }
    
    // Validate total matches calculation
    if math.Abs(r.Total-calculatedTotal) > 0.01 {
        verr.Add(
            "total",
            "mismatch",
            "total does not match item prices",
            map[string]any{
                "expected": calculatedTotal,
                "actual":   r.Total,
            },
        )
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
}

Testing Interface Validation

Test your validation methods directly:

func TestUserValidation(t *testing.T) {
    tests := []struct {
        name    string
        user    User
        wantErr bool
    }{
        {
            name:    "valid user",
            user:    User{Email: "test@example.com", Name: "Alice"},
            wantErr: false,
        },
        {
            name:    "invalid email",
            user:    User{Email: "invalid", Name: "Alice"},
            wantErr: true,
        },
        {
            name:    "short name",
            user:    User{Email: "test@example.com", Name: "A"},
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.user.Validate()
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Best Practices

1. Keep Methods Focused

// Good: Focused validation
func (u *User) Validate() error {
    if err := validateEmail(u.Email); err != nil {
        return err
    }
    if err := validateName(u.Name); err != nil {
        return err
    }
    return nil
}

// Bad: Too much logic in one method
func (u *User) Validate() error {
    // 200 lines of validation code...
}

2. Return Structured Errors

// Good: Structured errors
func (u *User) Validate() error {
    var verr validation.Error
    verr.Add("email", "invalid", "must be valid email", nil)
    return &verr
}

// Bad: Generic errors
func (u *User) Validate() error {
    return errors.New("email invalid")
}

3. Use Context for External Dependencies

// Good: Dependencies from context
func (u *User) ValidateContext(ctx context.Context) error {
    db := ctx.Value("db").(*sql.DB)
    return checkUsernameUnique(ctx, db, u.Username)
}

// Bad: Global dependencies
var globalDB *sql.DB
func (u *User) Validate() error {
    return checkUsernameUnique(context.Background(), globalDB, u.Username)
}

4. Consider Performance

// Good: Fast validation first
func (u *User) ValidateContext(ctx context.Context) error {
    // Quick checks first
    if u.Email == "" {
        return errors.New("email required")
    }
    
    // Expensive DB check last
    return checkEmailUnique(ctx, u.Email)
}

Error Metadata

Add metadata to errors for better debugging:

func (u *User) Validate() error {
    var verr validation.Error
    
    verr.Add("email", "blacklisted", "email domain is blacklisted", map[string]any{
        "domain":     extractDomain(u.Email),
        "reason":     "spam",
        "blocked_at": time.Now(),
    })
    
    return &verr
}

Next Steps

4.6 - Partial Validation

Validate only provided fields in PATCH requests

Partial validation is essential for PATCH requests. Only provided fields should be validated. Absent fields are ignored even if they have “required” constraints.

The Problem

Consider a user update endpoint:

type UpdateUserRequest struct {
    Email string `validate:"required,email"`
    Name  string `validate:"required,min=2"`
    Age   int    `validate:"min=18"`
}

With normal validation, a PATCH request like {"email": "new@example.com"} would fail. The name field is required but not provided. Partial validation solves this.

PresenceMap

A PresenceMap tracks which fields are present in the request:

type PresenceMap map[string]bool

Keys are JSON field paths (e.g., "email", "address.city", "items.0.name").

Computing Presence

Use ComputePresence to analyze raw JSON:

rawJSON := []byte(`{"email": "new@example.com"}`)

presence, err := validation.ComputePresence(rawJSON)
if err != nil {
    return fmt.Errorf("failed to compute presence: %w", err)
}

// presence = {"email": true}

ValidatePartial

Use ValidatePartial to validate only present fields:

func UpdateUserHandler(w http.ResponseWriter, r *http.Request) {
    // Read raw body
    rawJSON, _ := io.ReadAll(r.Body)
    
    // Compute presence
    presence, _ := validation.ComputePresence(rawJSON)
    
    // Parse into struct
    var req UpdateUserRequest
    json.Unmarshal(rawJSON, &req)
    
    // Validate only present fields
    err := validation.ValidatePartial(ctx, &req, presence)
    if err != nil {
        // Handle validation error
    }
}

Complete PATCH Example

type UpdateUserRequest struct {
    Email *string `json:"email" validate:"omitempty,email"`
    Name  *string `json:"name" validate:"omitempty,min=2"`
    Age   *int    `json:"age" validate:"omitempty,min=18"`
}

func UpdateUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Read raw body
    rawJSON, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }
    
    // Compute which fields are present
    presence, err := validation.ComputePresence(rawJSON)
    if err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Parse into struct
    var req UpdateUserRequest
    if err := json.Unmarshal(rawJSON, &req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Validate only present fields
    if err := validation.ValidatePartial(ctx, &req, presence); err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            // Return field errors
            w.Header().Set("Content-Type", "application/json")
            w.WriteHeader(http.StatusUnprocessableEntity)
            json.NewEncoder(w).Encode(verr)
            return
        }
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Update user with provided fields
    updateUser(ctx, req)
    
    w.WriteHeader(http.StatusOK)
}

Nested Structures

Presence tracking works with nested objects and arrays:

type UpdateOrderRequest struct {
    Status string  `json:"status"`
    Items  []Item  `json:"items"`
    Address Address `json:"address"`
}

type Item struct {
    ProductID int `json:"product_id"`
    Quantity  int `json:"quantity"`
}

type Address struct {
    Street string `json:"street"`
    City   string `json:"city"`
    Zip    string `json:"zip"`
}

rawJSON := []byte(`{
    "status": "confirmed",
    "items": [
        {"product_id": 123, "quantity": 2}
    ],
    "address": {
        "city": "San Francisco"
    }
}`)

presence, _ := validation.ComputePresence(rawJSON)
// presence = {
//     "status": true,
//     "items": true,
//     "items.0": true,
//     "items.0.product_id": true,
//     "items.0.quantity": true,
//     "address": true,
//     "address.city": true,
// }

Only address.city was provided, so address.street and address.zip won’t be validated.

Using WithPresence Option

You can also use the WithPresence option directly:

presence, _ := validation.ComputePresence(rawJSON)

err := validation.Validate(ctx, &req,
    validation.WithPartial(true),
    validation.WithPresence(presence),
)

PresenceMap Methods

Has

Check if an exact path is present:

if presence.Has("email") {
    // Email field was provided
}

HasPrefix

Check if any nested path exists:

if presence.HasPrefix("address") {
    // At least one address field was provided
    // (e.g., "address.city" or "address.street")
}

LeafPaths

Get only the deepest paths (no parent paths):

presence := PresenceMap{
    "address": true,
    "address.city": true,
    "address.street": true,
}

leaves := presence.LeafPaths()
// returns: ["address.city", "address.street"]
// "address" is excluded (it has children)

Useful for validating only actual data fields, not parent objects.

Pointer Fields for PATCH

Use pointers to distinguish between “not provided” and “zero value”:

type UpdateUserRequest struct {
    Email *string `json:"email"`
    Age   *int    `json:"age"`
    Active *bool  `json:"active"`
}

// Email: not provided
// Age: 0
// Active: false
rawJSON := []byte(`{"age": 0, "active": false}`)

With presence tracking:

  • email not in presence map → skip validation
  • age and active in presence map → validate even though they’re zero values

Struct Tag Strategy

For partial validation with struct tags, use omitempty instead of required:

// Good for PATCH
type UpdateUserRequest struct {
    Email string `json:"email" validate:"omitempty,email"`
    Age   int    `json:"age" validate:"omitempty,min=18"`
}

// Bad for PATCH
type UpdateUserRequest struct {
    Email string `json:"email" validate:"required,email"` // Will fail if not provided
    Age   int    `json:"age" validate:"required,min=18"`  // Will fail if not provided
}

Custom Interface with Partial Validation

Access the presence map in custom validation:

type UpdateOrderRequest struct {
    Items []OrderItem
}

func (r *UpdateOrderRequest) ValidateContext(ctx context.Context) error {
    // Get presence from context (if available)
    presence := ctx.Value("presence").(validation.PresenceMap)
    
    // Only validate items if provided
    if presence.HasPrefix("items") {
        if len(r.Items) == 0 {
            return errors.New("items cannot be empty when provided")
        }
    }
    
    return nil
}

// Pass presence via context
ctx = context.WithValue(ctx, "presence", presence)
err := validation.ValidatePartial(ctx, &req, presence)

Performance Considerations

  • ComputePresence parses JSON once (fast)
  • Presence map is cached per request
  • No reflection overhead for presence checks
  • Memory usage: ~100 bytes per field path

Limitations

Deep Nesting

ComputePresence has a maximum nesting depth of 100 to prevent stack overflow:

// This will stop at depth 100
deeplyNested := generateDeeplyNestedJSON(150)
presence, _ := validation.ComputePresence(deeplyNested)
// Only tracks first 100 levels

Maximum Fields

For security, limit the number of fields in partial validation:

validator := validation.MustNew(
    validation.WithMaxFields(5000), // Default: 10000
)

Testing Partial Validation

func TestPartialValidation(t *testing.T) {
    tests := []struct {
        name    string
        json    string
        wantErr bool
    }{
        {
            name:    "valid email update",
            json:    `{"email": "new@example.com"}`,
            wantErr: false,
        },
        {
            name:    "invalid email update",
            json:    `{"email": "invalid"}`,
            wantErr: true,
        },
        {
            name:    "empty body",
            json:    `{}`,
            wantErr: false, // No fields to validate
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            presence, _ := validation.ComputePresence([]byte(tt.json))
            
            var req UpdateUserRequest
            json.Unmarshal([]byte(tt.json), &req)
            
            err := validation.ValidatePartial(context.Background(), &req, presence)
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidatePartial() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Best Practices

1. Always Use Pointers for Optional Fields

// Good
type UpdateUserRequest struct {
    Email *string `json:"email" validate:"omitempty,email"`
    Age   *int    `json:"age" validate:"omitempty,min=18"`
}

// Bad - can't distinguish between "not provided" and "zero value"
type UpdateUserRequest struct {
    Email string `json:"email" validate:"omitempty,email"`
    Age   int    `json:"age" validate:"omitempty,min=18"`
}

2. Compute Presence Once

// Good
presence, _ := validation.ComputePresence(rawJSON)
err1 := validation.ValidatePartial(ctx, &req1, presence)
err2 := validation.ValidatePartial(ctx, &req2, presence)

// Bad - recomputes presence
validation.ValidatePartial(ctx, &req1, computePresence(rawJSON))
validation.ValidatePartial(ctx, &req2, computePresence(rawJSON))

3. Handle Empty Bodies

rawJSON, _ := io.ReadAll(r.Body)

if len(rawJSON) == 0 {
    http.Error(w, "empty body", http.StatusBadRequest)
    return
}

presence, _ := validation.ComputePresence(rawJSON)

4. Use omitempty Instead of required

// Good for PATCH
validate:"omitempty,email"

// Bad for PATCH
validate:"required,email"

Next Steps

4.7 - Error Handling

Work with structured validation errors

Validation errors in the Rivaas validation package are structured and detailed. They provide field-level error information with codes, messages, and metadata.

Error Types

validation.Error

The main validation error type containing multiple field errors:

type Error struct {
    Fields    []FieldError // List of field errors.
    Truncated bool         // True if errors were truncated due to maxErrors limit.
}

FieldError

Individual field error with detailed information:

type FieldError struct {
    Path    string         // JSON path like "items.2.price".
    Code    string         // Stable code like "tag.required", "schema.type".
    Message string         // Human-readable message.
    Meta    map[string]any // Additional metadata like tag, param, value.
}

Checking for Validation Errors

Use errors.As to extract structured errors:

err := validation.Validate(ctx, &req)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        // Access structured field errors
        for _, fieldErr := range verr.Fields {
            fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
        }
    }
}

Error Codes

Error codes follow a consistent pattern for programmatic handling:

Struct Tag Errors

Format: tag.<tagname>

Code: "tag.required"     // Required field missing
Code: "tag.email"        // Email format invalid
Code: "tag.min"          // Below minimum value/length
Code: "tag.max"          // Above maximum value/length
Code: "tag.oneof"        // Value not in enum

JSON Schema Errors

Format: schema.<constraint>

Code: "schema.type"      // Type mismatch
Code: "schema.required"  // Missing required field
Code: "schema.minimum"   // Below minimum value
Code: "schema.pattern"   // Pattern mismatch
Code: "schema.format"    // Format validation failed

Interface Method Errors

Custom codes from your validation methods:

Code: "validation_error" // Generic validation error
Code: "custom_code"      // Your custom code

Accessing Field Errors

Iterate Over All Errors

var verr *validation.Error
if errors.As(err, &verr) {
    for _, fieldErr := range verr.Fields {
        log.Printf("Field: %s, Code: %s, Message: %s",
            fieldErr.Path,
            fieldErr.Code,
            fieldErr.Message,
        )
    }
}

Check for Specific Field

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.Has("email") {
        fmt.Println("Email field has an error")
    }
}

Get First Error for Field

var verr *validation.Error
if errors.As(err, &verr) {
    fieldErr := verr.GetField("email")
    if fieldErr != nil {
        fmt.Printf("Email error: %s\n", fieldErr.Message)
    }
}

Check for Specific Error Code

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.HasCode("tag.required") {
        fmt.Println("Some required fields are missing")
    }
}

Error Metadata

Errors may include additional metadata:

var verr *validation.Error
if errors.As(err, &verr) {
    for _, fieldErr := range verr.Fields {
        fmt.Printf("Path: %s\n", fieldErr.Path)
        fmt.Printf("Code: %s\n", fieldErr.Code)
        fmt.Printf("Message: %s\n", fieldErr.Message)
        
        // Access metadata
        if tag, ok := fieldErr.Meta["tag"].(string); ok {
            fmt.Printf("Tag: %s\n", tag)
        }
        if param, ok := fieldErr.Meta["param"].(string); ok {
            fmt.Printf("Param: %s\n", param)
        }
        if value := fieldErr.Meta["value"]; value != nil {
            fmt.Printf("Value: %v\n", value)
        }
    }
}

Common metadata fields:

  • tag - The validation tag that failed (struct tags)
  • param - Tag parameter (e.g., “18” for min=18)
  • value - The actual value (may be redacted)
  • expected - Expected value for comparison errors
  • actual - Actual value for comparison errors

Error Messages

Default Messages

The package provides clear default messages:

"is required"
"must be a valid email address"
"must be at least 18"
"must be one of: pending, confirmed, shipped"

Custom Messages

Customize error messages when creating a validator:

validator := validation.MustNew(
    validation.WithMessages(map[string]string{
        "required": "cannot be empty",
        "email":    "invalid email format",
        "min":      "too small",
    }),
)

Dynamic Messages

Use WithMessageFunc for parameterized tags:

validator := validation.MustNew(
    validation.WithMessageFunc("min", func(param string, kind reflect.Kind) string {
        if kind == reflect.String {
            return fmt.Sprintf("must be at least %s characters", param)
        }
        return fmt.Sprintf("must be at least %s", param)
    }),
)

Limiting Errors

Max Errors

Limit the number of errors returned:

err := validation.Validate(ctx, &req,
    validation.WithMaxErrors(5),
)

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.Truncated {
        fmt.Println("More errors exist (showing first 5)")
    }
}

Fail Fast

Stop at the first error:

err := validation.Validate(ctx, &req,
    validation.WithMaxErrors(1),
)

Sorting Errors

Sort errors for consistent output:

var verr *validation.Error
if errors.As(err, &verr) {
    verr.Sort() // Sort by path, then by code
    
    for _, fieldErr := range verr.Fields {
        fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
    }
}

HTTP Error Responses

JSON Error Response

func HandleValidationError(w http.ResponseWriter, err error) {
    var verr *validation.Error
    if errors.As(err, &verr) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusUnprocessableEntity)
        json.NewEncoder(w).Encode(map[string]any{
            "error": "validation_failed",
            "fields": verr.Fields,
        })
        return
    }
    
    // Other error types
    http.Error(w, "internal server error", http.StatusInternalServerError)
}

Example response:

{
    "error": "validation_failed",
    "fields": [
        {
            "path": "email",
            "code": "tag.email",
            "message": "must be a valid email address",
            "meta": {
                "tag": "email",
                "value": "[REDACTED]"
            }
        },
        {
            "path": "age",
            "code": "tag.min",
            "message": "must be at least 18",
            "meta": {
                "tag": "min",
                "param": "18",
                "value": 15
            }
        }
    ]
}

Problem Details (RFC 7807)

func HandleValidationErrorProblemDetails(w http.ResponseWriter, err error) {
    var verr *validation.Error
    if !errors.As(err, &verr) {
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }
    
    // Convert to Problem Details format
    problems := make([]map[string]any, len(verr.Fields))
    for i, fieldErr := range verr.Fields {
        problems[i] = map[string]any{
            "field":   fieldErr.Path,
            "code":    fieldErr.Code,
            "message": fieldErr.Message,
        }
    }
    
    w.Header().Set("Content-Type", "application/problem+json")
    w.WriteHeader(http.StatusUnprocessableEntity)
    json.NewEncoder(w).Encode(map[string]any{
        "type":     "https://example.com/problems/validation-error",
        "title":    "Validation Error",
        "status":   422,
        "detail":   verr.Error(),
        "instance": r.URL.Path,
        "errors":   problems,
    })
}

Creating Custom Errors

Adding Errors Manually

var verr validation.Error

verr.Add("email", "invalid", "email is blacklisted", map[string]any{
    "domain": "example.com",
    "reason": "spam",
})

verr.Add("password", "weak", "password is too weak", nil)

if verr.HasErrors() {
    return &verr
}

Combining Errors

var allErrors validation.Error

// Add errors from multiple sources
allErrors.AddError(err1)
allErrors.AddError(err2)
allErrors.AddError(err3)

if allErrors.HasErrors() {
    return &allErrors
}

Error Interface Implementations

The Error type implements several interfaces:

error Interface

err := validation.Validate(ctx, &req)
fmt.Println(err.Error())
// Output: "validation failed: email: must be valid email; age: must be at least 18"

errors.Is

if errors.Is(err, validation.ErrValidation) {
    // This is a validation error
}

rivaas.dev/errors Interfaces

The Error type implements additional interfaces for the Rivaas error handling system:

// ErrorType - HTTP status code
func (e Error) HTTPStatus() int {
    return 422 // Unprocessable Entity
}

// ErrorCode - Stable error code
func (e Error) Code() string {
    return "validation_error"
}

// ErrorDetails - Detailed error information
func (e Error) Details() any {
    return e.Fields
}

Nil and Empty Errors

Nil Pointer Errors

var user *User
err := validation.Validate(ctx, user)
// Returns: *validation.Error with code "nil_pointer"

Invalid Value Errors

var invalid interface{} = nil
err := validation.Validate(ctx, invalid)
// Returns: *validation.Error with code "invalid"

Logging Errors

Structured Logging

var verr *validation.Error
if errors.As(err, &verr) {
    for _, fieldErr := range verr.Fields {
        log.With(
            "field", fieldErr.Path,
            "code", fieldErr.Code,
            "message", fieldErr.Message,
        ).Warn("validation failed")
    }
}

Summary Logging

var verr *validation.Error
if errors.As(err, &verr) {
    fieldPaths := make([]string, len(verr.Fields))
    for i, fieldErr := range verr.Fields {
        fieldPaths[i] = fieldErr.Path
    }
    
    log.With(
        "error_count", len(verr.Fields),
        "fields", strings.Join(fieldPaths, ", "),
    ).Warn("validation failed")
}

Testing Error Handling

func TestValidationErrors(t *testing.T) {
    req := CreateUserRequest{
        Email: "invalid",
        Age:   15,
    }
    
    err := validation.Validate(context.Background(), &req)
    
    var verr *validation.Error
    if !errors.As(err, &verr) {
        t.Fatal("expected validation.Error")
    }
    
    // Check error count
    if len(verr.Fields) != 2 {
        t.Errorf("expected 2 errors, got %d", len(verr.Fields))
    }
    
    // Check specific field error
    if !verr.Has("email") {
        t.Error("expected email error")
    }
    
    // Check error code
    if !verr.HasCode("tag.email") {
        t.Error("expected tag.email error code")
    }
    
    // Check error message
    emailErr := verr.GetField("email")
    if emailErr == nil {
        t.Fatal("email error not found")
    }
    if !strings.Contains(emailErr.Message, "email") {
        t.Errorf("unexpected message: %s", emailErr.Message)
    }
}

Next Steps

4.8 - Custom Validators

Register custom validation tags and functions

Extend the validation package with custom validation tags and functions to handle domain-specific validation rules.

Custom Validation Tags

Register custom tags for use in struct tags with WithCustomTag.

import (
    "github.com/go-playground/validator/v10"
    "rivaas.dev/validation"
)

validator := validation.MustNew(
    validation.WithCustomTag("phone", func(fl validator.FieldLevel) bool {
        return phoneRegex.MatchString(fl.Field().String())
    }),
)

type User struct {
    Phone string `validate:"phone"`
}

FieldLevel Interface

Custom tag functions receive a validator.FieldLevel with methods to access field information.

type FieldLevel interface {
    Field() reflect.Value         // The field being validated
    FieldName() string             // Field name
    StructFieldName() string       // Struct field name
    Param() string                 // Tag parameter
    GetStructFieldOK() (reflect.Value, reflect.Kind, bool)
    Parent() reflect.Value         // Parent struct
}

Simple Custom Tags

Phone Number Validation

import "regexp"

var phoneRegex = regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)

validator := validation.MustNew(
    validation.WithCustomTag("phone", func(fl validator.FieldLevel) bool {
        return phoneRegex.MatchString(fl.Field().String())
    }),
)

type Contact struct {
    Phone string `validate:"required,phone"`
}

Username Validation

var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)

validator := validation.MustNew(
    validation.WithCustomTag("username", func(fl validator.FieldLevel) bool {
        username := fl.Field().String()
        return usernameRegex.MatchString(username)
    }),
)

type User struct {
    Username string `validate:"required,username"`
}

Slug Validation

var slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`)

validator := validation.MustNew(
    validation.WithCustomTag("slug", func(fl validator.FieldLevel) bool {
        return slugRegex.MatchString(fl.Field().String())
    }),
)

type Article struct {
    Slug string `validate:"required,slug"`
}

Advanced Custom Tags

Password Strength

import "unicode"

func strongPassword(fl validator.FieldLevel) bool {
    password := fl.Field().String()
    
    if len(password) < 8 {
        return false
    }
    
    var hasUpper, hasLower, hasDigit, hasSpecial bool
    for _, c := range password {
        switch {
        case unicode.IsUpper(c):
            hasUpper = true
        case unicode.IsLower(c):
            hasLower = true
        case unicode.IsDigit(c):
            hasDigit = true
        case unicode.IsPunct(c) || unicode.IsSymbol(c):
            hasSpecial = true
        }
    }
    
    return hasUpper && hasLower && hasDigit && hasSpecial
}

validator := validation.MustNew(
    validation.WithCustomTag("strong_password", strongPassword),
)

type Registration struct {
    Password string `validate:"required,strong_password"`
}

Parameterized Tags

// Custom tag with parameter: divisible_by=N
func divisibleBy(fl validator.FieldLevel) bool {
    param := fl.Param() // Get parameter value
    divisor, err := strconv.Atoi(param)
    if err != nil {
        return false
    }
    
    value := fl.Field().Int()
    return value%int64(divisor) == 0
}

validator := validation.MustNew(
    validation.WithCustomTag("divisible_by", divisibleBy),
)

type Product struct {
    Quantity int `validate:"required,divisible_by=5"`
}

Cross-Field Validation

// Validate that EndDate is after StartDate
func afterStartDate(fl validator.FieldLevel) bool {
    endDate := fl.Field().Interface().(time.Time)
    
    // Access parent struct
    parent := fl.Parent()
    startDateField := parent.FieldByName("StartDate")
    if !startDateField.IsValid() {
        return false
    }
    
    startDate := startDateField.Interface().(time.Time)
    return endDate.After(startDate)
}

validator := validation.MustNew(
    validation.WithCustomTag("after_start_date", afterStartDate),
)

type Event struct {
    StartDate time.Time `validate:"required"`
    EndDate   time.Time `validate:"required,after_start_date"`
}

Multiple Custom Tags

Register multiple tags at once:

validator := validation.MustNew(
    validation.WithCustomTag("phone", validatePhone),
    validation.WithCustomTag("username", validateUsername),
    validation.WithCustomTag("slug", validateSlug),
    validation.WithCustomTag("strong_password", validateStrongPassword),
)

Custom Validator Functions

Use WithCustomValidator for one-off validation logic:

type CreateOrderRequest struct {
    Items []OrderItem
    Total float64
}

err := validator.Validate(ctx, &req,
    validation.WithCustomValidator(func(v any) error {
        req := v.(*CreateOrderRequest)
        
        // Calculate expected total
        var sum float64
        for _, item := range req.Items {
            sum += item.Price * float64(item.Quantity)
        }
        
        // Verify total matches
        if math.Abs(req.Total-sum) > 0.01 {
            return errors.New("total does not match item prices")
        }
        
        return nil
    }),
)

Type Assertion

validation.WithCustomValidator(func(v any) error {
    req, ok := v.(*CreateUserRequest)
    if !ok {
        return errors.New("unexpected type")
    }
    
    // Validate req
    return nil
})

Returning Structured Errors

validation.WithCustomValidator(func(v any) error {
    req := v.(*CreateUserRequest)
    
    var verr validation.Error
    
    if isBlacklisted(req.Email) {
        verr.Add("email", "blacklisted", "email domain is blacklisted", nil)
    }
    
    if !isUnique(req.Username) {
        verr.Add("username", "duplicate", "username already taken", nil)
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
})

Field Name Mapping

Transform field names in error messages:

validator := validation.MustNew(
    validation.WithFieldNameMapper(func(name string) string {
        // Convert snake_case to Title Case
        return strings.Title(strings.ReplaceAll(name, "_", " "))
    }),
)

type User struct {
    FirstName string `json:"first_name" validate:"required"`
}

// Error message will say "First Name is required" instead of "first_name is required"

Custom Error Messages

Static Messages

validator := validation.MustNew(
    validation.WithMessages(map[string]string{
        "required": "cannot be empty",
        "email":    "invalid email format",
        "min":      "value too small",
    }),
)

Dynamic Messages

import "reflect"

validator := validation.MustNew(
    validation.WithMessageFunc("min", func(param string, kind reflect.Kind) string {
        if kind == reflect.String {
            return fmt.Sprintf("must be at least %s characters long", param)
        }
        return fmt.Sprintf("must be at least %s", param)
    }),
    validation.WithMessageFunc("max", func(param string, kind reflect.Kind) string {
        if kind == reflect.String {
            return fmt.Sprintf("must be at most %s characters long", param)
        }
        return fmt.Sprintf("must be at most %s", param)
    }),
)

Combining Custom Validators

Mix custom tags, custom validators, and built-in validation:

type CreateUserRequest struct {
    Username string `validate:"required,username"` // Custom tag
    Email    string `validate:"required,email"`    // Built-in tag
    Age      int    `validate:"required,min=18"`   // Built-in tag
}

validator := validation.MustNew(
    validation.WithCustomTag("username", validateUsername),
)

err := validator.Validate(ctx, &req,
    validation.WithCustomValidator(func(v any) error {
        req := v.(*CreateUserRequest)
        // Additional custom validation
        if isBlacklisted(req.Email) {
            return errors.New("email is blacklisted")
        }
        return nil
    }),
    validation.WithRunAll(true), // Run all strategies
)

Testing Custom Validators

Testing Custom Tags

func TestPhoneValidation(t *testing.T) {
    validator := validation.MustNew(
        validation.WithCustomTag("phone", validatePhone),
    )
    
    tests := []struct {
        name    string
        phone   string
        wantErr bool
    }{
        {"valid US", "+12345678900", false},
        {"valid international", "+441234567890", false},
        {"invalid format", "123", true},
        {"invalid prefix", "0123456789", true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            type Test struct {
                Phone string `validate:"phone"`
            }
            
            test := Test{Phone: tt.phone}
            err := validator.Validate(context.Background(), &test)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Testing Custom Validator Functions

func TestCustomValidator(t *testing.T) {
    customValidator := func(v any) error {
        req := v.(*CreateOrderRequest)
        var sum float64
        for _, item := range req.Items {
            sum += item.Price * float64(item.Quantity)
        }
        if math.Abs(req.Total-sum) > 0.01 {
            return errors.New("total mismatch")
        }
        return nil
    }
    
    tests := []struct {
        name    string
        req     CreateOrderRequest
        wantErr bool
    }{
        {
            name: "valid total",
            req: CreateOrderRequest{
                Items: []OrderItem{{Price: 10.0, Quantity: 2}},
                Total: 20.0,
            },
            wantErr: false,
        },
        {
            name: "invalid total",
            req: CreateOrderRequest{
                Items: []OrderItem{{Price: 10.0, Quantity: 2}},
                Total: 25.0,
            },
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validation.Validate(context.Background(), &tt.req,
                validation.WithCustomValidator(customValidator),
            )
            
            if (err != nil) != tt.wantErr {
                t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Best Practices

1. Name Tags Clearly

// Good
validation.WithCustomTag("phone", validatePhone)
validation.WithCustomTag("strong_password", validateStrongPassword)

// Bad
validation.WithCustomTag("p", validatePhone)
validation.WithCustomTag("pass", validateStrongPassword)

2. Document Custom Tags

// validatePhone validates phone numbers in E.164 format.
// Examples: +12345678900, +441234567890
func validatePhone(fl validator.FieldLevel) bool {
    return phoneRegex.MatchString(fl.Field().String())
}

3. Handle Edge Cases

func validateUsername(fl validator.FieldLevel) bool {
    username := fl.Field().String()
    
    // Handle empty strings
    if username == "" {
        return false // Or true if username is optional
    }
    
    // Check length
    if len(username) < 3 || len(username) > 20 {
        return false
    }
    
    // Check format
    return usernameRegex.MatchString(username)
}

4. Use Validator Instance for Shared Tags

// Create validator once with custom tags
var appValidator = validation.MustNew(
    validation.WithCustomTag("phone", validatePhone),
    validation.WithCustomTag("username", validateUsername),
    validation.WithCustomTag("slug", validateSlug),
)

// Reuse across handlers
func Handler1(ctx context.Context, req Request1) error {
    return appValidator.Validate(ctx, &req)
}

func Handler2(ctx context.Context, req Request2) error {
    return appValidator.Validate(ctx, &req)
}

Next Steps

4.9 - Security

Protect sensitive data and prevent validation attacks

The validation package includes built-in security features to protect sensitive data and prevent various attacks through validation.

Sensitive Data Redaction

Protect sensitive data in error messages with redactors:

redactor := func(path string) bool {
    return strings.Contains(path, "password") ||
           strings.Contains(path, "token") ||
           strings.Contains(path, "secret") ||
           strings.Contains(path, "api_key")
}

validator := validation.MustNew(
    validation.WithRedactor(redactor),
)

How Redaction Works

When a field is redacted, its value in error messages is replaced with [REDACTED]:

type User struct {
    Email    string `validate:"required,email"`
    Password string `validate:"required,min=8"`
}

user := User{
    Email:    "invalid",
    Password: "secret123",
}

err := validator.Validate(ctx, &user)
// Error: email: must be valid email (value: "invalid")
// Error: password: too short (value: "[REDACTED]")

Pattern-Based Redaction

func sensitiveFieldRedactor(path string) bool {
    sensitive := []string{
        "password",
        "token",
        "secret",
        "api_key",
        "credit_card",
        "ssn",
        "private_key",
    }
    
    pathLower := strings.ToLower(path)
    for _, keyword := range sensitive {
        if strings.Contains(pathLower, keyword) {
            return true
        }
    }
    return false
}

validator := validation.MustNew(
    validation.WithRedactor(sensitiveFieldRedactor),
)

Path-Specific Redaction

func pathRedactor(path string) bool {
    redactedPaths := map[string]bool{
        "user.password":          true,
        "user.security_question": true,
        "payment.card_number":    true,
        "payment.cvv":            true,
        "auth.refresh_token":     true,
    }
    return redactedPaths[path]
}

Redacting Nested Fields

type Payment struct {
    CardNumber string `json:"card_number" validate:"required,credit_card"`
    CVV        string `json:"cvv" validate:"required,len=3"`
}

type Order struct {
    Payment Payment `json:"payment"`
}

redactor := func(path string) bool {
    // Redact payment.card_number and payment.cvv
    return strings.HasPrefix(path, "payment.card_number") ||
           strings.HasPrefix(path, "payment.cvv")
}

Security Limits

Maximum Nesting Depth

The package protects against stack overflow from deeply nested structures:

// Built-in protection: max depth = 100 levels
const maxRecursionDepth = 100

This applies to:

  • ComputePresence() - Presence map computation
  • Nested struct validation
  • Recursive data structures

Maximum Fields

Limit fields processed in partial validation:

validator := validation.MustNew(
    validation.WithMaxFields(5000), // Default: 10000
)

Prevents attacks with extremely large JSON objects:

{
  "field1": "value",
  "field2": "value",
  // ... 100,000 more fields
}

Maximum Errors

Limit errors returned to prevent memory exhaustion:

validator := validation.MustNew(
    validation.WithMaxErrors(100), // Default: unlimited
)

When limit is reached, Truncated flag is set:

var verr *validation.Error
if errors.As(err, &verr) {
    if verr.Truncated {
        log.Warn("more validation errors exist (truncated)")
    }
}

Schema Cache Size

Limit JSON Schema cache to prevent memory exhaustion:

validator := validation.MustNew(
    validation.WithMaxCachedSchemas(2048), // Default: 1024
)

Uses LRU eviction - oldest schemas are removed when limit is reached.

Input Validation Security

Prevent Injection Attacks

Always validate input format before using in queries or commands:

type SearchRequest struct {
    Query string `validate:"required,max=100,alphanum"`
}

// Safe from SQL injection (alphanumeric only)
err := validator.Validate(ctx, &req)

Sanitize HTML

import "html"

type CreatePostRequest struct {
    Title   string `validate:"required,max=200"`
    Content string `validate:"required,max=10000"`
}

func (r *CreatePostRequest) Validate() error {
    // Sanitize HTML
    r.Title = html.EscapeString(r.Title)
    r.Content = html.EscapeString(r.Content)
    return nil
}

Validate File Paths

import "path/filepath"

type UploadRequest struct {
    Filename string `validate:"required"`
}

func (r *UploadRequest) Validate() error {
    // Prevent path traversal attacks
    cleaned := filepath.Clean(r.Filename)
    if strings.Contains(cleaned, "..") {
        return errors.New("invalid filename: path traversal detected")
    }
    r.Filename = cleaned
    return nil
}

Rate Limiting

Combine validation with rate limiting to prevent abuse:

import "golang.org/x/time/rate"

var limiter = rate.NewLimiter(rate.Every(time.Second), 10)

func ValidateWithRateLimit(ctx context.Context, req any) error {
    // Check rate limit first (fast)
    if !limiter.Allow() {
        return errors.New("rate limit exceeded")
    }
    
    // Then validate (slower)
    return validation.Validate(ctx, req)
}

Denial of Service Prevention

Request Size Limits

func Handler(w http.ResponseWriter, r *http.Request) {
    // Limit request body size
    r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024) // 1MB max
    
    rawJSON, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
        return
    }
    
    // Continue with validation
}

Array/Slice Limits

type BatchRequest struct {
    Items []Item `validate:"required,min=1,max=100"`
}

// Prevents DoS with extremely large arrays

String Length Limits

type Request struct {
    Description string `validate:"max=10000"`
}

// Prevents memory exhaustion from huge strings

Validation Timeout

import "context"

func ValidateWithTimeout(req any) error {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    return validation.Validate(ctx, req)
}

Security Best Practices

1. Always Validate User Input

// Good
func CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)
    
    // ALWAYS validate
    if err := validation.Validate(r.Context(), &req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Safe to use req
}

// Bad
func CreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)
    
    // Using unvalidated input - DANGEROUS!
    db.Exec("INSERT INTO users VALUES (?, ?)", req.Username, req.Email)
}

2. Validate Before Database Operations

func UpdateUser(ctx context.Context, req UpdateUserRequest) error {
    // Validate first
    if err := validation.Validate(ctx, &req); err != nil {
        return err
    }
    
    // Then update database
    return db.UpdateUser(ctx, req)
}

3. Use Strict Mode for APIs

validator := validation.MustNew(
    validation.WithDisallowUnknownFields(true),
)

// Rejects requests with unexpected fields (typo detection)

4. Redact All Sensitive Fields

func comprehensiveRedactor(path string) bool {
    pathLower := strings.ToLower(path)
    
    // Passwords and secrets
    if strings.Contains(pathLower, "password") ||
       strings.Contains(pathLower, "secret") ||
       strings.Contains(pathLower, "token") ||
       strings.Contains(pathLower, "key") {
        return true
    }
    
    // Payment information
    if strings.Contains(pathLower, "card") ||
       strings.Contains(pathLower, "cvv") ||
       strings.Contains(pathLower, "credit") {
        return true
    }
    
    // Personal information
    if strings.Contains(pathLower, "ssn") ||
       strings.Contains(pathLower, "social_security") ||
       strings.Contains(pathLower, "tax_id") {
        return true
    }
    
    return false
}

5. Log Validation Failures

err := validation.Validate(ctx, &req)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        // Log validation failures for security monitoring
        log.With(
            "error_count", len(verr.Fields),
            "fields", getFieldPaths(verr.Fields),
            "ip", getClientIP(r),
        ).Warn("validation failed")
    }
    
    return err
}

6. Fail Secure

// Good - fail if validation library has issues
validator, err := validation.New(options...)
if err != nil {
    panic("failed to create validator: " + err.Error())
}

// Bad - continue without validation
validator, err := validation.New(options...)
if err != nil {
    log.Warn("validator creation failed, continuing anyway") // DANGEROUS
}

Common Security Vulnerabilities

SQL Injection

// VULNERABLE
type SearchRequest struct {
    Query string // No validation
}
db.Exec("SELECT * FROM users WHERE name = '" + req.Query + "'")

// SAFE
type SearchRequest struct {
    Query string `validate:"required,max=100,alphanum"`
}
if err := validation.Validate(ctx, &req); err != nil {
    return err
}
db.Exec("SELECT * FROM users WHERE name = ?", req.Query)

Path Traversal

// VULNERABLE
type FileRequest struct {
    Path string // No validation
}
os.ReadFile(req.Path) // Could be "../../etc/passwd"

// SAFE
type FileRequest struct {
    Path string `validate:"required,max=255"`
}

func (r *FileRequest) Validate() error {
    cleaned := filepath.Clean(r.Path)
    if strings.Contains(cleaned, "..") {
        return errors.New("path traversal detected")
    }
    if !strings.HasPrefix(cleaned, "/safe/directory/") {
        return errors.New("path outside allowed directory")
    }
    return nil
}

XXE (XML External Entity)

// VULNERABLE
xml.Unmarshal(req.Body, &data)

// SAFE
decoder := xml.NewDecoder(req.Body)
decoder.Strict = true
decoder.Entity = xml.HTMLEntity // Prevent external entities
err := decoder.Decode(&data)

Mass Assignment

// VULNERABLE
type UpdateUserRequest struct {
    Email string
    Role  string // User shouldn't be able to set this!
}

// SAFE - separate request types
type UpdateUserRequest struct {
    Email string `validate:"required,email"`
}

type AdminUpdateUserRequest struct {
    Email string `validate:"required,email"`
    Role  string `validate:"required,oneof=user admin"`
}

Security Checklist

  • All user input is validated before use
  • Sensitive fields are redacted in errors
  • Request size limits are enforced
  • Array/slice lengths are limited
  • Nesting depth is limited (handled automatically)
  • Unknown fields are rejected in strict mode
  • Validation failures are logged
  • Rate limiting is implemented
  • Timeouts are set for validation
  • SQL queries use parameterized statements
  • File paths are sanitized
  • HTML is escaped before rendering
  • Mass assignment is prevented

Next Steps

4.10 - Examples

Real-world validation patterns and integration examples

Complete examples showing how to use the validation package in real-world scenarios.

Basic REST API

Create User Endpoint

package main

import (
    "context"
    "encoding/json"
    "net/http"
    
    "rivaas.dev/validation"
)

type CreateUserRequest struct {
    Username string `json:"username" validate:"required,min=3,max=20,alphanum"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"required,min=18,max=120"`
    Password string `json:"password" validate:"required,min=8"`
}

var validator = validation.MustNew(
    validation.WithRedactor(func(path string) bool {
        return strings.Contains(path, "password")
    }),
    validation.WithMaxErrors(10),
)

func CreateUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    if err := validator.Validate(ctx, &req); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Create user
    user, err := createUser(ctx, req)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

func handleValidationError(w http.ResponseWriter, err error) {
    var verr *validation.Error
    if !errors.As(err, &verr) {
        http.Error(w, "validation failed", http.StatusBadRequest)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusUnprocessableEntity)
    json.NewEncoder(w).Encode(map[string]any{
        "error":  "validation_failed",
        "fields": verr.Fields,
    })
}

Update User Endpoint (PATCH)

type UpdateUserRequest struct {
    Email *string `json:"email" validate:"omitempty,email"`
    Age   *int    `json:"age" validate:"omitempty,min=18,max=120"`
}

func UpdateUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Read raw body
    rawJSON, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }
    
    // Compute which fields are present
    presence, err := validation.ComputePresence(rawJSON)
    if err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Parse into struct
    var req UpdateUserRequest
    if err := json.Unmarshal(rawJSON, &req); err != nil {
        http.Error(w, "invalid JSON", http.StatusBadRequest)
        return
    }
    
    // Validate only present fields
    if err := validation.ValidatePartial(ctx, &req, presence); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Update user
    userID := r.PathValue("id")
    if err := updateUser(ctx, userID, req, presence); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusOK)
}

Custom Validation Methods

Order Validation

type CreateOrderRequest struct {
    UserID    int         `json:"user_id"`
    Items     []OrderItem `json:"items"`
    CouponCode string     `json:"coupon_code"`
    Total     float64     `json:"total"`
}

type OrderItem struct {
    ProductID int     `json:"product_id" validate:"required"`
    Quantity  int     `json:"quantity" validate:"required,min=1"`
    Price     float64 `json:"price" validate:"required,min=0"`
}

func (r *CreateOrderRequest) ValidateContext(ctx context.Context) error {
    var verr validation.Error
    
    // Validate user exists
    if !userExists(ctx, r.UserID) {
        verr.Add("user_id", "not_found", "user does not exist", nil)
    }
    
    // Validate items
    if len(r.Items) == 0 {
        verr.Add("items", "required", "at least one item required", nil)
    }
    
    var calculatedTotal float64
    for i, item := range r.Items {
        // Validate product and price
        product, err := getProduct(ctx, item.ProductID)
        if err != nil {
            verr.Add(
                fmt.Sprintf("items.%d.product_id", i),
                "not_found",
                "product does not exist",
                nil,
            )
            continue
        }
        
        if item.Price != product.Price {
            verr.Add(
                fmt.Sprintf("items.%d.price", i),
                "mismatch",
                "price does not match current product price",
                map[string]any{
                    "expected": product.Price,
                    "actual":   item.Price,
                },
            )
        }
        
        calculatedTotal += item.Price * float64(item.Quantity)
    }
    
    // Validate coupon
    if r.CouponCode != "" {
        discount, err := validateCoupon(ctx, r.CouponCode)
        if err != nil {
            verr.Add("coupon_code", "invalid", err.Error(), nil)
        } else {
            calculatedTotal -= discount
        }
    }
    
    // Validate total
    if math.Abs(r.Total-calculatedTotal) > 0.01 {
        verr.Add(
            "total",
            "mismatch",
            "total does not match calculated amount",
            map[string]any{
                "expected": calculatedTotal,
                "actual":   r.Total,
            },
        )
    }
    
    if verr.HasErrors() {
        return &verr
    }
    return nil
}

Custom Validation Tags

Application Validator

package app

import (
    "regexp"
    "unicode"
    
    "github.com/go-playground/validator/v10"
    "rivaas.dev/validation"
)

var (
    phoneRegex    = regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)
    usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)
)

var Validator = validation.MustNew(
    // Custom tags
    validation.WithCustomTag("phone", validatePhone),
    validation.WithCustomTag("username", validateUsername),
    validation.WithCustomTag("strong_password", validateStrongPassword),
    
    // Security
    validation.WithRedactor(sensitiveFieldRedactor),
    validation.WithMaxErrors(20),
    
    // Custom messages
    validation.WithMessages(map[string]string{
        "required": "is required",
        "email":    "must be a valid email address",
    }),
)

func validatePhone(fl validator.FieldLevel) bool {
    return phoneRegex.MatchString(fl.Field().String())
}

func validateUsername(fl validator.FieldLevel) bool {
    return usernameRegex.MatchString(fl.Field().String())
}

func validateStrongPassword(fl validator.FieldLevel) bool {
    password := fl.Field().String()
    
    if len(password) < 8 {
        return false
    }
    
    var hasUpper, hasLower, hasDigit, hasSpecial bool
    for _, c := range password {
        switch {
        case unicode.IsUpper(c):
            hasUpper = true
        case unicode.IsLower(c):
            hasLower = true
        case unicode.IsDigit(c):
            hasDigit = true
        case unicode.IsPunct(c) || unicode.IsSymbol(c):
            hasSpecial = true
        }
    }
    
    return hasUpper && hasLower && hasDigit && hasSpecial
}

func sensitiveFieldRedactor(path string) bool {
    pathLower := strings.ToLower(path)
    return strings.Contains(pathLower, "password") ||
           strings.Contains(pathLower, "token") ||
           strings.Contains(pathLower, "secret") ||
           strings.Contains(pathLower, "card") ||
           strings.Contains(pathLower, "cvv")
}

Using Custom Tags

type Registration struct {
    Username string `validate:"required,username"`
    Phone    string `validate:"required,phone"`
    Password string `validate:"required,strong_password"`
}

func Register(w http.ResponseWriter, r *http.Request) {
    var req Registration
    json.NewDecoder(r.Body).Decode(&req)
    
    if err := app.Validator.Validate(r.Context(), &req); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Process registration
}

JSON Schema Validation

type Product struct {
    Name        string  `json:"name"`
    Price       float64 `json:"price"`
    Category    string  `json:"category"`
    InStock     bool    `json:"in_stock"`
    Tags        []string `json:"tags"`
}

func (p Product) JSONSchema() (id, schema string) {
    return "product-v1", `{
        "$schema": "http://json-schema.org/draft-07/schema#",
        "type": "object",
        "properties": {
            "name": {
                "type": "string",
                "minLength": 1,
                "maxLength": 100
            },
            "price": {
                "type": "number",
                "minimum": 0,
                "exclusiveMinimum": true
            },
            "category": {
                "type": "string",
                "enum": ["electronics", "clothing", "books", "food"]
            },
            "in_stock": {
                "type": "boolean"
            },
            "tags": {
                "type": "array",
                "items": {"type": "string"},
                "minItems": 1,
                "maxItems": 10,
                "uniqueItems": true
            }
        },
        "required": ["name", "price", "category"],
        "additionalProperties": false
    }`
}

func CreateProduct(w http.ResponseWriter, r *http.Request) {
    var product Product
    json.NewDecoder(r.Body).Decode(&product)
    
    // Validates using JSON Schema
    if err := validation.Validate(r.Context(), &product); err != nil {
        handleValidationError(w, err)
        return
    }
    
    // Save product
}

Multi-Strategy Validation

type User struct {
    Email    string `json:"email" validate:"required,email"`
    Username string `json:"username" validate:"required,min=3,max=20"`
}

// Add JSON Schema
func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{
        "type": "object",
        "properties": {
            "email": {"type": "string", "format": "email"},
            "username": {"type": "string", "minLength": 3, "maxLength": 20}
        }
    }`
}

// Add custom validation
func (u *User) ValidateContext(ctx context.Context) error {
    // Check username uniqueness
    if usernameExists(ctx, u.Username) {
        return errors.New("username already taken")
    }
    return nil
}

// Run all strategies
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

Testing

Testing Validation

func TestUserValidation(t *testing.T) {
    tests := []struct {
        name    string
        user    CreateUserRequest
        wantErr bool
        errCode string
    }{
        {
            name: "valid user",
            user: CreateUserRequest{
                Username: "alice",
                Email:    "alice@example.com",
                Age:      25,
                Password: "SecurePass123!",
            },
            wantErr: false,
        },
        {
            name: "invalid email",
            user: CreateUserRequest{
                Username: "alice",
                Email:    "invalid",
                Age:      25,
                Password: "SecurePass123!",
            },
            wantErr: true,
            errCode: "tag.email",
        },
        {
            name: "underage",
            user: CreateUserRequest{
                Username: "alice",
                Email:    "alice@example.com",
                Age:      15,
                Password: "SecurePass123!",
            },
            wantErr: true,
            errCode: "tag.min",
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validator.Validate(context.Background(), &tt.user)
            
            if tt.wantErr {
                if err == nil {
                    t.Error("expected error, got nil")
                    return
                }
                
                var verr *validation.Error
                if !errors.As(err, &verr) {
                    t.Error("expected validation.Error")
                    return
                }
                
                if tt.errCode != "" && !verr.HasCode(tt.errCode) {
                    t.Errorf("expected error code %s", tt.errCode)
                }
            } else {
                if err != nil {
                    t.Errorf("unexpected error: %v", err)
                }
            }
        })
    }
}

Testing Partial Validation

func TestPartialValidation(t *testing.T) {
    tests := []struct {
        name    string
        json    string
        wantErr bool
    }{
        {
            name:    "valid email update",
            json:    `{"email": "new@example.com"}`,
            wantErr: false,
        },
        {
            name:    "invalid email update",
            json:    `{"email": "invalid"}`,
            wantErr: true,
        },
        {
            name:    "valid age update",
            json:    `{"age": 25}`,
            wantErr: false,
        },
        {
            name:    "underage update",
            json:    `{"age": 15}`,
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            presence, _ := validation.ComputePresence([]byte(tt.json))
            
            var req UpdateUserRequest
            json.Unmarshal([]byte(tt.json), &req)
            
            err := validation.ValidatePartial(context.Background(), &req, presence)
            
            if (err != nil) != tt.wantErr {
                t.Errorf("ValidatePartial() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Integration with rivaas/router

import "rivaas.dev/router"

func Handler(c *router.Context) error {
    var req CreateUserRequest
    if err := c.BindJSON(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{
            "error": "invalid JSON",
        })
    }
    
    if err := validator.Validate(c.Request().Context(), &req); err != nil {
        var verr *validation.Error
        if errors.As(err, &verr) {
            return c.JSON(http.StatusUnprocessableEntity, map[string]any{
                "error":  "validation_failed",
                "fields": verr.Fields,
            })
        }
        return err
    }
    
    // Process request
    return c.JSON(http.StatusOK, createUser(req))
}

Integration with rivaas/app

import "rivaas.dev/app"

func Handler(c *app.Context) error {
    var req CreateUserRequest
    if err := c.Bind(&req); err != nil {
        return err // Automatically handled
    }
    
    // Validation happens automatically with app.Context
    // But you can also validate manually:
    if err := validator.Validate(c.Context(), &req); err != nil {
        return err // Automatically converted to proper HTTP response
    }
    
    return c.JSON(http.StatusOK, createUser(req))
}

Performance Tips

Reuse Validator Instances

// Good - create once
var appValidator = validation.MustNew(
    validation.WithMaxErrors(10),
)

func Handler1(ctx context.Context, req Request1) error {
    return appValidator.Validate(ctx, &req)
}

func Handler2(ctx context.Context, req Request2) error {
    return appValidator.Validate(ctx, &req)
}

// Bad - create every time (slow)
func Handler(ctx context.Context, req Request) error {
    validator := validation.MustNew()
    return validator.Validate(ctx, &req)
}

Use Package-Level Functions for Simple Cases

// Simple validation - use package-level function
err := validation.Validate(ctx, &req)

// Complex validation - create validator instance
validator := validation.MustNew(
    validation.WithCustomTag("phone", validatePhone),
    validation.WithRedactor(redactor),
)
err := validator.Validate(ctx, &req)

Next Steps

5 - Configuration Management

Learn how to manage application configuration with the Rivaas config package

The Rivaas Config package provides configuration management for Go applications. It simplifies handling settings across different environments and formats. Follows the Twelve-Factor App methodology.

Features

  • Easy Integration: Simple and intuitive API
  • Flexible Sources: Load from files, environment variables (with custom prefixes), Consul, and easily extend with custom sources
  • Dynamic Paths: Use ${VAR} in file and Consul paths for environment-based configuration
  • Format Agnostic: Supports JSON, YAML, TOML, and other formats via extensible codecs
  • Type Casting: Built-in caster codecs for automatic type conversion (bool, int, float, time, duration, etc.)
  • Hierarchical Merging: Configurations from multiple sources are merged, with later sources overriding earlier ones
  • Struct Binding: Automatically map configuration data to Go structs
  • Built-in Validation: Validate configuration using struct methods, JSON Schemas, or custom functions
  • Dot Notation Access: Navigate nested configuration easily (e.g., cfg.String("database.host"))
  • Type-Safe Retrieval: Get values as specific types (string, int, bool, etc.), with error-returning options for robust handling
  • Configuration Dumping: Save the effective configuration to files or other custom destinations
  • Thread-Safe: Safe for concurrent access and configuration loading in multi-goroutine applications
  • Nil-Safe Operations: All getter methods handle nil Config instances gracefully

Quick Start

Here’s a 30-second example to get you started:

package main

import (
    "rivaas.dev/config"
    "context"
    "log"
)

func main() {
    // Create config with multiple sources
    cfg := config.MustNew(
        config.WithFile("config.yaml"),   // Auto-detects YAML format
        config.WithFile("config.json"),   // Auto-detects JSON format
        config.WithEnv("APP_"),           // Load environment variables with APP_ prefix
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load configuration: %v", err)
    }

    // Access configuration values - simple and clean!
    port := cfg.Int("server.port")
    host := cfg.StringOr("server.host", "localhost")  // With default value
    debug := cfg.Bool("debug")
    
    log.Printf("Server running on %s:%d (debug: %v)", host, port, debug)
}

How It Works

  • Sources are loaded in order; later sources override earlier ones
  • Dot notation allows deep access: cfg.Get("database.host")
  • Type-safe accessors: String, Int, Bool, etc., plus generic Get[T], GetOr[T], GetE[T] for custom types
  • Context validation: Both Load() and Dump() methods validate that context is not nil
  • Error handling: All methods return descriptive errors for easier debugging

Learning Path

Follow these guides to master configuration management with Rivaas:

  1. Installation - Get started with the config package
  2. Basic Usage - Learn the fundamentals of loading and accessing configuration
  3. Environment Variables - Master environment variable integration
  4. Struct Binding - Map configuration to Go structs automatically
  5. Validation - Ensure configuration correctness with validation
  6. Multiple Sources - Combine configuration from different sources
  7. Custom Codecs - Extend support to custom formats
  8. Examples - See real-world usage patterns

Next Steps

5.1 - Installation

Install and set up the Rivaas config package for your Go application

Get started with the Rivaas config package by installing it in your Go project.

Prerequisites

  • Go 1.25 or higher - The config package requires Go 1.25+
  • Basic familiarity with Go modules

Installation

Install the config package using go get:

go get rivaas.dev/config

This will add the package to your go.mod file and download the dependencies.

Verify Installation

Create a simple test to verify the installation is working:

package main

import (
    "context"
    "fmt"
    "rivaas.dev/config"
)

func main() {
    cfg := config.MustNew()
    if err := cfg.Load(context.Background()); err != nil {
        panic(err)
    }
    fmt.Println("Config package installed successfully!")
}

Save this as main.go and run:

go run main.go

If you see “Config package installed successfully!”, the installation is complete!

Import Path

Always import the config package using:

import "rivaas.dev/config"

Additional Packages

Depending on your use case, you may also want to import sub-packages:

import (
    "rivaas.dev/config"
    "rivaas.dev/config/codec"   // For custom codecs
    "rivaas.dev/config/dumper"  // For custom dumpers
    "rivaas.dev/config/source"  // For custom sources
)

Common Issues

Go Version Too Old

If you get an error about Go version:

go: rivaas.dev/config requires go >= 1.25

Update your Go installation to version 1.25 or higher:

go version  # Check current version

Visit golang.org/dl/ to download the latest version.

Module Not Found

If you get a “module not found” error:

go: rivaas.dev/config: module rivaas.dev/config: Get "https://rivaas.dev/config": dial tcp: lookup rivaas.dev

Make sure you have network connectivity and try:

go clean -modcache
go get rivaas.dev/config

Dependency Conflicts

If you experience dependency conflicts, ensure your go.mod is up to date:

go mod tidy

Next Steps

Now that you have the config package installed:

For complete API documentation, visit the API Reference.

5.2 - Basic Usage

Learn the fundamentals of loading and accessing configuration with Rivaas

This guide covers the essential operations for working with the config package. Learn how to load configuration files, access values, and handle errors.

Loading Configuration Files

The config package automatically detects file formats based on the file extension. Supported formats include JSON, YAML, and TOML.

Simple File Loading

package main

import (
    "context"
    "log"
    "rivaas.dev/config"
)

func main() {
    cfg := config.MustNew(
        config.WithFile("config.yaml"),
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }
}

Multiple File Formats

You can load multiple configuration files of different formats:

cfg := config.MustNew(
    config.WithFile("config.yaml"),  // YAML format
    config.WithFile("config.json"),  // JSON format
    config.WithFile("config.toml"),  // TOML format
)

Environment Variables in Paths

You can use environment variables in file paths. This is useful when different environments use different directories:

// Use ${VAR} or $VAR in paths
cfg := config.MustNew(
    config.WithFile("${CONFIG_DIR}/app.yaml"),      // Expands to actual directory
    config.WithFile("${APP_ENV}/overrides.yaml"),   // e.g., "production/overrides.yaml"
)

This works with all path-based options: WithFile, WithFileAs, WithConsul, WithConsulAs, WithFileDumper, and WithFileDumperAs.

Built-in Format Support

The config package includes built-in codecs for common formats:

FormatExtensionCodec Type
JSON.jsoncodec.TypeJSON
YAML.yaml, .ymlcodec.TypeYAML
TOML.tomlcodec.TypeTOML
Environment Variables-codec.TypeEnvVar

Accessing Configuration Values

Once loaded, access configuration using dot notation and type-safe getters.

Dot Notation

Navigate nested configuration structures using dots:

// Given config: { "database": { "host": "localhost", "port": 5432 } }
host := cfg.String("database.host")      // "localhost"
port := cfg.Int("database.port")         // 5432

Type-Safe Getters

The config package provides type-safe getters for common data types:

// Basic types
stringVal := cfg.String("key")
intVal := cfg.Int("key")
boolVal := cfg.Bool("key")
floatVal := cfg.Float64("key")

// Time and duration
duration := cfg.Duration("timeout")
timestamp := cfg.Time("created_at")

// Collections
slice := cfg.StringSlice("tags")
mapping := cfg.StringMap("metadata")

Getters with Default Values

Use Or variants to provide fallback values:

host := cfg.StringOr("server.host", "localhost")
port := cfg.IntOr("server.port", 8080)
debug := cfg.BoolOr("debug", false)
timeout := cfg.DurationOr("timeout", 30*time.Second)

Generic Getters for Custom Types

For custom types or explicit error handling, use the generic GetE function:

// With error handling
port, err := config.GetE[int](cfg, "server.port")
if err != nil {
    log.Printf("invalid port: %v", err)
    port = 8080  // fallback
}

// For custom types
type DatabaseConfig struct {
    Host string
    Port int
}

dbConfig, err := config.GetE[DatabaseConfig](cfg, "database")
if err != nil {
    log.Fatalf("invalid database config: %v", err)
}

Error Handling

The config package provides comprehensive error handling through different getter variants.

Short Form (No Error)

Short methods return zero values for missing keys:

cfg.String("nonexistent")      // Returns ""
cfg.Int("nonexistent")         // Returns 0
cfg.Bool("nonexistent")        // Returns false
cfg.StringSlice("nonexistent") // Returns []string{}
cfg.StringMap("nonexistent")   // Returns map[string]any{}

This approach is ideal when you want simple access with sensible defaults.

Default Value Form (Or Methods)

Or methods provide explicit fallback values:

host := cfg.StringOr("host", "localhost")        // Returns "localhost" if missing
port := cfg.IntOr("port", 8080)                  // Returns 8080 if missing
debug := cfg.BoolOr("debug", false)              // Returns false if missing
timeout := cfg.DurationOr("timeout", 30*time.Second) // Returns 30s if missing

Error Returning Form (E Methods)

Use GetE for explicit error handling:

port, err := config.GetE[int](cfg, "server.port")
if err != nil {
    return fmt.Errorf("invalid port configuration: %w", err)
}

// Errors provide context
// Example: "config error: key 'server.port' not found"

ConfigError Structure

When errors occur during loading, they’re wrapped in ConfigError:

type ConfigError struct {
    Source    string // Where the error occurred (e.g., "source[0]")
    Field     string // Specific field with the error
    Operation string // Operation being performed (e.g., "load")
    Err       error  // Underlying error
}

Example error handling during load:

if err := cfg.Load(context.Background()); err != nil {
    // Error message includes context:
    // "config error in source[0] during load: file not found: config.yaml"
    log.Fatalf("configuration error: %v", err)
}

Nil-Safe Operations

All getter methods handle nil Config instances gracefully:

var cfg *config.Config  // nil

// Short methods return zero values (no panic)
cfg.String("key")       // Returns ""
cfg.Int("key")          // Returns 0
cfg.Bool("key")         // Returns false

// Error methods return errors
port, err := config.GetE[int](cfg, "key")
// err: "config instance is nil"

Complete Example

Putting it all together:

package main

import (
    "context"
    "log"
    "time"
    "rivaas.dev/config"
)

func main() {
    // Create config with file source
    cfg := config.MustNew(
        config.WithFile("config.yaml"),
    )

    // Load configuration
    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    // Access values with different approaches
    
    // Simple access (with zero values for missing keys)
    host := cfg.String("server.host")
    
    // With defaults
    port := cfg.IntOr("server.port", 8080)
    debug := cfg.BoolOr("debug", false)
    
    // With error handling
    timeout, err := config.GetE[time.Duration](cfg, "server.timeout")
    if err != nil {
        log.Printf("using default timeout: %v", err)
        timeout = 30 * time.Second
    }
    
    log.Printf("Server: %s:%d (debug: %v, timeout: %v)", 
        host, port, debug, timeout)
}

Sample config.yaml:

server:
  host: localhost
  port: 8080
  timeout: 30s
debug: true

Next Steps

For complete API details, see the API Reference.

5.3 - Environment Variables

Master environment variable integration with hierarchical naming conventions

The config package provides powerful environment variable support. It automatically maps environment variables to nested configuration structures. This follows the Twelve-Factor App methodology for configuration management.

Basic Usage

Enable environment variable support with a custom prefix:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("MYAPP_"),  // Only env vars with MYAPP_ prefix
)

The prefix helps avoid conflicts with system or other application variables.

Naming Convention

The config package uses a hierarchical naming convention where underscores (_) in environment variable names create nested configuration structures.

Transformation Rules

  1. Strip prefix: Remove the configured prefix like MYAPP_.
  2. Convert to lowercase: DATABASE_HOSTdatabase_host.
  3. Split by underscores: database_host["database", "host"].
  4. Filter empty parts: Consecutive underscores create no extra levels.
  5. Create dot notation: ["database", "host"]database.host.

Visualization

graph TB
    EnvVar[MYAPP_DATABASE_HOST=localhost] --> StripPrefix[Strip Prefix MYAPP_]
    StripPrefix --> Lowercase[database_host]
    Lowercase --> SplitUnderscore[Split by underscore]
    SplitUnderscore --> FilterEmpty[Filter empty parts]
    FilterEmpty --> DotNotation[database.host = localhost]

Examples

Environment VariableConfiguration PathValue
MYAPP_SERVER_PORTserver.port8080
MYAPP_DATABASE_HOSTdatabase.hostlocalhost
MYAPP_DATABASE_USER_NAMEdatabase.user.nameadmin
MYAPP_FOO__BARfoo.barvalue
MYAPP_A_B_C_Da.b.c.dnested

Basic Example

export MYAPP_SERVER_HOST=localhost
export MYAPP_SERVER_PORT=8080
export MYAPP_DEBUG=true
cfg := config.MustNew(
    config.WithEnv("MYAPP_"),
)
cfg.Load(context.Background())

host := cfg.String("server.host")   // "localhost"
port := cfg.Int("server.port")      // 8080
debug := cfg.Bool("debug")          // true

Nested Configuration

Environment variables naturally create nested structures:

export MYAPP_DATABASE_PRIMARY_HOST=db1.example.com
export MYAPP_DATABASE_PRIMARY_PORT=5432
export MYAPP_DATABASE_REPLICA_HOST=db2.example.com
export MYAPP_DATABASE_REPLICA_PORT=5432

Access nested values:

primaryHost := cfg.String("database.primary.host")  // "db1.example.com"
replicaHost := cfg.String("database.replica.host")  // "db2.example.com"

Struct Field Mapping

When using struct binding, environment variables map directly to struct fields using the config tag:

type Config struct {
    Port     int    `config:"port"`
    Host     string `config:"host"`
    Database struct {
        Host     string `config:"host"`
        Port     int    `config:"port"`
        Username string `config:"username"`
        Password string `config:"password"`
    } `config:"database"`
}

Required environment variables:

export MYAPP_PORT=8080
export MYAPP_HOST=localhost
export MYAPP_DATABASE_HOST=db.example.com
export MYAPP_DATABASE_PORT=5432
export MYAPP_DATABASE_USERNAME=admin
export MYAPP_DATABASE_PASSWORD=secret123

Usage:

var appConfig Config
cfg := config.MustNew(
    config.WithEnv("MYAPP_"),
    config.WithBinding(&appConfig),
)

if err := cfg.Load(context.Background()); err != nil {
    log.Fatalf("config error: %v", err)
}

// appConfig is now populated from environment variables

Advanced Nested Structures

For complex applications with deeply nested configuration:

type AppConfig struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
        TLS  struct {
            Enabled  bool   `config:"enabled"`
            CertFile string `config:"cert_file"`
            KeyFile  string `config:"key_file"`
        } `config:"tls"`
    } `config:"server"`
    Database struct {
        Primary struct {
            Host     string `config:"host"`
            Port     int    `config:"port"`
            Database string `config:"database"`
        } `config:"primary"`
        Replica struct {
            Host     string `config:"host"`
            Port     int    `config:"port"`
            Database string `config:"database"`
        } `config:"replica"`
    } `config:"database"`
}

Environment variables:

export MYAPP_SERVER_HOST=0.0.0.0
export MYAPP_SERVER_PORT=8080
export MYAPP_SERVER_TLS_ENABLED=true
export MYAPP_SERVER_TLS_CERT_FILE=/etc/ssl/certs/server.crt
export MYAPP_SERVER_TLS_KEY_FILE=/etc/ssl/private/server.key
export MYAPP_DATABASE_PRIMARY_HOST=primary.db.example.com
export MYAPP_DATABASE_PRIMARY_PORT=5432
export MYAPP_DATABASE_PRIMARY_DATABASE=myapp
export MYAPP_DATABASE_REPLICA_HOST=replica.db.example.com
export MYAPP_DATABASE_REPLICA_PORT=5432
export MYAPP_DATABASE_REPLICA_DATABASE=myapp

Edge Cases

Consecutive Underscores

Multiple consecutive underscores are filtered to prevent empty parts:

export MYAPP_FOO__BAR=value    # Becomes: foo.bar = "value"
export MYAPP_A___B=value       # Becomes: a.b = "value"

Type Conflicts

If environment variables create conflicts between scalar and nested values, the nested structure takes precedence:

export MYAPP_FOO=scalar_value
export MYAPP_FOO_BAR=nested_value
# Result: foo.bar = "nested_value" (scalar "foo" is overwritten)

Whitespace Handling

Keys and values are automatically trimmed:

export MYAPP_KEY="  value  "   # Becomes: key = "value"

Best Practices

1. Use Descriptive Prefixes

Always use application-specific prefixes to avoid conflicts:

# Good - Application-specific
export MYAPP_DATABASE_HOST=localhost
export WEBAPP_DATABASE_HOST=localhost

# Avoid - Too generic
export DATABASE_HOST=localhost

2. Consistent Naming

Use consistent patterns across your application:

# Consistent pattern
export MYAPP_SERVER_HOST=localhost
export MYAPP_SERVER_PORT=8080
export MYAPP_SERVER_TIMEOUT=30s

3. Document Your Variables

Document required and optional environment variables:

# Required environment variables:
# MYAPP_SERVER_HOST - Server hostname (default: localhost)
# MYAPP_SERVER_PORT - Server port (default: 8080)
# MYAPP_DATABASE_HOST - Database hostname (required)
# MYAPP_DATABASE_PORT - Database port (default: 5432)

4. Validate Configuration

Use struct validation to ensure required variables are set:

func (c *Config) Validate() error {
    if c.Server.Host == "" {
        return errors.New("MYAPP_SERVER_HOST is required")
    }
    if c.Server.Port <= 0 {
        return errors.New("MYAPP_SERVER_PORT must be positive")
    }
    return nil
}

Merging with Other Sources

Environment variables can override file-based configuration:

cfg := config.MustNew(
    config.WithFile("config.yaml"),      // Base configuration
    config.WithFile("config.prod.yaml"), // Production overrides
    config.WithEnv("MYAPP_"),            // Environment overrides all files
)

Source precedence: Later sources override earlier ones. Environment variables, being last, have the highest priority.

Complete Example

package main

import (
    "context"
    "log"
    "rivaas.dev/config"
)

type AppConfig struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
    } `config:"server"`
    Database struct {
        Host     string `config:"host"`
        Port     int    `config:"port"`
        Username string `config:"username"`
        Password string `config:"password"`
    } `config:"database"`
}

func (c *AppConfig) Validate() error {
    if c.Database.Host == "" {
        return errors.New("database host is required")
    }
    if c.Database.Username == "" {
        return errors.New("database username is required")
    }
    return nil
}

func main() {
    var appConfig AppConfig
    
    cfg := config.MustNew(
        config.WithFile("config.yaml"),  // Base config
        config.WithEnv("MYAPP_"),        // Override with env vars
        config.WithBinding(&appConfig),
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    log.Printf("Server: %s:%d", appConfig.Server.Host, appConfig.Server.Port)
    log.Printf("Database: %s:%d", appConfig.Database.Host, appConfig.Database.Port)
}

Next Steps

For technical details on the environment variable codec, see Codecs Reference.

5.4 - Struct Binding

Automatically map configuration data to Go structs with type safety

Struct binding allows you to automatically map configuration data to your own Go structs. This provides type safety and a clean, idiomatic way to work with configuration.

Basic Struct Binding

Define a struct and bind it during configuration initialization:

type Config struct {
    Port int    `config:"port"`
    Host string `config:"host"`
}

var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&c),
)

if err := cfg.Load(context.Background()); err != nil {
    log.Fatalf("failed to load config: %v", err)
}

// c.Port and c.Host are now populated
log.Printf("Server: %s:%d", c.Host, c.Port)

Config Tags

Use the config tag to specify the configuration key for each field:

type Config struct {
    Port    int    `config:"port"`
    Host    string `config:"host"`
    Timeout int    `config:"timeout"`
}

The tag value should match the key name at that struct’s level in the configuration hierarchy.

Tag Naming

  • Tags are case-sensitive.
  • Use snake_case or lowercase for consistency.
  • Match the structure of your configuration files.
# config.yaml
port: 8080
host: localhost
timeout: 30

Default Values

Specify default values using the default tag:

type Config struct {
    Port    int           `config:"port" default:"8080"`
    Host    string        `config:"host" default:"localhost"`
    Debug   bool          `config:"debug" default:"false"`
    Timeout time.Duration `config:"timeout" default:"30s"`
}

Default values are used when:

  • The configuration key is not found.
  • The configuration file doesn’t exist.
  • Environment variables don’t provide the value.
var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),  // May not exist or be incomplete
    config.WithBinding(&c),
)

cfg.Load(context.Background())
// Fields use defaults if not present in config.yaml

Nested Structs

Create hierarchical configuration by nesting structs:

type Config struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
    } `config:"server"`
    
    Database struct {
        Host     string `config:"host"`
        Port     int    `config:"port"`
        Username string `config:"username"`
        Password string `config:"password"`
    } `config:"database"`
}

Corresponding YAML:

server:
  host: localhost
  port: 8080

database:
  host: db.example.com
  port: 5432
  username: admin
  password: secret

Usage:

var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&c),
)

cfg.Load(context.Background())

log.Printf("Server: %s:%d", c.Server.Host, c.Server.Port)
log.Printf("Database: %s:%d", c.Database.Host, c.Database.Port)

Pointer Fields for Optional Values

Use pointer fields when values are truly optional:

type Config struct {
    Port     int     `config:"port"`
    Host     string  `config:"host"`
    CacheURL *string `config:"cache_url"`  // Optional
    Debug    *bool   `config:"debug"`       // Optional
}

If the configuration key is missing, pointer fields remain nil:

var c Config
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&c),
)

cfg.Load(context.Background())

if c.CacheURL != nil {
    log.Printf("Using cache: %s", *c.CacheURL)
} else {
    log.Printf("Cache disabled")
}

Deeply Nested Structures

For complex applications, create deeply nested configuration:

type AppConfig struct {
    Server struct {
        HTTP struct {
            Host string `config:"host"`
            Port int    `config:"port"`
        } `config:"http"`
        TLS struct {
            Enabled  bool   `config:"enabled"`
            CertFile string `config:"cert_file"`
            KeyFile  string `config:"key_file"`
        } `config:"tls"`
    } `config:"server"`
    
    Database struct {
        Primary struct {
            Host     string `config:"host"`
            Port     int    `config:"port"`
            Database string `config:"database"`
        } `config:"primary"`
        Replica struct {
            Host     string `config:"host"`
            Port     int    `config:"port"`
            Database string `config:"database"`
        } `config:"replica"`
    } `config:"database"`
}

Corresponding YAML:

server:
  http:
    host: 0.0.0.0
    port: 8080
  tls:
    enabled: true
    cert_file: /etc/ssl/certs/server.crt
    key_file: /etc/ssl/private/server.key

database:
  primary:
    host: primary.db.example.com
    port: 5432
    database: myapp
  replica:
    host: replica.db.example.com
    port: 5432
    database: myapp

Slices and Maps

Bind slices and maps for collection data:

type Config struct {
    Hosts    []string          `config:"hosts"`
    Ports    []int             `config:"ports"`
    Metadata map[string]string `config:"metadata"`
    Features map[string]bool   `config:"features"`
}

YAML:

hosts:
  - localhost
  - example.com
  - api.example.com

ports:
  - 8080
  - 8081
  - 8082

metadata:
  version: "1.0.0"
  environment: production

features:
  auth: true
  cache: true
  debug: false

Type Conversion

The config package automatically converts between compatible types:

type Config struct {
    Port    int           `config:"port"`     // Converts from string "8080"
    Debug   bool          `config:"debug"`    // Converts from string "true"
    Timeout time.Duration `config:"timeout"`  // Converts from string "30s"
}

YAML (as strings):

port: "8080"      # String converted to int
debug: "true"     # String converted to bool
timeout: "30s"    # String converted to time.Duration

Common Issues and Solutions

Issue: Struct Not Populating

Problem: Fields remain at zero values after loading.

Solutions:

  1. Pass a pointer: Use WithBinding(&c), not WithBinding(c)
// Wrong
cfg := config.MustNew(config.WithBinding(c))

// Correct
cfg := config.MustNew(config.WithBinding(&c))
  1. Check tag names: Ensure config tags match your configuration structure
// If your YAML has "server_port", use:
Port int `config:"server_port"`

// Not:
Port int `config:"port"`
  1. Verify nested tags: All nested structs need the config tag
// Wrong - missing tag on Server struct
type Config struct {
    Server struct {
        Port int `config:"port"`
    }  // Missing `config:"server"`
}

// Correct
type Config struct {
    Server struct {
        Port int `config:"port"`
    } `config:"server"`
}

Issue: Type Mismatch Errors

Problem: Error during binding due to type incompatibility.

Solution: Ensure your struct types match the configuration data types or are compatible:

// If YAML has: port: 8080 (number)
Port int `config:"port"`  // Correct

// If YAML has: port: "8080" (string)
Port int `config:"port"`  // Still works - automatic conversion

Issue: Optional Fields Always Present

Problem: Want to distinguish between “not set” and “set to zero value”.

Solution: Use pointer types:

type Config struct {
    // Can't distinguish "not set" vs "set to 0"
    MaxConnections int `config:"max_connections"`
    
    // Can distinguish: nil = not set, &0 = set to 0
    MaxConnections *int `config:"max_connections"`
}

Complete Example

package main

import (
    "context"
    "log"
    "time"
    "rivaas.dev/config"
)

type AppConfig struct {
    Server struct {
        Host    string        `config:"host" default:"localhost"`
        Port    int           `config:"port" default:"8080"`
        Timeout time.Duration `config:"timeout" default:"30s"`
    } `config:"server"`
    
    Database struct {
        Host     string `config:"host"`
        Port     int    `config:"port" default:"5432"`
        Username string `config:"username"`
        Password string `config:"password"`
        MaxConns *int   `config:"max_connections"` // Optional
    } `config:"database"`
    
    Features struct {
        EnableCache bool `config:"enable_cache" default:"true"`
        EnableAuth  bool `config:"enable_auth" default:"true"`
    } `config:"features"`
}

func (c *AppConfig) Validate() error {
    if c.Database.Host == "" {
        return errors.New("database host is required")
    }
    if c.Database.Username == "" {
        return errors.New("database username is required")
    }
    return nil
}

func main() {
    var appConfig AppConfig
    
    cfg := config.MustNew(
        config.WithFile("config.yaml"),
        config.WithEnv("MYAPP_"),
        config.WithBinding(&appConfig),
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    log.Printf("Server: %s:%d (timeout: %v)",
        appConfig.Server.Host,
        appConfig.Server.Port,
        appConfig.Server.Timeout)
    
    log.Printf("Database: %s:%d",
        appConfig.Database.Host,
        appConfig.Database.Port)
    
    if appConfig.Database.MaxConns != nil {
        log.Printf("Max DB connections: %d", *appConfig.Database.MaxConns)
    }
}

config.yaml:

server:
  host: 0.0.0.0
  port: 8080
  timeout: 60s

database:
  host: postgres.example.com
  port: 5432
  username: myapp
  password: secret123
  max_connections: 100

features:
  enable_cache: true
  enable_auth: true

Next Steps

For technical details, see the API Reference.

5.5 - Validation

Validate configuration to catch errors early and ensure application correctness

The config package supports multiple validation strategies. These help catch configuration errors early. They ensure your application runs with correct settings.

Validation Strategies

The config package provides three validation approaches:

  1. Struct-based validation - Implement Validate() error on your struct.
  2. JSON Schema validation - Validate against a JSON Schema.
  3. Custom validation functions - Use custom validation logic.

Struct-Based Validation

The most idiomatic approach for Go applications. Implement the Validate() method on your configuration struct:

type Validator interface {
    Validate() error
}

Basic Example

type Config struct {
    Port int    `config:"port"`
    Host string `config:"host"`
}

func (c *Config) Validate() error {
    if c.Port <= 0 || c.Port > 65535 {
        return errors.New("port must be between 1 and 65535")
    }
    if c.Host == "" {
        return errors.New("host is required")
    }
    return nil
}

var cfg Config
config := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&cfg),
)

// Validation runs automatically during Load()
if err := config.Load(context.Background()); err != nil {
    log.Fatalf("invalid configuration: %v", err)
}

Complex Validation

Validate nested structures and relationships:

type AppConfig struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
        TLS  struct {
            Enabled  bool   `config:"enabled"`
            CertFile string `config:"cert_file"`
            KeyFile  string `config:"key_file"`
        } `config:"tls"`
    } `config:"server"`
    
    Database struct {
        Host        string `config:"host"`
        Port        int    `config:"port"`
        MaxConns    int    `config:"max_connections"`
        IdleConns   int    `config:"idle_connections"`
    } `config:"database"`
}

func (c *AppConfig) Validate() error {
    // Server validation
    if c.Server.Port < 1 || c.Server.Port > 65535 {
        return fmt.Errorf("server.port must be between 1-65535, got %d", c.Server.Port)
    }
    
    // TLS validation
    if c.Server.TLS.Enabled {
        if c.Server.TLS.CertFile == "" {
            return errors.New("server.tls.cert_file required when TLS enabled")
        }
        if c.Server.TLS.KeyFile == "" {
            return errors.New("server.tls.key_file required when TLS enabled")
        }
    }
    
    // Database validation
    if c.Database.Host == "" {
        return errors.New("database.host is required")
    }
    if c.Database.MaxConns < c.Database.IdleConns {
        return fmt.Errorf("database.max_connections (%d) must be >= idle_connections (%d)",
            c.Database.MaxConns, c.Database.IdleConns)
    }
    
    return nil
}

Field-Level Validation

Create reusable validation helpers:

func validatePort(port int) error {
    if port < 1 || port > 65535 {
        return fmt.Errorf("invalid port %d: must be between 1-65535", port)
    }
    return nil
}

func validateHostname(host string) error {
    if host == "" {
        return errors.New("hostname cannot be empty")
    }
    if len(host) > 253 {
        return errors.New("hostname too long (max 253 characters)")
    }
    return nil
}

func (c *Config) Validate() error {
    if err := validatePort(c.Server.Port); err != nil {
        return fmt.Errorf("server.port: %w", err)
    }
    if err := validateHostname(c.Server.Host); err != nil {
        return fmt.Errorf("server.host: %w", err)
    }
    return nil
}

JSON Schema Validation

What is JSON Schema?
JSON Schema is a standard for describing the structure and validation rules of JSON data. It allows you to define required fields, data types, value constraints, and more.

Validate the merged configuration map against a JSON Schema:

schemaBytes, err := os.ReadFile("schema.json")
if err != nil {
    log.Fatalf("failed to read schema: %v", err)
}

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithJSONSchema(schemaBytes),
)

// Schema validation runs during Load()
if err := cfg.Load(context.Background()); err != nil {
    log.Fatalf("configuration validation failed: %v", err)
}

Example Schema

schema.json:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["server", "database"],
  "properties": {
    "server": {
      "type": "object",
      "required": ["host", "port"],
      "properties": {
        "host": {
          "type": "string",
          "minLength": 1
        },
        "port": {
          "type": "integer",
          "minimum": 1,
          "maximum": 65535
        }
      }
    },
    "database": {
      "type": "object",
      "required": ["host", "port"],
      "properties": {
        "host": {
          "type": "string",
          "minLength": 1
        },
        "port": {
          "type": "integer",
          "minimum": 1,
          "maximum": 65535
        },
        "username": {
          "type": "string"
        },
        "password": {
          "type": "string"
        }
      }
    }
  }
}

Custom Validation Functions

Register custom validation functions for flexible validation logic:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithValidator(func(data map[string]any) error {
        // Validate the configuration map
        port, ok := data["port"].(int)
        if !ok {
            return errors.New("port must be an integer")
        }
        if port <= 0 {
            return errors.New("port must be positive")
        }
        return nil
    }),
)

Multiple Validators

You can register multiple validators - all will be executed:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithValidator(validatePorts),
    config.WithValidator(validateHosts),
    config.WithValidator(validateFeatures),
)

func validatePorts(data map[string]any) error {
    // Port validation logic
}

func validateHosts(data map[string]any) error {
    // Host validation logic
}

func validateFeatures(data map[string]any) error {
    // Feature flag validation logic
}

Validation Workflow

The validation process follows this order:

graph TB
    Load[cfg.Load] --> Sources[Load Sources]
    Sources --> Merge[Merge Data]
    Merge --> JSONSchema{JSON Schema?}
    JSONSchema -->|Yes| ValidateSchema[Validate Schema]
    JSONSchema -->|No| CustomVal
    ValidateSchema --> CustomVal{Custom Validator?}
    CustomVal -->|Yes| RunCustom[Run Function]
    CustomVal -->|No| Binding
    RunCustom --> Binding{Binding?}
    Binding -->|Yes| BindStruct[Bind Struct]
    Binding -->|No| Success
    BindStruct --> StructVal{Has Validate?}
    StructVal -->|Yes| RunValidate[Run Validate]
    StructVal -->|No| Success[Success]
    RunValidate --> Success

Validation order:

  1. Load and merge all sources
  2. Run JSON Schema validation (if configured)
  3. Run custom validation functions (if configured)
  4. Bind to struct (if configured)
  5. Run struct Validate() method (if implemented)

Comparison Table

Validation TypeFor StructsFor MapsWhen to Use
Struct-based (Validate() error)✅ Yes❌ NoType-safe validation with Go code
JSON Schema❌ No✅ YesStandard schema validation, language-agnostic
Custom Function✅ Yes✅ YesComplex logic, cross-field validation

Combining Validation Strategies

You can combine multiple validation approaches:

type AppConfig struct {
    Server struct {
        Port int    `config:"port"`
        Host string `config:"host"`
    } `config:"server"`
}

func (c *AppConfig) Validate() error {
    if c.Server.Port <= 0 {
        return errors.New("server.port must be positive")
    }
    return nil
}

var appConfig AppConfig

schemaBytes, _ := os.ReadFile("schema.json")

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithJSONSchema(schemaBytes),      // 1. Schema validation
    config.WithValidator(customValidation),  // 2. Custom validation
    config.WithBinding(&appConfig),          // 3. Struct binding + validation
)

func customValidation(data map[string]any) error {
    // Custom validation logic
    return nil
}

All three validations will run in sequence.

Error Handling

Validation errors are wrapped in ConfigError with context:

if err := cfg.Load(context.Background()); err != nil {
    // Error format examples:
    // "config error in json-schema during validate: server.port: value must be >= 1"
    // "config error in binding during validate: port must be positive"
    log.Printf("Validation failed: %v", err)
}

Best Practices

1. Prefer Struct Validation

For Go applications, struct-based validation is most idiomatic:

func (c *Config) Validate() error {
    // Clear, type-safe validation logic
}

2. Provide Helpful Error Messages

Include field names and expected values:

// Bad
return errors.New("invalid value")

// Good
return fmt.Errorf("server.port must be between 1-65535, got %d", c.Server.Port)

3. Validate Relationships

Check dependencies between fields:

func (c *Config) Validate() error {
    if c.TLS.Enabled && c.TLS.CertFile == "" {
        return errors.New("tls.cert_file required when tls.enabled is true")
    }
    return nil
}

4. Use JSON Schema for APIs

When exposing configuration via APIs or accepting external config:

// Validate external configuration against schema
cfg := config.MustNew(
    config.WithContent(externalConfigBytes, codec.TypeJSON),
    config.WithJSONSchema(schemaBytes),
)

5. Fail Fast

Validate during initialization, not at runtime:

func main() {
    cfg := loadConfig()  // Validates during Load()
    // If we reach here, config is valid
    startServer(cfg)
}

Complete Example

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "os"
    "rivaas.dev/config"
)

type AppConfig struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
        TLS  struct {
            Enabled  bool   `config:"enabled"`
            CertFile string `config:"cert_file"`
            KeyFile  string `config:"key_file"`
        } `config:"tls"`
    } `config:"server"`
    
    Database struct {
        Host     string `config:"host"`
        Port     int    `config:"port"`
        MaxConns int    `config:"max_connections"`
    } `config:"database"`
}

func (c *AppConfig) Validate() error {
    // Server validation
    if c.Server.Port < 1 || c.Server.Port > 65535 {
        return fmt.Errorf("server.port must be 1-65535, got %d", c.Server.Port)
    }
    
    // TLS validation
    if c.Server.TLS.Enabled {
        if c.Server.TLS.CertFile == "" {
            return errors.New("server.tls.cert_file required when TLS enabled")
        }
        if _, err := os.Stat(c.Server.TLS.CertFile); err != nil {
            return fmt.Errorf("server.tls.cert_file not found: %w", err)
        }
    }
    
    // Database validation
    if c.Database.Host == "" {
        return errors.New("database.host is required")
    }
    if c.Database.MaxConns <= 0 {
        return errors.New("database.max_connections must be positive")
    }
    
    return nil
}

func main() {
    var appConfig AppConfig
    
    schemaBytes, err := os.ReadFile("schema.json")
    if err != nil {
        log.Fatalf("failed to read schema: %v", err)
    }
    
    cfg := config.MustNew(
        config.WithFile("config.yaml"),
        config.WithEnv("MYAPP_"),
        config.WithJSONSchema(schemaBytes),
        config.WithBinding(&appConfig),
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("configuration validation failed: %v", err)
    }

    log.Println("Configuration validated successfully!")
    log.Printf("Server: %s:%d", appConfig.Server.Host, appConfig.Server.Port)
}

Next Steps

For technical details on error handling, see Troubleshooting.

5.6 - Multiple Sources

Combine configuration from files, environment variables, and remote sources

The config package supports loading configuration from multiple sources simultaneously. This enables powerful patterns like base configuration with environment-specific overrides.

Source Precedence

When multiple sources are configured, later sources override earlier ones:

cfg := config.MustNew(
    config.WithFile("config.yaml"),      // Base configuration
    config.WithFile("config.prod.yaml"), // Production overrides
    config.WithEnv("MYAPP_"),            // Environment overrides all
)

Priority: Environment variables > config.prod.yaml > config.yaml

Visualization

graph LR
    File1[config.yaml] --> Merge1[Merge]
    File2[config.json] --> Merge2[Merge]
    Merge1 --> Merge2
    Env[Environment] --> Merge3[Merge]
    Merge2 --> Merge3
    Consul[Consul KV] --> Merge4[Merge]
    Merge3 --> Merge4
    Merge4 --> Final[Final Config]

Hierarchical Merging

Sources are merged hierarchically - nested structures are combined intelligently:

config.yaml (base):

server:
  host: localhost
  port: 8080
  timeout: 30s
database:
  host: localhost
  port: 5432

config.prod.yaml (overrides):

server:
  host: 0.0.0.0
database:
  host: db.example.com

Merged result:

server:
  host: 0.0.0.0      # Overridden.
  port: 8080         # From base.
  timeout: 30s       # From base.
database:
  host: db.example.com  # Overridden.
  port: 5432            # From base.

File Sources

Load configuration from local files:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFile("config.json"),
    config.WithFile("config.toml"),
)

Format Auto-Detection

File formats are detected automatically from extensions:

config.WithFile("app.yaml")  // YAML
config.WithFile("app.yml")   // YAML
config.WithFile("app.json")  // JSON
config.WithFile("app.toml")  // TOML

Explicit Format

Use explicit format when the file name doesn’t have an extension:

config.WithFileAs("config.txt", codec.TypeYAML)
config.WithFileAs("data.conf", codec.TypeJSON)

Environment Variable Sources

Load configuration from environment variables:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("MYAPP_"),  // Prefix filter
)

Environment variables override file-based configuration. See Environment Variables for detailed naming conventions.

Content Sources

Load configuration from byte slices. This is useful for testing or when you have dynamic configuration:

configData := []byte(`{
    "server": {
        "port": 8080,
        "host": "localhost"
    }
}`)

cfg := config.MustNew(
    config.WithContent(configData, codec.TypeJSON),
)

Use Cases for Content Sources

Testing:

func TestConfig(t *testing.T) {
    testConfig := []byte(`port: 8080`)
    cfg := config.MustNew(
        config.WithContent(testConfig, codec.TypeYAML),
    )
    cfg.Load(context.Background())
    
    assert.Equal(t, 8080, cfg.Int("port"))
}

Dynamic Configuration:

// Configuration from HTTP response
resp, _ := http.Get("https://config-server/config.json")
configBytes, _ := io.ReadAll(resp.Body)

cfg := config.MustNew(
    config.WithContent(configBytes, codec.TypeJSON),
)

Remote Sources

Consul

Load configuration from HashiCorp Consul:

cfg := config.MustNew(
    config.WithConsul("production/service.yaml"),
)

Environment variables:

export CONSUL_HTTP_ADDR=consul.example.com:8500
export CONSUL_HTTP_TOKEN=secret-token

Loading from Consul:

cfg := config.MustNew(
    config.WithFile("config.yaml"),           // Local defaults
    config.WithConsul("staging/myapp.json"),  // Staging overrides
    config.WithEnv("MYAPP_"),                 // Environment overrides
)

The format is detected from the key path extension (.json, .yaml, .toml).

Custom Sources

Implement custom sources for any data source:

type Source interface {
    Load(ctx context.Context) (map[string]any, error)
}

Example: Database Source

type DatabaseSource struct {
    db *sql.DB
}

func (s *DatabaseSource) Load(ctx context.Context) (map[string]any, error) {
    rows, err := s.db.QueryContext(ctx, "SELECT key, value FROM config")
    if err != nil {
        return nil, err
    }
    defer rows.Close()
    
    config := make(map[string]any)
    for rows.Next() {
        var key, value string
        if err := rows.Scan(&key, &value); err != nil {
            return nil, err
        }
        config[key] = value
    }
    
    return config, nil
}

// Usage
cfg := config.MustNew(
    config.WithSource(&DatabaseSource{db: db}),
)

Example: HTTP Source

type HTTPSource struct {
    url   string
    codec codec.Codec
}

func (s *HTTPSource) Load(ctx context.Context) (map[string]any, error) {
    req, _ := http.NewRequestWithContext(ctx, "GET", s.url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    data, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    
    var config map[string]any
    if err := s.codec.Decode(data, &config); err != nil {
        return nil, err
    }
    
    return config, nil
}

// Usage
cfg := config.MustNew(
    config.WithSource(&HTTPSource{
        url:   "https://config-server/config.json",
        codec: codec.JSON{},
    }),
)

Multi-Environment Pattern

There are two ways to handle environment-specific configuration.

The simplest approach is to use environment variables directly in paths:

cfg := config.MustNew(
    config.WithFile("config.yaml"),              // Base config
    config.WithFile("${APP_ENV}/config.yaml"),   // Environment-specific (e.g., "production/config.yaml")
    config.WithEnv("MYAPP_"),                    // Environment variables
)

This is cleaner and works great when your config files are in environment-named folders.

Using String Concatenation

If you need more control or want to set a default, use Go code:

package main

import (
    "context"
    "log"
    "os"
    "rivaas.dev/config"
)

func loadConfig() *config.Config {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }
    
    cfg := config.MustNew(
        config.WithFile("config.yaml"),              // Base config
        config.WithFile("config."+env+".yaml"),      // Environment-specific
        config.WithEnv("MYAPP_"),                    // Environment variables
    )
    
    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }
    
    return cfg
}

func main() {
    cfg := loadConfig()
    // Use configuration
}

File structure:

config.yaml           # Base configuration
config.development.yaml
config.staging.yaml
config.production.yaml

Dumping Configuration

Save the effective merged configuration to a file:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFile("config.prod.yaml"),
    config.WithEnv("MYAPP_"),
    config.WithFileDumper("effective-config.yaml"),
)

cfg.Load(context.Background())
cfg.Dump(context.Background())  // Writes merged config to effective-config.yaml

Use Cases for Dumping

Debugging:

See the final merged configuration:

config.WithFileDumper("debug-config.json")

Configuration Snapshots:

Save configuration state for auditing:

timestamp := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("config-snapshot-%s.yaml", timestamp)
config.WithFileDumper(filename)

Configuration Templates:

Generate configuration files:

cfg := config.MustNew(
    config.WithEnv("MYAPP_"),
    config.WithFileDumper("generated-config.yaml"),
)

Custom File Permissions

Control file permissions when dumping:

import "rivaas.dev/config/dumper"

// Default permissions (0644)
fileDumper := dumper.NewFile("config.yaml", encoder)

// Custom permissions (0600 - owner read/write only)
fileDumper := dumper.NewFileWithPermissions("config.yaml", encoder, 0600)

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithDumper(fileDumper),
)

Custom Dumpers

Implement custom dumpers for any destination:

type Dumper interface {
    Dump(ctx context.Context, data map[string]any) error
}

Example: S3 Dumper

type S3Dumper struct {
    bucket string
    key    string
    client *s3.Client
    codec  codec.Codec
}

func (d *S3Dumper) Dump(ctx context.Context, data map[string]any) error {
    bytes, err := d.codec.Encode(data)
    if err != nil {
        return err
    }
    
    _, err = d.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: aws.String(d.bucket),
        Key:    aws.String(d.key),
        Body:   bytes.NewReader(bytes),
    })
    return err
}

// Usage
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithDumper(&S3Dumper{
        bucket: "my-configs",
        key:    "app-config.json",
        client: s3Client,
        codec:  codec.JSON{},
    }),
)

Error Handling

Errors from sources include context about which source failed:

if err := cfg.Load(context.Background()); err != nil {
    // Error format:
    // "config error in source[0] during load: file not found: config.yaml"
    // "config error in source[2] during load: consul key not found"
    log.Printf("Configuration error: %v", err)
}

Complete Example

package main

import (
    "context"
    "log"
    "os"
    "rivaas.dev/config"
    "rivaas.dev/config/codec"
)

type AppConfig struct {
    Server struct {
        Host string `config:"host"`
        Port int    `config:"port"`
    } `config:"server"`
    Database struct {
        Host string `config:"host"`
        Port int    `config:"port"`
    } `config:"database"`
}

func main() {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }
    
    var appConfig AppConfig
    
    cfg := config.MustNew(
        // Base configuration
        config.WithFile("config.yaml"),
        
        // Environment-specific overrides
        config.WithFile("config."+env+".yaml"),
        
        // Remote configuration (production only)
        func() config.Option {
            if env == "production" {
                return config.WithConsul("production/myapp.json")
            }
            return nil
        }(),
        
        // Environment variables (highest priority)
        config.WithEnv("MYAPP_"),
        
        // Struct binding
        config.WithBinding(&appConfig),
        
        // Dump effective config for debugging
        config.WithFileDumper("effective-config.yaml"),
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load configuration: %v", err)
    }
    
    // Save effective configuration
    if err := cfg.Dump(context.Background()); err != nil {
        log.Printf("warning: failed to dump config: %v", err)
    }

    log.Printf("Server: %s:%d", appConfig.Server.Host, appConfig.Server.Port)
    log.Printf("Database: %s:%d", appConfig.Database.Host, appConfig.Database.Port)
}

Next Steps

For complete API details, see Options Reference.

5.7 - Custom Codecs

Extend configuration support to custom formats with codec implementation

The config package allows you to extend configuration support to any format by implementing and registering custom codecs.

Codec Interface

A codec is responsible for encoding and decoding configuration data.

type Codec interface {
    Encode(v any) ([]byte, error)
    Decode(data []byte, v any) error
}

Methods:

  • Encode(v any) ([]byte, error) - Convert Go data structures to bytes.
  • Decode(data []byte, v any) error - Convert bytes to Go data structures.

Built-in Codecs

The config package includes several built-in codecs.

Format Codecs

CodecTypeCapabilities
JSONcodec.TypeJSONEncode & Decode
YAMLcodec.TypeYAMLEncode & Decode
TOMLcodec.TypeTOMLEncode & Decode
EnvVarcodec.TypeEnvVarDecode only

Caster Codecs

Caster codecs handle type conversion.

CodecTypeConverts To
Boolcodec.TypeCasterBoolbool
Intcodec.TypeCasterIntint
Int8/16/32/64codec.TypeCasterInt8, etc.int8, int16, etc.
Uintcodec.TypeCasterUintuint
Uint8/16/32/64codec.TypeCasterUint8, etc.uint8, uint16, etc.
Float32/64codec.TypeCasterFloat32, codec.TypeCasterFloat64float32, float64
Stringcodec.TypeCasterStringstring
Timecodec.TypeCasterTimetime.Time
Durationcodec.TypeCasterDurationtime.Duration

Implementing a Custom Codec

Basic Example: INI Format

Let’s implement a simple INI file codec.

package inicodec

import (
    "bufio"
    "bytes"
    "fmt"
    "strings"
    "rivaas.dev/config/codec"
)

type INICodec struct{}

func (c INICodec) Decode(data []byte, v any) error {
    result := make(map[string]any)
    scanner := bufio.NewScanner(bytes.NewReader(data))
    
    var currentSection string
    
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        
        // Skip empty lines and comments
        if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
            continue
        }
        
        // Section header
        if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
            currentSection = strings.Trim(line, "[]")
            if result[currentSection] == nil {
                result[currentSection] = make(map[string]any)
            }
            continue
        }
        
        // Key-value pair
        parts := strings.SplitN(line, "=", 2)
        if len(parts) != 2 {
            continue
        }
        
        key := strings.TrimSpace(parts[0])
        value := strings.TrimSpace(parts[1])
        
        if currentSection != "" {
            section := result[currentSection].(map[string]any)
            section[key] = value
        } else {
            result[key] = value
        }
    }
    
    // Type assertion to set result
    target := v.(*map[string]any)
    *target = result
    
    return scanner.Err()
}

func (c INICodec) Encode(v any) ([]byte, error) {
    data, ok := v.(map[string]any)
    if !ok {
        return nil, fmt.Errorf("expected map[string]any, got %T", v)
    }
    
    var buf bytes.Buffer
    
    for section, values := range data {
        sectionMap, ok := values.(map[string]any)
        if !ok {
            // Top-level key-value
            buf.WriteString(fmt.Sprintf("%s = %v\n", section, values))
            continue
        }
        
        // Section header
        buf.WriteString(fmt.Sprintf("[%s]\n", section))
        
        // Section key-values
        for key, value := range sectionMap {
            buf.WriteString(fmt.Sprintf("%s = %v\n", key, value))
        }
        
        buf.WriteString("\n")
    }
    
    return buf.Bytes(), nil
}

func init() {
    codec.RegisterEncoder("ini", INICodec{})
    codec.RegisterDecoder("ini", INICodec{})
}

Using the Custom Codec

package main

import (
    "context"
    "log"
    "rivaas.dev/config"
    _ "yourmodule/inicodec"  // Register codec via init()
)

func main() {
    cfg := config.MustNew(
        config.WithFileAs("config.ini", "ini"),
    )
    
    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }
    
    host := cfg.String("server.host")
    port := cfg.Int("server.port")
    
    log.Printf("Server: %s:%d", host, port)
}

config.ini:

[server]
host = localhost
port = 8080

[database]
host = db.example.com
port = 5432

Registering Codecs

Codecs must be registered before use:

import "rivaas.dev/config/codec"

func init() {
    codec.RegisterEncoder("mytype", MyCodec{})
    codec.RegisterDecoder("mytype", MyCodec{})
}

Registration functions:

  • RegisterEncoder(name string, encoder Codec) - Register for encoding
  • RegisterDecoder(name string, decoder Codec) - Register for decoding

You can register the same codec for both or different codecs for each operation.

Decode-Only Codecs

Some codecs only support decoding (like the built-in EnvVar codec):

type EnvVarCodec struct{}

func (c EnvVarCodec) Decode(data []byte, v any) error {
    // Decode environment variable format
    // ...
}

func (c EnvVarCodec) Encode(v any) ([]byte, error) {
    return nil, errors.New("encoding to environment variables not supported")
}

func init() {
    codec.RegisterDecoder("envvar", EnvVarCodec{})
    // Note: Not registering encoder
}

Advanced Example: XML Codec

A more complete example with error handling:

package xmlcodec

import (
    "encoding/xml"
    "fmt"
    "rivaas.dev/config/codec"
)

type XMLCodec struct{}

func (c XMLCodec) Decode(data []byte, v any) error {
    target, ok := v.(*map[string]any)
    if !ok {
        return fmt.Errorf("expected *map[string]any, got %T", v)
    }
    
    // XML unmarshaling to intermediate structure
    var intermediate struct {
        XMLName xml.Name
        Content []byte `xml:",innerxml"`
    }
    
    if err := xml.Unmarshal(data, &intermediate); err != nil {
        return fmt.Errorf("xml decode error: %w", err)
    }
    
    // Convert XML to map structure
    result := make(map[string]any)
    // ... conversion logic ...
    
    *target = result
    return nil
}

func (c XMLCodec) Encode(v any) ([]byte, error) {
    data, ok := v.(map[string]any)
    if !ok {
        return nil, fmt.Errorf("expected map[string]any, got %T", v)
    }
    
    // Convert map to XML structure
    xmlData, err := xml.MarshalIndent(data, "", "  ")
    if err != nil {
        return nil, fmt.Errorf("xml encode error: %w", err)
    }
    
    return xmlData, nil
}

func init() {
    codec.RegisterEncoder("xml", XMLCodec{})
    codec.RegisterDecoder("xml", XMLCodec{})
}

Caster Codecs

Caster codecs provide type conversion. You typically don’t need to implement these - use the built-in casters:

import "rivaas.dev/config/codec"

// Get int value with automatic conversion
port := cfg.Int("server.port")  // Uses codec.TypeCasterInt internally

// Get duration with automatic conversion
timeout := cfg.Duration("timeout")  // Uses codec.TypeCasterDuration internally

Custom Caster Example

If you need custom type conversion:

type URLCaster struct{}

func (c URLCaster) Decode(data []byte, v any) error {
    target, ok := v.(*url.URL)
    if !ok {
        return fmt.Errorf("expected *url.URL, got %T", v)
    }
    
    parsedURL, err := url.Parse(string(data))
    if err != nil {
        return fmt.Errorf("invalid URL: %w", err)
    }
    
    *target = *parsedURL
    return nil
}

func (c URLCaster) Encode(v any) ([]byte, error) {
    u, ok := v.(*url.URL)
    if !ok {
        return nil, fmt.Errorf("expected *url.URL, got %T", v)
    }
    return []byte(u.String()), nil
}

When to Create Custom Codecs

Use Custom Codecs For:

  1. Unsupported formats - XML, INI, HCL, proprietary formats
  2. Legacy formats - Converting old configuration formats
  3. Encrypted configurations - Decrypting config data
  4. Compressed data - Handling gzip/compressed configs
  5. Custom protocols - Special encoding schemes

Use Built-in Codecs For:

  1. Standard formats - JSON, YAML, TOML
  2. Type conversion - Use caster codecs (Int, Bool, Duration, etc.)
  3. Simple text formats - Can often use JSON/YAML

Best Practices

1. Error Handling

Provide clear error messages:

func (c MyCodec) Decode(data []byte, v any) error {
    if len(data) == 0 {
        return errors.New("empty data")
    }
    
    target, ok := v.(*map[string]any)
    if !ok {
        return fmt.Errorf("expected *map[string]any, got %T", v)
    }
    
    // ... decoding logic ...
    
    if err != nil {
        return fmt.Errorf("decode error at line %d: %w", line, err)
    }
    
    return nil
}

2. Type Validation

Validate expected types:

func (c MyCodec) Decode(data []byte, v any) error {
    target, ok := v.(*map[string]any)
    if !ok {
        return fmt.Errorf("MyCodec requires *map[string]any, got %T", v)
    }
    // ...
}

3. Use init() for Registration

Register codecs in init() for automatic setup:

func init() {
    codec.RegisterEncoder("myformat", MyCodec{})
    codec.RegisterDecoder("myformat", MyCodec{})
}

4. Thread Safety

Ensure your codec is thread-safe:

type MyCodec struct {
    // No mutable state
}

// OR use proper synchronization
type MyCodec struct {
    mu    sync.Mutex
    cache map[string]any
}

5. Document Your Codec

Include usage examples:

// MyCodec implements encoding/decoding for the XYZ format.
//
// Example usage:
//
//   import _ "yourmodule/mycodec"
//
//   cfg := config.MustNew(
//       config.WithFileAs("config.xyz", "xyz"),
//   )
//
type MyCodec struct{}

Complete Example

package main

import (
    "context"
    "log"
    "rivaas.dev/config"
    _ "yourmodule/xmlcodec"   // Custom XML codec
    _ "yourmodule/inicodec"   // Custom INI codec
)

func main() {
    cfg := config.MustNew(
        config.WithFile("config.yaml"),           // Built-in YAML
        config.WithFileAs("config.xml", "xml"), // Custom XML
        config.WithFileAs("config.ini", "ini"), // Custom INI
        config.WithEnv("MYAPP_"),                  // Built-in EnvVar
    )

    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }

    port := cfg.Int("server.port")
    host := cfg.String("server.host")
    
    log.Printf("Server: %s:%d", host, port)
}

Testing Custom Codecs

Write tests for your codecs:

func TestMyCodec_Decode(t *testing.T) {
    codec := MyCodec{}
    
    input := []byte(`
        [server]
        host = localhost
        port = 8080
    `)
    
    var result map[string]any
    err := codec.Decode(input, &result)
    
    assert.NoError(t, err)
    assert.Equal(t, "localhost", result["server"].(map[string]any)["host"])
    assert.Equal(t, "8080", result["server"].(map[string]any)["port"])
}

func TestMyCodec_Encode(t *testing.T) {
    codec := MyCodec{}
    
    data := map[string]any{
        "server": map[string]any{
            "host": "localhost",
            "port": 8080,
        },
    }
    
    output, err := codec.Encode(data)
    
    assert.NoError(t, err)
    assert.Contains(t, string(output), "[server]")
    assert.Contains(t, string(output), "host = localhost")
}

Next Steps

Tip: If you create a useful codec, consider contributing it to the community!

5.8 - Examples

Real-world examples and production-ready patterns for configuration management

Learn from practical examples that demonstrate different configuration patterns and use cases.

Example Repository

All examples are available in the GitHub repository with complete, runnable code.

Example Overview

1. Basic Configuration

Path: config/examples/basic/

A simple example showing the most basic usage. Load configuration from a YAML file into a Go struct.

Features:

  • File source using YAML.
  • Struct binding.
  • Type conversion.
  • Nested structures.
  • Arrays and slices.
  • Time and URL types.

Best for: Getting started, understanding basic concepts

Quick start:

cd config/examples/basic
go run main.go

2. Environment Variables

Path: config/examples/environment/

Demonstrates loading configuration from environment variables, following the Twelve-Factor App methodology.

Features:

  • Environment variable source
  • Struct binding
  • Nested configuration
  • Direct access methods
  • Type conversion

Best for: Containerized applications, cloud deployments, 12-factor apps

Quick start:

cd config/examples/environment
export WEBAPP_SERVER_HOST=localhost
export WEBAPP_SERVER_PORT=8080
go run main.go

3. Mixed Configuration

Path: config/examples/mixed/

Shows how to combine YAML files and environment variables. Environment variables override YAML defaults.

Features:

  • Mixed configuration sources.
  • Configuration precedence.
  • Environment variable mapping.
  • Struct binding.
  • Direct access.

Best for: Applications that need both default configuration files and environment-specific overrides

Quick start:

cd config/examples/mixed
export WEBAPP_SERVER_PORT=8080  # Override YAML default
go run main.go

4. Comprehensive Example

Path: config/examples/comprehensive/

A complete example demonstrating advanced features with a realistic web application configuration.

Features:

  • Mixed configuration sources
  • Complex nested structures
  • Validation
  • Comprehensive testing
  • Production-ready patterns

Best for: Production applications, learning advanced features, understanding best practices

Quick start:

cd config/examples/comprehensive
go test -v
go run main.go

Dynamic Paths with Environment Variables

You can use environment variables in file and Consul paths. This makes it easy to use different configurations based on your environment.

Basic Path Expansion

// Set APP_ENV=production in your environment
cfg := config.MustNew(
    config.WithFile("config.yaml"),            // Base config
    config.WithFile("${APP_ENV}/config.yaml"), // Becomes "production/config.yaml"
)

Multiple Variables

You can use several variables in one path:

// Set REGION=us-west and ENV=staging
cfg := config.MustNew(
    config.WithFile("${REGION}/${ENV}/app.yaml"), // Becomes "us-west/staging/app.yaml"
)

Consul Paths

This also works with Consul:

// Set APP_ENV=production
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithConsul("${APP_ENV}/service.yaml"), // Fetches from Consul: "production/service.yaml"
)

Output Paths

You can also use variables in dumper paths:

// Set LOG_DIR=/var/log/myapp
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFileDumper("${LOG_DIR}/effective-config.yaml"), // Writes to /var/log/myapp/
)

Production Configuration Example

Here’s a complete production-ready configuration pattern:

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "os"
    "time"
    "rivaas.dev/config"
)

type AppConfig struct {
    Server struct {
        Host         string        `config:"host" default:"localhost"`
        Port         int           `config:"port" default:"8080"`
        ReadTimeout  time.Duration `config:"read_timeout" default:"30s"`
        WriteTimeout time.Duration `config:"write_timeout" default:"30s"`
        TLS          struct {
            Enabled  bool   `config:"enabled" default:"false"`
            CertFile string `config:"cert_file"`
            KeyFile  string `config:"key_file"`
        } `config:"tls"`
    } `config:"server"`
    
    Database struct {
        Primary struct {
            Host     string `config:"host"`
            Port     int    `config:"port" default:"5432"`
            Database string `config:"database"`
            Username string `config:"username"`
            Password string `config:"password"`
            SSLMode  string `config:"ssl_mode" default:"require"`
        } `config:"primary"`
        Replica struct {
            Host     string `config:"host"`
            Port     int    `config:"port" default:"5432"`
            Database string `config:"database"`
        } `config:"replica"`
        Pool struct {
            MaxOpenConns    int           `config:"max_open_conns" default:"25"`
            MaxIdleConns    int           `config:"max_idle_conns" default:"5"`
            ConnMaxLifetime time.Duration `config:"conn_max_lifetime" default:"5m"`
        } `config:"pool"`
    } `config:"database"`
    
    Redis struct {
        Host     string        `config:"host" default:"localhost"`
        Port     int           `config:"port" default:"6379"`
        Database int           `config:"database" default:"0"`
        Password string        `config:"password"`
        Timeout  time.Duration `config:"timeout" default:"5s"`
    } `config:"redis"`
    
    Auth struct {
        JWTSecret       string        `config:"jwt_secret"`
        TokenDuration   time.Duration `config:"token_duration" default:"24h"`
    } `config:"auth"`
    
    Logging struct {
        Level  string `config:"level" default:"info"`
        Format string `config:"format" default:"json"`
        Output string `config:"output" default:"/var/log/app.log"`
    } `config:"logging"`
    
    Monitoring struct {
        Enabled     bool   `config:"enabled" default:"true"`
        MetricsPort int    `config:"metrics_port" default:"9090"`
        HealthPath  string `config:"health_path" default:"/health"`
    } `config:"monitoring"`
    
    Features struct {
        RateLimit bool `config:"rate_limit" default:"true"`
        Cache     bool `config:"cache" default:"true"`
        DebugMode bool `config:"debug_mode" default:"false"`
    } `config:"features"`
}

func (c *AppConfig) Validate() error {
    // Server validation
    if c.Server.Port < 1 || c.Server.Port > 65535 {
        return fmt.Errorf("server.port must be 1-65535, got %d", c.Server.Port)
    }
    
    // TLS validation
    if c.Server.TLS.Enabled {
        if c.Server.TLS.CertFile == "" {
            return errors.New("server.tls.cert_file required when TLS enabled")
        }
        if c.Server.TLS.KeyFile == "" {
            return errors.New("server.tls.key_file required when TLS enabled")
        }
    }
    
    // Database validation
    if c.Database.Primary.Host == "" {
        return errors.New("database.primary.host is required")
    }
    if c.Database.Primary.Database == "" {
        return errors.New("database.primary.database is required")
    }
    if c.Database.Primary.Username == "" {
        return errors.New("database.primary.username is required")
    }
    if c.Database.Primary.Password == "" {
        return errors.New("database.primary.password is required")
    }
    
    // Auth validation
    if c.Auth.JWTSecret == "" {
        return errors.New("auth.jwt_secret is required")
    }
    if len(c.Auth.JWTSecret) < 32 {
        return errors.New("auth.jwt_secret must be at least 32 characters")
    }
    
    return nil
}

func loadConfig() (*AppConfig, error) {
    var appConfig AppConfig
    
    // Determine environment
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }
    
    cfg := config.MustNew(
        // Base configuration
        config.WithFile("config.yaml"),
        
        // Environment-specific configuration
        config.WithFile(fmt.Sprintf("config.%s.yaml", env)),
        
        // Environment variables (highest priority)
        config.WithEnv("MYAPP_"),
        
        // Struct binding with validation
        config.WithBinding(&appConfig),
    )
    
    if err := cfg.Load(context.Background()); err != nil {
        return nil, fmt.Errorf("failed to load configuration: %w", err)
    }
    
    return &appConfig, nil
}

func main() {
    appConfig, err := loadConfig()
    if err != nil {
        log.Fatalf("Configuration error: %v", err)
    }
    
    log.Printf("Server: %s:%d", appConfig.Server.Host, appConfig.Server.Port)
    log.Printf("Database: %s:%d/%s", 
        appConfig.Database.Primary.Host,
        appConfig.Database.Primary.Port,
        appConfig.Database.Primary.Database)
    log.Printf("Redis: %s:%d", appConfig.Redis.Host, appConfig.Redis.Port)
    log.Printf("Features: RateLimit=%v, Cache=%v, Debug=%v",
        appConfig.Features.RateLimit,
        appConfig.Features.Cache,
        appConfig.Features.DebugMode)
}

Multi-Environment Setup

Organize configuration for different environments:

File structure:

config/
├── config.yaml              # Base configuration (shared defaults)
├── config.development.yaml  # Development overrides
├── config.staging.yaml      # Staging overrides
├── config.production.yaml   # Production overrides
└── config.test.yaml         # Test overrides

config.yaml (base):

server:
  host: localhost
  port: 8080
  read_timeout: 30s
  write_timeout: 30s

database:
  pool:
    max_open_conns: 25
    max_idle_conns: 5
    conn_max_lifetime: 5m

logging:
  level: info
  format: json

config.production.yaml:

server:
  host: 0.0.0.0
  port: 443
  tls:
    enabled: true
    cert_file: /etc/ssl/certs/server.crt
    key_file: /etc/ssl/private/server.key

database:
  primary:
    host: db.prod.example.com
    ssl_mode: require
  replica:
    host: db-replica.prod.example.com

logging:
  level: warn
  output: /var/log/production/app.log

features:
  debug_mode: false

config.development.yaml:

server:
  host: localhost
  port: 3000

database:
  primary:
    host: localhost
    ssl_mode: disable

logging:
  level: debug
  format: text
  output: stdout

features:
  debug_mode: true

Integration with Rivaas App

Integrate configuration with the rivaas/app framework:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    
    "rivaas.dev/app"
    "rivaas.dev/config"
)

type AppConfig struct {
    Server struct {
        Host string `config:"host" default:"localhost"`
        Port int    `config:"port" default:"8080"`
    } `config:"server"`
}

func main() {
    var appConfig AppConfig
    
    cfg := config.MustNew(
        config.WithFile("config.yaml"),
        config.WithEnv("MYAPP_"),
        config.WithBinding(&appConfig),
    )
    
    if err := cfg.Load(context.Background()); err != nil {
        log.Fatalf("failed to load config: %v", err)
    }
    
    // Create rivaas/app with configuration from config
    a := app.MustNew(
        app.WithServiceName("myapp"),
        app.WithServiceVersion("v1.0.0"),
        app.WithHost(appConfig.Server.Host),
        app.WithPort(appConfig.Server.Port),
    )
    
    // Define routes
    a.GET("/", func(c *app.Context) {
        c.JSON(200, map[string]string{"status": "ok"})
    })
    
    // Setup graceful shutdown
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()
    
    if err := a.Start(ctx); err != nil {
        log.Fatalf("server error: %v", err)
    }
}

Testing Configuration

Example test patterns:

package main

import (
    "context"
    "testing"
    "rivaas.dev/config"
    "rivaas.dev/config/codec"
)

func TestConfigLoading(t *testing.T) {
    testConfig := []byte(`
server:
  host: localhost
  port: 8080
database:
  primary:
    host: localhost
    database: testdb
    username: test
    password: test123
`)
    
    var appConfig AppConfig
    
    cfg := config.MustNew(
        config.WithContent(testConfig, codec.TypeYAML),
        config.WithBinding(&appConfig),
    )
    
    if err := cfg.Load(context.Background()); err != nil {
        t.Fatalf("failed to load config: %v", err)
    }
    
    // Assertions
    if appConfig.Server.Host != "localhost" {
        t.Errorf("expected localhost, got %s", appConfig.Server.Host)
    }
    if appConfig.Server.Port != 8080 {
        t.Errorf("expected 8080, got %d", appConfig.Server.Port)
    }
}

func TestConfigValidation(t *testing.T) {
    invalidConfig := []byte(`
server:
  host: localhost
  port: 99999  # Invalid port
`)
    
    var appConfig AppConfig
    
    cfg := config.MustNew(
        config.WithContent(invalidConfig, codec.TypeYAML),
        config.WithBinding(&appConfig),
    )
    
    err := cfg.Load(context.Background())
    if err == nil {
        t.Error("expected validation error, got nil")
    }
}

Common Patterns

Pattern 1: Secrets from Environment

Keep secrets out of config files:

# config.yaml - No secrets
database:
  primary:
    host: localhost
    port: 5432
    database: myapp
    # username and password from environment
# Environment variables for secrets
export MYAPP_DATABASE_PRIMARY_USERNAME=admin
export MYAPP_DATABASE_PRIMARY_PASSWORD=secret123

Pattern 2: Feature Flags

Use configuration for feature flags:

type Config struct {
    Features struct {
        NewUI        bool `config:"new_ui" default:"false"`
        BetaFeatures bool `config:"beta_features" default:"false"`
        Analytics    bool `config:"analytics" default:"true"`
    } `config:"features"`
}

// In application code
if appConfig.Features.NewUI {
    // Use new UI
} else {
    // Use old UI
}

Pattern 3: Dynamic Reloading

For applications that need dynamic configuration updates (advanced):

type ConfigManager struct {
    cfg    *config.Config
    appCfg *AppConfig
    mu     sync.RWMutex
}

func (cm *ConfigManager) Reload(ctx context.Context) error {
    cm.mu.Lock()
    defer cm.mu.Unlock()
    
    return cm.cfg.Load(ctx)
}

func (cm *ConfigManager) Get() *AppConfig {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    
    return cm.appCfg
}

Next Steps

For questions or contributions, visit the GitHub repository.

6 - OpenAPI Specification

Learn how to generate OpenAPI specifications from Go code with automatic parameter discovery and schema generation

The Rivaas OpenAPI package provides automatic OpenAPI 3.0.4 and 3.1.2 specification generation from Go code. Uses struct tags and reflection with a clean, type-safe API. Minimal boilerplate required.

Features

  • Clean API - Builder-style API.Generate() method for specification generation
  • Type-Safe Version Selection - V30x and V31x constants with IDE autocomplete
  • Fluent HTTP Method Constructors - GET(), POST(), PUT(), etc. for clean operation definitions
  • Functional Options - Consistent With* pattern for all configuration
  • Type-Safe Warning Diagnostics - diag package for fine-grained warning control
  • Automatic Parameter Discovery - Extracts query, path, header, and cookie parameters from struct tags
  • Schema Generation - Converts Go types to OpenAPI schemas automatically
  • Swagger UI Configuration - Built-in, customizable Swagger UI settings
  • Semantic Operation IDs - Auto-generates operation IDs from HTTP methods and paths
  • Security Schemes - Support for Bearer, API Key, OAuth2, and OpenID Connect
  • Collision-Resistant Naming - Schema names use pkgname.TypeName format to prevent collisions
  • Built-in Validation - Validates generated specs against official OpenAPI meta-schemas
  • Standalone Validator - Validate external OpenAPI specs with pre-compiled schemas

Quick Start

Here’s a 30-second example to get you started:

package main

import (
    "context"
    "fmt"
    "log"

    "rivaas.dev/openapi"
)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("My API", "1.0.0"),
        openapi.WithInfoDescription("API for managing users"),
        openapi.WithServer("http://localhost:8080", "Local development"),
        openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
    )

    result, err := api.Generate(context.Background(),
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithResponse(200, User{}),
            openapi.WithSecurity("bearerAuth"),
        ),
        openapi.POST("/users",
            openapi.WithSummary("Create user"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(201, User{}),
        ),
        openapi.DELETE("/users/:id",
            openapi.WithSummary("Delete user"),
            openapi.WithResponse(204, nil),
            openapi.WithSecurity("bearerAuth"),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Check for warnings (optional)
    if len(result.Warnings) > 0 {
        fmt.Printf("Generated with %d warnings\n", len(result.Warnings))
    }

    fmt.Println(string(result.JSON))
}

How It Works

  • API configuration is done through functional options with With* prefix
  • Operations are defined using HTTP method constructors: GET(), POST(), etc.
  • Types are automatically converted to OpenAPI schemas using reflection
  • Parameters are discovered from struct tags: path, query, header, cookie
  • Validation is optional but recommended for production use

Learning Path

Follow these guides to master OpenAPI specification generation with Rivaas:

  1. Installation - Get started with the openapi package
  2. Basic Usage - Learn the fundamentals of generating specifications
  3. Configuration - Configure API info, servers, and version selection
  4. Security - Add authentication and authorization schemes
  5. Operations - Define HTTP operations with methods and options
  6. Auto-Discovery - Use struct tags for automatic parameter discovery
  7. Schema Generation - Understand Go type to OpenAPI schema conversion
  8. Swagger UI - Customize the Swagger UI interface
  9. Validation - Validate generated specifications
  10. Diagnostics - Handle warnings with type-safe diagnostics
  11. Advanced Usage - Extensions, custom operation IDs, and strict mode
  12. Examples - See real-world usage patterns

Next Steps

6.1 - Installation

Install and set up the OpenAPI package for Go

Get started with the OpenAPI package by adding it to your Go project.

Prerequisites

  • Go 1.25 or higher - The package requires Go 1.25+
  • Go modules - Your project should use Go modules for dependency management

Installation

Install the package using go get:

go get rivaas.dev/openapi

This will download the latest version of the package and add it to your go.mod file.

Verifying Installation

Create a simple test file to verify the installation:

package main

import (
    "context"
    "fmt"
    "log"

    "rivaas.dev/openapi"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("Test API", "1.0.0"),
    )

    result, err := api.Generate(context.Background(),
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithResponse(200, User{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("OpenAPI spec generated successfully!")
    fmt.Printf("JSON spec: %d bytes\n", len(result.JSON))
}

Run the test:

go run main.go

If you see “OpenAPI spec generated successfully!”, the package is installed correctly.

Sub-packages

The OpenAPI package includes two sub-packages that are automatically available when you install the main package:

Diagnostics (diag)

Type-safe warning handling:

import "rivaas.dev/openapi/diag"

Validator (validate)

Standalone specification validator for validating external OpenAPI specs:

import "rivaas.dev/openapi/validate"

Updating

To update to the latest version:

go get -u rivaas.dev/openapi

Next Steps

6.2 - Basic Usage

Learn the fundamentals of generating OpenAPI specifications

Learn how to generate OpenAPI specifications from Go code using the openapi package.

Creating an API Configuration

The first step is to create an API configuration using New() or MustNew():

import "rivaas.dev/openapi"

// With error handling
api, err := openapi.New(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithInfoDescription("API description"),
)
if err != nil {
    log.Fatal(err)
}

// Without error handling (panics on error)
api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithInfoDescription("API description"),
)

The MustNew() function is convenient for initialization code. Use it where panicking on error is acceptable.

Generating Specifications

Use api.Generate() with a context and variadic operation arguments:

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithResponse(200, []User{}),
    ),
    openapi.GET("/users/:id",
        openapi.WithSummary("Get user"),
        openapi.WithResponse(200, User{}),
    ),
    openapi.POST("/users",
        openapi.WithSummary("Create user"),
        openapi.WithRequest(CreateUserRequest{}),
        openapi.WithResponse(201, User{}),
    ),
)
if err != nil {
    log.Fatal(err)
}

The Result Object

The Generate() method returns a Result object containing:

  • JSON - The OpenAPI specification as JSON bytes.
  • YAML - The OpenAPI specification as YAML bytes.
  • Warnings - Any generation warnings. See Diagnostics for details.
// Use the JSON specification
fmt.Println(string(result.JSON))

// Or use the YAML specification
fmt.Println(string(result.YAML))

// Check for warnings
if len(result.Warnings) > 0 {
    fmt.Printf("Generated with %d warnings\n", len(result.Warnings))
}

Defining Operations

Operations are defined using HTTP method constructors:

openapi.GET("/path", options...)
openapi.POST("/path", options...)
openapi.PUT("/path", options...)
openapi.PATCH("/path", options...)
openapi.DELETE("/path", options...)
openapi.HEAD("/path", options...)
openapi.OPTIONS("/path", options...)
openapi.TRACE("/path", options...)

Each constructor takes a path and optional operation options.

Path Parameters

Use colon syntax for path parameters:

openapi.GET("/users/:id",
    openapi.WithSummary("Get user by ID"),
    openapi.WithResponse(200, User{}),
)

openapi.GET("/orgs/:orgId/users/:userId",
    openapi.WithSummary("Get user in organization"),
    openapi.WithResponse(200, User{}),
)

Path parameters are automatically discovered and marked as required.

Request and Response Types

Define request and response types using Go structs:

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

// Use in operations
openapi.POST("/users",
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithResponse(201, User{}),
)

The package automatically converts Go types to OpenAPI schemas.

Multiple Response Types

Operations can have multiple response types for different status codes:

type ErrorResponse struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
    openapi.WithResponse(404, ErrorResponse{}),
    openapi.WithResponse(500, ErrorResponse{}),
)

Empty Responses

For responses with no body, use nil:

openapi.DELETE("/users/:id",
    openapi.WithSummary("Delete user"),
    openapi.WithResponse(204, nil),
)

Complete Example

Here’s a complete example putting it all together:

package main

import (
    "context"
    "fmt"
    "log"
    "os"

    "rivaas.dev/openapi"
)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

type ErrorResponse struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("User API", "1.0.0"),
        openapi.WithInfoDescription("API for managing users"),
        openapi.WithServer("http://localhost:8080", "Local development"),
    )

    result, err := api.Generate(context.Background(),
        openapi.GET("/users",
            openapi.WithSummary("List users"),
            openapi.WithResponse(200, []User{}),
        ),
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithResponse(200, User{}),
            openapi.WithResponse(404, ErrorResponse{}),
        ),
        openapi.POST("/users",
            openapi.WithSummary("Create user"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(201, User{}),
            openapi.WithResponse(400, ErrorResponse{}),
        ),
        openapi.DELETE("/users/:id",
            openapi.WithSummary("Delete user"),
            openapi.WithResponse(204, nil),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Write to file
    if err := os.WriteFile("openapi.json", result.JSON, 0644); err != nil {
        log.Fatal(err)
    }

    fmt.Println("OpenAPI specification written to openapi.json")
}

Next Steps

6.3 - Configuration

Configure API metadata, version selection, servers, and tags

Learn how to configure your OpenAPI specification with metadata, servers, and version selection.

Basic Configuration

Configuration is done exclusively through functional options with With* prefix:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithInfoDescription("API description"),
    openapi.WithInfoSummary("Short summary"), // 3.1.x only
    openapi.WithTermsOfService("https://example.com/terms"),
)

Required Configuration

Only WithTitle() is required when creating an API configuration:

// Minimal configuration
api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
)

Contact Information

Add contact information for API support:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithContact(
        "API Support",
        "https://example.com/support",
        "support@example.com",
    ),
)

License Information

Specify the API license:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithLicense(
        "Apache 2.0",
        "https://www.apache.org/licenses/LICENSE-2.0.html",
    ),
)

Version Selection

The package supports two OpenAPI version families:

// Target OpenAPI 3.0.x (generates 3.0.4)
api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithVersion(openapi.V30x), // Default
)

// Target OpenAPI 3.1.x (generates 3.1.2)
api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithVersion(openapi.V31x),
)

The constants V30x and V31x represent version families. Internally they map to specific versions. 3.0.4 and 3.1.2 are used in the generated specification.

Version-Specific Features

Some features are only available in OpenAPI 3.1.x:

  • WithInfoSummary() - Short summary for the API
  • WithLicenseIdentifier() - SPDX license identifier
  • Webhooks support
  • Mutual TLS authentication

When using these features with a 3.0.x target, the package will generate warnings (see Diagnostics).

Servers

Add server configurations to specify where the API is available:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithServer("https://api.example.com", "Production"),
    openapi.WithServer("https://staging.example.com", "Staging"),
    openapi.WithServer("http://localhost:8080", "Local development"),
)

Server Variables

Add variables to server URLs for flexible configuration:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithServer("https://{environment}.example.com", "Environment-based"),
    openapi.WithServerVariable("environment", "api", 
        []string{"api", "staging", "dev"},
        "Environment to use",
    ),
)

Multiple variables can be defined for a single server:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithServer("https://{subdomain}.{domain}", "Custom domain"),
    openapi.WithServerVariable("subdomain", "api", 
        []string{"api", "staging"},
        "Subdomain",
    ),
    openapi.WithServerVariable("domain", "example.com", 
        []string{"example.com", "test.com"},
        "Domain",
    ),
)

Tags

Tags help organize operations in the documentation:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithTag("users", "User management operations"),
    openapi.WithTag("posts", "Post management operations"),
    openapi.WithTag("auth", "Authentication operations"),
)

Tags are then referenced in operations:

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithTags("users"),
        openapi.WithResponse(200, []User{}),
    ),
    openapi.GET("/posts",
        openapi.WithSummary("List posts"),
        openapi.WithTags("posts"),
        openapi.WithResponse(200, []Post{}),
    ),
)

External Documentation

Link to external documentation:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithExternalDocs(
        "https://docs.example.com",
        "Full API Documentation",
    ),
)

Complete Configuration Example

Here’s a complete example with all common configuration options:

package main

import (
    "context"
    "log"

    "rivaas.dev/openapi"
)

func main() {
    api := openapi.MustNew(
        // Basic info
        openapi.WithTitle("User Management API", "2.1.0"),
        openapi.WithInfoDescription("Comprehensive API for managing users and their profiles"),
        openapi.WithTermsOfService("https://example.com/terms"),
        
        // Contact
        openapi.WithContact(
            "API Support Team",
            "https://example.com/support",
            "api-support@example.com",
        ),
        
        // License
        openapi.WithLicense(
            "Apache 2.0",
            "https://www.apache.org/licenses/LICENSE-2.0.html",
        ),
        
        // Version selection
        openapi.WithVersion(openapi.V31x),
        
        // Servers
        openapi.WithServer("https://api.example.com", "Production"),
        openapi.WithServer("https://staging-api.example.com", "Staging"),
        openapi.WithServer("http://localhost:8080", "Local development"),
        
        // Tags
        openapi.WithTag("users", "User management operations"),
        openapi.WithTag("profiles", "User profile operations"),
        openapi.WithTag("auth", "Authentication and authorization"),
        
        // External docs
        openapi.WithExternalDocs(
            "https://docs.example.com/api",
            "Complete API Documentation",
        ),
        
        // Security schemes (covered in detail in Security guide)
        openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
    )

    result, err := api.Generate(context.Background(),
        // ... operations here
    )
    if err != nil {
        log.Fatal(err)
    }

    // Use result...
}

Next Steps

6.4 - Security

Add authentication and authorization schemes to your OpenAPI specification

Learn how to add security schemes to your OpenAPI specification for authentication and authorization.

Security Scheme Types

The package supports four types of security schemes:

  1. Bearer Authentication - JWT or token-based authentication.
  2. API Key - API keys in headers, query parameters, or cookies.
  3. OAuth2 - OAuth 2.0 authorization flows.
  4. OpenID Connect - OpenID Connect authentication.

Bearer Authentication

Bearer authentication is commonly used for JWT tokens:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
)

Using Bearer Authentication in Operations

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithSecurity("bearerAuth"),
        openapi.WithResponse(200, []User{}),
    ),
)

The generated specification will expect an Authorization: Bearer <token> header.

API Key Authentication

API keys can be placed in headers, query parameters, or cookies:

Header-Based API Key

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithAPIKey(
        "apiKey",
        "X-API-Key",
        openapi.InHeader,
        "API key for authentication",
    ),
)

Query Parameter API Key

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithAPIKey(
        "apiKey",
        "api_key",
        openapi.InQuery,
        "API key for authentication",
    ),
)
api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithAPIKey(
        "apiKey",
        "api_key",
        openapi.InCookie,
        "API key for authentication",
    ),
)

Using API Key in Operations

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithSecurity("apiKey"),
        openapi.WithResponse(200, []User{}),
    ),
)

OAuth2

OAuth2 supports multiple flows: authorization code, implicit, password, and client credentials.

Authorization Code Flow

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithOAuth2(
        "oauth2",
        "OAuth2 authentication",
        openapi.OAuth2Flow{
            Type:             openapi.FlowAuthorizationCode,
            AuthorizationURL: "https://example.com/oauth/authorize",
            TokenURL:         "https://example.com/oauth/token",
            Scopes: map[string]string{
                "read":  "Read access to resources",
                "write": "Write access to resources",
                "admin": "Administrative access",
            },
        },
    ),
)

Implicit Flow

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithOAuth2(
        "oauth2",
        "OAuth2 authentication",
        openapi.OAuth2Flow{
            Type:             openapi.FlowImplicit,
            AuthorizationURL: "https://example.com/oauth/authorize",
            Scopes: map[string]string{
                "read":  "Read access",
                "write": "Write access",
            },
        },
    ),
)

Password Flow

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithOAuth2(
        "oauth2",
        "OAuth2 authentication",
        openapi.OAuth2Flow{
            Type:     openapi.FlowPassword,
            TokenURL: "https://example.com/oauth/token",
            Scopes: map[string]string{
                "read":  "Read access",
                "write": "Write access",
            },
        },
    ),
)

Client Credentials Flow

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithOAuth2(
        "oauth2",
        "OAuth2 authentication",
        openapi.OAuth2Flow{
            Type:     openapi.FlowClientCredentials,
            TokenURL: "https://example.com/oauth/token",
            Scopes: map[string]string{
                "api": "API access",
            },
        },
    ),
)

Using OAuth2 in Operations

Specify which scopes are required for an operation:

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithSecurity("oauth2", "read"),
        openapi.WithResponse(200, []User{}),
    ),
    openapi.POST("/users",
        openapi.WithSummary("Create user"),
        openapi.WithSecurity("oauth2", "read", "write"),
        openapi.WithRequest(CreateUserRequest{}),
        openapi.WithResponse(201, User{}),
    ),
)

OpenID Connect

OpenID Connect provides authentication on top of OAuth2:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithOpenIDConnect(
        "openId",
        "https://example.com/.well-known/openid-configuration",
        "OpenID Connect authentication",
    ),
)

Using OpenID Connect in Operations

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithSecurity("openId"),
        openapi.WithResponse(200, []User{}),
    ),
)

Multiple Security Schemes

You can define multiple security schemes:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
    openapi.WithAPIKey(
        "apiKey",
        "X-API-Key",
        openapi.InHeader,
        "API key authentication",
    ),
)

Alternative Security Requirements (OR)

Allow multiple authentication methods for a single operation:

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithSecurity("bearerAuth"),  // Can use bearer auth
        openapi.WithSecurity("apiKey"),      // OR can use API key
        openapi.WithResponse(200, []User{}),
    ),
)

This means the client can authenticate using either bearer auth or an API key.

Optional vs Required Security

Required Security

Apply security at the operation level:

openapi.GET("/users",
    openapi.WithSecurity("bearerAuth"),
    openapi.WithResponse(200, []User{}),
)

Optional Security (Public Endpoint)

Omit the WithSecurity() option:

openapi.GET("/public/status",
    openapi.WithSummary("Public status endpoint"),
    openapi.WithResponse(200, StatusResponse{}),
)

Complete Security Example

Here’s a complete example with multiple security schemes:

package main

import (
    "context"
    "log"

    "rivaas.dev/openapi"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

type CreateUserRequest struct {
    Name string `json:"name"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("Secure API", "1.0.0"),
        
        // Define multiple security schemes
        openapi.WithBearerAuth("bearerAuth", "JWT token authentication"),
        openapi.WithAPIKey(
            "apiKey",
            "X-API-Key",
            openapi.InHeader,
            "API key authentication",
        ),
        openapi.WithOAuth2(
            "oauth2",
            "OAuth2 authentication",
            openapi.OAuth2Flow{
                Type:             openapi.FlowAuthorizationCode,
                AuthorizationURL: "https://example.com/oauth/authorize",
                TokenURL:         "https://example.com/oauth/token",
                Scopes: map[string]string{
                    "read":  "Read access",
                    "write": "Write access",
                },
            },
        ),
    )

    result, err := api.Generate(context.Background(),
        // Public endpoint (no security)
        openapi.GET("/health",
            openapi.WithSummary("Health check"),
            openapi.WithResponse(200, nil),
        ),
        
        // Bearer auth only
        openapi.GET("/users",
            openapi.WithSummary("List users"),
            openapi.WithSecurity("bearerAuth"),
            openapi.WithResponse(200, []User{}),
        ),
        
        // API key or bearer auth (alternative)
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithSecurity("bearerAuth"),
            openapi.WithSecurity("apiKey"),
            openapi.WithResponse(200, User{}),
        ),
        
        // OAuth2 with specific scopes
        openapi.POST("/users",
            openapi.WithSummary("Create user"),
            openapi.WithSecurity("oauth2", "read", "write"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(201, User{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Use result...
}

Next Steps

6.5 - Operations

Define HTTP operations with methods, options, and composable configurations

Learn how to define HTTP operations using method constructors and operation options.

HTTP Method Constructors

The package provides HTTP method constructors for defining operations:

openapi.GET("/users/:id", opts...)
openapi.POST("/users", opts...)
openapi.PUT("/users/:id", opts...)
openapi.PATCH("/users/:id", opts...)
openapi.DELETE("/users/:id", opts...)
openapi.HEAD("/users/:id", opts...)
openapi.OPTIONS("/users", opts...)
openapi.TRACE("/debug", opts...)

Each constructor takes a path and optional operation options.

Operation Options

All operation options follow the With* naming convention:

FunctionDescription
WithSummary(s)Set operation summary
WithDescription(s)Set operation description
WithOperationID(id)Set custom operation ID
WithRequest(type, examples...)Set request body type
WithResponse(status, type, examples...)Set response type for status code
WithTags(tags...)Add tags to operation
WithSecurity(scheme, scopes...)Add security requirement
WithDeprecated()Mark operation as deprecated
WithConsumes(types...)Set accepted content types
WithProduces(types...)Set returned content types
WithOperationExtension(key, value)Add operation extension

Basic Operation Definition

Define a simple GET operation:

result, err := api.Generate(context.Background(),
    openapi.GET("/users/:id",
        openapi.WithSummary("Get user by ID"),
        openapi.WithDescription("Retrieves a user by their unique identifier"),
        openapi.WithResponse(200, User{}),
    ),
)

Request Bodies

Use WithRequest() to specify the request body type:

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

openapi.POST("/users",
    openapi.WithSummary("Create user"),
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithResponse(201, User{}),
)

Request with Examples

Provide example request bodies:

exampleUser := CreateUserRequest{
    Name:  "John Doe",
    Email: "john@example.com",
}

openapi.POST("/users",
    openapi.WithSummary("Create user"),
    openapi.WithRequest(CreateUserRequest{}, exampleUser),
    openapi.WithResponse(201, User{}),
)

Response Types

Define multiple response types for different status codes:

type ErrorResponse struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
    openapi.WithResponse(404, ErrorResponse{}),
    openapi.WithResponse(500, ErrorResponse{}),
)

Response with Examples

Provide example responses:

exampleUser := User{
    ID:    123,
    Name:  "John Doe",
    Email: "john@example.com",
}

openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}, exampleUser),
)

Tags

Organize operations with tags:

result, err := api.Generate(context.Background(),
    openapi.GET("/users",
        openapi.WithSummary("List users"),
        openapi.WithTags("users"),
        openapi.WithResponse(200, []User{}),
    ),
    openapi.GET("/posts",
        openapi.WithSummary("List posts"),
        openapi.WithTags("posts"),
        openapi.WithResponse(200, []Post{}),
    ),
)

Multiple tags per operation:

openapi.GET("/users/:id/posts",
    openapi.WithSummary("Get user's posts"),
    openapi.WithTags("users", "posts"),
    openapi.WithResponse(200, []Post{}),
)

Security Requirements

Apply security to operations:

// Single security scheme
openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithSecurity("bearerAuth"),
    openapi.WithResponse(200, User{}),
)

// OAuth2 with scopes
openapi.POST("/users",
    openapi.WithSummary("Create user"),
    openapi.WithSecurity("oauth2", "read", "write"),
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithResponse(201, User{}),
)

// Multiple security schemes (OR)
openapi.DELETE("/users/:id",
    openapi.WithSummary("Delete user"),
    openapi.WithSecurity("bearerAuth"),
    openapi.WithSecurity("apiKey"),
    openapi.WithResponse(204, nil),
)

Deprecated Operations

Mark operations as deprecated:

openapi.GET("/users/legacy",
    openapi.WithSummary("Legacy user list"),
    openapi.WithDescription("This endpoint is deprecated. Use /users instead."),
    openapi.WithDeprecated(),
    openapi.WithResponse(200, []User{}),
)

Content Types

Specify content types for requests and responses:

openapi.POST("/users",
    openapi.WithSummary("Create user"),
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithConsumes("application/json", "application/xml"),
    openapi.WithProduces("application/json", "application/xml"),
    openapi.WithResponse(201, User{}),
)

Operation Extensions

Add custom x-* extensions to operations:

openapi.GET("/users",
    openapi.WithSummary("List users"),
    openapi.WithOperationExtension("x-rate-limit", 100),
    openapi.WithOperationExtension("x-internal-only", false),
    openapi.WithResponse(200, []User{}),
)

Complete Operation Example

Here’s a complete example with all options:

openapi.PUT("/users/:id",
    openapi.WithSummary("Update user"),
    openapi.WithDescription("Updates an existing user's information"),
    openapi.WithOperationID("updateUser"),
    openapi.WithRequest(UpdateUserRequest{}),
    openapi.WithResponse(200, User{}),
    openapi.WithResponse(400, ErrorResponse{}),
    openapi.WithResponse(404, ErrorResponse{}),
    openapi.WithResponse(500, ErrorResponse{}),
    openapi.WithTags("users"),
    openapi.WithSecurity("bearerAuth"),
    openapi.WithConsumes("application/json"),
    openapi.WithProduces("application/json"),
    openapi.WithOperationExtension("x-rate-limit", 50),
)

Composable Operation Options

Use WithOptions() to create reusable option sets:

// Define reusable option sets
var (
    CommonErrors = openapi.WithOptions(
        openapi.WithResponse(400, ErrorResponse{}),
        openapi.WithResponse(401, ErrorResponse{}),
        openapi.WithResponse(500, ErrorResponse{}),
    )
    
    UserEndpoint = openapi.WithOptions(
        openapi.WithTags("users"),
        openapi.WithSecurity("bearerAuth"),
        CommonErrors,
    )
    
    JSONContent = openapi.WithOptions(
        openapi.WithConsumes("application/json"),
        openapi.WithProduces("application/json"),
    )
)

// Apply to operations
result, err := api.Generate(context.Background(),
    openapi.GET("/users/:id",
        UserEndpoint,
        JSONContent,
        openapi.WithSummary("Get user"),
        openapi.WithResponse(200, User{}),
    ),
    
    openapi.POST("/users",
        UserEndpoint,
        JSONContent,
        openapi.WithSummary("Create user"),
        openapi.WithRequest(CreateUserRequest{}),
        openapi.WithResponse(201, User{}),
    ),
    
    openapi.PUT("/users/:id",
        UserEndpoint,
        JSONContent,
        openapi.WithSummary("Update user"),
        openapi.WithRequest(UpdateUserRequest{}),
        openapi.WithResponse(200, User{}),
    ),
)

Nested Composable Options

Option sets can be nested:

var (
    ErrorResponses = openapi.WithOptions(
        openapi.WithResponse(400, ErrorResponse{}),
        openapi.WithResponse(500, ErrorResponse{}),
    )
    
    AuthRequired = openapi.WithOptions(
        openapi.WithSecurity("bearerAuth"),
        openapi.WithResponse(401, ErrorResponse{}),
        ErrorResponses,
    )
    
    UserAPI = openapi.WithOptions(
        openapi.WithTags("users"),
        AuthRequired,
    )
)

Custom Operation IDs

By default, operation IDs are auto-generated from the HTTP method and path. Override with WithOperationID():

openapi.GET("/users/:id",
    openapi.WithOperationID("getUserById"),
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
)

Without WithOperationID(), the operation ID would be auto-generated as getUsers_id.

Next Steps

6.6 - Auto-Discovery

Use struct tags for automatic parameter discovery

Learn how the package automatically discovers API parameters from struct tags.

Overview

The package automatically discovers parameters from struct tags. This eliminates the need to manually define parameters in the OpenAPI specification.

Supported Parameter Types

The package supports four parameter locations:

  • path - Path parameters. Always required.
  • query - Query parameters.
  • header - Header parameters.
  • cookie - Cookie parameters.

Basic Parameter Discovery

Define parameters using struct tags:

type GetUserRequest struct {
    ID int `path:"id" doc:"User ID" example:"123"`
}

result, err := api.Generate(context.Background(),
    openapi.GET("/users/:id",
        openapi.WithSummary("Get user"),
        openapi.WithResponse(200, User{}),
    ),
)

The package automatically discovers the id path parameter from the struct tag.

Path Parameters

Path parameters are always required and are extracted from the URL path:

type GetUserPostRequest struct {
    UserID int `path:"user_id" doc:"User ID" example:"123"`
    PostID int `path:"post_id" doc:"Post ID" example:"456"`
}

openapi.GET("/users/:user_id/posts/:post_id",
    openapi.WithSummary("Get user's post"),
    openapi.WithResponse(200, Post{}),
)

Query Parameters

Query parameters are extracted from the URL query string:

type ListUsersRequest struct {
    Page     int      `query:"page" doc:"Page number" example:"1" validate:"min=1"`
    PerPage  int      `query:"per_page" doc:"Items per page" example:"20" validate:"min=1,max=100"`
    Sort     string   `query:"sort" doc:"Sort field" enum:"name,created_at"`
    Tags     []string `query:"tags" doc:"Filter by tags"`
    Verified *bool    `query:"verified" doc:"Filter by verification status"`
}

openapi.GET("/users",
    openapi.WithSummary("List users"),
    openapi.WithResponse(200, []User{}),
)

Header Parameters

Header parameters are extracted from HTTP headers:

type GetUserRequest struct {
    ID            int    `path:"id"`
    Accept        string `header:"Accept" doc:"Content type" enum:"application/json,application/xml"`
    IfNoneMatch   string `header:"If-None-Match" doc:"ETag for caching"`
    XRequestID    string `header:"X-Request-ID" doc:"Request correlation ID"`
}

openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
)

Cookie parameters are extracted from HTTP cookies:

type GetUserRequest struct {
    ID        int    `path:"id"`
    SessionID string `cookie:"session_id" doc:"Session identifier"`
    Theme     string `cookie:"theme" doc:"UI theme preference" enum:"light,dark"`
}

openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
)

Request Body Fields

Fields in the request body use the json tag:

type CreateUserRequest struct {
    Name  string `json:"name" doc:"User's full name" example:"John Doe" validate:"required"`
    Email string `json:"email" doc:"User's email address" example:"john@example.com" validate:"required,email"`
    Age   *int   `json:"age,omitempty" doc:"User's age" example:"30" validate:"min=0,max=150"`
}

openapi.POST("/users",
    openapi.WithSummary("Create user"),
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithResponse(201, User{}),
)

Additional Tags

Use these tags to enhance parameter documentation:

doc Tag

Add descriptions to parameters:

type ListUsersRequest struct {
    Page int `query:"page" doc:"Page number for pagination, starting at 1"`
}

example Tag

Provide example values:

type GetUserRequest struct {
    ID int `path:"id" doc:"User ID" example:"123"`
}

enum Tag

Specify allowed values (comma-separated):

type ListUsersRequest struct {
    Sort   string `query:"sort" doc:"Sort field" enum:"name,email,created_at"`
    Format string `query:"format" doc:"Response format" enum:"json,xml"`
}

validate Tag

Mark parameters as required or add validation constraints:

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"min=0,max=150"`
}

The required validation affects the required field in the OpenAPI spec.

Complete Struct Tag Example

Here’s a comprehensive example using all tag types:

type CreateOrderRequest struct {
    // Path parameter (always required)
    UserID int `path:"user_id" doc:"User ID" example:"123"`
    
    // Query parameters
    Coupon    string `query:"coupon" doc:"Coupon code for discount" example:"SAVE20"`
    SendEmail *bool  `query:"send_email" doc:"Send confirmation email" example:"true"`
    
    // Header parameters
    IdempotencyKey string `header:"Idempotency-Key" doc:"Idempotency key for request" example:"550e8400-e29b-41d4-a716-446655440000"`
    
    // Cookie parameters
    SessionID string `cookie:"session_id" doc:"Session identifier"`
    
    // Request body fields
    Items []OrderItem `json:"items" doc:"Order items" validate:"required,min=1"`
    Total float64     `json:"total" doc:"Order total" example:"99.99" validate:"required,min=0"`
    Notes string      `json:"notes,omitempty" doc:"Additional notes" example:"Please gift wrap"`
}

type OrderItem struct {
    ProductID int     `json:"product_id" validate:"required"`
    Quantity  int     `json:"quantity" validate:"required,min=1"`
    Price     float64 `json:"price" validate:"required,min=0"`
}

Parameter Discovery Rules

Required vs Optional

  • Path parameters: Always required
  • Query/Header/Cookie parameters:
    • Required if validate:"required" tag is present
    • Optional otherwise
  • Request body fields:
    • Required if validate:"required" tag is present
    • Optional if pointer type or omitempty JSON tag

Type Conversion

The package automatically converts Go types to OpenAPI types:

type Parameters struct {
    // String types
    Name   string `query:"name"`    // type: string
    
    // Integer types
    Count  int    `query:"count"`   // type: integer, format: int32
    BigNum int64  `query:"big"`     // type: integer, format: int64
    
    // Floating-point types
    Price  float64 `query:"price"`  // type: number, format: double
    Rate   float32 `query:"rate"`   // type: number, format: float
    
    // Boolean types
    Active bool `query:"active"`    // type: boolean
    
    // Array types
    Tags []string `query:"tags"`    // type: array, items: string
    IDs  []int    `query:"ids"`     // type: array, items: integer
    
    // Pointer types (optional)
    Size *int `query:"size"`        // type: integer, optional
}

Combining Parameters with Request Bodies

A single struct can contain both parameters and request body fields:

type UpdateUserRequest struct {
    // Path parameter
    ID int `path:"id" doc:"User ID"`
    
    // Query parameter
    Notify bool `query:"notify" doc:"Send notification"`
    
    // Request body fields
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
}

openapi.PUT("/users/:id",
    openapi.WithSummary("Update user"),
    openapi.WithRequest(UpdateUserRequest{}),
    openapi.WithResponse(200, User{}),
)

Nested Structures

Parameters can be in nested structures:

type GetUserRequest struct {
    ID     int             `path:"id"`
    Filter UserListFilter  `query:",inline"`
}

type UserListFilter struct {
    Active   *bool  `query:"active" doc:"Filter by active status"`
    Role     string `query:"role" doc:"Filter by role" enum:"admin,user,guest"`
    Since    string `query:"since" doc:"Filter by creation date"`
}

Next Steps

6.7 - Schema Generation

Understand how Go types are converted to OpenAPI schemas

Learn how the package automatically converts Go types to OpenAPI schemas.

Overview

The package uses reflection to convert Go types into OpenAPI schemas. This eliminates the need to manually define schemas in the specification.

Supported Go Types

Primitive Types

Go TypeOpenAPI TypeOpenAPI Format
stringstring-
boolboolean-
int, int32integerint32
int64integerint64
uint, uint32integerint32
uint64integerint64
float32numberfloat
float64numberdouble
bytestringbyte

String Types

type User struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

Generates:

type: object
properties:
  name:
    type: string
  email:
    type: string

Integer Types

type Product struct {
    ID       int   `json:"id"`
    Quantity int32 `json:"quantity"`
    Stock    int64 `json:"stock"`
}

Generates:

type: object
properties:
  id:
    type: integer
    format: int32
  quantity:
    type: integer
    format: int32
  stock:
    type: integer
    format: int64

Floating-Point Types

type Product struct {
    Price   float64 `json:"price"`
    Weight  float32 `json:"weight"`
}

Generates:

type: object
properties:
  price:
    type: number
    format: double
  weight:
    type: number
    format: float

Boolean Types

type User struct {
    Active   bool `json:"active"`
    Verified bool `json:"verified"`
}

Generates:

type: object
properties:
  active:
    type: boolean
  verified:
    type: boolean

Pointer Types

Pointer types are nullable and optional:

type User struct {
    Name  string `json:"name"`
    Age   *int   `json:"age,omitempty"`
    Email *string `json:"email,omitempty"`
}

In OpenAPI 3.1.x, pointers generate nullable: true. In OpenAPI 3.0.x, they’re optional fields.

Slices and Arrays

Slices become OpenAPI arrays:

type User struct {
    Tags   []string `json:"tags"`
    Scores []int    `json:"scores"`
    Posts  []Post   `json:"posts"`
}

Generates:

type: object
properties:
  tags:
    type: array
    items:
      type: string
  scores:
    type: array
    items:
      type: integer
  posts:
    type: array
    items:
      $ref: '#/components/schemas/Post'

Maps

Maps become OpenAPI objects with additionalProperties:

type User struct {
    Metadata map[string]string `json:"metadata"`
    Scores   map[string]int    `json:"scores"`
}

Generates:

type: object
properties:
  metadata:
    type: object
    additionalProperties:
      type: string
  scores:
    type: object
    additionalProperties:
      type: integer

Nested Structs

Nested structs are converted to nested schemas or references:

type User struct {
    ID      int     `json:"id"`
    Name    string  `json:"name"`
    Address Address `json:"address"`
}

type Address struct {
    Street  string `json:"street"`
    City    string `json:"city"`
    ZipCode string `json:"zip_code"`
}

Generates component schemas with references:

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        address:
          $ref: '#/components/schemas/Address'
    Address:
      type: object
      properties:
        street:
          type: string
        city:
          type: string
        zip_code:
          type: string

Embedded Structs

Embedded struct fields are flattened into the parent:

type Timestamps struct {
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
    Timestamps
}

Generates:

type: object
properties:
  id:
    type: integer
  name:
    type: string
  created_at:
    type: string
    format: date-time
  updated_at:
    type: string
    format: date-time

Time Types

time.Time becomes a string with date-time format:

type User struct {
    CreatedAt time.Time  `json:"created_at"`
    UpdatedAt *time.Time `json:"updated_at,omitempty"`
}

Generates:

type: object
properties:
  created_at:
    type: string
    format: date-time
  updated_at:
    type: string
    format: date-time

JSON Tags

The package respects json struct tags:

type User struct {
    ID        int    `json:"id"`
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    Internal  string `json:"-"`           // Ignored
    Optional  string `json:"opt,omitempty"` // Optional
}
  • json:"name" - Sets the property name
  • json:"-" - Field is ignored
  • json:",omitempty" - Field is optional (not required)

Schema Naming

Component schema names use the format pkgname.TypeName to prevent collisions:

// In package "api"
type User struct { ... }  // Becomes "api.User"

// In package "models"
type User struct { ... }  // Becomes "models.User"

This prevents naming collisions when the same type name exists in different packages.

Custom Schema Names

If you need custom schema names, use the openapi struct tag:

type User struct {
    ID   int    `json:"id" openapi:"name=CustomUser"`
    Name string `json:"name"`
}

Validation Tags

Validation tags affect the OpenAPI schema:

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age" validate:"min=0,max=150"`
}
  • required - Adds field to required array
  • min/max - Sets minimum/maximum for numbers
  • email - Sets format: email for strings

Documentation Tags

Use doc and example tags to enhance schemas:

type User struct {
    ID    int    `json:"id" doc:"Unique user identifier" example:"123"`
    Name  string `json:"name" doc:"User's full name" example:"John Doe"`
    Email string `json:"email" doc:"User's email address" example:"john@example.com"`
}

Generates:

type: object
properties:
  id:
    type: integer
    description: Unique user identifier
    example: 123
  name:
    type: string
    description: User's full name
    example: John Doe
  email:
    type: string
    description: User's email address
    example: john@example.com

Enum Types

Use enum tag to specify allowed values:

type User struct {
    Role   string `json:"role" enum:"admin,user,guest"`
    Status string `json:"status" enum:"active,inactive,pending"`
}

Generates:

type: object
properties:
  role:
    type: string
    enum: [admin, user, guest]
  status:
    type: string
    enum: [active, inactive, pending]

Complete Schema Example

Here’s a comprehensive example using all features:

package main

import (
    "time"
)

type User struct {
    // Basic types
    ID       int    `json:"id" doc:"Unique user identifier" example:"123"`
    Name     string `json:"name" doc:"User's full name" example:"John Doe" validate:"required"`
    Email    string `json:"email" doc:"Email address" example:"john@example.com" validate:"required,email"`
    
    // Optional field
    Bio *string `json:"bio,omitempty" doc:"User biography"`
    
    // Numeric types
    Age    int     `json:"age" doc:"User's age" validate:"min=0,max=150"`
    Score  float64 `json:"score" doc:"User score" example:"95.5"`
    
    // Boolean
    Active bool `json:"active" doc:"Whether user is active" example:"true"`
    
    // Enum
    Role string `json:"role" doc:"User role" enum:"admin,user,guest"`
    
    // Arrays
    Tags   []string `json:"tags" doc:"User tags"`
    Scores []int    `json:"scores" doc:"Test scores"`
    
    // Map
    Metadata map[string]string `json:"metadata" doc:"Additional metadata"`
    
    // Nested struct
    Address Address `json:"address" doc:"User address"`
    
    // Time
    CreatedAt time.Time  `json:"created_at" doc:"Creation timestamp"`
    UpdatedAt *time.Time `json:"updated_at,omitempty" doc:"Last update timestamp"`
    
    // Ignored field
    Internal string `json:"-"`
}

type Address struct {
    Street  string `json:"street" validate:"required"`
    City    string `json:"city" validate:"required"`
    State   string `json:"state"`
    ZipCode string `json:"zip_code" validate:"required"`
    Country string `json:"country" validate:"required"`
}

Next Steps

  • Learn about Operations to use your schemas in API endpoints
  • Explore Validation to validate generated specifications
  • See Examples for complete schema patterns

6.8 - Swagger UI

Customize the Swagger UI interface for API documentation

Learn how to configure and customize the Swagger UI interface for your OpenAPI specification.

Overview

The package includes built-in Swagger UI support with extensive customization options. Swagger UI provides an interactive interface for exploring and testing your API.

Basic Configuration

Enable Swagger UI by specifying the path:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithSwaggerUI("/docs"),
)

This serves Swagger UI at /docs with default settings.

Disabling Swagger UI

To disable Swagger UI completely:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithoutSwaggerUI(),
)

Display Options

Document Expansion

Control how documentation is initially displayed:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIExpansion(openapi.DocExpansionList),
)

Available options:

  • DocExpansionList - Show endpoints, hide details. This is the default.
  • DocExpansionFull - Show endpoints and details.
  • DocExpansionNone - Hide everything.

Model Rendering

Control how models/schemas are rendered:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIDefaultModelRendering(openapi.ModelRenderingExample),
)

Options:

  • ModelRenderingExample - Show example values. This is the default.
  • ModelRenderingModel - Show schema structure.

Model Expand Depth

Control how deeply nested models are expanded:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIModelExpandDepth(1),      // How deep to expand a single model
    openapi.WithUIModelsExpandDepth(1),     // How deep to expand models section
)

Set to -1 to disable expansion, 1 for shallow, higher numbers for deeper.

Display Operation IDs

Show operation IDs alongside summaries:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIDisplayOperationID(true),
)

Try It Out Features

Enable Try It Out

Allow users to test API endpoints directly:

openapi.WithSwaggerUI("/docs",
    openapi.WithUITryItOut(true),
)

Request Snippets

Show code snippets for making requests:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIRequestSnippets(true,
        openapi.SnippetCurlBash,
        openapi.SnippetCurlPowerShell,
        openapi.SnippetCurlCmd,
    ),
)

Available snippet types:

  • SnippetCurlBash - curl for bash/sh shells
  • SnippetCurlPowerShell - curl for PowerShell
  • SnippetCurlCmd - curl for Windows CMD

Request Snippets Expanded

Expand request snippets by default:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIRequestSnippets(true, openapi.SnippetCurlBash),
    openapi.WithUIRequestSnippetsExpanded(true),
)

Display Request Duration

Show how long requests take:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIDisplayRequestDuration(true),
)

Filtering and Sorting

Filter

Enable a filter/search box:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIFilter(true),
)

Max Displayed Tags

Limit the number of tags displayed:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIMaxDisplayedTags(10),
)

Operations Sorting

Sort operations alphabetically or by HTTP method:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIOperationsSorter(openapi.OperationsSorterAlpha),
)

Options:

  • OperationsSorterAlpha - Sort alphabetically
  • OperationsSorterMethod - Sort by HTTP method
  • Leave unset for default order

Tags Sorting

Sort tags alphabetically:

openapi.WithSwaggerUI("/docs",
    openapi.WithUITagsSorter(openapi.TagsSorterAlpha),
)

Syntax Highlighting

Enable/Disable Syntax Highlighting

openapi.WithSwaggerUI("/docs",
    openapi.WithUISyntaxHighlight(true),
)

Syntax Theme

Choose a color theme for code highlighting:

openapi.WithSwaggerUI("/docs",
    openapi.WithUISyntaxTheme(openapi.SyntaxThemeMonokai),
)

Available themes:

  • SyntaxThemeAgate - Dark theme with blue accents
  • SyntaxThemeArta - Dark theme with orange accents
  • SyntaxThemeMonokai - Dark theme with vibrant colors
  • SyntaxThemeNord - Dark theme with cool blue tones
  • SyntaxThemeObsidian - Dark theme with green accents
  • SyntaxThemeTomorrowNight - Dark theme with muted colors
  • SyntaxThemeIdea - Light theme similar to IntelliJ IDEA

Authentication

Persist Authentication

Keep auth credentials across browser refreshes:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIPersistAuth(true),
)

Send Credentials

Include credentials in requests:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIWithCredentials(true),
)

Validation

Control OpenAPI specification validation:

// Use local validation (recommended)
openapi.WithSwaggerUI("/docs",
    openapi.WithUIValidator(openapi.ValidatorLocal),
)

// Use external validator
openapi.WithSwaggerUI("/docs",
    openapi.WithUIValidator("https://validator.swagger.io/validator"),
)

// Disable validation
openapi.WithSwaggerUI("/docs",
    openapi.WithUIValidator(openapi.ValidatorNone),
)

Complete Swagger UI Example

Here’s a comprehensive example with all common options:

package main

import (
    "rivaas.dev/openapi"
)

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("My API", "1.0.0"),
        openapi.WithSwaggerUI("/docs",
            // Document expansion
            openapi.WithUIExpansion(openapi.DocExpansionList),
            openapi.WithUIModelsExpandDepth(1),
            openapi.WithUIModelExpandDepth(1),
            
            // Display options
            openapi.WithUIDisplayOperationID(true),
            openapi.WithUIDefaultModelRendering(openapi.ModelRenderingExample),
            
            // Try it out
            openapi.WithUITryItOut(true),
            openapi.WithUIRequestSnippets(true,
                openapi.SnippetCurlBash,
                openapi.SnippetCurlPowerShell,
                openapi.SnippetCurlCmd,
            ),
            openapi.WithUIRequestSnippetsExpanded(true),
            openapi.WithUIDisplayRequestDuration(true),
            
            // Filtering and sorting
            openapi.WithUIFilter(true),
            openapi.WithUIMaxDisplayedTags(10),
            openapi.WithUIOperationsSorter(openapi.OperationsSorterAlpha),
            openapi.WithUITagsSorter(openapi.TagsSorterAlpha),
            
            // Syntax highlighting
            openapi.WithUISyntaxHighlight(true),
            openapi.WithUISyntaxTheme(openapi.SyntaxThemeMonokai),
            
            // Authentication
            openapi.WithUIPersistAuth(true),
            openapi.WithUIWithCredentials(true),
            
            // Validation
            openapi.WithUIValidator(openapi.ValidatorLocal),
        ),
    )

    // Generate specification...
}

Swagger UI Path

The Swagger UI path can be any valid URL path:

openapi.WithSwaggerUI("/api-docs")
openapi.WithSwaggerUI("/swagger")
openapi.WithSwaggerUI("/docs/api")

Integration with Web Frameworks

The package generates the OpenAPI specification, but you need to integrate it with your web framework to serve Swagger UI. The typical pattern is:

// Generate the spec
result, err := api.Generate(context.Background(), operations...)
if err != nil {
    log.Fatal(err)
}

// Serve the spec at /openapi.json
http.HandleFunc("/openapi.json", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write(result.JSON)
})

// Serve Swagger UI at /docs
// (Framework-specific implementation)

Next Steps

6.9 - Validation

Validate OpenAPI specifications against official meta-schemas

Learn how to validate OpenAPI specifications using built-in validation against official meta-schemas.

Overview

The package provides built-in validation against official OpenAPI meta-schemas for both 3.0.x and 3.1.x specifications.

Enabling Validation

Validation is disabled by default for performance. Enable it during development or in CI/CD pipelines:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithValidation(true), // Enable validation
)

result, err := api.Generate(context.Background(), operations...)
if err != nil {
    log.Fatal(err) // Will fail if spec is invalid
}

Why Validation is Disabled by Default

Validation has a performance cost:

  • Schema compilation on first use.
  • JSON schema validation for every generation.
  • Not necessary for production spec generation.

When to enable:

  • During development.
  • In CI/CD pipelines.
  • When debugging specification issues.
  • When accepting external specifications.

When to disable:

  • Production spec generation.
  • When performance is critical.
  • After spec validation is confirmed.

Validation Errors

When validation fails, you’ll receive a detailed error:

result, err := api.Generate(context.Background(), operations...)
if err != nil {
    // Error contains validation details
    fmt.Printf("Validation failed: %v\n", err)
}

Common validation errors:

  • Missing required fields like info, openapi, paths.
  • Invalid field types.
  • Invalid format values.
  • Schema constraint violations.
  • Invalid references.

Validating External Specifications

The package includes a standalone validator for external OpenAPI specifications:

import "rivaas.dev/openapi/validate"

// Read external spec
specJSON, err := os.ReadFile("external-api.json")
if err != nil {
    log.Fatal(err)
}

// Create validator
validator := validate.New()

// Validate against OpenAPI 3.0.x
err = validator.Validate(context.Background(), specJSON, validate.V30)
if err != nil {
    log.Printf("Validation failed: %v\n", err)
}

// Or validate against OpenAPI 3.1.x
err = validator.Validate(context.Background(), specJSON, validate.V31)
if err != nil {
    log.Printf("Validation failed: %v\n", err)
}

Auto-Detection

The validator can auto-detect the OpenAPI version:

validator := validate.New()

// Auto-detects version from the spec
err := validator.ValidateAuto(context.Background(), specJSON)
if err != nil {
    log.Printf("Validation failed: %v\n", err)
}

Swagger UI Validation

Configure validation in Swagger UI:

Use the built-in validator (no external calls):

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithSwaggerUI("/docs",
        openapi.WithUIValidator(openapi.ValidatorLocal),
    ),
)

External Validator

Use an external validation service:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithSwaggerUI("/docs",
        openapi.WithUIValidator("https://validator.swagger.io/validator"),
    ),
)

Disable Validation

Disable validation in Swagger UI:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithSwaggerUI("/docs",
        openapi.WithUIValidator(openapi.ValidatorNone),
    ),
)

Validation in CI/CD

Add validation to your CI/CD pipeline:

# generate-openapi.go
package main

import (
    "context"
    "log"
    "os"
    
    "rivaas.dev/openapi"
)

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("My API", "1.0.0"),
        openapi.WithValidation(true), // Enable for CI/CD
    )
    
    result, err := api.Generate(context.Background(),
        // ... operations
    )
    if err != nil {
        log.Fatalf("Validation failed: %v", err)
    }
    
    // Write to file
    if err := os.WriteFile("openapi.json", result.JSON, 0644); err != nil {
        log.Fatal(err)
    }
    
    log.Println("Valid OpenAPI specification generated")
}

In your CI pipeline:

# .github/workflows/validate-openapi.yml
name: Validate OpenAPI

on: [push, pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v4
        with:
          go-version: '1.25'
      - run: go run generate-openapi.go

Validation Performance

Validation performance characteristics:

  • First validation: ~10-20ms (schema compilation)
  • Subsequent validations: ~1-5ms (using cached schema)
  • External spec validation: Depends on spec size

For high-performance scenarios, consider:

  • Validate once during build/deployment
  • Cache validated specifications
  • Disable validation in production spec generation

Complete Validation Example

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    
    "rivaas.dev/openapi"
    "rivaas.dev/openapi/validate"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    // Generate with validation enabled
    api := openapi.MustNew(
        openapi.WithTitle("User API", "1.0.0"),
        openapi.WithValidation(true),
    )
    
    result, err := api.Generate(context.Background(),
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithResponse(200, User{}),
        ),
    )
    if err != nil {
        log.Fatalf("Generation/validation failed: %v", err)
    }
    
    fmt.Println("Generated valid OpenAPI 3.0.4 specification")
    
    // Write to file
    if err := os.WriteFile("openapi.json", result.JSON, 0644); err != nil {
        log.Fatal(err)
    }
    
    // Validate external spec (e.g., from a file)
    externalSpec, err := os.ReadFile("external-api.json")
    if err != nil {
        log.Fatal(err)
    }
    
    validator := validate.New()
    if err := validator.ValidateAuto(context.Background(), externalSpec); err != nil {
        log.Printf("External spec validation failed: %v\n", err)
    } else {
        fmt.Println("External spec is valid")
    }
}

Validation vs Warnings

It’s important to distinguish between validation errors and warnings:

  • Validation errors: The specification violates OpenAPI schema requirements
  • Warnings: The specification is valid but uses version-specific features (see Diagnostics)
api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithVersion(openapi.V30x),
    openapi.WithInfoSummary("Summary"), // 3.1-only feature
    openapi.WithValidation(true),
)

result, err := api.Generate(context.Background(), ops...)
// err is nil (spec is valid)
// result.Warnings contains warning about info.summary being dropped

Next Steps

6.10 - Diagnostics

Handle warnings with type-safe diagnostics

Learn how to work with warnings using the type-safe diagnostics package.

Overview

The package generates warnings when using version-specific features. For example, using OpenAPI 3.1 features with a 3.0 target generates warnings instead of errors.

Working with Warnings

Check for warnings in the generation result:

result, err := api.Generate(context.Background(), operations...)
if err != nil {
    log.Fatal(err)
}

// Basic warning check
if len(result.Warnings) > 0 {
    fmt.Printf("Generated with %d warnings\n", len(result.Warnings))
}

// Iterate through warnings
for _, warn := range result.Warnings {
    fmt.Printf("[%s] %s\n", warn.Code(), warn.Message())
}

The diag Package

Import the diag package for type-safe warning handling:

import "rivaas.dev/openapi/diag"

Warning Interface

Each warning implements the Warning interface:

type Warning interface {
    Code() WarningCode        // Unique warning code
    Message() string          // Human-readable message
    Path() string            // Location in spec (e.g., "info.summary")
    Category() WarningCategory // Warning category
}

Type-Safe Warning Checks

Check for specific warnings using type-safe constants:

import "rivaas.dev/openapi/diag"

result, err := api.Generate(context.Background(), ops...)
if err != nil {
    log.Fatal(err)
}

// Check for specific warning
if result.Warnings.Has(diag.WarnDownlevelWebhooks) {
    log.Warn("webhooks not supported in OpenAPI 3.0")
}

// Check for any of multiple codes
if result.Warnings.HasAny(
    diag.WarnDownlevelMutualTLS,
    diag.WarnDownlevelWebhooks,
) {
    log.Warn("Some 3.1 security features were dropped")
}

Warning Categories

Warnings are organized into categories:

// Filter by category
downlevelWarnings := result.Warnings.FilterCategory(diag.CategoryDownlevel)
fmt.Printf("Downlevel warnings: %d\n", len(downlevelWarnings))

deprecationWarnings := result.Warnings.FilterCategory(diag.CategoryDeprecation)
fmt.Printf("Deprecation warnings: %d\n", len(deprecationWarnings))

Available categories:

  • CategoryDownlevel - 3.1 to 3.0 conversion feature losses
  • CategoryDeprecation - Deprecated feature usage warnings
  • CategoryUnknown - Unrecognized warning codes

Warning Codes

Common warning codes:

Downlevel Warnings

These occur when using 3.1 features with a 3.0 target:

  • WarnDownlevelWebhooks - Webhooks dropped
  • WarnDownlevelInfoSummary - info.summary dropped
  • WarnDownlevelLicenseIdentifier - license.identifier dropped
  • WarnDownlevelMutualTLS - mutualTLS security scheme dropped
  • WarnDownlevelConstToEnum - JSON Schema const converted to enum
  • WarnDownlevelMultipleExamples - Multiple examples collapsed to one
  • WarnDownlevelPatternProperties - patternProperties dropped
  • WarnDownlevelUnevaluatedProperties - unevaluatedProperties dropped
  • WarnDownlevelContentEncoding - contentEncoding dropped
  • WarnDownlevelContentMediaType - contentMediaType dropped

Deprecation Warnings

These occur when using deprecated features:

  • WarnDeprecationExampleSingular - Using deprecated singular example field

Filtering Warnings

Filter Specific Warnings

Get only specific warning types:

licenseWarnings := result.Warnings.Filter(diag.WarnDownlevelLicenseIdentifier)
for _, warn := range licenseWarnings {
    fmt.Printf("%s: %s\n", warn.Path(), warn.Message())
}

Exclude Expected Warnings

Exclude warnings you expect and want to ignore:

unexpected := result.Warnings.Exclude(
    diag.WarnDownlevelInfoSummary,
    diag.WarnDownlevelLicenseIdentifier,
)

if len(unexpected) > 0 {
    fmt.Printf("Unexpected warnings: %d\n", len(unexpected))
    for _, warn := range unexpected {
        fmt.Printf("[%s] %s\n", warn.Code(), warn.Message())
    }
}

Complete Diagnostics Example

package main

import (
    "context"
    "fmt"
    "log"
    
    "rivaas.dev/openapi"
    "rivaas.dev/openapi/diag"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    // Create API with 3.0 target but use 3.1 features
    api := openapi.MustNew(
        openapi.WithTitle("My API", "1.0.0"),
        openapi.WithVersion(openapi.V30x),
        openapi.WithInfoSummary("Short summary"), // 3.1-only feature
    )
    
    result, err := api.Generate(context.Background(),
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithResponse(200, User{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Check for specific warning
    if result.Warnings.Has(diag.WarnDownlevelInfoSummary) {
        fmt.Println("info.summary was dropped (3.1 feature with 3.0 target)")
    }
    
    // Filter by category
    downlevelWarnings := result.Warnings.FilterCategory(diag.CategoryDownlevel)
    if len(downlevelWarnings) > 0 {
        fmt.Printf("\nDownlevel warnings (%d):\n", len(downlevelWarnings))
        for _, warn := range downlevelWarnings {
            fmt.Printf("  [%s] %s at %s\n", 
                warn.Code(), 
                warn.Message(), 
                warn.Path(),
            )
        }
    }
    
    // Check for unexpected warnings
    expected := []diag.WarningCode{
        diag.WarnDownlevelInfoSummary,
    }
    unexpected := result.Warnings.Exclude(expected...)
    
    if len(unexpected) > 0 {
        fmt.Printf("\nUnexpected warnings (%d):\n", len(unexpected))
        for _, warn := range unexpected {
            fmt.Printf("  [%s] %s\n", warn.Code(), warn.Message())
        }
    }
    
    fmt.Printf("\nGenerated %d byte specification with %d warnings\n",
        len(result.JSON), 
        len(result.Warnings),
    )
}

Warning vs Error

The package distinguishes between warnings and errors:

  • Warnings: The specification is valid but features were dropped or converted
  • Errors: The specification is invalid or generation failed
result, err := api.Generate(context.Background(), ops...)
if err != nil {
    // Hard error - generation failed
    log.Fatal(err)
}

if len(result.Warnings) > 0 {
    // Soft warnings - generation succeeded with caveats
    for _, warn := range result.Warnings {
        log.Printf("Warning: %s\n", warn.Message())
    }
}

Strict Downlevel Mode

To treat downlevel warnings as errors, enable strict mode (see Advanced Usage):

api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithVersion(openapi.V30x),
    openapi.WithStrictDownlevel(true), // Error on 3.1 features
    openapi.WithInfoSummary("Summary"), // This will cause an error
)

_, err := api.Generate(context.Background(), ops...)
// err will be non-nil due to strict mode violation

Warning Suppression

Currently, the package does not support per-warning suppression. To handle expected warnings:

  1. Filter them out after generation
  2. Use strict mode to error on any warnings
  3. Log and ignore specific warning codes
// Filter out expected warnings
expected := []diag.WarningCode{
    diag.WarnDownlevelInfoSummary,
    diag.WarnDownlevelLicenseIdentifier,
}

unexpected := result.Warnings.Exclude(expected...)
if len(unexpected) > 0 {
    log.Fatalf("Unexpected warnings: %d", len(unexpected))
}

Next Steps

6.11 - Advanced Usage

Custom operation IDs, extensions, and strict downlevel mode

Learn about advanced features including custom operation IDs, extensions, and strict downlevel mode.

Custom Operation IDs

By default, operation IDs are auto-generated from the HTTP method and path. You can override this behavior.

Auto-Generated Operation IDs

openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
)
// Generated operation ID: "getUsers_id"

openapi.POST("/users",
    openapi.WithSummary("Create user"),
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithResponse(201, User{}),
)
// Generated operation ID: "postUsers"

Custom Operation IDs

Override with WithOperationID():

openapi.GET("/users/:id",
    openapi.WithOperationID("getUserById"),
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
)

openapi.POST("/users",
    openapi.WithOperationID("createNewUser"),
    openapi.WithSummary("Create user"),
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithResponse(201, User{}),
)

Operation ID Best Practices

  • Use camelCase - Consistent with most API conventions.
  • Be descriptive - getUserById rather than getUser1.
  • Avoid conflicts - Ensure unique IDs across all operations.
  • Consider generation - Some tools generate client code from operation IDs.

Extensions

OpenAPI allows custom x-* extensions for vendor-specific metadata.

Root-Level Extensions

Add extensions to the root of the specification:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithExtension("x-api-version", "v2"),
    openapi.WithExtension("x-custom-feature", true),
    openapi.WithExtension("x-rate-limit-config", map[string]interface{}{
        "requests": 100,
        "period": "1m",
    }),
)

Operation Extensions

Add extensions to specific operations.

openapi.GET("/users",
    openapi.WithSummary("List users"),
    openapi.WithOperationExtension("x-rate-limit", 100),
    openapi.WithOperationExtension("x-cache-ttl", 300),
    openapi.WithOperationExtension("x-internal-only", false),
    openapi.WithResponse(200, []User{}),
)

Extension Naming Rules

  • Must start with x- - Required by OpenAPI specification
  • Reserved prefixes - x-oai- and x-oas- are reserved in 3.1.x
  • Case-sensitive - x-Custom and x-custom are different

Extension Validation

Extensions are validated:

// Valid
openapi.WithExtension("x-custom", "value")

// Invalid - doesn't start with x-
openapi.WithExtension("custom", "value") // Error

// Invalid - reserved prefix in 3.1.x
openapi.WithExtension("x-oai-custom", "value") // Filtered out in 3.1.x

Common Extension Use Cases

// API versioning
openapi.WithExtension("x-api-version", "2.0")

// Rate limiting
openapi.WithOperationExtension("x-rate-limit", map[string]interface{}{
    "requests": 100,
    "window": "1m",
})

// Caching
openapi.WithOperationExtension("x-cache", map[string]interface{}{
    "ttl": 300,
    "vary": []string{"Authorization", "Accept-Language"},
})

// Internal metadata
openapi.WithOperationExtension("x-internal", map[string]interface{}{
    "team": "platform",
    "cost": "low",
})

// Feature flags
openapi.WithOperationExtension("x-feature-flag", "new-user-flow")

// Code generation hints
openapi.WithOperationExtension("x-codegen", map[string]interface{}{
    "methodName": "customMethodName",
    "packageName": "users",
})

Strict Downlevel Mode

By default, using 3.1 features with a 3.0 target generates warnings. Enable strict mode to error instead:

Default Behavior (Warnings)

api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithVersion(openapi.V30x),
    openapi.WithInfoSummary("Summary"), // 3.1-only feature
)

result, err := api.Generate(context.Background(), ops...)
// err is nil (generation succeeds)
// result.Warnings contains warning about info.summary being dropped

Strict Mode (Errors)

api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithVersion(openapi.V30x),
    openapi.WithStrictDownlevel(true), // Enable strict mode
    openapi.WithInfoSummary("Summary"), // This will cause an error
)

result, err := api.Generate(context.Background(), ops...)
// err is non-nil (generation fails)

When to Use Strict Mode

Use strict mode when:

  • Enforcing version compliance - Prevent accidental 3.1 feature usage
  • CI/CD validation - Fail builds on version violations
  • Team standards - Ensure consistent OpenAPI version usage
  • Client compatibility - Target clients require strict 3.0 compliance

Don’t use strict mode when:

  • Graceful degradation - You’re okay with features being dropped
  • Development - Exploring features without hard errors
  • Flexible deployments - Different environments support different versions

Features Affected by Strict Mode

3.1-only features that trigger strict mode:

  • WithInfoSummary() - Short API summary
  • WithLicenseIdentifier() - SPDX license identifier
  • Webhooks - Webhook definitions
  • Mutual TLS - mutualTLS security scheme
  • const in schemas - JSON Schema const keyword
  • Multiple examples - Multiple schema examples

Complete Advanced Example

package main

import (
    "context"
    "fmt"
    "log"
    
    "rivaas.dev/openapi"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

type CreateUserRequest struct {
    Name string `json:"name" validate:"required"`
}

func main() {
    api := openapi.MustNew(
        // Basic configuration
        openapi.WithTitle("Advanced API", "1.0.0"),
        openapi.WithVersion(openapi.V30x),
        
        // Root-level extensions
        openapi.WithExtension("x-api-version", "v2"),
        openapi.WithExtension("x-environment", "production"),
        openapi.WithExtension("x-service-info", map[string]interface{}{
            "team": "platform",
            "repository": "github.com/example/api",
        }),
        
        // Strict mode (optional)
        openapi.WithStrictDownlevel(false), // Allow graceful degradation
    )
    
    result, err := api.Generate(context.Background(),
        // Custom operation IDs
        openapi.GET("/users/:id",
            openapi.WithOperationID("getUserById"),
            openapi.WithSummary("Get user by ID"),
            
            // Operation extensions
            openapi.WithOperationExtension("x-rate-limit", 100),
            openapi.WithOperationExtension("x-cache-ttl", 300),
            openapi.WithOperationExtension("x-internal-team", "users"),
            
            openapi.WithResponse(200, User{}),
        ),
        
        openapi.POST("/users",
            openapi.WithOperationID("createUser"),
            openapi.WithSummary("Create a new user"),
            
            // Different extensions per operation
            openapi.WithOperationExtension("x-rate-limit", 10),
            openapi.WithOperationExtension("x-feature-flag", "new-user-flow"),
            openapi.WithOperationExtension("x-mutation", true),
            
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(201, User{}),
        ),
        
        openapi.PUT("/users/:id",
            openapi.WithOperationID("updateUser"),
            openapi.WithSummary("Update user"),
            
            openapi.WithOperationExtension("x-rate-limit", 50),
            openapi.WithOperationExtension("x-mutation", true),
            
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(200, User{}),
        ),
        
        openapi.DELETE("/users/:id",
            openapi.WithOperationID("deleteUser"),
            openapi.WithSummary("Delete user"),
            
            openapi.WithOperationExtension("x-rate-limit", 10),
            openapi.WithOperationExtension("x-mutation", true),
            openapi.WithOperationExtension("x-dangerous", true),
            
            openapi.WithResponse(204, nil),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Check for warnings
    if len(result.Warnings) > 0 {
        fmt.Printf("Generated with %d warnings:\n", len(result.Warnings))
        for _, warn := range result.Warnings {
            fmt.Printf("  - %s\n", warn.Message())
        }
    }
    
    fmt.Printf("Generated %d byte specification\n", len(result.JSON))
}

Best Practices

Operation IDs

  1. Be consistent - Use the same naming convention across all operations
  2. Make them unique - Avoid duplicate operation IDs
  3. Consider clients - Generated client libraries use these names
  4. Document the convention - Help team members follow the pattern

Extensions

  1. Use sparingly - Only add extensions when necessary
  2. Document them - Explain what custom extensions mean
  3. Validate format - Ensure extensions follow your schema
  4. Version them - Consider versioning extension formats
  5. Tool compatibility - Check if tools support your extensions

Strict Mode

  1. Enable in CI/CD - Catch version issues early
  2. Document the choice - Explain why strict mode is enabled/disabled
  3. Test both modes - Ensure graceful degradation works if disabled
  4. Communicate clearly - Make version requirements explicit

Next Steps

6.12 - Examples

Complete examples and real-world usage patterns

Complete examples demonstrating real-world usage patterns for the OpenAPI package.

Basic CRUD API

A simple CRUD API with all HTTP methods.

package main

import (
    "context"
    "log"
    "os"
    "time"

    "rivaas.dev/openapi"
)

type User struct {
    ID        int       `json:"id" doc:"User ID" example:"123"`
    Name      string    `json:"name" doc:"User's full name" example:"John Doe"`
    Email     string    `json:"email" doc:"Email address" example:"john@example.com"`
    CreatedAt time.Time `json:"created_at" doc:"Creation timestamp"`
}

type CreateUserRequest struct {
    Name  string `json:"name" doc:"User's full name" validate:"required"`
    Email string `json:"email" doc:"Email address" validate:"required,email"`
}

type ErrorResponse struct {
    Code    int    `json:"code" doc:"Error code"`
    Message string `json:"message" doc:"Error message"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("User API", "1.0.0"),
        openapi.WithInfoDescription("Simple CRUD API for user management"),
        openapi.WithServer("http://localhost:8080", "Local development"),
        openapi.WithServer("https://api.example.com", "Production"),
        openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
        openapi.WithTag("users", "User management operations"),
    )

    result, err := api.Generate(context.Background(),
        openapi.GET("/users",
            openapi.WithSummary("List users"),
            openapi.WithDescription("Retrieve a list of all users"),
            openapi.WithTags("users"),
            openapi.WithSecurity("bearerAuth"),
            openapi.WithResponse(200, []User{}),
            openapi.WithResponse(401, ErrorResponse{}),
        ),
        
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithDescription("Retrieve a specific user by ID"),
            openapi.WithTags("users"),
            openapi.WithSecurity("bearerAuth"),
            openapi.WithResponse(200, User{}),
            openapi.WithResponse(404, ErrorResponse{}),
            openapi.WithResponse(401, ErrorResponse{}),
        ),
        
        openapi.POST("/users",
            openapi.WithSummary("Create user"),
            openapi.WithDescription("Create a new user"),
            openapi.WithTags("users"),
            openapi.WithSecurity("bearerAuth"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(201, User{}),
            openapi.WithResponse(400, ErrorResponse{}),
            openapi.WithResponse(401, ErrorResponse{}),
        ),
        
        openapi.PUT("/users/:id",
            openapi.WithSummary("Update user"),
            openapi.WithDescription("Update an existing user"),
            openapi.WithTags("users"),
            openapi.WithSecurity("bearerAuth"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(200, User{}),
            openapi.WithResponse(400, ErrorResponse{}),
            openapi.WithResponse(404, ErrorResponse{}),
            openapi.WithResponse(401, ErrorResponse{}),
        ),
        
        openapi.DELETE("/users/:id",
            openapi.WithSummary("Delete user"),
            openapi.WithDescription("Delete a user"),
            openapi.WithTags("users"),
            openapi.WithSecurity("bearerAuth"),
            openapi.WithResponse(204, nil),
            openapi.WithResponse(404, ErrorResponse{}),
            openapi.WithResponse(401, ErrorResponse{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    if err := os.WriteFile("openapi.json", result.JSON, 0644); err != nil {
        log.Fatal(err)
    }

    log.Println("OpenAPI specification generated: openapi.json")
}

API with Query Parameters and Pagination

package main

import (
    "context"
    "log"
    
    "rivaas.dev/openapi"
)

type ListUsersRequest struct {
    Page    int      `query:"page" doc:"Page number" example:"1" validate:"min=1"`
    PerPage int      `query:"per_page" doc:"Items per page" example:"20" validate:"min=1,max=100"`
    Sort    string   `query:"sort" doc:"Sort field" enum:"name,email,created_at"`
    Order   string   `query:"order" doc:"Sort order" enum:"asc,desc"`
    Tags    []string `query:"tags" doc:"Filter by tags"`
    Active  *bool    `query:"active" doc:"Filter by active status"`
}

type User struct {
    ID     int      `json:"id"`
    Name   string   `json:"name"`
    Email  string   `json:"email"`
    Active bool     `json:"active"`
    Tags   []string `json:"tags"`
}

type PaginatedResponse struct {
    Data       []User `json:"data"`
    Page       int    `json:"page"`
    PerPage    int    `json:"per_page"`
    TotalPages int    `json:"total_pages"`
    TotalItems int    `json:"total_items"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("Paginated API", "1.0.0"),
        openapi.WithInfoDescription("API with pagination and filtering"),
    )

    result, err := api.Generate(context.Background(),
        openapi.GET("/users",
            openapi.WithSummary("List users with pagination"),
            openapi.WithDescription("Retrieve paginated list of users with filtering"),
            openapi.WithResponse(200, PaginatedResponse{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Use result...
}

Multi-Source Parameters

package main

import (
    "context"
    "log"
    
    "rivaas.dev/openapi"
)

type CreateOrderRequest struct {
    // Path parameter
    UserID int `path:"user_id" doc:"User ID" example:"123"`
    
    // Query parameters
    Coupon    string `query:"coupon" doc:"Coupon code" example:"SAVE20"`
    SendEmail *bool  `query:"send_email" doc:"Send confirmation email"`
    
    // Header parameters
    IdempotencyKey string `header:"Idempotency-Key" doc:"Idempotency key"`
    
    // Request body
    Items []OrderItem `json:"items" validate:"required,min=1"`
    Total float64     `json:"total" validate:"required,min=0"`
    Notes string      `json:"notes,omitempty"`
}

type OrderItem struct {
    ProductID int     `json:"product_id" validate:"required"`
    Quantity  int     `json:"quantity" validate:"required,min=1"`
    Price     float64 `json:"price" validate:"required,min=0"`
}

type Order struct {
    ID     int         `json:"id"`
    UserID int         `json:"user_id"`
    Items  []OrderItem `json:"items"`
    Total  float64     `json:"total"`
    Status string      `json:"status" enum:"pending,processing,completed,cancelled"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("E-commerce API", "1.0.0"),
    )

    result, err := api.Generate(context.Background(),
        openapi.POST("/users/:user_id/orders",
            openapi.WithSummary("Create order"),
            openapi.WithDescription("Create a new order for a user"),
            openapi.WithRequest(CreateOrderRequest{}),
            openapi.WithResponse(201, Order{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Use result...
}

Composable Options Pattern

package main

import (
    "context"
    "log"
    
    "rivaas.dev/openapi"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

type ErrorResponse struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

// Define reusable option sets
var (
    // Common error responses
    CommonErrors = openapi.WithOptions(
        openapi.WithResponse(400, ErrorResponse{}),
        openapi.WithResponse(401, ErrorResponse{}),
        openapi.WithResponse(500, ErrorResponse{}),
    )
    
    // Authenticated user endpoints
    UserEndpoint = openapi.WithOptions(
        openapi.WithTags("users"),
        openapi.WithSecurity("bearerAuth"),
        CommonErrors,
    )
    
    // JSON content type
    JSONContent = openapi.WithOptions(
        openapi.WithConsumes("application/json"),
        openapi.WithProduces("application/json"),
    )
    
    // Read operations
    ReadOperation = openapi.WithOptions(
        UserEndpoint,
        JSONContent,
    )
    
    // Write operations
    WriteOperation = openapi.WithOptions(
        UserEndpoint,
        JSONContent,
        openapi.WithResponse(404, ErrorResponse{}),
    )
)

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("Composable API", "1.0.0"),
        openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
    )

    result, err := api.Generate(context.Background(),
        openapi.GET("/users/:id",
            ReadOperation,
            openapi.WithSummary("Get user"),
            openapi.WithResponse(200, User{}),
        ),
        
        openapi.POST("/users",
            WriteOperation,
            openapi.WithSummary("Create user"),
            openapi.WithRequest(User{}),
            openapi.WithResponse(201, User{}),
        ),
        
        openapi.PUT("/users/:id",
            WriteOperation,
            openapi.WithSummary("Update user"),
            openapi.WithRequest(User{}),
            openapi.WithResponse(200, User{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Use result...
}

OAuth2 with Multiple Flows

package main

import (
    "context"
    "log"
    
    "rivaas.dev/openapi"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("OAuth2 API", "1.0.0"),
        
        // Authorization code flow (for web apps)
        openapi.WithOAuth2(
            "oauth2AuthCode",
            "OAuth2 authorization code flow",
            openapi.OAuth2Flow{
                Type:             openapi.FlowAuthorizationCode,
                AuthorizationURL: "https://auth.example.com/authorize",
                TokenURL:         "https://auth.example.com/token",
                Scopes: map[string]string{
                    "read":  "Read access",
                    "write": "Write access",
                    "admin": "Admin access",
                },
            },
        ),
        
        // Client credentials flow (for service-to-service)
        openapi.WithOAuth2(
            "oauth2ClientCreds",
            "OAuth2 client credentials flow",
            openapi.OAuth2Flow{
                Type:     openapi.FlowClientCredentials,
                TokenURL: "https://auth.example.com/token",
                Scopes: map[string]string{
                    "api": "API access",
                },
            },
        ),
    )

    result, err := api.Generate(context.Background(),
        // Public endpoint
        openapi.GET("/health",
            openapi.WithSummary("Health check"),
            openapi.WithResponse(200, nil),
        ),
        
        // User-facing endpoint (auth code flow)
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithSecurity("oauth2AuthCode", "read"),
            openapi.WithResponse(200, User{}),
        ),
        
        // Service endpoint (client credentials flow)
        openapi.POST("/users/sync",
            openapi.WithSummary("Sync users"),
            openapi.WithSecurity("oauth2ClientCreds", "api"),
            openapi.WithResponse(200, nil),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Use result...
}

Version-Aware API with Diagnostics

package main

import (
    "context"
    "fmt"
    "log"
    
    "rivaas.dev/openapi"
    "rivaas.dev/openapi/diag"
)

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("Version-Aware API", "1.0.0"),
        openapi.WithVersion(openapi.V30x), // Target 3.0.x
        openapi.WithInfoSummary("API with 3.1 features"), // 3.1-only feature
    )

    result, err := api.Generate(context.Background(),
        openapi.GET("/users/:id",
            openapi.WithSummary("Get user"),
            openapi.WithResponse(200, User{}),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Handle warnings
    if result.Warnings.Has(diag.WarnDownlevelInfoSummary) {
        fmt.Println("Note: info.summary was dropped (3.1 feature with 3.0 target)")
    }

    // Filter by category
    downlevelWarnings := result.Warnings.FilterCategory(diag.CategoryDownlevel)
    if len(downlevelWarnings) > 0 {
        fmt.Printf("Downlevel warnings: %d\n", len(downlevelWarnings))
        for _, warn := range downlevelWarnings {
            fmt.Printf("  [%s] %s\n", warn.Code(), warn.Message())
        }
    }

    // Fail on unexpected warnings
    expected := []diag.WarningCode{
        diag.WarnDownlevelInfoSummary,
    }
    unexpected := result.Warnings.Exclude(expected...)
    if len(unexpected) > 0 {
        log.Fatalf("Unexpected warnings: %d", len(unexpected))
    }

    fmt.Println("Specification generated successfully")
}

Complete Production Example

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "time"

    "rivaas.dev/openapi"
    "rivaas.dev/openapi/diag"
)

// Domain models
type User struct {
    ID        int       `json:"id" doc:"User ID"`
    Name      string    `json:"name" doc:"User's full name"`
    Email     string    `json:"email" doc:"Email address"`
    Role      string    `json:"role" doc:"User role" enum:"admin,user,guest"`
    Active    bool      `json:"active" doc:"Whether user is active"`
    CreatedAt time.Time `json:"created_at" doc:"Creation timestamp"`
    UpdatedAt time.Time `json:"updated_at" doc:"Last update timestamp"`
}

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
    Role  string `json:"role" validate:"required" enum:"admin,user,guest"`
}

type ErrorResponse struct {
    Code      int       `json:"code"`
    Message   string    `json:"message"`
    Details   string    `json:"details,omitempty"`
    Timestamp time.Time `json:"timestamp"`
}

// Reusable option sets
var (
    CommonErrors = openapi.WithOptions(
        openapi.WithResponse(400, ErrorResponse{}),
        openapi.WithResponse(401, ErrorResponse{}),
        openapi.WithResponse(500, ErrorResponse{}),
    )
    
    UserEndpoint = openapi.WithOptions(
        openapi.WithTags("users"),
        openapi.WithSecurity("bearerAuth"),
        CommonErrors,
    )
)

func main() {
    api := openapi.MustNew(
        // Basic info
        openapi.WithTitle("User Management API", "2.1.0"),
        openapi.WithInfoDescription("Production-ready API for managing users and permissions"),
        openapi.WithTermsOfService("https://example.com/terms"),
        
        // Contact
        openapi.WithContact(
            "API Support",
            "https://example.com/support",
            "api-support@example.com",
        ),
        
        // License
        openapi.WithLicense("Apache 2.0", "https://www.apache.org/licenses/LICENSE-2.0.html"),
        
        // Version
        openapi.WithVersion(openapi.V31x),
        
        // Servers
        openapi.WithServer("https://api.example.com/v2", "Production"),
        openapi.WithServer("https://staging-api.example.com/v2", "Staging"),
        openapi.WithServer("http://localhost:8080/v2", "Development"),
        
        // Security
        openapi.WithBearerAuth("bearerAuth", "JWT token authentication"),
        
        // Tags
        openapi.WithTag("users", "User management operations"),
        
        // Extensions
        openapi.WithExtension("x-api-version", "2.1"),
        openapi.WithExtension("x-environment", os.Getenv("ENVIRONMENT")),
        
        // Enable validation
        openapi.WithValidation(true),
    )

    result, err := api.Generate(context.Background(),
        // Public endpoints
        openapi.GET("/health",
            openapi.WithSummary("Health check"),
            openapi.WithDescription("Check API health status"),
            openapi.WithResponse(200, map[string]string{"status": "ok"}),
        ),
        
        // User CRUD operations
        openapi.GET("/users",
            UserEndpoint,
            openapi.WithSummary("List users"),
            openapi.WithDescription("Retrieve paginated list of users"),
            openapi.WithResponse(200, []User{}),
        ),
        
        openapi.GET("/users/:id",
            UserEndpoint,
            openapi.WithSummary("Get user"),
            openapi.WithDescription("Retrieve a specific user by ID"),
            openapi.WithResponse(200, User{}),
            openapi.WithResponse(404, ErrorResponse{}),
        ),
        
        openapi.POST("/users",
            UserEndpoint,
            openapi.WithSummary("Create user"),
            openapi.WithDescription("Create a new user"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(201, User{}),
        ),
        
        openapi.PUT("/users/:id",
            UserEndpoint,
            openapi.WithSummary("Update user"),
            openapi.WithDescription("Update an existing user"),
            openapi.WithRequest(CreateUserRequest{}),
            openapi.WithResponse(200, User{}),
            openapi.WithResponse(404, ErrorResponse{}),
        ),
        
        openapi.DELETE("/users/:id",
            UserEndpoint,
            openapi.WithSummary("Delete user"),
            openapi.WithDescription("Delete a user"),
            openapi.WithResponse(204, nil),
            openapi.WithResponse(404, ErrorResponse{}),
        ),
    )
    if err != nil {
        log.Fatalf("Generation failed: %v", err)
    }

    // Handle warnings
    if len(result.Warnings) > 0 {
        fmt.Printf("Generated with %d warnings:\n", len(result.Warnings))
        for _, warn := range result.Warnings {
            fmt.Printf("  [%s] %s at %s\n", 
                warn.Code(), 
                warn.Message(),
                warn.Path(),
            )
        }
    }

    // Write specification files
    if err := os.WriteFile("openapi.json", result.JSON, 0644); err != nil {
        log.Fatal(err)
    }
    
    if err := os.WriteFile("openapi.yaml", result.YAML, 0644); err != nil {
        log.Fatal(err)
    }

    fmt.Printf("✓ Generated OpenAPI %s specification\n", api.Version())
    fmt.Printf("✓ JSON: openapi.json (%d bytes)\n", len(result.JSON))
    fmt.Printf("✓ YAML: openapi.yaml (%d bytes)\n", len(result.YAML))
}

Next Steps

7 - Structured Logging

Learn how to implement structured logging with Rivaas using Go’s standard log/slog

The Rivaas Logging package provides production-ready structured logging with minimal dependencies. Uses Go’s built-in log/slog for high performance and native integration with the Go ecosystem.

Features

  • Multiple Output Formats: JSON, text, and human-friendly console output
  • Context-Aware Logging: Automatic trace correlation with OpenTelemetry
  • Sensitive Data Redaction: Automatic sanitization of passwords, tokens, and secrets
  • Log Sampling: Reduce log volume in high-traffic scenarios
  • Convenience Methods: HTTP request logging, error logging with context, duration tracking
  • Dynamic Log Levels: Change log levels at runtime without restart
  • Functional Options API: Clean, composable configuration
  • Router Integration: Seamless integration following metrics/tracing patterns
  • Zero External Dependencies: Uses only Go standard library (except OpenTelemetry for trace correlation)

Quick Start

package main

import (
    "rivaas.dev/logging"
)

func main() {
    // Create a logger with console output
    log := logging.MustNew(
        logging.WithConsoleHandler(),
        logging.WithDebugLevel(),
    )

    log.Info("service started", "port", 8080, "env", "production")
    log.Debug("debugging information", "key", "value")
    log.Error("operation failed", "error", "connection timeout")
}
package main

import (
    "rivaas.dev/logging"
)

func main() {
    // Create a logger with JSON output
    log := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithServiceName("my-api"),
        logging.WithServiceVersion("v1.0.0"),
        logging.WithEnvironment("production"),
    )

    log.Info("user action", "user_id", "123", "action", "login")
    // Output: {"time":"2024-01-15T10:30:45.123Z","level":"INFO","msg":"user action","service":"my-api","version":"v1.0.0","env":"production","user_id":"123","action":"login"}
}
package main

import (
    "rivaas.dev/logging"
)

func main() {
    // Create a logger with text output
    log := logging.MustNew(
        logging.WithTextHandler(),
        logging.WithServiceName("my-api"),
    )

    log.Info("service started", "port", 8080)
    // Output: time=2024-01-15T10:30:45.123Z level=INFO msg="service started" service=my-api port=8080
}

How It Works

  • Handler types determine output format (JSON, Text, Console)
  • Structured fields are key-value pairs, not string concatenation
  • Log levels control verbosity (Debug, Info, Warn, Error)
  • Service metadata automatically added to every log entry
  • Sensitive data automatically redacted (passwords, tokens, keys)

Learning Path

Follow these guides to master logging with Rivaas:

  1. Installation - Get started with the logging package
  2. Basic Usage - Learn handler types and output formats
  3. Configuration - Configure loggers with all available options
  4. Context Logging - Add trace correlation with OpenTelemetry
  5. Convenience Methods - Use helper methods for common patterns
  6. Log Sampling - Reduce log volume in high-traffic scenarios
  7. Dynamic Log Levels - Change log levels at runtime
  8. Router Integration - Integrate with Rivaas router
  9. Testing - Test utilities and patterns
  10. Best Practices - Performance tips and patterns
  11. Migration - Switch from other logging libraries
  12. Examples - See real-world usage patterns

Next Steps

7.1 - Installation

How to install and set up the Rivaas logging package

This guide covers how to install the logging package and understand its dependencies.

Installation

Install the logging package using go get:

go get rivaas.dev/logging

Requirements: Go 1.25 or higher

Dependencies

The logging package has minimal external dependencies to maintain simplicity and avoid bloat.

DependencyPurposeRequired
Go stdlib (log/slog)Core loggingYes
go.opentelemetry.io/otel/traceTrace correlation in ContextLoggerOptional*
github.com/stretchr/testifyTest utilitiesTest only

* The OpenTelemetry trace dependency is only used by NewContextLogger() for automatic trace/span ID extraction. If you don’t use context-aware logging with tracing, this dependency has no runtime impact.

Verifying Installation

Create a simple test to verify the installation:

package main

import (
    "rivaas.dev/logging"
)

func main() {
    log := logging.MustNew(
        logging.WithConsoleHandler(),
    )
    
    log.Info("installation successful", "version", "v1.0.0")
}

Run the program:

go run main.go

You should see output like:

10:30:45.123 INFO  installation successful version=v1.0.0

Import Statement

Import the logging package in your Go files:

import "rivaas.dev/logging"

For context-aware logging with OpenTelemetry:

import (
    "rivaas.dev/logging"
    "go.opentelemetry.io/otel/trace"
)

Module Integration

Add to your go.mod:

module example.com/myapp

go 1.25

require (
    rivaas.dev/logging v1.0.0
)

Run go mod tidy to download dependencies:

go mod tidy

Next Steps

For complete API details, see the API Reference.

7.2 - Basic Usage

Learn the fundamentals of structured logging with handler types and output formats

This guide covers the essential operations for working with the logging package. Learn to choose handler types, set log levels, and produce structured log output.

Handler Types

The logging package supports three output formats, each optimized for different use cases.

JSON Handler (Production)

JSON format is ideal for production environments and log aggregation systems:

log := logging.MustNew(
    logging.WithJSONHandler(),
)

log.Info("user action", "user_id", "123", "action", "login")

Output:

{"time":"2024-01-15T10:30:45.123Z","level":"INFO","msg":"user action","user_id":"123","action":"login"}

Use cases:

  • Production environments.
  • Log aggregation systems like ELK, Splunk, Datadog.
  • Machine-parseable logs.
  • Cloud logging services.

Text Handler

Text format outputs key=value pairs, readable but still parseable:

log := logging.MustNew(
    logging.WithTextHandler(),
)

log.Info("request processed", "method", "GET", "path", "/api/users")

Output:

time=2024-01-15T10:30:45.123Z level=INFO msg="request processed" method=GET path=/api/users

Use cases:

  • Systems that prefer key=value format
  • Legacy log parsers
  • Environments where JSON is too verbose

Console Handler (Development)

Console format provides human-readable colored output for development:

log := logging.MustNew(
    logging.WithConsoleHandler(),
)

log.Info("server starting", "port", 8080)

Output (with colors):

10:30:45.123 INFO  server starting port=8080

Use cases:

  • Local development.
  • Debugging.
  • Terminal output.
  • Interactive troubleshooting.

Note: Console handler uses ANSI colors automatically. Colors are optimized for dark terminal themes.

Log Levels

Control log verbosity with log levels. Each level has a specific purpose.

Available Levels

// From most to least verbose:
logging.LevelDebug   // Detailed debugging information
logging.LevelInfo    // General informational messages
logging.LevelWarn    // Warning messages (not errors)
logging.LevelError   // Error messages

Setting Log Level

Configure the minimum log level during initialization:

log := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),  // Only Info, Warn, Error
)

log.Debug("this won't appear")  // Filtered out
log.Info("this will appear")    // Logged
log.Error("this will appear")   // Logged

Debug Level Shortcut

Enable debug logging with a convenience function:

log := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithDebugLevel(),  // Same as WithLevel(logging.LevelDebug)
)

Level Usage Guidelines

DEBUG - Detailed information for debugging

log.Debug("cache hit", "key", cacheKey, "ttl", ttl)

INFO - General informational messages

log.Info("server started", "port", 8080)

WARN - Warning but not an error

log.Warn("high memory usage", "used_mb", 8192, "total_mb", 16384)

ERROR - Errors that need attention

log.Error("database connection failed", "error", err, "retry_count", retries)

Structured Logging

The logging package uses structured logging with key-value pairs, not string concatenation.

Basic Structured Fields

log.Info("user logged in",
    "user_id", userID,
    "ip_address", ipAddress,
    "session_id", sessionID,
)

Output (JSON):

{
  "time": "2024-01-15T10:30:45.123Z",
  "level": "INFO",
  "msg": "user logged in",
  "user_id": "123",
  "ip_address": "192.168.1.1",
  "session_id": "abc-xyz"
}

Why Structured Logging?

BAD - String concatenation:

log.Info("User " + userID + " logged in from " + ipAddress)

GOOD - Structured fields:

log.Info("user logged in",
    "user_id", userID,
    "ip_address", ipAddress,
)

Benefits:

  • Machine-parseable
  • Searchable by field
  • Type-safe (numbers stay numbers)
  • Easier to aggregate and analyze

Type Support

The logger handles various types automatically:

log.Info("operation details",
    "name", "process_data",           // string
    "count", 1024,                     // int
    "enabled", true,                   // bool
    "duration", 250*time.Millisecond,  // duration
    "rate", 99.5,                      // float64
    "timestamp", time.Now(),           // time
    "error", err,                      // error
)

Complete Example

Putting it all together:

package main

import (
    "rivaas.dev/logging"
)

func main() {
    // Create logger for development
    log := logging.MustNew(
        logging.WithConsoleHandler(),
        logging.WithDebugLevel(),
    )

    // Log at different levels
    log.Debug("application starting", "version", "v1.0.0")
    log.Info("server listening", "port", 8080, "env", "development")
    log.Warn("high latency detected", "latency_ms", 250, "threshold_ms", 200)
    log.Error("database connection failed", "error", "connection timeout")
}

Common Patterns

Logging with Context

Add related fields that persist across multiple log calls:

// Create a logger with persistent fields
requestLog := log.With(
    "request_id", "req-123",
    "user_id", "user-456",
)

requestLog.Info("validation started")
requestLog.Info("validation completed")
// Both logs include request_id and user_id

Logging Errors

Always include the error:

if err := db.Connect(); err != nil {
    log.Error("database connection failed",
        "error", err,
        "host", dbHost,
        "port", dbPort,
        "retry_count", retries,
    )
}

Avoid Logging in Tight Loops

// BAD - logs thousands of times
for _, item := range items {
    log.Debug("processing", "item", item)
    process(item)
}

// GOOD - log once with summary
log.Info("processing batch", "count", len(items))
for _, item := range items {
    process(item)
}
log.Info("batch completed", "processed", len(items))

Next Steps

For complete API details, see the API Reference.

7.3 - Configuration

Configure loggers with all available options for production readiness

This guide covers all configuration options available in the logging package. It covers handler selection to service metadata.

Handler Configuration

Choose the appropriate handler type for your environment.

Handler Types

// JSON structured logging. Default and best for production.
logging.WithJSONHandler()

// Text key=value logging.
logging.WithTextHandler()

// Human-readable colored console. Best for development.
logging.WithConsoleHandler()

See Basic Usage for detailed handler comparison.

Log Level Configuration

Control which log messages are output.

Setting Minimum Level

// Set specific level
logging.WithLevel(logging.LevelDebug)
logging.WithLevel(logging.LevelInfo)
logging.WithLevel(logging.LevelWarn)
logging.WithLevel(logging.LevelError)

// Convenience function for debug
logging.WithDebugLevel()

Example:

log := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),  // Info, Warn, Error only
)

See Dynamic Log Levels to change levels at runtime.

Output Destination

By default, logs write to os.Stdout. Customize the output destination:

File Output

logFile, err := os.OpenFile("app.log", 
    os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
    log.Fatal(err)
}
defer logFile.Close()

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithOutput(logFile),
)

Custom Writer

Any io.Writer can be used:

var buf bytes.Buffer
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithOutput(&buf),
)

Multiple Writers

Use io.MultiWriter to write to multiple destinations:

logFile, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithOutput(io.MultiWriter(os.Stdout, logFile)),
)

Service Information

Add service metadata automatically to every log entry.

Service Metadata

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithServiceName("my-api"),
    logging.WithServiceVersion("v1.0.0"),
    logging.WithEnvironment("production"),
)

logger.Info("server started", "port", 8080)

Output:

{
  "level": "INFO",
  "msg": "server started",
  "service": "my-api",
  "version": "v1.0.0",
  "env": "production",
  "port": 8080
}

Why Service Metadata?

  • Filtering: Query logs by service in aggregation tools
  • Correlation: Track logs across distributed services
  • Versioning: Identify which version produced logs
  • Environment: Distinguish between dev/staging/prod logs

Reading Service Information

Access configured service info programmatically:

serviceName := logger.ServiceName()
version := logger.ServiceVersion()
env := logger.Environment()

Source Code Location

Add file and line information to log entries for debugging.

Enable Source Location

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithSource(true),
)

logger.Info("debug message")

Output:

{
  "level": "INFO",
  "msg": "debug message",
  "source": {
    "file": "main.go",
    "line": 42
  }
}

Performance note: Source location adds overhead. Enable only for debugging.

Custom Attribute Replacer

Transform or filter log attributes with a custom function.

Redacting Custom Fields

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
        // Redact credit card numbers
        if a.Key == "credit_card" {
            return slog.String(a.Key, "***REDACTED***")
        }
        // Transform time format
        if a.Key == "time" {
            if t, ok := a.Value.Any().(time.Time); ok {
                return slog.String(a.Key, t.Format(time.RFC3339))
            }
        }
        return a
    }),
)

Built-in Redaction

The following fields are automatically redacted:

  • password
  • token
  • secret
  • api_key
  • authorization
log.Info("authentication", 
    "username", "john",
    "password", "secret123",  // Automatically redacted
)
// Output: {...,"username":"john","password":"***REDACTED***"}

Dropping Attributes

Return an empty slog.Attr to drop an attribute:

logging.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
    if a.Key == "internal_field" {
        return slog.Attr{}  // Drop this field
    }
    return a
})

Global Logger Registration

By default, loggers are not registered globally, allowing multiple independent logger instances.

Register as Global Default

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithServiceName("my-api"),
    logging.WithGlobalLogger(),  // Register as slog default
)
defer logger.Shutdown(context.Background())

// Now third-party libraries using slog will use your logger
slog.Info("using global logger", "key", "value")

When to Use Global Registration

Use global registration when:

  • Third-party libraries use slog directly
  • You prefer slog.Info() over logger.Info()
  • Migrating from direct slog usage

Don’t use global registration when:

  • Running tests with isolated loggers
  • Creating libraries (avoid affecting global state)
  • Using multiple logging configurations

Default Behavior

Without WithGlobalLogger(), each logger is independent:

logger1 := logging.MustNew(logging.WithJSONHandler())
logger2 := logging.MustNew(logging.WithConsoleHandler())

logger1.Info("from logger1")  // JSON output
logger2.Info("from logger2")  // Console output
slog.Info("from default slog") // Standard slog output (independent)

Custom Logger

Provide your own slog.Logger for advanced scenarios.

Using Custom slog.Logger

customLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug,
    AddSource: true,
}))

logger := logging.MustNew(
    logging.WithCustomLogger(customLogger),
)

Limitations:

  • Dynamic level changes (SetLevel) not supported with custom loggers
  • Service metadata must be added to custom logger directly

Debug Mode

Enable comprehensive debugging with a single option.

Enable Debug Mode

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithDebugMode(true),
)

Automatically enables:

  • Debug log level (WithDebugLevel())
  • Source code location (WithSource(true))

Use cases:

  • Troubleshooting production issues
  • Development environments
  • Detailed debugging sessions

Complete Configuration Example

Putting all options together:

package main

import (
    "os"
    "rivaas.dev/logging"
    "log/slog"
)

func main() {
    // Production configuration
    prodLogger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithLevel(logging.LevelInfo),
        logging.WithServiceName("payment-api"),
        logging.WithServiceVersion("v2.1.0"),
        logging.WithEnvironment("production"),
        logging.WithOutput(os.Stdout),
    )
    defer prodLogger.Shutdown(context.Background())

    // Development configuration
    devLogger := logging.MustNew(
        logging.WithConsoleHandler(),
        logging.WithDebugLevel(),
        logging.WithSource(true),
        logging.WithServiceName("payment-api"),
        logging.WithEnvironment("development"),
    )
    defer devLogger.Shutdown(context.Background())

    // Choose based on environment
    var logger *logging.Logger
    if os.Getenv("ENV") == "production" {
        logger = prodLogger
    } else {
        logger = devLogger
    }

    logger.Info("application started")
}

Configuration Best Practices

Production Settings

logger := logging.MustNew(
    logging.WithJSONHandler(),           // Machine-parseable
    logging.WithLevel(logging.LevelInfo), // No debug spam
    logging.WithServiceName("my-api"),    // Service identification
    logging.WithServiceVersion(version),  // Version tracking
    logging.WithEnvironment("production"), // Environment filtering
)

Development Settings

logger := logging.MustNew(
    logging.WithConsoleHandler(),  // Human-readable
    logging.WithDebugLevel(),      // See everything
    logging.WithSource(true),      // File:line info
)

Testing Settings

buf := &bytes.Buffer{}
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithOutput(buf),
    logging.WithLevel(logging.LevelDebug),
)
// Inspect buf for assertions

Next Steps

For complete option details, see the Options Reference.

7.4 - Context-Aware Logging

Add trace correlation and contextual information to logs with ContextLogger

This guide covers context-aware logging with automatic trace correlation for distributed tracing integration.

Overview

Context-aware logging automatically extracts trace and span IDs from OpenTelemetry contexts, enabling correlation between logs and distributed traces.

Why context-aware logging:

  • Correlate logs with distributed traces.
  • Track requests across service boundaries.
  • Debug multi-service workflows.
  • Include trace IDs automatically without manual passing.

ContextLogger Basics

ContextLogger wraps a standard Logger and automatically extracts trace information from context.

Creating a ContextLogger

import (
    "context"
    "rivaas.dev/logging"
    "rivaas.dev/tracing"
)

// Create base logger
log := logging.MustNew(logging.WithJSONHandler())

// In a request handler with traced context
func handler(ctx context.Context) {
    // Create context logger
    cl := logging.NewContextLogger(ctx, log)
    
    cl.Info("processing request", "user_id", "123")
    // Output includes: "trace_id":"abc123...", "span_id":"def456..."
}

With OpenTelemetry Tracing

Full integration with OpenTelemetry:

package main

import (
    "context"
    "rivaas.dev/logging"
    "rivaas.dev/tracing"
)

func main() {
    // Initialize tracing
    tracer := tracing.MustNew(
        tracing.WithOTLP("localhost:4317"),
        tracing.WithServiceName("my-api"),
    )
    defer tracer.Shutdown(context.Background())

    // Initialize logging
    log := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithServiceName("my-api"),
    )

    // Start a trace
    ctx, span := tracer.Start(context.Background(), "operation")
    defer span.End()

    // Create context logger
    cl := logging.NewContextLogger(ctx, log)
    
    cl.Info("operation started")
    // Automatically includes trace_id and span_id
}

Output:

{
  "time": "2024-01-15T10:30:45.123Z",
  "level": "INFO",
  "msg": "operation started",
  "service": "my-api",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7"
}

Automatic Trace Correlation

When a context contains an active OpenTelemetry span, ContextLogger automatically extracts:

  • trace_id - Unique identifier for the entire trace
  • span_id - Unique identifier for this operation

Field Names

The logger uses OpenTelemetry semantic conventions:

{
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7"
}

These field names match standard observability tools (Jaeger, Tempo, Honeycomb).

Using ContextLogger Methods

ContextLogger provides the same logging methods as Logger.

Logging at Different Levels

cl := logging.NewContextLogger(ctx, log)

cl.Debug("debugging info", "detail", "value")
cl.Info("informational message", "status", "ok")
cl.Warn("warning condition", "threshold", 100)
cl.Error("error occurred", "error", err)

All methods automatically include trace and span IDs if available.

Adding Additional Context

Use With() to add persistent fields:

// Add fields that persist across log calls
requestLogger := cl.With(
    "request_id", "req-123",
    "user_id", "user-456",
)

requestLogger.Info("validation started")
requestLogger.Info("validation completed")
// Both logs include request_id, user_id, trace_id, span_id

Accessing Trace Information

Retrieve trace IDs programmatically:

cl := logging.NewContextLogger(ctx, log)

traceID := cl.TraceID()   // "4bf92f3577b34da6a3ce929d0e0e4736"
spanID := cl.SpanID()     // "00f067aa0ba902b7"

if traceID != "" {
    // Context has active trace
    log.Info("traced operation", "trace_id", traceID)
}

Use cases:

  • Include trace ID in API responses
  • Add to custom headers
  • Pass to external systems

Without Active Trace

If context has no active span, ContextLogger behaves like a normal logger:

ctx := context.Background()  // No span
cl := logging.NewContextLogger(ctx, log)

cl.Info("message")
// Output: No trace_id or span_id fields

This makes ContextLogger safe to use everywhere, whether tracing is enabled or not.

Structured Context

Combine context logging with grouped attributes for clean organization.

// Get underlying slog.Logger for grouping
logger := cl.Logger()

requestLogger := logger.WithGroup("request")
requestLogger.Info("received", 
    "method", "POST",
    "path", "/api/users",
)

Output:

{
  "msg": "received",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "request": {
    "method": "POST",
    "path": "/api/users"
  }
}

Request Handler Pattern

Common pattern for HTTP request handlers:

func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
    // Extract or create traced context
    ctx := r.Context()
    
    // Create context logger
    cl := logging.NewContextLogger(ctx, s.logger)
    
    // Add request-specific fields
    requestLog := cl.With(
        "request_id", generateRequestID(),
        "method", r.Method,
        "path", r.URL.Path,
    )
    
    requestLog.Info("request started")
    
    // Process request...
    
    requestLog.Info("request completed", "status", 200)
}

Performance Considerations

Trace Extraction Overhead

Trace ID extraction happens once during NewContextLogger() creation:

// Trace extraction happens here (one-time cost)
cl := logging.NewContextLogger(ctx, log)

// No additional overhead
cl.Info("message 1")
cl.Info("message 2")
cl.Info("message 3")

Best practice: Create ContextLogger once per request/operation, reuse for all logging.

Pooling for High Load

For extreme high-load scenarios, consider pooling ContextLogger instances:

var contextLoggerPool = sync.Pool{
    New: func() any {
        return &logging.ContextLogger{}
    },
}

func getContextLogger(ctx context.Context, log *logging.Logger) *logging.ContextLogger {
    cl := contextLoggerPool.Get().(*logging.ContextLogger)
    // Reinitialize with new context
    *cl = *logging.NewContextLogger(ctx, log)
    return cl
}

func putContextLogger(cl *logging.ContextLogger) {
    contextLoggerPool.Put(cl)
}

Note: Only needed for >10k requests/second with extremely tight latency requirements.

Integration with Router

The Rivaas router automatically provides traced contexts:

import (
    "rivaas.dev/router"
    "rivaas.dev/logging"
)

r := router.MustNew()
logger := logging.MustNew(logging.WithJSONHandler())
r.SetLogger(logger)

r.GET("/api/users", func(c *router.Context) {
    // Context is already traced if tracing is enabled
    cl := logging.NewContextLogger(c.Request.Context(), logger)
    
    cl.Info("fetching users")
    
    // Or use the router's logger directly (already context-aware)
    c.Logger().Info("using router logger")
    
    c.JSON(200, users)
})

See Router Integration for more details.

Complete Example

Putting it all together:

package main

import (
    "context"
    "net/http"
    "rivaas.dev/logging"
    "rivaas.dev/tracing"
)

func main() {
    // Initialize tracing
    tracer := tracing.MustNew(
        tracing.WithOTLP("localhost:4317"),
        tracing.WithServiceName("payment-api"),
    )
    defer tracer.Shutdown(context.Background())

    // Initialize logging
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithServiceName("payment-api"),
        logging.WithServiceVersion("v1.0.0"),
    )
    defer logger.Shutdown(context.Background())

    // HTTP handler
    http.HandleFunc("/process", func(w http.ResponseWriter, r *http.Request) {
        // Start trace
        ctx, span := tracer.Start(r.Context(), "process_payment")
        defer span.End()

        // Create context logger
        cl := logging.NewContextLogger(ctx, logger)
        
        // Add request context
        requestLog := cl.With(
            "request_id", r.Header.Get("X-Request-ID"),
            "user_id", r.Header.Get("X-User-ID"),
        )

        requestLog.Info("payment processing started")

        // Process payment...

        requestLog.Info("payment processing completed", "status", "success")

        w.WriteHeader(http.StatusOK)
    })

    http.ListenAndServe(":8080", nil)
}

Next Steps

For API details, see the API Reference.

7.5 - Convenience Methods

Use helper methods for common logging patterns like HTTP requests and errors

This guide covers convenience methods that simplify common logging patterns with pre-structured fields.

Overview

The logging package provides helper methods for frequently-used logging scenarios:

  • LogRequest - HTTP request logging with standard fields
  • LogError - Error logging with context
  • LogDuration - Operation timing with automatic duration calculation
  • ErrorWithStack - Critical error logging with stack traces

LogRequest - HTTP Request Logging

Automatically log HTTP requests with standard fields.

Basic Usage

func handleRequest(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    
    // Process request...
    status := 200
    bytesWritten := 1024
    
    logger.LogRequest(r, 
        "status", status,
        "duration_ms", time.Since(start).Milliseconds(),
        "bytes", bytesWritten,
    )
}

Output:

{
  "level": "INFO",
  "msg": "http request",
  "method": "GET",
  "path": "/api/users",
  "remote": "192.168.1.1:54321",
  "user_agent": "Mozilla/5.0...",
  "status": 200,
  "duration_ms": 45,
  "bytes": 1024
}

Standard Fields Included

LogRequest automatically includes:

FieldDescriptionExample
methodHTTP methodGET, POST, PUT
pathRequest path (without query)/api/users
remoteClient remote address192.168.1.1:54321
user_agentClient User-Agent headerMozilla/5.0...
queryQuery string (only if non-empty)page=1&limit=10

Additional Fields

Pass additional fields as key-value pairs:

logger.LogRequest(r,
    "status", statusCode,
    "duration_ms", elapsed,
    "bytes_written", bytesWritten,
    "user_id", userID,
    "cached", wasCached,
)

With Router Middleware

LogRequest is particularly useful in custom middleware:

func loggingMiddleware(logger *logging.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            
            // Wrap response writer to capture status/size
            wrapped := &responseWriter{ResponseWriter: w}
            
            next.ServeHTTP(wrapped, r)
            
            logger.LogRequest(r,
                "status", wrapped.status,
                "duration_ms", time.Since(start).Milliseconds(),
                "bytes", wrapped.written,
            )
        })
    }
}

Note: The Rivaas router includes built-in access log middleware. See Router Integration.

LogError - Error Logging with Context

Convenient error logging with automatic error field.

Basic Usage

if err := db.Insert(user); err != nil {
    logger.LogError(err, "database operation failed",
        "operation", "INSERT",
        "table", "users",
        "user_id", user.ID,
    )
    return err
}

Output:

{
  "level": "ERROR",
  "msg": "database operation failed",
  "error": "connection timeout: unable to reach database",
  "operation": "INSERT",
  "table": "users",
  "user_id": "123"
}

Why Use LogError?

Instead of:

log.Error("database operation failed", "error", err.Error(), "table", "users")

Use:

logger.LogError(err, "database operation failed", "table", "users")

Benefits:

  • Shorter, cleaner code
  • Consistent error field naming
  • Automatic error message extraction
  • Clear intent (logging an error condition)

With Retry Logic

func connectWithRetry(maxRetries int) error {
    for i := 0; i < maxRetries; i++ {
        if err := connect(); err != nil {
            logger.LogError(err, "connection failed",
                "attempt", i+1,
                "max_retries", maxRetries,
            )
            time.Sleep(backoff(i))
            continue
        }
        return nil
    }
    return errors.New("max retries exceeded")
}

LogDuration - Operation Timing

Track operation duration automatically.

Basic Usage

start := time.Now()

result, err := processData(data)

logger.LogDuration("data processing completed", start,
    "rows_processed", result.Count,
    "errors", result.Errors,
)

Output:

{
  "level": "INFO",
  "msg": "data processing completed",
  "duration_ms": 1543,
  "duration": "1.543s",
  "rows_processed": 1000,
  "errors": 0
}

Included Fields

LogDuration automatically adds:

FieldDescriptionExample
duration_msDuration in milliseconds (for filtering/alerting)1543
durationHuman-readable duration string"1.543s"

Why Two Duration Fields?

  • duration_ms - Numeric value for:

    • Filtering: duration_ms > 1000
    • Alerting: Alert on slow operations
    • Aggregation: Average, percentiles, etc.
  • duration - Human-readable for:

    • Quick visual inspection
    • Log reading and debugging
    • Formats like "250ms", "1.5s", "2m30s"

Multiple Checkpoints

Track multiple stages:

start := time.Now()

// Stage 1
dataFetched := time.Now()
logger.LogDuration("data fetched", start, "rows", rowCount)

// Stage 2
processData(data)
logger.LogDuration("data processed", dataFetched, "rows", rowCount)

// Overall
logger.LogDuration("operation completed", start, "total_rows", rowCount)

With Error Handling

start := time.Now()
result, err := expensiveOperation()

if err != nil {
    logger.LogError(err, "operation failed")
    logger.LogDuration("operation failed", start, "partial_results", result.Count)
    return err
}

logger.LogDuration("operation succeeded", start, "results", result.Count)

ErrorWithStack - Error with Stack Traces

Log critical errors with stack traces for debugging.

Basic Usage

if err := criticalOperation(); err != nil {
    logger.ErrorWithStack("critical failure", err, true,
        "user_id", userID,
        "transaction_id", txID,
    )
    // Handle critical error...
}

Output:

{
  "level": "ERROR",
  "msg": "critical failure",
  "error": "database corruption detected",
  "user_id": "123",
  "transaction_id": "tx-456",
  "stack": "main.processPayment\n\t/app/main.go:42\nmain.handleRequest\n\t/app/main.go:28\n..."
}

When to Use Stack Traces

✓ Use for:

  • Critical errors requiring debugging
  • Unexpected conditions (panics, invariant violations)
  • Production incidents that need investigation
  • Errors in rarely-executed code paths

✗ Don’t use for:

  • Expected errors (validation failures, not found)
  • High-frequency errors (performance impact)
  • Errors where context is sufficient
  • Non-critical warnings

Stack Capture Cost

Stack traces have overhead:

// Low overhead - no stack trace
logger.LogError(err, "validation failed", "field", field)

// Higher overhead - captures stack trace
logger.ErrorWithStack("unexpected error", err, true, "field", field)

Performance impact:

  • Stack capture: ~100µs per call
  • Stack formatting: ~50µs per call
  • Additional log size: ~2-5KB

Recommendation: Use ErrorWithStack(includeStack: true) sparingly, only for critical errors.

Conditional Stack Traces

Include stack traces only when needed:

func handleError(err error, critical bool) {
    logger.ErrorWithStack("operation failed", err, critical,
        "severity", map[bool]string{true: "critical", false: "normal"}[critical],
    )
}

// Normal error - no stack
handleError(validationErr, false)

// Critical error - with stack
handleError(dbCorruptionErr, true)

With Panic Recovery

func recoverPanic() {
    if r := recover(); r != nil {
        err := fmt.Errorf("panic: %v", r)
        logger.ErrorWithStack("panic recovered", err, true,
            "panic_value", r,
        )
    }
}

func riskyOperation() {
    defer recoverPanic()
    
    // Operations that might panic...
}

Combining Convenience Methods

Use multiple convenience methods together:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    
    // Process request
    result, err := processRequest(r)
    
    if err != nil {
        // Log error with context
        logger.LogError(err, "request processing failed",
            "path", r.URL.Path,
        )
        
        // Log request details
        logger.LogRequest(r, "status", 500)
        
        http.Error(w, "Internal Server Error", 500)
        return
    }
    
    // Log successful request
    logger.LogRequest(r, 
        "status", 200,
        "items", len(result.Items),
    )
    
    // Log timing
    logger.LogDuration("request completed", start,
        "items_processed", len(result.Items),
    )
    
    json.NewEncoder(w).Encode(result)
}

Performance Considerations

Pooled Attribute Slices

Convenience methods use pooled slices internally for efficiency:

// No allocations beyond the log entry itself
logger.LogRequest(r, "status", 200, "bytes", 1024)
logger.LogError(err, "failed", "retry", 3)
logger.LogDuration("done", start, "count", 100)

Implementation detail: Methods use sync.Pool for attribute slices, reducing GC pressure.

Zero Allocations

Standard logging with convenience methods:

// Benchmark: 0 allocs/op for standard use
logger.LogRequest(r, "status", 200)
logger.LogError(err, "failed")
logger.LogDuration("done", start)

Exception: ErrorWithStack allocates for stack trace capture (intentional trade-off).

Next Steps

For API details, see the API Reference.

7.6 - Log Sampling

Reduce log volume in high-traffic scenarios with intelligent sampling

This guide covers log sampling to reduce log volume in high-throughput production environments while maintaining visibility.

Overview

Log sampling reduces the number of log entries written while preserving statistical sampling for debugging and analysis.

Why log sampling:

  • Reduce log storage costs in high-traffic scenarios.
  • Prevent log flooding during traffic spikes.
  • Maintain representative sample for debugging.
  • Always log critical errors. Sampling bypasses ERROR level.

When to use:

  • Services handling more than 1000 logs per second.
  • Cost-constrained log storage.
  • High-volume debug or info logging.
  • Noisy services with repetitive logs.

Basic Configuration

Configure sampling with SamplingConfig:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    100,           // Log first 100 entries unconditionally
        Thereafter: 100,           // After that, log 1 in every 100
        Tick:       time.Minute,   // Reset counter every minute
    }),
)

How Sampling Works

The sampling algorithm has three phases:

1. Initial Phase

Log the first Initial entries unconditionally:

SamplingConfig{
    Initial: 100,  // First 100 logs always written
    // ...
}

Purpose: Ensure you always see the beginning of operations, even if they’re short-lived.

2. Sampling Phase

After Initial entries, log 1 in every Thereafter entries:

SamplingConfig{
    Initial:    100,
    Thereafter: 100,  // Log 1%, drop 99%
    // ...
}

Examples:

  • Thereafter: 100 → 1% sampling (log 1 in 100)
  • Thereafter: 10 → 10% sampling (log 1 in 10)
  • Thereafter: 1000 → 0.1% sampling (log 1 in 1000)

3. Reset Phase

Reset counter every Tick interval:

SamplingConfig{
    Initial:    100,
    Thereafter: 100,
    Tick:       time.Minute,  // Reset every minute
}

Purpose: Ensure recent activity is always visible. Without resets, you might miss important recent events.

Sampling Behavior

Visual Timeline

Time:     0s    30s   60s   90s   120s  150s
          |-----|-----|-----|-----|-----|
Logs:     [Initial] [Sample] [Reset→Initial] [Sample]
          ▓▓▓▓▓     ░░░     ▓▓▓▓▓           ░░░
          100%      1%      100%            1%
  • ▓▓▓▓▓ - Initial phase (100% logging)
  • ░░░ - Sampling phase (1% logging)
  • Reset - Counter resets at Tick interval

Error Bypass

Errors (level >= ERROR) always bypass sampling:

logger := logging.MustNew(
    logging.WithSampling(logging.SamplingConfig{
        Initial:    100,
        Thereafter: 100,  // 1% sampling
        Tick:       time.Minute,
    }),
)

// These may be sampled
logger.Debug("processing item", "id", id)  // May be dropped
logger.Info("request handled", "path", path)  // May be dropped

// These are NEVER sampled
logger.Error("database error", "error", err)  // Always logged
logger.Error("payment failed", "tx_id", txID)  // Always logged

Rationale: Critical errors should never be lost, regardless of sampling configuration.

Configuration Examples

High-Traffic API

// Log all errors, but only 1% of info/debug
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    1000,          // First 1000 requests fully logged
        Thereafter: 100,           // Then 1% sampling
        Tick:       5 * time.Minute, // Reset every 5 minutes
    }),
)

Result:

  • Startup: All logs for first 1000 requests
  • Steady state: 1% of logs (99% reduction)
  • Every 5 minutes: Full logging resumes briefly

Debug Logging in Production

// Enable debug logs with heavy sampling
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelDebug),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    50,            // See first 50 debug logs
        Thereafter: 1000,          // Then 0.1% sampling
        Tick:       10 * time.Minute, // Reset every 10 minutes
    }),
)

Use case: Temporarily enable debug logging in production without overwhelming logs.

Cost Optimization

// Aggressive sampling for cost reduction
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    500,
        Thereafter: 1000,  // 0.1% sampling (99.9% reduction)
        Tick:       time.Hour,
    }),
)

Result: Dramatic log volume reduction while maintaining statistical samples.

Special Configurations

No Sampling After Initial

Set Thereafter: 0 to log everything after initial:

SamplingConfig{
    Initial:    100,  // First 100 sampled
    Thereafter: 0,    // Then log everything
    Tick:       time.Minute,
}

Use case: Rate limiting only during burst startup.

No Reset

Set Tick: 0 to never reset the counter:

SamplingConfig{
    Initial:    1000,
    Thereafter: 100,
    Tick:       0,  // Never reset
}

Result: Sample continuously without periodic full logging.

Monitoring Sampling

Check Sampling State

Use DebugInfo() to inspect sampling state:

info := logger.DebugInfo()
samplingInfo := info["sampling"].(map[string]any)

fmt.Printf("Initial: %d\n", samplingInfo["initial"])
fmt.Printf("Thereafter: %d\n", samplingInfo["thereafter"])
fmt.Printf("Counter: %d\n", samplingInfo["counter"])

Output:

Initial: 100
Thereafter: 100
Counter: 1543

Log Sampling Metrics

Periodically log sampling state:

ticker := time.NewTicker(time.Minute)
go func() {
    for range ticker.C {
        info := logger.DebugInfo()
        if sampling, ok := info["sampling"].(map[string]any); ok {
            logger.Info("sampling state",
                "counter", sampling["counter"],
                "config", fmt.Sprintf("%d/%d/%v", 
                    sampling["initial"], 
                    sampling["thereafter"],
                    sampling["tick"]),
            )
        }
    }
}()

Performance Impact

Overhead

Sampling adds minimal overhead:

// Without sampling: ~500ns per log
// With sampling: ~520ns per log
// Overhead: ~20ns (4%)

Breakdown:

  • Atomic counter increment: ~10ns
  • Sampling decision: ~10ns
  • No additional allocations

When to Skip Sampling

Skip sampling if:

  • Logging <100 entries/second
  • Log storage cost is not a concern
  • Need every log entry (compliance, debugging)
  • Using external sampling (e.g., log aggregation system does sampling)

Best Practices

Start Conservative

Begin with light sampling, increase if needed:

// Phase 1: Start conservative
SamplingConfig{
    Initial:    1000,
    Thereafter: 10,  // 10% sampling
    Tick:       time.Minute,
}

// Phase 2: If still too much, increase sampling
SamplingConfig{
    Initial:    1000,
    Thereafter: 100,  // 1% sampling
    Tick:       time.Minute,
}

Reset Frequently

Reset counters to maintain visibility:

// Good - see recent activity
Tick: time.Minute

// Better - more responsive
Tick: 30 * time.Second

// Too aggressive - missing too much
Tick: time.Hour

Per-Logger Sampling

Use different sampling for different loggers:

// Access logs - heavy sampling
accessLogger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    100,
        Thereafter: 1000,  // 0.1%
        Tick:       time.Minute,
    }),
)

// Application logs - light sampling
appLogger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    500,
        Thereafter: 10,  // 10%
        Tick:       time.Minute,
    }),
)

Monitor Log Volume

Track log volume to tune sampling:

var logCount atomic.Int64

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithSampling(config),
)

// Periodically check
ticker := time.NewTicker(time.Minute)
go func() {
    for range ticker.C {
        count := logCount.Swap(0)
        fmt.Printf("Logs/minute: %d\n", count)
        
        // Adjust sampling if needed
        if count > 10000 {
            // Consider more aggressive sampling
        }
    }
}()

Troubleshooting

Missing Expected Logs

Problem: Important logs being sampled out.

Solution: Use ERROR level for critical logs:

// May be sampled
logger.Info("payment processed", "tx_id", txID)

// Never sampled
logger.Error("payment failed", "tx_id", txID)

Too Much Log Volume

Problem: Sampling not reducing volume enough.

Solutions:

  1. Increase Thereafter value:
SamplingConfig{
    Thereafter: 1000,  // More aggressive: 0.1% instead of 1%
}
  1. Reduce Initial value:
SamplingConfig{
    Initial: 50,  // Fewer initial logs
}
  1. Increase Tick interval:
SamplingConfig{
    Tick: 5 * time.Minute,  // Reset less frequently
}

Lost Debug Context

Problem: Sampling makes debugging difficult.

Solution: Temporarily disable sampling:

// Create logger without sampling for debugging session
debugLogger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelDebug),
    // No WithSampling() call
)

Next Steps

For API details, see the Options Reference.

7.7 - Dynamic Log Levels

Change log levels at runtime without restarting your application

This guide covers dynamic log level changes. You can adjust logging verbosity at runtime for troubleshooting and performance tuning.

Overview

Dynamic log levels enable changing the minimum log level without restarting your application.

Why dynamic log levels:

  • Enable debug logging temporarily for troubleshooting.
  • Reduce log volume during traffic spikes.
  • Runtime configuration via HTTP endpoint or signal handler.
  • Quick response to production issues without deployment.

Limitations:

  • Not supported with custom loggers.
  • Brief window where old and new levels may race during transition.

Basic Usage

Change log level with SetLevel:

logger := logging.MustNew(logging.WithJSONHandler())

// Initial level is Info (default)
logger.Info("this appears")
logger.Debug("this doesn't appear")

// Enable debug logging
if err := logger.SetLevel(logging.LevelDebug); err != nil {
    log.Printf("failed to change level: %v", err)
}

// Now debug logs appear
logger.Debug("this now appears")

Available Log Levels

Four log levels from least to most restrictive:

logging.LevelDebug   // Most verbose: Debug, Info, Warn, Error
logging.LevelInfo    // Info, Warn, Error
logging.LevelWarn    // Warn, Error
logging.LevelError   // Error only

Setting Levels

// Enable debug logging
logger.SetLevel(logging.LevelDebug)

// Reduce to warnings only
logger.SetLevel(logging.LevelWarn)

// Errors only
logger.SetLevel(logging.LevelError)

// Back to info
logger.SetLevel(logging.LevelInfo)

Checking Current Level

Get the current log level:

currentLevel := logger.Level()

switch currentLevel {
case logging.LevelDebug:
    fmt.Println("Debug mode enabled")
case logging.LevelInfo:
    fmt.Println("Info mode")
case logging.LevelWarn:
    fmt.Println("Warnings only")
case logging.LevelError:
    fmt.Println("Errors only")
}

HTTP Endpoint for Level Changes

Expose an HTTP endpoint to change log levels:

package main

import (
    "fmt"
    "net/http"
    "rivaas.dev/logging"
)

func main() {
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithLevel(logging.LevelInfo),
    )

    // Admin endpoint to change log level
    http.HandleFunc("/admin/loglevel", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            return
        }
        
        levelStr := r.URL.Query().Get("level")
        var level logging.Level
        
        switch levelStr {
        case "debug":
            level = logging.LevelDebug
        case "info":
            level = logging.LevelInfo
        case "warn":
            level = logging.LevelWarn
        case "error":
            level = logging.LevelError
        default:
            http.Error(w, "Invalid level. Use: debug, info, warn, error", 
                http.StatusBadRequest)
            return
        }
        
        if err := logger.SetLevel(level); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        
        w.WriteHeader(http.StatusOK)
        fmt.Fprintf(w, "Log level changed to %s\n", levelStr)
    })

    http.ListenAndServe(":8080", nil)
}

Usage:

# Enable debug logging
curl -X POST "http://localhost:8080/admin/loglevel?level=debug"

# Reduce to errors only
curl -X POST "http://localhost:8080/admin/loglevel?level=error"

# Back to info
curl -X POST "http://localhost:8080/admin/loglevel?level=info"

Signal Handler for Level Changes

Use Unix signals to change log levels:

package main

import (
    "os"
    "os/signal"
    "syscall"
    "rivaas.dev/logging"
)

func main() {
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithLevel(logging.LevelInfo),
    )

    // Setup signal handlers
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGUSR1, syscall.SIGUSR2)

    go func() {
        for sig := range sigChan {
            switch sig {
            case syscall.SIGUSR1:
                // SIGUSR1: Enable debug logging
                logger.SetLevel(logging.LevelDebug)
                logger.Info("debug logging enabled via SIGUSR1")
                
            case syscall.SIGUSR2:
                // SIGUSR2: Back to info logging
                logger.SetLevel(logging.LevelInfo)
                logger.Info("info logging restored via SIGUSR2")
            }
        }
    }()

    // Application logic...
    select {}
}

Usage:

# Get process ID
PID=$(pgrep myapp)

# Enable debug logging
kill -USR1 $PID

# Restore info logging
kill -USR2 $PID

Temporary Debug Sessions

Enable debug logging temporarily:

func enableDebugTemporarily(logger *logging.Logger, duration time.Duration) {
    oldLevel := logger.Level()
    
    logger.SetLevel(logging.LevelDebug)
    logger.Info("debug logging enabled temporarily", "duration", duration)
    
    time.AfterFunc(duration, func() {
        logger.SetLevel(oldLevel)
        logger.Info("debug logging disabled, restored to", "level", oldLevel)
    })
}

// Usage
enableDebugTemporarily(logger, 5*time.Minute)

With Configuration Management

Integrate with configuration reloading:

type Config struct {
    LogLevel string `config:"log_level"`
}

func (c *Config) Validate() error {
    validLevels := map[string]bool{
        "debug": true, "info": true, "warn": true, "error": true,
    }
    if !validLevels[c.LogLevel] {
        return fmt.Errorf("invalid log level: %s", c.LogLevel)
    }
    return nil
}

func applyConfig(logger *logging.Logger, cfg *Config) error {
    var level logging.Level
    switch cfg.LogLevel {
    case "debug":
        level = logging.LevelDebug
    case "info":
        level = logging.LevelInfo
    case "warn":
        level = logging.LevelWarn
    case "error":
        level = logging.LevelError
    }
    
    return logger.SetLevel(level)
}

Error Handling

Custom Logger Limitation

Dynamic level changes don’t work with custom loggers:

customLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger := logging.MustNew(
    logging.WithCustomLogger(customLogger),
)

// This fails
err := logger.SetLevel(logging.LevelDebug)
if errors.Is(err, logging.ErrCannotChangeLevel) {
    fmt.Println("Cannot change level on custom logger")
}

Workaround: Control level in your custom logger directly:

var levelVar slog.LevelVar
levelVar.Set(slog.LevelInfo)

customLogger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: &levelVar,
}))

// Change level directly
levelVar.Set(slog.LevelDebug)

Validation

Validate level before setting:

func setLevelSafe(logger *logging.Logger, levelStr string) error {
    levelMap := map[string]logging.Level{
        "debug": logging.LevelDebug,
        "info":  logging.LevelInfo,
        "warn":  logging.LevelWarn,
        "error": logging.LevelError,
    }
    
    level, ok := levelMap[levelStr]
    if !ok {
        return fmt.Errorf("invalid level: %s", levelStr)
    }
    
    return logger.SetLevel(level)
}

Use Cases

Troubleshooting Production

Enable debug logging temporarily to diagnose an issue:

# Enable debug logs
curl -X POST "http://localhost:8080/admin/loglevel?level=debug"

# Reproduce issue and capture logs

# Restore normal level
curl -X POST "http://localhost:8080/admin/loglevel?level=info"

Traffic Spike Response

Reduce logging during high traffic:

func monitorTraffic(logger *logging.Logger) {
    ticker := time.NewTicker(time.Minute)
    for range ticker.C {
        rps := getCurrentRPS()
        
        if rps > 10000 {
            // High traffic - reduce logging
            logger.SetLevel(logging.LevelWarn)
            logger.Warn("high traffic detected, reducing log level", "rps", rps)
        } else if rps < 5000 {
            // Normal traffic - restore info logging
            logger.SetLevel(logging.LevelInfo)
        }
    }
}

Gradual Rollout

Gradually enable debug logging across a fleet:

func gradualDebugRollout(logger *logging.Logger, percentage int) {
    // Only enable debug on N% of instances
    if rand.Intn(100) < percentage {
        logger.SetLevel(logging.LevelDebug)
        logger.Info("debug logging enabled in rollout", "percentage", percentage)
    }
}

Environment-Based Levels

Set initial level based on environment, allow runtime changes:

func initLogger() *logging.Logger {
    initialLevel := logging.LevelInfo
    
    switch os.Getenv("ENV") {
    case "development":
        initialLevel = logging.LevelDebug
    case "staging":
        initialLevel = logging.LevelInfo
    case "production":
        initialLevel = logging.LevelWarn
    }
    
    return logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithLevel(initialLevel),
    )
}

Best Practices

Secure Admin Endpoints

Protect level-changing endpoints:

func logLevelHandler(logger *logging.Logger) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Authenticate admin
        token := r.Header.Get("X-Admin-Token")
        if !isValidAdminToken(token) {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        // Rate limit
        if !rateLimiter.Allow() {
            http.Error(w, "Too many requests", http.StatusTooManyRequests)
            return
        }
        
        // Change level logic...
    }
}

Log Level Changes

Always log when level changes:

oldLevel := logger.Level()
logger.SetLevel(newLevel)
logger.Info("log level changed",
    "old_level", oldLevel.String(),
    "new_level", newLevel.String(),
    "reason", reason,
)

Automatic Restoration

Reset to safe level after debugging:

func debugWithTimeout(logger *logging.Logger, duration time.Duration) func() {
    oldLevel := logger.Level()
    logger.SetLevel(logging.LevelDebug)
    
    timer := time.AfterFunc(duration, func() {
        logger.SetLevel(oldLevel)
        logger.Info("debug session ended, level restored")
    })
    
    // Return cancellation function
    return func() {
        timer.Stop()
        logger.SetLevel(oldLevel)
    }
}

// Usage
cancel := debugWithTimeout(logger, 10*time.Minute)
defer cancel()

Monitor Level Changes

Track level changes over time:

type LevelChangeTracker struct {
    changes []LevelChange
    mu      sync.Mutex
}

type LevelChange struct {
    Timestamp time.Time
    OldLevel  logging.Level
    NewLevel  logging.Level
    Reason    string
}

func (t *LevelChangeTracker) Track(old, new logging.Level, reason string) {
    t.mu.Lock()
    defer t.mu.Unlock()
    
    t.changes = append(t.changes, LevelChange{
        Timestamp: time.Now(),
        OldLevel:  old,
        NewLevel:  new,
        Reason:    reason,
    })
}

Performance Considerations

Level Check Cost

Level checks are very fast:

// ~5ns per call
if logger.Logger().Enabled(ctx, logging.LevelDebug) {
    // Expensive debug operation
}

Transitional Race

Brief window where log level is transitioning:

// T0: Level is Info
logger.SetLevel(logging.LevelDebug)  // T1: Transitioning...
// T2: Level is Debug

Impact: Some logs during T1 may use old or new level inconsistently.

Mitigation: Accept minor inconsistency during transition (typically <1ms).

Next Steps

For API details, see the API Reference.

7.8 - Router Integration

Integrate logging with Rivaas router and the app package for full observability

This guide covers integrating the logging package with the Rivaas router and the app package for comprehensive observability.

Overview

The logging package integrates seamlessly with the Rivaas ecosystem:

  • Router - Set logger via SetLogger() method
  • App package - Automatic wiring with metrics and tracing
  • Context propagation - Automatic context-aware logging
  • Middleware - Access log and custom middleware support

Basic Router Integration

Set a logger on the router to enable request logging.

Simple Integration

import (
    "rivaas.dev/router"
    "rivaas.dev/logging"
)

func main() {
    // Create logger
    logger := logging.MustNew(
        logging.WithConsoleHandler(),
        logging.WithDebugLevel(),
    )
    
    // Create router and set logger
    r := router.MustNew()
    r.SetLogger(logger)
    
    r.GET("/", func(c *router.Context) {
        c.Logger().Info("handling request")
        c.JSON(200, map[string]string{"status": "ok"})
    })
    
    r.Run(":8080")
}

Accessing Logger in Handlers

The router context provides a logger instance:

r.GET("/api/users/:id", func(c *router.Context) {
    userID := c.Param("id")
    
    // Get logger from context
    log := c.Logger()
    log.Info("fetching user", "user_id", userID)
    
    user, err := fetchUser(userID)
    if err != nil {
        log.Error("failed to fetch user", "error", err, "user_id", userID)
        c.JSON(500, gin.H{"error": "internal server error"})
        return
    }
    
    c.JSON(200, user)
})

App Package Integration

The app package provides batteries-included observability wiring.

Full Observability Setup

import (
    "rivaas.dev/app"
    "rivaas.dev/logging"
    "rivaas.dev/tracing"
)

func main() {
    a, err := app.New(
        app.WithServiceName("my-api"),
        app.WithObservability(
            app.WithLogging(
                logging.WithJSONHandler(),
                logging.WithLevel(logging.LevelInfo),
            ),
            app.WithMetrics(), // Prometheus is default
            app.WithTracing(
                tracing.WithOTLP("localhost:4317"),
            ),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer a.Shutdown(context.Background())
    
    // Get router with logging, metrics, and tracing configured
    router := a.Router()
    
    router.GET("/api/users", func(c *router.Context) {
        // Logger automatically includes trace_id and span_id
        c.Logger().Info("fetching users")
        c.JSON(200, fetchUsers())
    })
    
    a.Run(":8080")
}

Benefits:

  • Automatic service metadata (name, version, environment)
  • Trace correlation (logs include trace_id and span_id)
  • Metrics integration (log metrics alongside custom metrics)
  • Graceful shutdown handling

Component Access

Access observability components from the app:

a, _ := app.New(
    app.WithServiceName("my-api"),
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
        app.WithMetrics(),
        app.WithTracing(tracing.WithOTLP("localhost:4317")),
    ),
)

// Access components
logger := a.Logger()
router := a.Router()
tracer := a.Tracer()
metrics := a.Metrics()

// Use logger directly
logger.Info("application started", "port", 8080)

Context-Aware Logging

Router contexts automatically support trace correlation.

Automatic Trace Correlation

r.GET("/api/process", func(c *router.Context) {
    // Logger from context is automatically trace-aware
    log := c.Logger()
    
    log.Info("processing started")
    // Output includes trace_id and span_id if tracing enabled
    
    result := processData()
    
    log.Info("processing completed", "items", result.Count)
})

Output (with tracing enabled):

{
  "level": "INFO",
  "msg": "processing started",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "service": "my-api"
}

Manual Context Logger

Create a context logger explicitly:

r.GET("/api/data", func(c *router.Context) {
    // Get base logger
    baseLogger := a.Logger()
    
    // Create context logger with trace info
    cl := logging.NewContextLogger(c.Request.Context(), baseLogger)
    
    cl.Info("processing request")
})

Access Log Middleware

The router includes built-in access log middleware.

Enable Access Logging

import "rivaas.dev/router/middleware/accesslog"

r := router.MustNew()
logger := logging.MustNew(logging.WithJSONHandler())
r.SetLogger(logger)

// Enable access logging
r.Use(accesslog.New())

r.GET("/", func(c *router.Context) {
    c.JSON(200, gin.H{"status": "ok"})
})

Output:

{
  "level": "INFO",
  "msg": "http request",
  "method": "GET",
  "path": "/",
  "status": 200,
  "duration_ms": 5,
  "bytes": 18,
  "remote": "192.168.1.1:54321",
  "user_agent": "Mozilla/5.0..."
}

Customize Access Logs

Exclude specific paths from access logs:

r.Use(accesslog.New(
    accesslog.WithExcludePaths("/health", "/metrics", "/ready"),
))

Add custom fields:

r.Use(accesslog.New(
    accesslog.WithFields(func(c *router.Context) map[string]any {
        return map[string]any{
            "api_version": c.GetHeader("X-API-Version"),
            "client_id": c.GetHeader("X-Client-ID"),
        }
    }),
))

Environment Variables

Configure logging via environment variables.

Standard OpenTelemetry Variables

# Service identification
export OTEL_SERVICE_NAME=my-api
export OTEL_SERVICE_VERSION=v1.0.0
export RIVAAS_ENVIRONMENT=production

The app package automatically reads these:

a, _ := app.New(
    // Service name from OTEL_SERVICE_NAME
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
    ),
)

logger := a.Logger()
logger.Info("service started")
// Automatically includes service="my-api", version="v1.0.0", env="production"

Custom Environment Configuration

func createLogger() *logging.Logger {
    var opts []logging.Option
    
    // Handler based on environment
    switch os.Getenv("ENV") {
    case "development":
        opts = append(opts, logging.WithConsoleHandler())
    default:
        opts = append(opts, logging.WithJSONHandler())
    }
    
    // Level from environment
    logLevel := os.Getenv("LOG_LEVEL")
    switch logLevel {
    case "debug":
        opts = append(opts, logging.WithDebugLevel())
    case "warn":
        opts = append(opts, logging.WithLevel(logging.LevelWarn))
    case "error":
        opts = append(opts, logging.WithLevel(logging.LevelError))
    default:
        opts = append(opts, logging.WithLevel(logging.LevelInfo))
    }
    
    // Service metadata
    opts = append(opts,
        logging.WithServiceName(os.Getenv("SERVICE_NAME")),
        logging.WithServiceVersion(os.Getenv("SERVICE_VERSION")),
        logging.WithEnvironment(os.Getenv("ENV")),
    )
    
    return logging.MustNew(opts...)
}

Custom Middleware

Create custom logging middleware for specialized needs.

Request ID Middleware

func requestIDMiddleware(logger *logging.Logger) router.HandlerFunc {
    return func(c *router.Context) {
        requestID := c.GetHeader("X-Request-ID")
        if requestID == "" {
            requestID = generateRequestID()
        }
        
        // Add request ID to request context
        ctx := c.Request.Context()
        ctx = context.WithValue(ctx, "request_id", requestID)
        
        // Create logger with request ID
        reqLogger := logger.With("request_id", requestID)
        ctx = context.WithValue(ctx, "logger", reqLogger)
        c.Request = c.Request.WithContext(ctx)
        
        c.Next()
    }
}

// Usage
r.Use(requestIDMiddleware(logger))

User Context Middleware

func userContextMiddleware() router.HandlerFunc {
    return func(c *router.Context) {
        userID := extractUserID(c)
        
        if userID != "" {
            // Add user ID to logger
            log := c.Logger().With("user_id", userID)
            ctx := context.WithValue(c.Request.Context(), "logger", log)
            c.Request = c.Request.WithContext(ctx)
        }
        
        c.Next()
    }
}

Error Logging Middleware

func errorLoggingMiddleware() router.HandlerFunc {
    return func(c *router.Context) {
        c.Next()
        
        // Log errors after handler completes
        if c.HasErrors() {
            log := c.Logger()
            for _, err := range c.Errors() {
                log.Error("request error",
                    "error", err.Error(),
                    "type", err.Type,
                    "path", c.Request.URL.Path,
                )
            }
        }
    }
}

Complete Integration Example

Putting it all together:

package main

import (
    "context"
    "os"
    "rivaas.dev/app"
    "rivaas.dev/logging"
    "rivaas.dev/tracing"
    "rivaas.dev/router/middleware/accesslog"
)

func main() {
    // Initialize app with full observability
    a, err := app.New(
        app.WithServiceName("payment-api"),
        app.WithServiceVersion("v2.1.0"),
        app.WithObservability(
            app.WithLogging(
                logging.WithJSONHandler(),
                logging.WithLevel(logging.LevelInfo),
                logging.WithEnvironment(os.Getenv("ENV")),
            ),
            app.WithMetrics(),
            app.WithTracing(
                tracing.WithOTLP("localhost:4317"),
            ),
        ),
    )
    if err != nil {
        panic(err)
    }
    defer a.Shutdown(context.Background())
    
    router := a.Router()
    logger := a.Logger()
    
    // Add middleware
    router.Use(accesslog.New(
        accesslog.WithExcludePaths("/health", "/ready"),
    ))
    
    // Health endpoint (no logging)
    router.GET("/health", func(c *router.Context) {
        c.JSON(200, gin.H{"status": "healthy"})
    })
    
    // API endpoints (with logging and tracing)
    api := router.Group("/api/v1")
    {
        api.POST("/payments", func(c *router.Context) {
            log := c.Logger()
            log.Info("payment request received")
            
            var payment Payment
            if err := c.BindJSON(&payment); err != nil {
                log.Error("invalid payment request", "error", err)
                c.JSON(400, gin.H{"error": "invalid request"})
                return
            }
            
            result, err := processPayment(c.Request.Context(), payment)
            if err != nil {
                log.Error("payment processing failed", 
                    "error", err,
                    "payment_id", payment.ID,
                )
                c.JSON(500, gin.H{"error": "processing failed"})
                return
            }
            
            log.Info("payment processed successfully",
                "payment_id", payment.ID,
                "amount", payment.Amount,
                "status", result.Status,
            )
            
            c.JSON(200, result)
        })
    }
    
    // Start server
    logger.Info("starting server", "port", 8080)
    if err := a.Run(":8080"); err != nil {
        logger.Error("server error", "error", err)
    }
}

Best Practices

Per-Request Loggers

Create request-scoped loggers with context:

r.GET("/api/data", func(c *router.Context) {
    log := c.Logger().With(
        "request_id", c.GetHeader("X-Request-ID"),
        "user_id", extractUserID(c),
    )
    
    log.Info("request started")
    // All subsequent logs include request_id and user_id
    log.Info("processing")
    log.Info("request completed")
})

Structured Context

Add structured context early in request lifecycle:

func contextMiddleware() router.HandlerFunc {
    return func(c *router.Context) {
        log := c.Logger().With(
            "path", c.Request.URL.Path,
            "method", c.Request.Method,
            "request_id", c.Request.Header.Get("X-Request-ID"),
        )
        ctx := context.WithValue(c.Request.Context(), "logger", log)
        c.Request = c.Request.WithContext(ctx)
        c.Next()
    }
}

Avoid Logging in Hot Paths

Use access log middleware instead of manual logging:

// BAD - manual logging in every handler
r.GET("/api/users", func(c *router.Context) {
    log := c.Logger()
    log.Info("request", "path", c.Request.URL.Path) // Duplicate
    // ... handle request
    log.Info("response", "status", 200) // Use access log instead
})

// GOOD - use access log middleware
r.Use(accesslog.New())
r.GET("/api/users", func(c *router.Context) {
    // Handle request - logging handled by middleware
})

Next Steps

For API details, see the API Reference.

7.9 - Testing

Test utilities and patterns for logging in unit and integration tests

This guide covers testing with the logging package. It includes test utilities, assertions, and best practices.

Overview

The logging package provides comprehensive testing utilities:

  • TestHelper - High-level test utilities with assertions.
  • NewTestLogger - Simple logger with in-memory buffer.
  • MockWriter - Record and inspect write operations.
  • CountingWriter - Track log volume without storing content.
  • SlowWriter - Simulate slow I/O for timeout testing.
  • HandlerSpy - Spy on slog.Handler operations.

Quick Start

Simple Test Logger

Create a logger with in-memory output:

func TestMyFunction(t *testing.T) {
    logger, buf := logging.NewTestLogger()
    
    myFunction(logger)
    
    // Parse and inspect logs
    entries, err := logging.ParseJSONLogEntries(buf)
    require.NoError(t, err)
    
    require.Len(t, entries, 1)
    assert.Equal(t, "INFO", entries[0].Level)
    assert.Equal(t, "operation completed", entries[0].Message)
}

TestHelper

High-level testing utility with convenience methods.

Basic Usage

func TestUserService(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    svc := NewUserService(th.Logger)
    svc.CreateUser("alice")
    
    // Check logs were written
    th.AssertLog(t, "INFO", "user created", map[string]any{
        "username": "alice",
    })
}

TestHelper Methods

ContainsLog - Check if message exists:

if !th.ContainsLog("user created") {
    t.Error("expected user created log")
}

ContainsAttr - Check if attribute exists:

if !th.ContainsAttr("user_id", "123") {
    t.Error("expected user_id attribute")
}

CountLevel - Count logs by level:

errorCount := th.CountLevel("ERROR")
assert.Equal(t, 2, errorCount)

LastLog - Get most recent log:

last, err := th.LastLog()
require.NoError(t, err)
assert.Equal(t, "INFO", last.Level)

Logs - Get all logs:

logs, err := th.Logs()
require.NoError(t, err)
for _, log := range logs {
    fmt.Printf("%s: %s\n", log.Level, log.Message)
}

Reset - Clear buffer:

th.Reset()  // Start fresh for next test phase

Custom Configuration

Pass options to customize the test logger:

th := logging.NewTestHelper(t,
    logging.WithLevel(logging.LevelWarn),  // Only warnings and errors
    logging.WithServiceName("test-service"),
)

Parsing Log Entries

Parse JSON logs for inspection.

ParseJSONLogEntries

func TestLogging(t *testing.T) {
    logger, buf := logging.NewTestLogger()
    
    logger.Info("test message", "key", "value")
    logger.Error("test error", "error", "something failed")
    
    entries, err := logging.ParseJSONLogEntries(buf)
    require.NoError(t, err)
    require.Len(t, entries, 2)
    
    // First entry
    assert.Equal(t, "INFO", entries[0].Level)
    assert.Equal(t, "test message", entries[0].Message)
    assert.Equal(t, "value", entries[0].Attrs["key"])
    
    // Second entry
    assert.Equal(t, "ERROR", entries[1].Level)
    assert.Equal(t, "something failed", entries[1].Attrs["error"])
}

LogEntry Structure

type LogEntry struct {
    Time    time.Time       // Log timestamp
    Level   string          // "DEBUG", "INFO", "WARN", "ERROR"
    Message string          // Log message
    Attrs   map[string]any  // All other fields
}

Mock Writers

Test utilities for inspecting write behavior.

MockWriter

Records all writes for inspection:

func TestWriteBehavior(t *testing.T) {
    mw := &logging.MockWriter{}
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithOutput(mw),
    )
    
    logger.Info("test 1")
    logger.Info("test 2")
    logger.Info("test 3")
    
    // Verify write count
    assert.Equal(t, 3, mw.WriteCount())
    
    // Inspect last write
    lastWrite := mw.LastWrite()
    assert.Contains(t, string(lastWrite), "test 3")
    
    // Check total bytes
    assert.Greater(t, mw.BytesWritten(), 0)
    
    // Reset for next test
    mw.Reset()
}

CountingWriter

Count bytes without storing content:

func TestLogVolume(t *testing.T) {
    cw := &logging.CountingWriter{}
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithOutput(cw),
    )
    
    for i := 0; i < 1000; i++ {
        logger.Info("test message", "index", i)
    }
    
    // Verify volume
    bytesLogged := cw.Count()
    t.Logf("Total bytes logged: %d", bytesLogged)
    
    // Useful for volume tests without memory overhead
}

SlowWriter

Simulate slow I/O:

func TestSlowLogging(t *testing.T) {
    buf := &bytes.Buffer{}
    sw := logging.NewSlowWriter(100*time.Millisecond, buf)
    
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithOutput(sw),
    )
    
    start := time.Now()
    logger.Info("test")
    duration := time.Since(start)
    
    // Verify delay
    assert.GreaterOrEqual(t, duration, 100*time.Millisecond)
}

Testing Patterns

Testing Error Logging

func TestErrorHandling(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    svc := NewService(th.Logger)
    err := svc.DoSomethingThatFails()
    
    require.Error(t, err)
    
    // Verify error was logged
    th.AssertLog(t, "ERROR", "operation failed", map[string]any{
        "error": "expected failure",
    })
}

Testing Log Levels

func TestLogLevels(t *testing.T) {
    th := logging.NewTestHelper(t,
        logging.WithLevel(logging.LevelWarn),
    )
    
    th.Logger.Debug("debug message")  // Won't appear
    th.Logger.Info("info message")    // Won't appear
    th.Logger.Warn("warn message")    // Will appear
    th.Logger.Error("error message")  // Will appear
    
    logs, _ := th.Logs()
    assert.Len(t, logs, 2)
    assert.Equal(t, "WARN", logs[0].Level)
    assert.Equal(t, "ERROR", logs[1].Level)
}

Testing Structured Fields

func TestStructuredLogging(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    th.Logger.Info("user action",
        "user_id", "123",
        "action", "login",
        "timestamp", time.Now().Unix(),
    )
    
    // Verify specific attributes
    assert.True(t, th.ContainsAttr("user_id", "123"))
    assert.True(t, th.ContainsAttr("action", "login"))
    
    // Or use AssertLog for multiple attributes
    th.AssertLog(t, "INFO", "user action", map[string]any{
        "user_id": "123",
        "action":  "login",
    })
}

Testing Sampling

func TestSampling(t *testing.T) {
    th := logging.NewTestHelper(t,
        logging.WithSampling(logging.SamplingConfig{
            Initial:    10,
            Thereafter: 100,
            Tick:       time.Minute,
        }),
    )
    
    // Log many entries
    for i := 0; i < 1000; i++ {
        th.Logger.Info("test", "index", i)
    }
    
    logs, _ := th.Logs()
    
    // Should have ~20 logs (10 initial + ~10 sampled)
    assert.Less(t, len(logs), 50)
    assert.Greater(t, len(logs), 10)
}

Testing Context Logging

func TestContextLogger(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    // Create context with trace info (mocked)
    ctx := context.Background()
    // Add trace to context...
    
    cl := logging.NewContextLogger(ctx, th.Logger)
    cl.Info("traced message")
    
    // Verify trace IDs in logs
    logs, _ := th.Logs()
    require.Len(t, logs, 1)
    
    // Check for trace_id if tracing was active
    if traceID := cl.TraceID(); traceID != "" {
        assert.Equal(t, traceID, logs[0].Attrs["trace_id"])
    }
}

Table-Driven Tests

Use table-driven tests for comprehensive coverage:

func TestLogLevels(t *testing.T) {
    tests := []struct {
        name          string
        level         logging.Level
        logFunc       func(*logging.Logger)
        expectLogged  bool
    }{
        {
            name:  "debug at info level",
            level: logging.LevelInfo,
            logFunc: func(l *logging.Logger) {
                l.Debug("debug message")
            },
            expectLogged: false,
        },
        {
            name:  "info at info level",
            level: logging.LevelInfo,
            logFunc: func(l *logging.Logger) {
                l.Info("info message")
            },
            expectLogged: true,
        },
        {
            name:  "error at warn level",
            level: logging.LevelWarn,
            logFunc: func(l *logging.Logger) {
                l.Error("error message")
            },
            expectLogged: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            th := logging.NewTestHelper(t,
                logging.WithLevel(tt.level),
            )
            
            tt.logFunc(th.Logger)
            
            logs, _ := th.Logs()
            if tt.expectLogged {
                assert.Len(t, logs, 1)
            } else {
                assert.Len(t, logs, 0)
            }
        })
    }
}

Integration Testing

Test logger integration with other components.

With HTTP Handlers

func TestHTTPHandler(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    handler := NewHandler(th.Logger)
    
    req := httptest.NewRequest("GET", "/api/users", nil)
    rec := httptest.NewRecorder()
    
    handler.ServeHTTP(rec, req)
    
    // Verify logging
    th.AssertLog(t, "INFO", "request processed", map[string]any{
        "method": "GET",
        "path":   "/api/users",
    })
}

With Router

func TestRouterLogging(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    r := router.MustNew()
    r.SetLogger(th.Logger)
    
    r.GET("/test", func(c *router.Context) {
        c.Logger().Info("handler called")
        c.JSON(200, gin.H{"status": "ok"})
    })
    
    // Make request
    req := httptest.NewRequest("GET", "/test", nil)
    rec := httptest.NewRecorder()
    r.ServeHTTP(rec, req)
    
    // Verify handler logged
    assert.True(t, th.ContainsLog("handler called"))
}

Best Practices

Reset Between Tests

func TestMultiplePhases(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    // Phase 1
    th.Logger.Info("phase 1")
    assert.True(t, th.ContainsLog("phase 1"))
    
    // Reset for phase 2
    th.Reset()
    
    // Phase 2
    th.Logger.Info("phase 2")
    logs, _ := th.Logs()
    assert.Len(t, logs, 1)  // Only phase 2 log
}

Use Subtests

func TestLogging(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    t.Run("info logging", func(t *testing.T) {
        th.Logger.Info("info message")
        assert.True(t, th.ContainsLog("info message"))
        th.Reset()
    })
    
    t.Run("error logging", func(t *testing.T) {
        th.Logger.Error("error message")
        assert.Equal(t, 1, th.CountLevel("ERROR"))
        th.Reset()
    })
}

Test Isolation

Each test should have its own logger:

func TestA(t *testing.T) {
    th := logging.NewTestHelper(t)  // Independent logger
    // Test A logic...
}

func TestB(t *testing.T) {
    th := logging.NewTestHelper(t)  // Independent logger
    // Test B logic...
}

Running Tests

# Run all tests
go test ./...

# Run with verbose output
go test -v ./...

# Run specific test
go test -run TestMyFunction

# With coverage
go test -cover ./...

# With race detector
go test -race ./...

Next Steps

For complete API details, see the API Reference.

7.10 - Best Practices

Production-ready logging patterns, performance tips, and recommended practices

This guide covers best practices for using the logging package in production environments.

Structured Logging

Always use structured fields instead of string concatenation.

Use Structured Fields

BAD - String concatenation:

log.Info("User " + userID + " logged in from " + ipAddress)

GOOD - Structured fields:

log.Info("user logged in",
    "user_id", userID,
    "ip_address", ipAddress,
    "session_id", sessionID,
)

Benefits:

  • Machine-parseable
  • Searchable by specific fields
  • Type-safe (numbers stay numbers)
  • Easier to aggregate and visualize
  • Better for log aggregation tools

Consistent Field Naming

Use consistent field names across your application:

// Good - consistent naming
log.Info("request started", "user_id", userID)
log.Info("database query", "user_id", userID)
log.Info("response sent", "user_id", userID)

// Bad - inconsistent naming
log.Info("request started", "user_id", userID)
log.Info("database query", "userId", userID)      // Different name
log.Info("response sent", "user", userID)         // Different name

Recommended conventions:

  • Use snake_case: user_id, request_id, duration_ms
  • Be specific: http_status not status, db_host not host
  • Use consistent units: duration_ms, size_bytes, count

Log Appropriate Levels

Choose the right log level for each message.

Level Guidelines

DEBUG - Detailed information for debugging

log.Debug("cache lookup",
    "key", cacheKey,
    "ttl", ttl,
    "hit", hit,
)

Use DEBUG for:

  • Internal state inspection
  • Flow control details
  • Cache hits/misses
  • Detailed algorithm steps

INFO - General informational messages

log.Info("server started",
    "port", 8080,
    "version", version,
)

Use INFO for:

  • Application lifecycle events (start, stop)
  • Significant business events
  • Successful operations
  • Configuration values

WARN - Warning but not an error

log.Warn("high memory usage",
    "used_mb", 8192,
    "total_mb", 16384,
    "percentage", 50,
)

Use WARN for:

  • Degraded performance
  • Using fallback behavior
  • Deprecated feature usage
  • Resource constraints

ERROR - Errors that need attention

log.Error("database connection failed",
    "error", err,
    "host", dbHost,
    "retry_count", retries,
)

Use ERROR for:

  • Operation failures
  • Exception conditions
  • Data integrity issues
  • External service failures

Include Context

Always include relevant context with log messages.

Minimal Context

// Bad - no context
log.Error("failed to save", "error", err)

Better - Includes Context

// Good - includes relevant context
log.Error("failed to save user data",
    "error", err,
    "user_id", user.ID,
    "operation", "update_profile",
    "retry_count", retries,
    "elapsed_ms", elapsed.Milliseconds(),
)

Context checklist:

  • What operation failed?
  • Which entity was involved?
  • What were the inputs?
  • How many times did we retry?
  • How long did it take?

Performance Considerations

Follow these guidelines for high-performance logging.

Avoid Logging in Tight Loops

BAD - logs thousands of times:

for _, item := range items {
    log.Debug("processing item", "item", item)
    process(item)
}

GOOD - log once with summary:

log.Info("processing batch started", "count", len(items))

for _, item := range items {
    process(item)
}

log.Info("processing batch completed",
    "count", len(items),
    "duration_ms", elapsed.Milliseconds(),
)

Use Appropriate Log Levels in Production

// Production configuration
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),  // Skip debug logs
)

Impact:

  • DEBUG logs have overhead even if not written
  • Level checks are fast but not free
  • Set INFO or WARN in production

Defer Expensive Operations

Only compute expensive values if the log will be written:

// Bad - always computes
log.Debug("state", "expensive", expensiveComputation())

// Good - only compute if debug enabled
if log.Logger().Enabled(context.Background(), logging.LevelDebug) {
    log.Debug("state", "expensive", expensiveComputation())
}

Use Log Sampling

For high-volume services:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    1000,
        Thereafter: 100,  // 1% sampling
        Tick:       time.Minute,
    }),
)

See Log Sampling for details.

Don’t Log Sensitive Data

Protect user privacy and security.

Automatically Redacted Fields

These fields are automatically redacted:

  • password
  • token
  • secret
  • api_key
  • authorization
log.Info("authentication attempt",
    "username", "alice",
    "password", "secret123",  // Automatically redacted
)
// Output: {...,"password":"***REDACTED***"}

Custom Sensitive Fields

Add custom redaction:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
        // Redact credit cards
        if a.Key == "credit_card" {
            return slog.String(a.Key, "***REDACTED***")
        }
        // Redact email addresses
        if a.Key == "email" {
            return slog.String(a.Key, maskEmail(a.Value.String()))
        }
        return a
    }),
)

What Not to Log

Never log:

  • Passwords or password hashes
  • Credit card numbers
  • Social Security numbers
  • API keys and tokens
  • Private keys
  • Session tokens
  • Personal health information (PHI)
  • Personally identifiable information (PII) without consent

Production Configuration

Recommended production setup.

Production Logger

func NewProductionLogger() *logging.Logger {
    return logging.MustNew(
        logging.WithJSONHandler(),              // Machine-parseable
        logging.WithLevel(logging.LevelInfo),   // No debug spam
        logging.WithServiceName(os.Getenv("SERVICE_NAME")),
        logging.WithServiceVersion(os.Getenv("VERSION")),
        logging.WithEnvironment("production"),
        logging.WithOutput(os.Stdout),          // Stdout for container logs
    )
}

Development Logger

func NewDevelopmentLogger() *logging.Logger {
    return logging.MustNew(
        logging.WithConsoleHandler(),  // Human-readable
        logging.WithDebugLevel(),      // See everything
        logging.WithSource(true),      // File:line info
    )
}

Environment-Based Configuration

func NewLogger() *logging.Logger {
    if os.Getenv("ENV") == "production" {
        return NewProductionLogger()
    }
    return NewDevelopmentLogger()
}

Error Handling Patterns

Best practices for logging errors.

Always Include Error Details

if err := db.Connect(); err != nil {
    log.Error("database connection failed",
        "error", err,
        "host", dbHost,
        "port", dbPort,
        "database", dbName,
        "retry_count", retries,
    )
    return err
}

Use LogError for Consistency

if err := operation(); err != nil {
    logger.LogError(err, "operation failed",
        "operation", "process_payment",
        "user_id", userID,
    )
    return err
}

Stack Traces for Critical Errors Only

// Normal error - no stack trace
if err := validation(); err != nil {
    logger.LogError(err, "validation failed", "field", field)
    return err
}

// Critical error - with stack trace
if err := criticalOperation(); err != nil {
    logger.ErrorWithStack("critical failure", err, true,
        "operation", "process_payment",
        "amount", amount,
    )
    return err
}

Request Logging

Best practices for HTTP request logging.

Use Access Log Middleware

import "rivaas.dev/router/middleware/accesslog"

r := router.MustNew()
r.SetLogger(logger)
r.Use(accesslog.New(
    accesslog.WithExcludePaths("/health", "/metrics"),
))

Don’t manually log every request:

// Bad - redundant with access log
r.GET("/api/users", func(c *router.Context) {
    c.Logger().Info("request received")  // Don't do this
    // ... handle request
    c.Logger().Info("request completed") // Don't do this
})

Per-Request Context

Add request-specific fields:

r.Use(func(c *router.Context) {
    reqLogger := c.Logger().With(
        "request_id", c.GetHeader("X-Request-ID"),
        "user_id", extractUserID(c),
    )
    ctx := context.WithValue(c.Request.Context(), "logger", reqLogger)
        c.Request = c.Request.WithContext(ctx)
    c.Next()
})

Graceful Shutdown

Always shut down loggers gracefully.

With Context Timeout

func main() {
    logger := logging.MustNew(logging.WithJSONHandler())
    defer func() {
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        
        if err := logger.Shutdown(ctx); err != nil {
            fmt.Fprintf(os.Stderr, "logger shutdown error: %v\n", err)
        }
    }()
    
    // Application logic...
}

With Signal Handling

func main() {
    logger := logging.MustNew(logging.WithJSONHandler())
    
    // Setup signal handling
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-sigChan
        logger.Info("shutting down...")
        
        ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        logger.Shutdown(ctx)
        
        os.Exit(0)
    }()
    
    // Application logic...
}

Testing Considerations

Make logging testable.

Inject Loggers

// Good - logger injected
type Service struct {
    logger *logging.Logger
}

func NewService(logger *logging.Logger) *Service {
    return &Service{logger: logger}
}

// In tests
func TestService(t *testing.T) {
    th := logging.NewTestHelper(t)
    svc := NewService(th.Logger)
    // Test and verify logs
}

Don’t use global loggers:

// Bad - global logger
var log = logging.MustNew(logging.WithJSONHandler())

type Service struct{}

func (s *Service) DoSomething() {
    log.Info("doing something")  // Can't test
}

Common Anti-Patterns

Avoid these common mistakes.

String Formatting in Log Calls

// Bad - string formatting
log.Info(fmt.Sprintf("User %s did %s", user, action))

// Good - structured fields
log.Info("user action", "user", user, "action", action)

Logging in Library Code

// Bad - library logging directly
func LibraryFunction() {
    log.Info("library function called")
}

// Good - library returns errors
func LibraryFunction() error {
    if err := something(); err != nil {
        return fmt.Errorf("library operation failed: %w", err)
    }
    return nil
}

// Caller logs
if err := LibraryFunction(); err != nil {
    log.Error("library call failed", "error", err)
}

Ignoring Shutdown Errors

// Bad - ignoring shutdown
defer logger.Shutdown(context.Background())

// Good - handling shutdown errors
defer func() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := logger.Shutdown(ctx); err != nil {
        fmt.Fprintf(os.Stderr, "shutdown error: %v\n", err)
    }
}()

Monitoring and Alerting

Set up log-based monitoring.

Log Metrics

Track log volumes by level:

var logMetrics = struct {
    debugCount, infoCount, warnCount, errorCount atomic.Int64
}{}

// Periodically export metrics
go func() {
    ticker := time.NewTicker(time.Minute)
    for range ticker.C {
        metricsLogger.Info("log metrics",
            "debug_count", logMetrics.debugCount.Swap(0),
            "info_count", logMetrics.infoCount.Swap(0),
            "warn_count", logMetrics.warnCount.Swap(0),
            "error_count", logMetrics.errorCount.Swap(0),
        )
    }
}()

Alert on Error Rates

Configure alerts in your logging system:

  • Alert if ERROR count > 100/minute
  • Alert if ERROR rate increases >50% baseline
  • Alert on specific error patterns

Next Steps

For complete API details, see the API Reference.

7.11 - Migration Guides

Switch from other popular Go logging libraries to Rivaas logging

This guide helps you migrate from other popular Go logging libraries to Rivaas logging.

Overview

Switching to Rivaas logging is straightforward. The package offers better performance and stdlib integration while maintaining familiar patterns.

Common migrations:

From logrus

logrus is a popular structured logger, but Rivaas logging offers better performance and native Go integration.

Basic Setup

BEFORE (logrus):

import "github.com/sirupsen/logrus"

log := logrus.New()
log.SetFormatter(&logrus.JSONFormatter{})
log.SetLevel(logrus.InfoLevel)
log.SetOutput(os.Stdout)

log.WithFields(logrus.Fields{
    "user_id": "123",
    "action": "login",
}).Info("User logged in")

AFTER (rivaas/logging):

import "rivaas.dev/logging"

log := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
    logging.WithOutput(os.Stdout),
)

log.Info("User logged in",
    "user_id", "123",
    "action", "login",
)

Key Differences

Featurelogrusrivaas/logging
Format config&logrus.JSONFormatter{}logging.WithJSONHandler()
FieldsWithFields(logrus.Fields{})Inline key-value pairs
Levellogrus.InfoLevellogging.LevelInfo
Performance~2000 ns/op~500 ns/op
DependenciesMany externalGo stdlib only

Migration Steps

  1. Replace import:
// Old
import "github.com/sirupsen/logrus"

// New
import "rivaas.dev/logging"
  1. Update initialization:
// Old
log := logrus.New()
log.SetFormatter(&logrus.JSONFormatter{})
log.SetLevel(logrus.InfoLevel)

// New
log := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
)
  1. Convert WithFields calls:
// Old
log.WithFields(logrus.Fields{
    "user_id": "123",
    "action": "login",
}).Info("message")

// New
log.Info("message",
    "user_id", "123",
    "action", "login",
)
  1. Update log levels:
// Old
logrus.DebugLevel -> logging.LevelDebug
logrus.InfoLevel  -> logging.LevelInfo
logrus.WarnLevel  -> logging.LevelWarn
logrus.ErrorLevel -> logging.LevelError

From zap

zap is very fast, but Rivaas logging offers similar performance with a simpler API.

Basic Setup

BEFORE (zap):

import "go.uber.org/zap"

logger, _ := zap.NewProduction()
defer logger.Sync()

logger.Info("User logged in",
    zap.String("user_id", "123"),
    zap.String("action", "login"),
    zap.Int("status", 200),
)

AFTER (rivaas/logging):

import "rivaas.dev/logging"

logger := logging.MustNew(logging.WithJSONHandler())
defer logger.Shutdown(context.Background())

logger.Info("User logged in",
    "user_id", "123",
    "action", "login",
    "status", 200,
)

Key Differences

Featurezaprivaas/logging
Typed fieldszap.String("key", val)Direct values
Shutdownlogger.Sync()logger.Shutdown(ctx)
API styleTyped wrappersNative Go types
Performance~450 ns/op~500 ns/op
ComplexityHighLow

Migration Steps

  1. Replace import:
// Old
import "go.uber.org/zap"

// New
import "rivaas.dev/logging"
  1. Simplify initialization:
// Old
logger, _ := zap.NewProduction()

// New
logger := logging.MustNew(logging.WithJSONHandler())
  1. Remove type wrappers:
// Old
logger.Info("message",
    zap.String("name", name),
    zap.Int("count", count),
    zap.Bool("enabled", enabled),
)

// New
logger.Info("message",
    "name", name,
    "count", count,
    "enabled", enabled,
)
  1. Update shutdown:
// Old
defer logger.Sync()

// New
defer logger.Shutdown(context.Background())

From zerolog

zerolog is very fast, but Rivaas logging is simpler and uses stdlib.

Basic Setup

BEFORE (zerolog):

import "github.com/rs/zerolog"

logger := zerolog.New(os.Stdout).With().
    Str("service", "myapp").
    Str("version", "1.0.0").
    Logger()

logger.Info().
    Str("user_id", "123").
    Str("action", "login").
    Msg("User logged in")

AFTER (rivaas/logging):

import "rivaas.dev/logging"

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithServiceName("myapp"),
    logging.WithServiceVersion("1.0.0"),
)

logger.Info("User logged in",
    "user_id", "123",
    "action", "login",
)

Key Differences

Featurezerologrivaas/logging
API styleChainingFunctional options
Context.With().Str().Logger()WithServiceName()
Fields.Str("k", v).Msg()Inline pairs
Performance~400 ns/op~500 ns/op
ReadabilityMediumHigh

Migration Steps

  1. Replace import:
// Old
import "github.com/rs/zerolog"

// New
import "rivaas.dev/logging"
  1. Simplify initialization:
// Old
logger := zerolog.New(os.Stdout).With().
    Str("service", "myapp").
    Str("version", "1.0.0").
    Logger()

// New
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithServiceName("myapp"),
    logging.WithServiceVersion("1.0.0"),
)
  1. Remove chaining:
// Old
logger.Info().
    Str("user_id", "123").
    Str("action", "login").
    Msg("User logged in")

// New
logger.Info("User logged in",
    "user_id", "123",
    "action", "login",
)

From stdlib log

Standard library log is simple but unstructured. Rivaas logging adds structure while using stdlib slog.

Basic Setup

BEFORE (stdlib log):

import "log"

log.SetOutput(os.Stdout)
log.SetPrefix("[INFO] ")
log.Printf("User %s logged in from %s", userID, ipAddress)

AFTER (rivaas/logging):

import "rivaas.dev/logging"

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
)

logger.Info("User logged in",
    "user_id", userID,
    "ip_address", ipAddress,
)

Key Benefits

Featurestdlib logrivaas/logging
StructureNoYes
Log levelsNoYes
FormatsText onlyJSON, Text, Console
PerformanceFastFast
ParsingManualAutomatic

Migration Steps

  1. Replace import:
// Old
import "log"

// New
import "rivaas.dev/logging"
  1. Update initialization:
// Old
log.SetOutput(os.Stdout)
log.SetPrefix("[INFO] ")

// New
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
)
  1. Convert Printf to structured:
// Old
log.Printf("User %s logged in from %s", userID, ipAddress)

// New
logger.Info("User logged in",
    "user_id", userID,
    "ip_address", ipAddress,
)

Migration Checklist

Use this checklist when migrating:

  • Replace logger initialization
  • Update all log calls to structured format
  • Replace log level constants
  • Update context/field methods (WithFields → inline)
  • Replace typed field methods (zap.String → direct values)
  • Update error handling (Sync → Shutdown)
  • Test with new logger
  • Update imports
  • Remove old logger dependency from go.mod
  • Update documentation and examples

Gradual Migration

Migrate gradually to minimize risk.

Phase 1: Parallel Logging

Run both loggers side-by-side:

// Keep old logger
oldLogger := logrus.New()

// Add new logger
newLogger := logging.MustNew(logging.WithJSONHandler())

// Log to both
func logInfo(msg string, fields map[string]any) {
    // Old logger
    oldLogger.WithFields(logrus.Fields(fields)).Info(msg)
    
    // New logger
    args := make([]any, 0, len(fields)*2)
    for k, v := range fields {
        args = append(args, k, v)
    }
    newLogger.Info(msg, args...)
}

Phase 2: Feature Flag

Use feature flag to switch between loggers:

func getLogger() Logger {
    if os.Getenv("USE_NEW_LOGGER") == "true" {
        return logging.MustNew(logging.WithJSONHandler())
    }
    return logrus.New()
}

Phase 3: Full Migration

Once validated, remove old logger completely.

Performance Comparison

Benchmark results (lower is better):

Loggerns/opallocs/opDependencies
stdlib slog45000
rivaas/logging50001 (OTel)
zap4500Many
zerolog4000Several
logrus20005Many

Note: rivaas/logging overhead is minimal compared to stdlib slog while adding valuable features.

Common Migration Issues

Issue: Missing Fields

Problem: Fields not appearing in logs.

Solution: Check field names match new format:

// Wrong - using old field format
log.Info("message", logrus.Fields{"key": "value"})

// Right - inline key-value pairs
log.Info("message", "key", "value")

Issue: Log Level Not Working

Problem: Debug logs not appearing.

Solution: Check log level configuration:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithDebugLevel(),  // Make sure to set debug level
)

Issue: Performance Regression

Problem: Logging slower than expected.

Solution: Check for common issues:

  • Logging in tight loops
  • Source location enabled in production
  • Not using appropriate log level

Getting Help

If you encounter issues during migration:

  1. Check the Troubleshooting guide
  2. Review Examples for patterns
  3. See Best Practices for recommendations
  4. Consult the API Reference

Next Steps

For complete API details, see the API Reference.

7.12 - Examples

Complete real-world examples of using the logging package

This guide provides complete, real-world examples of using the logging package in various scenarios.

Basic Application

Simple application with structured logging.

package main

import (
    "context"
    "os"
    "rivaas.dev/logging"
)

func main() {
    // Create logger
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithLevel(logging.LevelInfo),
        logging.WithServiceName("myapp"),
        logging.WithServiceVersion("v1.0.0"),
    )
    defer logger.Shutdown(context.Background())

    // Application logic
    logger.Info("application started",
        "port", 8080,
        "environment", os.Getenv("ENV"),
    )

    // Simulate work
    processData(logger)

    logger.Info("application stopped")
}

func processData(logger *logging.Logger) {
    logger.Info("processing data", "items", 100)
    // Process logic...
    logger.Info("data processing completed", "processed", 100)
}

HTTP Server

HTTP server with request logging.

package main

import (
    "context"
    "net/http"
    "time"
    "rivaas.dev/logging"
)

func main() {
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithServiceName("api-server"),
    )
    defer logger.Shutdown(context.Background())

    mux := http.NewServeMux()
    
    // Add logging middleware
    mux.HandleFunc("/", loggingMiddleware(logger, handleRoot))
    mux.HandleFunc("/api/users", loggingMiddleware(logger, handleUsers))

    logger.Info("server starting", "port", 8080)
    http.ListenAndServe(":8080", mux)
}

func loggingMiddleware(logger *logging.Logger, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Wrap response writer to capture status
        wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
        
        next(wrapped, r)
        
        logger.LogRequest(r,
            "status", wrapped.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    }
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (w *responseWriter) WriteHeader(code int) {
    w.statusCode = code
    w.ResponseWriter.WriteHeader(code)
}

func handleRoot(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello, World!"))
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`{"users": []}`))
}

Router Integration

Full router integration with tracing.

package main

import (
    "context"
    "rivaas.dev/app"
    "rivaas.dev/logging"
    "rivaas.dev/tracing"
    "rivaas.dev/router/middleware/accesslog"
)

func main() {
    // Create app with full observability
    a, err := app.New(
        app.WithServiceName("user-api"),
        app.WithServiceVersion("v2.0.0"),
        app.WithObservability(
            app.WithLogging(
                logging.WithJSONHandler(),
                logging.WithLevel(logging.LevelInfo),
            ),
            app.WithTracing(
                tracing.WithOTLP("localhost:4317"),
            ),
        ),
    )
    if err != nil {
        panic(err)
    }
    defer a.Shutdown(context.Background())

    router := a.Router()
    logger := a.Logger()

    // Add access log middleware
    router.Use(accesslog.New(
        accesslog.WithExcludePaths("/health"),
    ))

    // Health endpoint
    router.GET("/health", func(c *router.Context) {
        c.JSON(200, map[string]string{"status": "healthy"})
    })

    // API endpoints
    api := router.Group("/api/v1")
    {
        api.GET("/users", getUsers(logger))
        api.POST("/users", createUser(logger))
    }

    logger.Info("server starting", "port", 8080)
    a.Run(":8080")
}

func getUsers(logger *logging.Logger) router.HandlerFunc {
    return func(c *router.Context) {
        log := c.Logger()
        log.Info("fetching users")
        
        users := fetchUsers()
        
        log.Info("users fetched", "count", len(users))
        c.JSON(200, users)
    }
}

func createUser(logger *logging.Logger) router.HandlerFunc {
    return func(c *router.Context) {
        log := c.Logger()
        
        var user User
        if err := c.BindJSON(&user); err != nil {
            log.Error("invalid request", "error", err)
            c.JSON(400, map[string]string{"error": "invalid request"})
            return
        }
        
        if err := saveUser(user); err != nil {
            log.Error("failed to save user", "error", err)
            c.JSON(500, map[string]string{"error": "internal error"})
            return
        }
        
        log.Info("user created", "user_id", user.ID)
        c.JSON(201, user)
    }
}

Multiple Loggers

Different loggers for different purposes.

package main

import (
    "context"
    "os"
    "rivaas.dev/logging"
)

type Application struct {
    appLogger   *logging.Logger
    debugLogger *logging.Logger
    auditLogger *logging.Logger
}

func NewApplication() *Application {
    // Application logger - JSON for production
    appLogger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithLevel(logging.LevelInfo),
        logging.WithServiceName("myapp"),
    )

    // Debug logger - Console with source info
    debugLogger := logging.MustNew(
        logging.WithConsoleHandler(),
        logging.WithDebugLevel(),
        logging.WithSource(true),
    )

    // Audit logger - Separate file for compliance
    auditFile, _ := os.OpenFile("audit.log",
        os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
    auditLogger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithOutput(auditFile),
        logging.WithServiceName("myapp-audit"),
    )

    return &Application{
        appLogger:   appLogger,
        debugLogger: debugLogger,
        auditLogger: auditLogger,
    }
}

func (a *Application) Run() {
    defer a.appLogger.Shutdown(context.Background())
    defer a.debugLogger.Shutdown(context.Background())
    defer a.auditLogger.Shutdown(context.Background())

    // Normal application log
    a.appLogger.Info("application started")

    // Debug information
    a.debugLogger.Debug("initialization complete",
        "config_loaded", true,
        "db_connected", true,
    )

    // Audit event
    a.auditLogger.Info("user action",
        "user_id", "123",
        "action", "login",
        "success", true,
    )
}

func main() {
    app := NewApplication()
    app.Run()
}

Environment-Based Configuration

Configure logging based on environment.

package main

import (
    "os"
    "rivaas.dev/logging"
)

func createLogger() *logging.Logger {
    env := os.Getenv("ENV")
    
    var opts []logging.Option
    
    switch env {
    case "development":
        opts = []logging.Option{
            logging.WithConsoleHandler(),
            logging.WithDebugLevel(),
            logging.WithSource(true),
        }
    case "staging":
        opts = []logging.Option{
            logging.WithJSONHandler(),
            logging.WithLevel(logging.LevelInfo),
            logging.WithServiceName(os.Getenv("SERVICE_NAME")),
            logging.WithEnvironment("staging"),
        }
    case "production":
        opts = []logging.Option{
            logging.WithJSONHandler(),
            logging.WithLevel(logging.LevelWarn),
            logging.WithServiceName(os.Getenv("SERVICE_NAME")),
            logging.WithServiceVersion(os.Getenv("VERSION")),
            logging.WithEnvironment("production"),
            logging.WithSampling(logging.SamplingConfig{
                Initial:    1000,
                Thereafter: 100,
                Tick:       time.Minute,
            }),
        }
    default:
        opts = []logging.Option{
            logging.WithJSONHandler(),
            logging.WithLevel(logging.LevelInfo),
        }
    }
    
    return logging.MustNew(opts...)
}

func main() {
    logger := createLogger()
    defer logger.Shutdown(context.Background())
    
    logger.Info("application started", "environment", os.Getenv("ENV"))
}

Worker Pool with Per-Worker Logging

Logging in concurrent workers.

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
    "rivaas.dev/logging"
)

type Worker struct {
    id     int
    logger *logging.Logger
}

func NewWorker(id int, baseLogger *logging.Logger) *Worker {
    // Create worker-specific logger
    workerLogger := baseLogger.With("worker_id", id)
    
    return &Worker{
        id:     id,
        logger: baseLogger,
    }
}

func (w *Worker) Process(job Job) {
    start := time.Now()
    
    w.logger.Info("job started",
        "worker_id", w.id,
        "job_id", job.ID,
    )
    
    // Process job
    time.Sleep(100 * time.Millisecond)
    
    w.logger.LogDuration("job completed", start,
        "worker_id", w.id,
        "job_id", job.ID,
    )
}

type Job struct {
    ID int
}

func main() {
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithServiceName("worker-pool"),
    )
    defer logger.Shutdown(context.Background())

    // Create worker pool
    numWorkers := 4
    jobs := make(chan Job, 100)
    var wg sync.WaitGroup

    // Start workers
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        worker := NewWorker(i, logger)
        
        go func() {
            defer wg.Done()
            for job := range jobs {
                worker.Process(job)
            }
        }()
    }

    // Send jobs
    for i := 0; i < 10; i++ {
        jobs <- Job{ID: i}
    }
    close(jobs)

    wg.Wait()
    logger.Info("all jobs completed")
}

Error Handling with Context

Comprehensive error logging.

package main

import (
    "context"
    "errors"
    "time"
    "rivaas.dev/logging"
)

type Service struct {
    logger *logging.Logger
}

func NewService(logger *logging.Logger) *Service {
    return &Service{logger: logger}
}

func (s *Service) ProcessPayment(ctx context.Context, payment Payment) error {
    log := s.logger.With(
        "payment_id", payment.ID,
        "amount", payment.Amount,
    )

    log.Info("processing payment")

    // Validation
    if err := s.validatePayment(payment); err != nil {
        log.LogError(err, "payment validation failed",
            "step", "validation",
        )
        return err
    }

    // Process with retry
    var lastErr error
    for retry := 0; retry < 3; retry++ {
        if err := s.chargePayment(payment); err != nil {
            lastErr = err
            log.LogError(err, "payment charge failed",
                "retry", retry,
                "max_retries", 3,
            )
            time.Sleep(time.Second * time.Duration(retry+1))
            continue
        }
        
        log.Info("payment processed successfully")
        return nil
    }

    // Critical failure - log with stack trace
    s.logger.ErrorWithStack("payment processing failed after retries",
        lastErr, true,
        "payment_id", payment.ID,
        "retries", 3,
    )
    
    return lastErr
}

func (s *Service) validatePayment(payment Payment) error {
    if payment.Amount <= 0 {
        return errors.New("invalid amount")
    }
    return nil
}

func (s *Service) chargePayment(payment Payment) error {
    // Simulate charging
    return nil
}

type Payment struct {
    ID     string
    Amount float64
}

Testing Example

Complete testing setup.

package myservice_test

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "rivaas.dev/logging"
)

func TestUserService(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    svc := NewUserService(th.Logger)
    
    t.Run("create user", func(t *testing.T) {
        th.Reset()
        
        user, err := svc.CreateUser("alice", "alice@example.com")
        require.NoError(t, err)
        require.NotNil(t, user)
        
        // Verify logging
        th.AssertLog(t, "INFO", "user created", map[string]any{
            "username": "alice",
            "email":    "alice@example.com",
        })
    })
    
    t.Run("duplicate user", func(t *testing.T) {
        th.Reset()
        
        _, err := svc.CreateUser("alice", "alice@example.com")
        require.Error(t, err)
        
        // Verify error logging
        assert.True(t, th.ContainsLog("user creation failed"))
        assert.True(t, th.ContainsAttr("error", "user already exists"))
    })
}

Next Steps

For more examples, check the examples directory on GitHub.

8 - Metrics Collection

Learn how to collect and export application metrics with Rivaas metrics package

The Rivaas Metrics package provides OpenTelemetry-based metrics collection. Supports multiple exporters including Prometheus, OTLP, and stdout. Enables observability best practices with minimal configuration.

Features

  • Multiple Providers: Prometheus, OTLP, and stdout exporters
  • Built-in HTTP Metrics: Request duration, count, active requests, and more
  • Custom Metrics: Support for counters, histograms, and gauges with error handling
  • Thread-Safe: All methods are safe for concurrent use
  • Context Support: All metrics methods accept context for cancellation
  • Structured Logging: Pluggable logger interface for error and warning messages
  • HTTP Middleware: Integration with any HTTP framework
  • Security: Automatic filtering of sensitive headers

Quick Start

package main

import (
    "context"
    "log"
    "net/http"
    "os/signal"
    
    "rivaas.dev/metrics"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    recorder, err := metrics.New(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-api"),
        metrics.WithServiceVersion("v1.0.0"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())

    // Record custom metrics
    _ = recorder.IncrementCounter(ctx, "requests_total")
    
    // Prometheus metrics available at http://localhost:9090/metrics
}
package main

import (
    "context"
    "log"
    "os/signal"
    
    "rivaas.dev/metrics"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    recorder, err := metrics.New(
        metrics.WithOTLP("http://localhost:4318"),
        metrics.WithServiceName("my-api"),
        metrics.WithServiceVersion("v1.0.0"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())

    // Metrics pushed to OTLP collector
    _ = recorder.IncrementCounter(ctx, "requests_total")
}
package main

import (
    "context"
    "log"
    
    "rivaas.dev/metrics"
)

func main() {
    recorder := metrics.MustNew(
        metrics.WithStdout(),
        metrics.WithServiceName("my-api"),
    )

    ctx := context.Background()
    
    // Metrics printed to stdout
    _ = recorder.IncrementCounter(ctx, "requests_total")
}

How It Works

  • Providers determine where metrics are exported (Prometheus, OTLP, stdout)
  • Lifecycle management ensures proper initialization and graceful shutdown
  • HTTP middleware automatically collects request metrics
  • Custom metrics can be recorded with type-safe methods
  • Context support enables cancellation and request tracing

Learning Path

Follow these guides to master metrics collection with Rivaas:

  1. Installation - Get started with the metrics package
  2. Basic Usage - Learn the fundamentals of metrics collection
  3. Providers - Understand Prometheus, OTLP, and stdout exporters
  4. Configuration - Configure service metadata, histograms, and advanced options
  5. Custom Metrics - Create counters, histograms, and gauges
  6. Middleware - Integrate HTTP metrics with your application
  7. Testing - Test your metrics with provided utilities
  8. Examples - See real-world usage patterns

Next Steps

8.1 - Installation

How to install and set up the Rivaas metrics package

This guide covers installing the metrics package and verifying your setup.

Requirements

  • Go 1.25 or later
  • OpenTelemetry dependencies (automatically installed)

Installation

Install the metrics package using go get:

go get rivaas.dev/metrics

The package will automatically install its dependencies, including:

  • go.opentelemetry.io/otel - OpenTelemetry SDK
  • go.opentelemetry.io/otel/exporters/prometheus - Prometheus exporter
  • go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp - OTLP exporter
  • go.opentelemetry.io/otel/exporters/stdout/stdoutmetric - Stdout exporter

Verify Installation

Create a simple test file to verify the installation:

package main

import (
    "context"
    "fmt"
    "log"
    
    "rivaas.dev/metrics"
)

func main() {
    // Create a basic metrics recorder
    recorder, err := metrics.New(
        metrics.WithStdout(),
        metrics.WithServiceName("test-service"),
    )
    if err != nil {
        log.Fatalf("Failed to create recorder: %v", err)
    }
    
    // Start the recorder (optional for stdout, but good practice)
    if err := recorder.Start(context.Background()); err != nil {
        log.Fatalf("Failed to start recorder: %v", err)
    }
    defer recorder.Shutdown(context.Background())
    
    fmt.Println("Metrics package installed successfully!")
}

Run the test:

go run main.go

You should see output confirming the installation was successful.

Import Path

Import the metrics package in your code:

import "rivaas.dev/metrics"

Module Setup

If you’re starting a new project, initialize a Go module first:

go mod init your-project-name
go get rivaas.dev/metrics

Dependency Management

The metrics package uses Go modules for dependency management. After installation, your go.mod file will include:

require (
    rivaas.dev/metrics v0.1.0
    // OpenTelemetry dependencies added automatically
)

Run go mod tidy to clean up dependencies:

go mod tidy

Version Compatibility

The metrics package follows semantic versioning:

  • Stable API: The public API is stable and follows semantic versioning
  • Breaking Changes: Only introduced in major version updates
  • Go Version: Requires Go 1.25 or later

Check the releases page for the latest version.

Next Steps

Troubleshooting

Import Errors

If you see import errors:

go mod tidy
go mod download

Version Conflicts

If you have dependency conflicts with OpenTelemetry:

# Update to latest versions
go get -u rivaas.dev/metrics
go get -u go.opentelemetry.io/otel
go mod tidy

Build Errors

Ensure you’re using Go 1.25 or later:

go version

If you need to upgrade Go, visit golang.org/dl.

8.2 - Basic Usage

Learn the fundamentals of metrics collection with Rivaas

This guide covers the basic patterns for using the metrics package in your Go applications.

Creating a Metrics Recorder

The core of the metrics package is the Recorder type. Create a recorder by choosing a provider and configuring it:

package main

import (
    "context"
    "log"
    "os/signal"
    
    "rivaas.dev/metrics"
)

func main() {
    // Create context for application lifecycle
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create recorder with error handling
    recorder, err := metrics.New(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-api"),
        metrics.WithServiceVersion("v1.0.0"),
    )
    if err != nil {
        log.Fatalf("Failed to create recorder: %v", err)
    }
    
    // Start metrics server
    if err := recorder.Start(ctx); err != nil {
        log.Fatalf("Failed to start metrics: %v", err)
    }
    
    // Your application code here...
}

Using MustNew

For applications that should fail fast on configuration errors:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)
// Panics if configuration is invalid

Lifecycle Management

Proper lifecycle management ensures metrics are properly initialized and flushed on shutdown.

Start and Shutdown

func main() {
    // Create lifecycle context
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    recorder := metrics.MustNew(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-api"),
    )
    
    // Start with lifecycle context
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    
    // Ensure graceful shutdown
    defer func() {
        shutdownCtx, shutdownCancel := context.WithTimeout(
            context.Background(),
            5*time.Second,
        )
        defer shutdownCancel()
        
        if err := recorder.Shutdown(shutdownCtx); err != nil {
            log.Printf("Metrics shutdown error: %v", err)
        }
    }()
    
    // Your application code...
}

Why Start() is Important

Different providers require Start() for different reasons:

  • OTLP: Requires lifecycle context for network connections and graceful shutdown
  • Prometheus: Starts the HTTP metrics server
  • Stdout: Works without Start(), but calling it is harmless

Best Practice: Always call Start(ctx) with a lifecycle context, regardless of provider.

Force Flush

For push-based providers (OTLP, stdout), you can force immediate export of pending metrics:

// Before critical operation or deployment
if err := recorder.ForceFlush(ctx); err != nil {
    log.Printf("Failed to flush metrics: %v", err)
}

This is useful for:

  • Ensuring metrics are exported before deployment
  • Checkpointing during long-running operations
  • Guaranteeing metrics visibility before shutdown

Note: For Prometheus (pull-based), this is typically a no-op as metrics are collected on-demand.

Standalone Usage

Use the recorder directly without HTTP middleware:

package main

import (
    "context"
    "log"
    "os/signal"
    
    "rivaas.dev/metrics"
    "go.opentelemetry.io/otel/attribute"
)

func main() {
    // Create context for application lifecycle
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create metrics recorder
    recorder := metrics.MustNew(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-service"),
    )
    
    // Start metrics server
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    
    defer recorder.Shutdown(context.Background())

    // Record custom metrics with error handling
    if err := recorder.RecordHistogram(ctx, "processing_duration", 1.5,
        attribute.String("operation", "create_user"),
    ); err != nil {
        log.Printf("metrics error: %v", err)
    }
    
    // Or fire-and-forget (ignore errors)
    _ = recorder.IncrementCounter(ctx, "requests_total",
        attribute.String("status", "success"),
    )
    
    _ = recorder.SetGauge(ctx, "active_connections", 42)
}

HTTP Integration

Integrate metrics with your HTTP server using middleware:

package main

import (
    "context"
    "log"
    "net/http"
    "os/signal"
    "time"
    
    "rivaas.dev/metrics"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create metrics recorder
    recorder, err := metrics.New(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-api"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    
    defer func() {
        shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer shutdownCancel()
        recorder.Shutdown(shutdownCtx)
    }()

    // Create HTTP handlers
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"message": "Hello"}`))
    })
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    // Wrap with metrics middleware
    handler := metrics.Middleware(recorder,
        metrics.WithExcludePaths("/health", "/metrics"),
    )(mux)

    // Start HTTP server
    server := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }
    
    go func() {
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    
    // Wait for interrupt
    <-ctx.Done()
    
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer shutdownCancel()
    server.Shutdown(shutdownCtx)
}

Built-in Metrics

When using the HTTP middleware, the following metrics are automatically collected:

MetricTypeDescription
http_request_duration_secondsHistogramRequest duration distribution
http_requests_totalCounterTotal request count by status, method, path
http_requests_activeGaugeCurrent active requests
http_request_size_bytesHistogramRequest body size distribution
http_response_size_bytesHistogramResponse body size distribution
http_errors_totalCounterHTTP errors by status code

Viewing Metrics

With Prometheus provider, metrics are available at the configured endpoint:

curl http://localhost:9090/metrics

Example output:

# HELP target_info Target metadata
# TYPE target_info gauge
target_info{service_name="my-api",service_version="v1.0.0"} 1

# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",http_route="/",http_status_code="200"} 42

# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.005"} 10
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.01"} 25
...

The target_info metric contains your service metadata. Individual metrics include request-specific labels like method, http_route, and http_status_code.

Error Handling

The metrics package provides two patterns for error handling:

Check Errors

For critical metrics where errors matter:

if err := recorder.IncrementCounter(ctx, "critical_operations",
    attribute.String("type", "payment"),
); err != nil {
    log.Printf("Failed to record metric: %v", err)
    // Handle error appropriately
}

Fire-and-Forget

For best-effort metrics where errors can be ignored:

// Ignore errors - metrics are best-effort
_ = recorder.IncrementCounter(ctx, "page_views")
_ = recorder.RecordHistogram(ctx, "query_duration", duration)

Best Practice: Use fire-and-forget for most metrics to avoid impacting application performance.

Thread Safety

All Recorder methods are thread-safe and can be called concurrently:

// Safe to call from multiple goroutines
go func() {
    _ = recorder.IncrementCounter(ctx, "worker_1")
}()

go func() {
    _ = recorder.IncrementCounter(ctx, "worker_2")
}()

Context Usage

All metrics methods accept a context for cancellation and tracing:

// Use request context for tracing
func handleRequest(w http.ResponseWriter, r *http.Request) {
    // Metrics will inherit trace context from request
    _ = recorder.IncrementCounter(r.Context(), "requests_processed")
}

// Use timeout context
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = recorder.RecordHistogram(ctx, "operation_duration", 1.5)

Common Patterns

Service Initialization

type Service struct {
    recorder *metrics.Recorder
}

func NewService() (*Service, error) {
    recorder, err := metrics.New(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-service"),
    )
    if err != nil {
        return nil, err
    }
    
    return &Service{recorder: recorder}, nil
}

func (s *Service) Start(ctx context.Context) error {
    return s.recorder.Start(ctx)
}

func (s *Service) Shutdown(ctx context.Context) error {
    return s.recorder.Shutdown(ctx)
}

Dependency Injection

type Handler struct {
    recorder *metrics.Recorder
}

func NewHandler(recorder *metrics.Recorder) *Handler {
    return &Handler{recorder: recorder}
}

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    _ = h.recorder.IncrementCounter(r.Context(), "handler_calls")
    // Handle request...
}

Next Steps

8.3 - Metrics Providers

Understand Prometheus, OTLP, and stdout metrics exporters

The metrics package supports three provider types for exporting metrics. Each provider has different characteristics and use cases.

Provider Overview

ProviderUse CaseNetworkPush/Pull
PrometheusProduction monitoringHTTP serverPull
OTLPOpenTelemetry collectorsHTTP clientPush
StdoutDevelopment/debuggingConsole outputPush

Important: Only one provider can be used per Recorder instance. Using multiple provider options will result in a validation error.

Basic Configuration

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-service"),
)
recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
    metrics.WithServiceVersion("v1.0.0"),
)
recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("my-service"),
)

Prometheus Provider

Initialization Behavior

The Prometheus provider:

  1. Initializes immediately in New()
  2. Starts the HTTP server when Start(ctx) is called
  3. Metrics are available immediately after Start() returns
recorder, err := metrics.New(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)
if err != nil {
    log.Fatal(err)
}

// HTTP server starts here
if err := recorder.Start(ctx); err != nil {
    log.Fatal(err)
}

// Metrics endpoint is now available at http://localhost:9090/metrics

Port Configuration

By default, if the requested port is unavailable, the server automatically finds the next available port (up to 100 ports searched).

Strict Port Mode

For production, use WithStrictPort() to ensure the exact port is used:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithStrictPort(),  // Fail if port 9090 is unavailable
    metrics.WithServiceName("my-service"),
)

Production Best Practice: Always use WithStrictPort() to avoid port conflicts.

Finding the Actual Port

If not using strict mode, check which port was actually used:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-service"),
)

if err := recorder.Start(ctx); err != nil {
    log.Fatal(err)
}

// Get the actual address (returns port like ":9090")
address := recorder.ServerAddress()
log.Printf("Metrics available at: http://localhost%s/metrics", address)

Manual Server Management

Disable automatic server startup and serve metrics on your own HTTP server:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServerDisabled(),
    metrics.WithServiceName("my-service"),
)

// Get the metrics handler
handler, err := recorder.Handler()
if err != nil {
    log.Fatalf("Failed to get metrics handler: %v", err)
}

// Serve on your own server
mux := http.NewServeMux()
mux.Handle("/metrics", handler)
mux.HandleFunc("/health", healthHandler)

http.ListenAndServe(":8080", mux)

Use Case: Serve metrics on the same port as your application server.

Viewing Metrics

Access metrics via HTTP:

curl http://localhost:9090/metrics

Example output:

# HELP target_info Target metadata
# TYPE target_info gauge
target_info{service_name="my-service",service_version="v1.0.0"} 1

# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",http_route="/api/users",http_status_code="200"} 1543

# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",http_route="/api/users",le="0.005"} 245
http_request_duration_seconds_bucket{method="GET",http_route="/api/users",le="0.01"} 892
http_request_duration_seconds_sum{method="GET",http_route="/api/users"} 15.432
http_request_duration_seconds_count{method="GET",http_route="/api/users"} 1543

Prometheus Scrape Configuration

Configure Prometheus to scrape your service:

# prometheus.yml
scrape_configs:
  - job_name: 'my-service'
    static_configs:
      - targets: ['localhost:9090']
    scrape_interval: 15s
    scrape_timeout: 10s
    metrics_path: /metrics

OTLP Provider

The OTLP (OpenTelemetry Protocol) provider pushes metrics to an OpenTelemetry collector.

Basic Configuration

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
    metrics.WithServiceVersion("v1.0.0"),
)

Parameter:

  • Endpoint: OTLP collector HTTP endpoint (e.g., http://localhost:4318)

Initialization Behavior

The OTLP provider:

  1. Defers initialization until Start(ctx) is called
  2. Uses the lifecycle context for network connections
  3. Enables graceful shutdown of connections

Critical: You must call Start(ctx) before recording metrics, or metrics will be silently dropped.

recorder, err := metrics.New(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
)
if err != nil {
    log.Fatal(err)
}

// OTLP connection established here
if err := recorder.Start(ctx); err != nil {
    log.Fatal(err)
}

// Metrics are now exported to collector
_ = recorder.IncrementCounter(ctx, "requests_total")

Why Deferred Initialization?

OTLP initialization is deferred to:

  • Use the application lifecycle context for network connections
  • Enable proper graceful shutdown
  • Avoid establishing connections during configuration

Export Interval

OTLP exports metrics periodically (default: 30 seconds):

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithExportInterval(10 * time.Second),  // Export every 10s
    metrics.WithServiceName("my-service"),
)

Force Flush

Force immediate export before the next interval:

// Ensure all metrics are sent immediately
if err := recorder.ForceFlush(ctx); err != nil {
    log.Printf("Failed to flush metrics: %v", err)
}

Use cases:

  • Before deployment or shutdown
  • Checkpointing during long operations
  • Guaranteeing metric visibility

OpenTelemetry Collector Setup

Example collector configuration:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  prometheus:
    endpoint: "0.0.0.0:8889"
  logging:
    loglevel: debug

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [prometheus, logging]

Run the collector:

otel-collector --config=otel-collector-config.yaml

Stdout Provider

The stdout provider prints metrics to the console. Ideal for development and debugging.

Basic Configuration

recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("my-service"),
)

Initialization Behavior

The stdout provider:

  1. Initializes immediately in New()
  2. Works without calling Start() (but calling it is harmless)
  3. Prints metrics to stdout periodically
recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("my-service"),
)

// Optional: Start() does nothing for stdout but doesn't hurt
recorder.Start(context.Background())

// Metrics are printed to stdout
_ = recorder.IncrementCounter(ctx, "requests_total")

Export Interval

Configure how often metrics are printed (default: 30 seconds):

recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithExportInterval(5 * time.Second),  // Print every 5s
    metrics.WithServiceName("my-service"),
)

Example Output

{
  "Resource": {
    "service.name": "my-service",
    "service.version": "v1.0.0"
  },
  "ScopeMetrics": [
    {
      "Scope": {
        "Name": "rivaas.dev/metrics"
      },
      "Metrics": [
        {
          "Name": "http_requests_total",
          "Data": {
            "DataPoints": [
              {
                "Attributes": {
                  "method": "GET",
                  "path": "/api/users",
                  "status": "200"
                },
                "Value": 42
              }
            ]
          }
        }
      ]
    }
  ]
}

Use Cases

  • Local development
  • Debugging metric collection
  • CI/CD pipeline validation
  • Unit tests (with TestingRecorder)

Provider Comparison

Prometheus

Pros:

  • Industry standard for metrics
  • Rich ecosystem (dashboards, alerting)
  • Simple pull-based model
  • No external dependencies

Cons:

  • Requires network port
  • Pull-based (can’t push on-demand)
  • Requires Prometheus server setup

Best For: Production services, microservices, containerized applications

OTLP

Pros:

  • Vendor-neutral standard
  • Flexible routing via collector
  • Push-based (immediate export)
  • Integrates with OpenTelemetry tracing

Cons:

  • Requires collector setup
  • More complex infrastructure
  • Network dependency

Best For: OpenTelemetry-native applications, multi-vendor observability, cloud environments

Stdout

Pros:

  • No external dependencies
  • Immediate visibility
  • Simple setup
  • Works everywhere

Cons:

  • Not for production
  • No aggregation or visualization
  • High output volume
  • No persistence

Best For: Development, debugging, testing, CI/CD pipelines

Choosing a Provider

Development

Use stdout for quick feedback:

recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("dev-service"),
)

Production (Simple)

Use Prometheus for straightforward monitoring:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithStrictPort(),
    metrics.WithServiceName("my-service"),
    metrics.WithServiceVersion("v1.2.3"),
)

Production (OpenTelemetry)

Use OTLP for OpenTelemetry-native environments:

recorder := metrics.MustNew(
    metrics.WithOTLP(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")),
    metrics.WithServiceName("my-service"),
    metrics.WithServiceVersion(version),
)

Testing

Use testing utilities (based on stdout):

func TestHandler(t *testing.T) {
    recorder := metrics.TestingRecorder(t, "test-service")
    // Test code...
}

Multiple Recorder Instances

You can create multiple recorder instances with different providers:

// Development recorder (stdout)
devRecorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("dev-metrics"),
)

// Production recorder (Prometheus)
prodRecorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("prod-metrics"),
)

// Both work independently without conflicts

Note: By default, recorders do NOT set the global OpenTelemetry meter provider. See Configuration for details.

Next Steps

8.4 - Configuration

Configure service metadata, histograms, and advanced options

This guide covers all configuration options for the metrics package beyond basic provider setup.

Service Configuration

Service metadata helps identify your application in metrics dashboards and monitoring systems.

Service Name

Required metadata that identifies your service:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)

The service name appears in the target_info metric, which holds resource-level information about your service:

target_info{service_name="my-api",service_version="v1.2.3"} 1

Individual metrics like http_requests_total do not include service_name as a label. This keeps label cardinality low, which follows Prometheus best practices. The target_info metric is used for service discovery and correlating metrics across your infrastructure.

Best Practices:

  • Use lowercase with hyphens: user-service, payment-api.
  • Be consistent across services.
  • Avoid changing names in production.

Service Version

Optional version metadata for tracking deployments:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
    metrics.WithServiceVersion("v1.2.3"),
)

Use cases:

  • Track metrics across deployments.
  • Compare performance between versions.
  • Debug version-specific issues.

Best Practices:

  • Use semantic versioning: v1.2.3.
  • Include in all production deployments.
  • Automate from CI/CD pipelines:
var Version = "dev" // Set by build flags

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
    metrics.WithServiceVersion(Version),
)

Prometheus-Specific Options

Strict Port Mode

Fail immediately if the configured port is unavailable:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithStrictPort(),  // Production recommendation
    metrics.WithServiceName("my-api"),
)

Default Behavior: If port is unavailable, automatically searches up to 100 ports.

With Strict Mode: Fails with error if exact port is unavailable.

Production Best Practice: Always use WithStrictPort() to ensure predictable port allocation.

Without Scope Info

Remove OpenTelemetry instrumentation scope labels from metrics:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithoutScopeInfo(),  // Remove otel_scope_* labels
    metrics.WithServiceName("my-api"),
)

What It Does: By default, OpenTelemetry adds labels like otel_scope_name, otel_scope_version, and otel_scope_schema_url to every metric. These labels identify which instrumentation library produced each metric.

When to Use: If you only have one instrumentation scope (which is common), you can remove these labels to keep your metrics clean and reduce label cardinality.

Only Affects: Prometheus provider (OTLP and stdout ignore this option).

Without Target Info

Disable the target_info metric:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithoutTargetInfo(),  // Remove target_info metric
    metrics.WithServiceName("my-api"),
)

What It Does: By default, OpenTelemetry creates a target_info metric containing resource attributes like service_name and service_version.

When to Use: If you already identify services through Prometheus external labels or other means, you can disable this metric.

Only Affects: Prometheus provider (OTLP and stdout ignore this option).

Server Disabled

Disable automatic metrics server and manage it yourself:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServerDisabled(),
    metrics.WithServiceName("my-api"),
)

// Get the metrics handler
handler, err := recorder.Handler()
if err != nil {
    log.Fatalf("Failed to get handler: %v", err)
}

// Serve on your own HTTP server
http.Handle("/metrics", handler)
http.ListenAndServe(":8080", nil)

Use Cases:

  • Serve metrics on same port as application
  • Custom server configuration
  • Integration with existing HTTP servers

Note: Handler() only works with Prometheus provider.

Histogram Bucket Configuration

Customize histogram bucket boundaries for better resolution in specific ranges.

Duration Buckets

Configure buckets for duration metrics (in seconds):

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithDurationBuckets(0.001, 0.01, 0.1, 0.5, 1, 5, 10),
    metrics.WithServiceName("my-api"),
)

Default Buckets: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 seconds

When to Customize:

  • Most requests < 100ms: Use finer buckets at low end
  • Slow operations (seconds): Use coarser buckets
  • Specific SLA requirements

Examples:

// Fast API (most requests < 100ms)
metrics.WithDurationBuckets(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.5, 1)

// Slow batch operations (seconds to minutes)
metrics.WithDurationBuckets(1, 5, 10, 30, 60, 120, 300, 600)

// Mixed workload
metrics.WithDurationBuckets(0.01, 0.1, 0.5, 1, 5, 10, 30, 60)

Size Buckets

Configure buckets for size metrics (in bytes):

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithSizeBuckets(100, 1000, 10000, 100000, 1000000),
    metrics.WithServiceName("my-api"),
)

Default Buckets: 1024, 2048, 4096, 8192, 16384, 32768, 65536, 131072, 262144, 524288, 1048576 bytes

When to Customize:

  • Small payloads (< 10KB): Use finer buckets
  • Large payloads (MB+): Use coarser buckets
  • Specific size requirements

Examples:

// Small JSON API (< 10KB)
metrics.WithSizeBuckets(100, 500, 1000, 5000, 10000, 50000)

// File uploads (KB to MB)
metrics.WithSizeBuckets(1024, 10240, 102400, 1048576, 10485760, 104857600)

// Mixed sizes
metrics.WithSizeBuckets(100, 1000, 10000, 100000, 1000000, 10000000)

Impact on Cardinality

Important: More buckets = higher metric cardinality = more storage.

// 7 buckets (lower cardinality)
metrics.WithDurationBuckets(0.01, 0.1, 0.5, 1, 5, 10)

// 15 buckets (higher cardinality, better resolution)
metrics.WithDurationBuckets(
    0.001, 0.005, 0.01, 0.025, 0.05,
    0.1, 0.25, 0.5, 1, 2.5,
    5, 10, 30, 60, 120,
)

Best Practice: Use the minimum number of buckets that provide sufficient resolution for your use case.

Advanced Options

Logging

Configure how internal events are logged:

import "log/slog"

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithLogger(slog.Default()),
    metrics.WithServiceName("my-api"),
)

The logger receives:

  • Initialization events
  • Error messages (metric creation failures, etc.)
  • Warning messages (port conflicts, etc.)

Example Output:

INFO metrics server started on :9090
WARN custom metric limit reached (1000/1000)
ERROR failed to create metric: invalid name "__reserved"

Event Handler

For advanced use cases, handle events programmatically:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithEventHandler(func(e metrics.Event) {
        switch e.Type {
        case metrics.EventError:
            // Send to error tracking
            sentry.CaptureMessage(e.Message)
        case metrics.EventWarning:
            // Log warnings
            log.Printf("WARN: %s", e.Message)
        case metrics.EventInfo:
            // Log info
            log.Printf("INFO: %s", e.Message)
        }
    }),
    metrics.WithServiceName("my-api"),
)

Event Types:

  • EventInfo - Informational messages
  • EventWarning - Non-critical warnings
  • EventError - Error conditions

Use Cases:

  • Send errors to external monitoring
  • Custom logging formats
  • Metric collection about metric collection

Custom Metrics Limit

Set maximum number of custom metrics that can be created:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithMaxCustomMetrics(5000),  // Default: 1000
    metrics.WithServiceName("my-api"),
)

Why Limit Metrics?

  • Prevent unbounded cardinality
  • Protect against memory exhaustion
  • Enforce metric discipline

Built-in Metrics Don’t Count: HTTP metrics are always available.

Monitor Usage:

count := recorder.CustomMetricCount()
log.Printf("Custom metrics: %d/%d", count, maxLimit)

What Happens at Limit?

  • New metric creation returns an error
  • Existing metrics continue to work
  • Error is logged via logger/event handler

Export Interval

Configure how often metrics are exported (OTLP and stdout only):

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithExportInterval(10 * time.Second),  // Default: 30s
    metrics.WithServiceName("my-api"),
)

Applies To: OTLP (push), Stdout (push)

Does NOT Apply To: Prometheus (pull-based, scraped on-demand)

Trade-offs:

  • Shorter interval: More timely data, higher overhead
  • Longer interval: Lower overhead, delayed visibility

Best Practices:

  • Development: 5-10 seconds
  • Production: 15-30 seconds
  • High-volume: 30-60 seconds

Global Meter Provider

By default, the metrics package does NOT set the global OpenTelemetry meter provider.

Multiple independent recorder instances work without conflicts:

// Create independent recorders (no global state!)
recorder1 := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("service-1"),
)

recorder2 := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("service-2"),
)

// Both work independently without conflicts

Opt-in to Global Registration

Explicitly set the global meter provider:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-service"),
    metrics.WithGlobalMeterProvider(),  // Explicit opt-in
)

When to Use:

  • OpenTelemetry instrumentation libraries need global provider
  • Third-party libraries expect otel.GetMeterProvider()
  • Centralized metrics collection across libraries

When NOT to Use:

  • Multiple services in same process (e.g., tests)
  • Avoid global state
  • Custom meter provider management

Configuration Examples

Production API

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithStrictPort(),
    metrics.WithServiceName("payment-api"),
    metrics.WithServiceVersion(version),
    metrics.WithLogger(slog.Default()),
    metrics.WithDurationBuckets(0.01, 0.1, 0.5, 1, 5, 10),
    metrics.WithMaxCustomMetrics(2000),
)

Development

recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("dev-api"),
    metrics.WithExportInterval(5 * time.Second),
)

OpenTelemetry Native

recorder := metrics.MustNew(
    metrics.WithOTLP(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")),
    metrics.WithServiceName(os.Getenv("SERVICE_NAME")),
    metrics.WithServiceVersion(os.Getenv("SERVICE_VERSION")),
    metrics.WithExportInterval(15 * time.Second),
    metrics.WithLogger(slog.Default()),
)

Embedded Metrics Server

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServerDisabled(),
    metrics.WithServiceName("api"),
)

handler, _ := recorder.Handler()

// Serve on application port
mux := http.NewServeMux()
mux.Handle("/metrics", handler)
mux.HandleFunc("/", appHandler)
http.ListenAndServe(":8080", mux)

Configuration from Environment

Load configuration from environment variables:

func configFromEnv() []metrics.Option {
    opts := []metrics.Option{
        metrics.WithServiceName(os.Getenv("SERVICE_NAME")),
    }
    
    if version := os.Getenv("SERVICE_VERSION"); version != "" {
        opts = append(opts, metrics.WithServiceVersion(version))
    }
    
    switch os.Getenv("METRICS_PROVIDER") {
    case "prometheus":
        addr := os.Getenv("METRICS_ADDR")
        if addr == "" {
            addr = ":9090"
        }
        opts = append(opts, 
            metrics.WithPrometheus(addr, "/metrics"),
            metrics.WithStrictPort(),
        )
    case "otlp":
        endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
        opts = append(opts, metrics.WithOTLP(endpoint))
    default:
        opts = append(opts, metrics.WithStdout())
    }
    
    return opts
}

recorder := metrics.MustNew(configFromEnv()...)

Next Steps

8.5 - Custom Metrics

Create counters, histograms, and gauges with proper naming conventions

This guide covers recording custom metrics beyond the built-in HTTP metrics.

Metric Types

The metrics package supports three metric types from OpenTelemetry:

TypeDescriptionUse CaseExample
CounterMonotonically increasing valueCounts of eventsRequests processed, errors occurred
HistogramDistribution of valuesDurations, sizesQuery time, response size
GaugePoint-in-time valueCurrent stateActive connections, queue depth

Counters

Counters track cumulative totals that only increase.

Increment Counter

Add 1 to a counter:

// With error handling
if err := recorder.IncrementCounter(ctx, "orders_processed_total",
    attribute.String("status", "success"),
    attribute.String("payment_method", "card"),
); err != nil {
    log.Printf("Failed to record metric: %v", err)
}

// Fire-and-forget (ignore errors)
_ = recorder.IncrementCounter(ctx, "page_views_total")

Add to Counter

Add a specific value to a counter:

// Add multiple items (value is int64)
_ = recorder.AddCounter(ctx, "bytes_processed_total", 1024,
    attribute.String("direction", "inbound"),
)

// Batch processing
itemsProcessed := int64(50)
_ = recorder.AddCounter(ctx, "items_processed_total", itemsProcessed,
    attribute.String("batch_id", batchID),
)

Important: Counter values must be non-negative integers (int64).

Counter Examples

// Simple event counting
_ = recorder.IncrementCounter(ctx, "user_registrations_total")

// With attributes
_ = recorder.IncrementCounter(ctx, "api_calls_total",
    attribute.String("endpoint", "/api/users"),
    attribute.String("method", "POST"),
    attribute.Int("status_code", 201),
)

// Tracking errors
_ = recorder.IncrementCounter(ctx, "errors_total",
    attribute.String("type", "validation"),
    attribute.String("field", "email"),
)

// Data volume
_ = recorder.AddCounter(ctx, "data_transferred_bytes", float64(len(data)),
    attribute.String("protocol", "https"),
    attribute.String("direction", "upload"),
)

Histograms

Histograms record distributions of values, useful for durations and sizes.

Record Histogram

startTime := time.Now()
// ... perform operation ...
duration := time.Since(startTime).Seconds()

_ = recorder.RecordHistogram(ctx, "operation_duration_seconds", duration,
    attribute.String("operation", "create_user"),
    attribute.String("status", "success"),
)

Histogram Examples

// Request duration
start := time.Now()
result, err := processRequest(ctx, req)
duration := time.Since(start).Seconds()

_ = recorder.RecordHistogram(ctx, "request_processing_duration_seconds", duration,
    attribute.String("operation", "process_request"),
    attribute.Bool("cache_hit", result.FromCache),
)

// Database query time
start = time.Now()
rows, err := db.QueryContext(ctx, query)
duration = time.Since(start).Seconds()

_ = recorder.RecordHistogram(ctx, "db_query_duration_seconds", duration,
    attribute.String("query_type", "select"),
    attribute.String("table", "users"),
)

// Response size
responseSize := len(responseData)
_ = recorder.RecordHistogram(ctx, "response_size_bytes", float64(responseSize),
    attribute.String("endpoint", "/api/users"),
    attribute.String("format", "json"),
)

// Payment amount
_ = recorder.RecordHistogram(ctx, "payment_amount_usd", amount,
    attribute.String("currency", "USD"),
    attribute.String("payment_method", "credit_card"),
)

Histogram Bucket Configuration

Customize bucket boundaries for better resolution (see Configuration):

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    // Fine-grained buckets for fast operations
    metrics.WithDurationBuckets(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.5),
    metrics.WithServiceName("my-api"),
)

Gauges

Gauges represent point-in-time values that can increase or decrease.

Set Gauge

// Current connections
activeConnections := connectionPool.Active()
_ = recorder.SetGauge(ctx, "active_connections", float64(activeConnections),
    attribute.String("pool", "database"),
)

// Queue depth
queueSize := queue.Len()
_ = recorder.SetGauge(ctx, "queue_depth", float64(queueSize),
    attribute.String("queue", "tasks"),
)

Gauge Examples

// Memory usage
var m runtime.MemStats
runtime.ReadMemStats(&m)
_ = recorder.SetGauge(ctx, "memory_allocated_bytes", float64(m.Alloc))

// Goroutine count
_ = recorder.SetGauge(ctx, "goroutines_active", float64(runtime.NumGoroutine()))

// Cache size
cacheSize := cache.Len()
_ = recorder.SetGauge(ctx, "cache_entries", float64(cacheSize),
    attribute.String("cache", "users"),
)

// Connection pool
_ = recorder.SetGauge(ctx, "db_connections_active", float64(pool.Stats().InUse),
    attribute.String("database", "postgres"),
)

// Worker pool
_ = recorder.SetGauge(ctx, "worker_pool_idle", float64(workerPool.IdleCount()),
    attribute.String("pool", "background_jobs"),
)

// Temperature (example from IoT)
_ = recorder.SetGauge(ctx, "sensor_temperature_celsius", temperature,
    attribute.String("sensor_id", sensorID),
    attribute.String("location", "datacenter-1"),
)

Gauge Best Practices

DO:

  • Record current state: active connections, queue depth
  • Update regularly with latest values
  • Use for resource utilization metrics

DON’T:

  • Use for cumulative counts (use Counter instead)
  • Forget to update when value changes
  • Use for values that only increase (use Counter)

Metric Naming Conventions

Follow OpenTelemetry and Prometheus naming conventions for consistent metrics.

Valid Metric Names

Metric names must:

  • Start with a letter (a-z, A-Z)
  • Contain only alphanumeric, underscores, dots, hyphens
  • Maximum 255 characters
  • Not use reserved prefixes

Valid Examples:

_ = recorder.IncrementCounter(ctx, "orders_total")
_ = recorder.RecordHistogram(ctx, "processing_duration_seconds", 1.5)
_ = recorder.SetGauge(ctx, "active_users", 42)
_ = recorder.IncrementCounter(ctx, "api.v1.requests_total")
_ = recorder.RecordHistogram(ctx, "payment-processing-time", 2.0)

Invalid Metric Names

These will return an error:

// Reserved prefix: __
recorder.IncrementCounter(ctx, "__internal_metric")

// Reserved prefix: http_
recorder.RecordHistogram(ctx, "http_custom_duration", 1.0)

// Reserved prefix: router_
recorder.SetGauge(ctx, "router_custom_gauge", 10)

// Starts with number
recorder.IncrementCounter(ctx, "1st_metric")

// Invalid characters
recorder.IncrementCounter(ctx, "my metric!")  // Space and !
recorder.IncrementCounter(ctx, "metric@count")  // @ symbol

Reserved Prefixes

These prefixes are reserved for built-in metrics:

  • __ - Prometheus internal metrics
  • http_ - Built-in HTTP metrics
  • router_ - Built-in router metrics

Naming Best Practices

Units in Name:

// Good - includes unit
_ = recorder.RecordHistogram(ctx, "processing_duration_seconds", 1.5)
_ = recorder.RecordHistogram(ctx, "response_size_bytes", 1024)
_ = recorder.SetGauge(ctx, "temperature_celsius", 25.5)

// Bad - no unit
_ = recorder.RecordHistogram(ctx, "processing_duration", 1.5)
_ = recorder.RecordHistogram(ctx, "response_size", 1024)

Counter Suffix:

// Good - ends with _total
_ = recorder.IncrementCounter(ctx, "requests_total")
_ = recorder.IncrementCounter(ctx, "errors_total")
_ = recorder.AddCounter(ctx, "bytes_processed_total", 1024)

// Acceptable - clear it's a count
_ = recorder.IncrementCounter(ctx, "request_count")

// Bad - unclear
_ = recorder.IncrementCounter(ctx, "requests")

Descriptive Names:

// Good - clear and specific
_ = recorder.RecordHistogram(ctx, "db_query_duration_seconds", 0.15)
_ = recorder.IncrementCounter(ctx, "payment_failures_total")
_ = recorder.SetGauge(ctx, "redis_connections_active", 10)

// Bad - too generic
_ = recorder.RecordHistogram(ctx, "duration", 0.15)
_ = recorder.IncrementCounter(ctx, "failures")
_ = recorder.SetGauge(ctx, "connections", 10)

Consistent Style:

// Good - consistent snake_case
_ = recorder.IncrementCounter(ctx, "user_registrations_total")
_ = recorder.IncrementCounter(ctx, "order_completions_total")

// Avoid mixing styles
_ = recorder.IncrementCounter(ctx, "userRegistrations")  // camelCase
_ = recorder.IncrementCounter(ctx, "order-completions")  // kebab-case

Attributes (Labels)

Attributes add dimensions to metrics for filtering and grouping.

Using Attributes

import "go.opentelemetry.io/otel/attribute"

_ = recorder.IncrementCounter(ctx, "requests_total",
    attribute.String("method", "GET"),
    attribute.String("path", "/api/users"),
    attribute.Int("status_code", 200),
)

Attribute Types

// String
attribute.String("status", "success")
attribute.String("region", "us-east-1")

// Integer
attribute.Int("status_code", 200)
attribute.Int("retry_count", 3)

// Boolean
attribute.Bool("cache_hit", true)
attribute.Bool("authenticated", false)

// Float
attribute.Float64("error_rate", 0.05)

Attribute Best Practices

Keep Cardinality Low:

// Good - low cardinality
attribute.String("status", "success")  // success, error, timeout
attribute.String("method", "GET")      // GET, POST, PUT, DELETE

// Bad - high cardinality (unbounded)
attribute.String("user_id", userID)         // Millions of unique values
attribute.String("request_id", requestID)   // Unique per request
attribute.String("timestamp", time.Now().String())  // Always unique

Use Consistent Names:

// Good - consistent across metrics
attribute.String("status", "success")
attribute.String("method", "GET")
attribute.String("region", "us-east-1")

// Bad - inconsistent
attribute.String("status", "success")
attribute.String("http_method", "GET")  // Should be "method"
attribute.String("aws_region", "us-east-1")  // Should be "region"

Limit Attribute Count:

// Good - focused attributes
_ = recorder.IncrementCounter(ctx, "requests_total",
    attribute.String("method", "GET"),
    attribute.String("status", "success"),
)

// Bad - too many attributes
_ = recorder.IncrementCounter(ctx, "requests_total",
    attribute.String("method", "GET"),
    attribute.String("status", "success"),
    attribute.String("user_agent", ua),
    attribute.String("ip_address", ip),
    attribute.String("country", country),
    attribute.String("device", device),
    // ... creates explosion of metric combinations
)

Monitoring Custom Metrics

Track how many custom metrics have been created:

count := recorder.CustomMetricCount()
log.Printf("Custom metrics created: %d/%d", count, maxLimit)

// Expose as a metric
_ = recorder.SetGauge(ctx, "custom_metrics_count", float64(count))

Custom Metric Limit

Default limit: 1000 custom metrics

Increase the limit:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithMaxCustomMetrics(5000),
    metrics.WithServiceName("my-api"),
)

What Counts as Custom Metric?

Counts toward limit:

  • Each unique metric name created with IncrementCounter, AddCounter, RecordHistogram, or SetGauge

Does NOT count:

  • Built-in HTTP metrics (http_requests_total, etc.)
  • Different attribute combinations of same metric name
  • Re-recording same metric name

Example:

// Creates 1 custom metric
_ = recorder.IncrementCounter(ctx, "orders_total")
_ = recorder.IncrementCounter(ctx, "orders_total", attribute.String("status", "success"))
_ = recorder.IncrementCounter(ctx, "orders_total", attribute.String("status", "failed"))

// Creates 2 more custom metrics (total: 3)
_ = recorder.IncrementCounter(ctx, "payments_total")
_ = recorder.RecordHistogram(ctx, "order_duration_seconds", 1.5)

Error Handling

All metric methods return an error. Choose your handling strategy:

Check Errors (Critical Metrics)

if err := recorder.IncrementCounter(ctx, "payment_processed_total",
    attribute.String("method", "credit_card"),
); err != nil {
    log.Printf("Failed to record payment metric: %v", err)
    // Alert or handle appropriately
}

Fire-and-Forget (Best Effort)

// Most metrics - don't impact application performance
_ = recorder.IncrementCounter(ctx, "page_views_total")
_ = recorder.RecordHistogram(ctx, "render_time_seconds", duration)

Common Errors

  • Invalid name: Violates naming rules
  • Reserved prefix: Uses __, http_, or router_
  • Limit reached: Custom metric limit exceeded
  • Provider not started: OTLP provider not initialized

Built-in Metrics

The package automatically collects these HTTP metrics (when using middleware):

MetricTypeDescription
http_request_duration_secondsHistogramRequest duration distribution
http_requests_totalCounterTotal requests by method, path, status
http_requests_activeGaugeCurrently active requests
http_request_size_bytesHistogramRequest body size distribution
http_response_size_bytesHistogramResponse body size distribution
http_errors_totalCounterHTTP errors by status code
custom_metric_failures_totalCounterFailed custom metric creations

Note: Built-in metrics don’t count toward the custom metrics limit.

Next Steps

8.6 - HTTP Middleware

Integrate automatic HTTP metrics collection with middleware

This guide covers using the metrics middleware to automatically collect HTTP metrics.

Overview

The metrics middleware automatically records metrics for HTTP requests:

  • Request duration as histogram.
  • Request count as counter.
  • Active requests as gauge.
  • Request and response sizes as histograms.
  • Error counts as counter.

Basic Usage

Wrap your HTTP handler with the metrics middleware:

package main

import (
    "net/http"
    "rivaas.dev/metrics"
)

func main() {
    // Create recorder
    recorder := metrics.MustNew(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-api"),
    )
    defer recorder.Shutdown(context.Background())

    // Create your HTTP handlers
    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)
    mux.HandleFunc("/api/users", usersHandler)
    mux.HandleFunc("/health", healthHandler)

    // Wrap with metrics middleware
    handler := metrics.Middleware(recorder)(mux)

    http.ListenAndServe(":8080", handler)
}

Collected Metrics

The middleware automatically collects:

MetricTypeLabelsDescription
http_request_duration_secondsHistogrammethod, http_route, http_status_codeRequest duration distribution
http_requests_totalCountermethod, http_route, http_status_codeTotal request count
http_requests_activeGauge-Currently active requests
http_request_size_bytesHistogrammethod, http_routeRequest body size
http_response_size_bytesHistogrammethod, http_route, http_status_codeResponse body size
http_errors_totalCountermethod, http_route, http_status_codeHTTP error count

Metric Labels

Each metric includes relevant labels:

  • method: HTTP method like GET, POST, PUT, DELETE.
  • http_route: Route pattern for cardinality control (e.g., /api/users/{id}).
  • http_status_code: HTTP status code like 200, 404, 500.

Path Exclusion

Exclude specific paths from metrics collection to reduce noise and cardinality.

Exact Path Exclusion

Exclude specific paths:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health", "/metrics", "/ready"),
)(mux)

Use Case: Health checks, metrics endpoints, readiness probes

Prefix Exclusion

Exclude all paths with specific prefixes:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePrefixes("/debug/", "/internal/", "/_/"),
)(mux)

Use Case: Debug endpoints, internal APIs, administrative paths

Pattern Exclusion

Exclude paths matching regex patterns:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePatterns(
        `^/v[0-9]+/internal/.*`,  // /v1/internal/*, /v2/internal/*
        `^/api/[0-9]+$`,           // /api/123, /api/456 (avoid high cardinality)
    ),
)(mux)

Use Case: Version-specific internal paths, high-cardinality routes

Combining Exclusions

Use multiple exclusion strategies together:

handler := metrics.Middleware(recorder,
    // Exact paths
    metrics.WithExcludePaths("/health", "/metrics"),
    
    // Prefixes
    metrics.WithExcludePrefixes("/debug/", "/internal/"),
    
    // Patterns
    metrics.WithExcludePatterns(`^/admin/.*`),
)(mux)

Header Recording

Record specific HTTP headers as metric attributes.

Basic Header Recording

handler := metrics.Middleware(recorder,
    metrics.WithHeaders("X-Request-ID", "X-Correlation-ID"),
)(mux)

Headers are recorded as metric attributes:

http_requests_total{
    method="GET",
    path="/api/users",
    status="200",
    x_request_id="abc123",
    x_correlation_id="def456"
} 1

Header Name Normalization

Header names are automatically normalized:

  • Converted to lowercase
  • Hyphens replaced with underscores

Examples:

  • X-Request-IDx_request_id
  • Content-Typecontent_type
  • User-Agentuser_agent

Multiple Headers

Record multiple headers:

handler := metrics.Middleware(recorder,
    metrics.WithHeaders(
        "X-Request-ID",
        "X-Correlation-ID", 
        "X-Client-Version",
        "X-API-Key",  // This will be filtered out (sensitive)
    ),
)(mux)

Security

The middleware automatically protects sensitive headers.

Automatic Header Filtering

These headers are always filtered and never recorded as metrics, even if explicitly requested:

  • Authorization
  • Cookie
  • Set-Cookie
  • X-API-Key
  • X-Auth-Token
  • Proxy-Authorization
  • WWW-Authenticate

Example

handler := metrics.Middleware(recorder,
    // Only X-Request-ID will be recorded
    // Authorization and Cookie are automatically filtered
    metrics.WithHeaders(
        "Authorization",      // Filtered
        "X-Request-ID",       // Recorded
        "Cookie",             // Filtered
        "X-Correlation-ID",   // Recorded
    ),
)(mux)

Headers are normalized to lowercase with underscores:

http_requests_total{
    method="GET",
    http_route="/api/users",
    http_status_code="200",
    x_request_id="abc123",
    x_correlation_id="def456"
} 1

Why Filter Sensitive Headers?

Recording sensitive headers in metrics can:

  • Leak authentication credentials
  • Expose API keys in monitoring systems
  • Violate security policies
  • Create compliance issues

Best Practice: Only record non-sensitive, low-cardinality headers.

Complete Example

package main

import (
    "context"
    "log"
    "net/http"
    "os/signal"
    "time"
    
    "rivaas.dev/metrics"
)

func main() {
    // Create lifecycle context
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create metrics recorder
    recorder, err := metrics.New(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-api"),
        metrics.WithServiceVersion("v1.0.0"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    
    defer func() {
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        recorder.Shutdown(shutdownCtx)
    }()

    // Create HTTP handlers
    mux := http.NewServeMux()
    
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })
    
    mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"users": []}`))
    })
    
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    // Configure middleware with all options
    handler := metrics.Middleware(recorder,
        // Exclude health and metrics endpoints
        metrics.WithExcludePaths("/health", "/metrics"),
        
        // Exclude debug and internal paths
        metrics.WithExcludePrefixes("/debug/", "/internal/"),
        
        // Exclude admin paths
        metrics.WithExcludePatterns(`^/admin/.*`),
        
        // Record tracing headers
        metrics.WithHeaders("X-Request-ID", "X-Correlation-ID"),
    )(mux)

    // Start HTTP server
    server := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }
    
    go func() {
        log.Printf("Server listening on :8080")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    
    // Wait for interrupt
    <-ctx.Done()
    log.Println("Shutting down...")
    
    // Graceful shutdown
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    server.Shutdown(shutdownCtx)
}

Integration Patterns

Standalone HTTP Server

mux := http.NewServeMux()
mux.HandleFunc("/", handler)

wrappedHandler := metrics.Middleware(recorder)(mux)

http.ListenAndServe(":8080", wrappedHandler)

With Router Middleware Chain

// Apply metrics middleware first in chain
handler := metrics.Middleware(recorder)(
    loggingMiddleware(
        authMiddleware(mux),
    ),
)

Gorilla Mux

import "github.com/gorilla/mux"

r := mux.NewRouter()
r.HandleFunc("/", homeHandler)
r.HandleFunc("/api/users", usersHandler)

// Wrap the router
handler := metrics.Middleware(recorder)(r)

http.ListenAndServe(":8080", handler)

Chi Router

import "github.com/go-chi/chi/v5"

r := chi.NewRouter()
r.Get("/", homeHandler)
r.Get("/api/users", usersHandler)

// Chi router is already http.Handler
handler := metrics.Middleware(recorder)(r)

http.ListenAndServe(":8080", handler)

Path Cardinality

Warning: High-cardinality paths can create excessive metrics.

Problematic Paths

// DON'T: These create unique paths for each request
/api/users/12345       // User ID in path
/api/orders/abc-123    // Order ID in path
/files/document-xyz    // Document ID in path

Each unique path creates separate metric series, leading to:

  • Excessive memory usage
  • Slow query performance
  • Storage bloat

Solutions

1. Exclude High-Cardinality Paths

handler := metrics.Middleware(recorder,
    // Exclude paths with IDs
    metrics.WithExcludePatterns(
        `^/api/users/[^/]+$`,      // /api/users/{id}
        `^/api/orders/[^/]+$`,     // /api/orders/{id}
        `^/files/[^/]+$`,          // /files/{id}
    ),
)(mux)

2. Use Path Normalization

Some routers support path normalization:

// Router provides normalized path
// /api/users/123 → /api/users/{id}

Check your router documentation for normalization support.

3. Record Fewer Labels

// Instead of recording full path, use endpoint name
// This requires custom instrumentation

Performance Considerations

Middleware Overhead

The middleware adds minimal overhead:

  • ~1-2 microseconds per request
  • Safe for production use
  • Thread-safe for concurrent requests

Memory Usage

Memory usage scales with:

  • Number of unique paths
  • Number of unique label combinations
  • Histogram bucket count

Best Practice: Exclude high-cardinality paths.

CPU Impact

Histogram recording is the most CPU-intensive operation. If needed, adjust bucket count:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    // Fewer buckets = lower CPU overhead
    metrics.WithDurationBuckets(0.01, 0.1, 1, 10),
    metrics.WithServiceName("my-api"),
)

Viewing Metrics

Access metrics via the Prometheus endpoint:

curl http://localhost:9090/metrics

Example output:

# HELP target_info Target metadata
# TYPE target_info gauge
target_info{service_name="my-api",service_version="v1.0.0"} 1

# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",http_route="/",http_status_code="200"} 42
http_requests_total{method="GET",http_route="/api/users",http_status_code="200"} 128
http_requests_total{method="POST",http_route="/api/users",http_status_code="201"} 15

# HELP http_request_duration_seconds HTTP request duration
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.005"} 10
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.01"} 35
http_request_duration_seconds_bucket{method="GET",http_route="/",le="0.025"} 42
http_request_duration_seconds_sum{method="GET",http_route="/"} 0.523
http_request_duration_seconds_count{method="GET",http_route="/"} 42

# HELP http_requests_active Currently active HTTP requests
# TYPE http_requests_active gauge
http_requests_active 3

The http_requests_active gauge accurately tracks the number of requests currently being processed.

Middleware Options Reference

OptionDescription
WithExcludePaths(paths...)Exclude exact paths from metrics
WithExcludePrefixes(prefixes...)Exclude path prefixes from metrics
WithExcludePatterns(patterns...)Exclude paths matching regex patterns
WithHeaders(headers...)Record specific headers as metric attributes

See Middleware Options Reference for complete details.

Next Steps

8.7 - Testing

Test utilities for metrics collection

This guide covers testing utilities provided by the metrics package.

Testing Utilities

The metrics package provides utilities for testing without port conflicts or complex setup.

TestingRecorder

Create a test recorder with stdout provider. No network is required.

package myapp_test

import (
    "testing"
    "rivaas.dev/metrics"
)

func TestHandler(t *testing.T) {
    t.Parallel()
    
    // Create test recorder (uses stdout, avoids port conflicts)
    recorder := metrics.TestingRecorder(t, "test-service")
    
    // Use recorder in tests...
    handler := NewHandler(recorder)
    
    // Test your handler
    req := httptest.NewRequest("GET", "/", nil)
    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)
    
    // Assertions...
    // Cleanup is automatic via t.Cleanup()
}

// With additional options
func TestWithOptions(t *testing.T) {
    recorder := metrics.TestingRecorder(t, "test-service",
        metrics.WithMaxCustomMetrics(100),
    )
    // ...
}

Signature

func TestingRecorder(tb testing.TB, serviceName string, opts ...Option) *Recorder

Parameters:

  • tb testing.TB - Test or benchmark instance.
  • serviceName string - Service name for metrics.
  • opts ...Option - Optional additional configuration options.

Features

  • No port conflicts: Uses stdout provider, no network required.
  • Automatic cleanup: Registers cleanup via t.Cleanup().
  • Parallel safe: Safe to use in parallel tests.
  • Simple setup: One-line initialization.
  • Works with benchmarks: Accepts testing.TB (both *testing.T and *testing.B).

Example

func TestMetricsCollection(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorder(t, "test-service")
    
    // Record some metrics
    ctx := context.Background()
    err := recorder.IncrementCounter(ctx, "test_counter")
    if err != nil {
        t.Errorf("Failed to record counter: %v", err)
    }
    
    err = recorder.RecordHistogram(ctx, "test_duration", 1.5)
    if err != nil {
        t.Errorf("Failed to record histogram: %v", err)
    }
    
    // Test passes if no errors
}

TestingRecorderWithPrometheus

Create a test recorder with Prometheus provider (for endpoint testing):

func TestPrometheusEndpoint(t *testing.T) {
    t.Parallel()
    
    // Create test recorder with Prometheus (dynamic port)
    recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")
    
    // Wait for server to be ready
    err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
    if err != nil {
        t.Fatal(err)
    }
    
    // Test metrics endpoint (note: ServerAddress returns port like ":9090")
    resp, err := http.Get("http://localhost" + recorder.ServerAddress() + "/metrics")
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        t.Errorf("Expected status 200, got %d", resp.StatusCode)
    }
}

Signature

func TestingRecorderWithPrometheus(tb testing.TB, serviceName string, opts ...Option) *Recorder

Parameters:

  • tb testing.TB - Test or benchmark instance
  • serviceName string - Service name for metrics
  • opts ...Option - Optional additional configuration options

Features

  • Dynamic port allocation: Automatically finds available port
  • Real Prometheus endpoint: Test actual HTTP metrics endpoint
  • Server readiness check: Use WaitForMetricsServer to wait for startup
  • Automatic cleanup: Shuts down server via t.Cleanup()
  • Works with benchmarks: Accepts testing.TB (both *testing.T and *testing.B)

WaitForMetricsServer

Wait for Prometheus metrics server to be ready:

func TestMetricsEndpoint(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")
    
    // Wait up to 5 seconds for server to start
    err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
    if err != nil {
        t.Fatalf("Metrics server not ready: %v", err)
    }
    
    // Server is ready, make requests (note: ServerAddress returns port like ":9090")
    resp, err := http.Get("http://localhost" + recorder.ServerAddress() + "/metrics")
    // ... test response
}

Signature

func WaitForMetricsServer(tb testing.TB, address string, timeout time.Duration) error

Parameters

  • tb testing.TB: Test or benchmark instance for logging
  • address string: Server address (e.g., :9090)
  • timeout time.Duration: Maximum wait time

Returns

  • error: Returns error if server doesn’t become ready within timeout

Testing Middleware

Test HTTP middleware with metrics collection:

func TestMiddleware(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorder(t, "test-service")
    
    // Create test handler
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    })
    
    // Wrap with metrics middleware
    wrappedHandler := metrics.Middleware(recorder)(handler)
    
    // Make test request
    req := httptest.NewRequest("GET", "/test", nil)
    w := httptest.NewRecorder()
    
    wrappedHandler.ServeHTTP(w, req)
    
    // Assert response
    if w.Code != http.StatusOK {
        t.Errorf("Expected status 200, got %d", w.Code)
    }
    
    if w.Body.String() != "OK" {
        t.Errorf("Expected body 'OK', got %s", w.Body.String())
    }
    
    // Metrics are recorded (visible in test logs if verbose)
}

Testing Custom Metrics

Test custom metric recording:

func TestCustomMetrics(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorder(t, "test-service")
    ctx := context.Background()
    
    tests := []struct {
        name    string
        record  func() error
        wantErr bool
    }{
        {
            name: "valid counter",
            record: func() error {
                return recorder.IncrementCounter(ctx, "test_counter")
            },
            wantErr: false,
        },
        {
            name: "invalid counter name",
            record: func() error {
                return recorder.IncrementCounter(ctx, "__reserved")
            },
            wantErr: true,
        },
        {
            name: "valid histogram",
            record: func() error {
                return recorder.RecordHistogram(ctx, "test_duration", 1.5)
            },
            wantErr: false,
        },
        {
            name: "valid gauge",
            record: func() error {
                return recorder.SetGauge(ctx, "test_gauge", 42)
            },
            wantErr: false,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := tt.record()
            if (err != nil) != tt.wantErr {
                t.Errorf("wantErr=%v, got err=%v", tt.wantErr, err)
            }
        })
    }
}

Testing Error Handling

Test metric recording error handling:

func TestMetricErrors(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorder(t, "test-service")
    ctx := context.Background()
    
    // Test invalid metric name
    err := recorder.IncrementCounter(ctx, "http_invalid")
    if err == nil {
        t.Error("Expected error for reserved prefix, got nil")
    }
    
    // Test reserved prefix
    err = recorder.IncrementCounter(ctx, "__internal")
    if err == nil {
        t.Error("Expected error for reserved prefix, got nil")
    }
    
    // Test valid metric
    err = recorder.IncrementCounter(ctx, "valid_metric")
    if err != nil {
        t.Errorf("Expected no error, got %v", err)
    }
}

Integration Testing

Test complete HTTP server with metrics:

func TestServerWithMetrics(t *testing.T) {
    recorder := metrics.TestingRecorderWithPrometheus(t, "test-api")
    
    // Wait for metrics server
    err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
    if err != nil {
        t.Fatal(err)
    }
    
    // Create test HTTP server
    mux := http.NewServeMux()
    mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status": "ok"}`))
    })
    
    handler := metrics.Middleware(recorder)(mux)
    
    server := httptest.NewServer(handler)
    defer server.Close()
    
    // Make requests
    resp, err := http.Get(server.URL + "/api")
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        t.Errorf("Expected status 200, got %d", resp.StatusCode)
    }
    
    // Check metrics endpoint (note: ServerAddress returns port like ":9090")
    metricsResp, err := http.Get("http://localhost" + recorder.ServerAddress() + "/metrics")
    if err != nil {
        t.Fatal(err)
    }
    defer metricsResp.Body.Close()
    
    body, _ := io.ReadAll(metricsResp.Body)
    bodyStr := string(body)
    
    // Verify metrics exist
    if !strings.Contains(bodyStr, "http_requests_total") {
        t.Error("Expected http_requests_total metric")
    }
}

Parallel Tests

The testing utilities support parallel test execution:

func TestMetricsParallel(t *testing.T) {
    tests := []struct {
        name string
        path string
    }{
        {"endpoint1", "/api/users"},
        {"endpoint2", "/api/orders"},
        {"endpoint3", "/api/products"},
    }
    
    for _, tt := range tests {
        tt := tt // Capture range variable
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            // Each test gets its own recorder
            recorder := metrics.TestingRecorder(t, "test-"+tt.name)
            
            // Test handler
            handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
            })
            
            wrapped := metrics.Middleware(recorder)(handler)
            
            req := httptest.NewRequest("GET", tt.path, nil)
            w := httptest.NewRecorder()
            wrapped.ServeHTTP(w, req)
            
            if w.Code != http.StatusOK {
                t.Errorf("Expected 200, got %d", w.Code)
            }
        })
    }
}

Benchmarking

Benchmark metrics collection performance:

func BenchmarkMetricsMiddleware(b *testing.B) {
    // Create recorder (use t=nil for benchmarks)
    recorder, err := metrics.New(
        metrics.WithStdout(),
        metrics.WithServiceName("bench-service"),
    )
    if err != nil {
        b.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())
    
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
    
    wrapped := metrics.Middleware(recorder)(handler)
    
    req := httptest.NewRequest("GET", "/test", nil)
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        w := httptest.NewRecorder()
        wrapped.ServeHTTP(w, req)
    }
}

func BenchmarkCustomMetrics(b *testing.B) {
    recorder, err := metrics.New(
        metrics.WithStdout(),
        metrics.WithServiceName("bench-service"),
    )
    if err != nil {
        b.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())
    
    ctx := context.Background()
    
    b.Run("Counter", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = recorder.IncrementCounter(ctx, "bench_counter")
        }
    })
    
    b.Run("Histogram", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = recorder.RecordHistogram(ctx, "bench_duration", 1.5)
        }
    })
    
    b.Run("Gauge", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = recorder.SetGauge(ctx, "bench_gauge", 42)
        }
    })
}

Testing Best Practices

Use Parallel Tests

Enable parallel execution to run tests faster:

func TestSomething(t *testing.T) {
    t.Parallel() // Always use t.Parallel() when safe
    
    recorder := metrics.TestingRecorder(t, "test-service")
    // ... test code
}

Prefer TestingRecorder

Use TestingRecorder (stdout) unless you specifically need to test the HTTP endpoint:

// Good - fast, no port allocation
recorder := metrics.TestingRecorder(t, "test-service")

// Only when needed - tests HTTP endpoint
recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")

Wait for Server Ready

Always wait for Prometheus server before making requests:

recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")
err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
if err != nil {
    t.Fatal(err)
}
// Now safe to make requests

Don’t Forget Context

Always pass context to metric methods:

ctx := context.Background()
err := recorder.IncrementCounter(ctx, "test_counter")

Test Error Cases

Test both success and error cases:

// Test valid metric
err := recorder.IncrementCounter(ctx, "valid_metric")
if err != nil {
    t.Errorf("Unexpected error: %v", err)
}

// Test invalid metric
err = recorder.IncrementCounter(ctx, "__reserved")
if err == nil {
    t.Error("Expected error for reserved prefix")
}

Example Test Suite

Complete example test suite:

package api_test

import (
    "context"
    "net/http"
    "net/http/httptest"
    "testing"
    "time"
    
    "rivaas.dev/metrics"
    "myapp/api"
)

func TestAPI(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorder(t, "test-api")
    
    server := api.NewServer(recorder)
    
    tests := []struct {
        name       string
        method     string
        path       string
        wantStatus int
    }{
        {"home", "GET", "/", 200},
        {"users", "GET", "/api/users", 200},
        {"not found", "GET", "/invalid", 404},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := httptest.NewRequest(tt.method, tt.path, nil)
            w := httptest.NewRecorder()
            
            server.ServeHTTP(w, req)
            
            if w.Code != tt.wantStatus {
                t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
            }
        })
    }
}

func TestMetricsEndpoint(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorderWithPrometheus(t, "test-api")
    
    err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
    if err != nil {
        t.Fatal(err)
    }
    
    resp, err := http.Get("http://localhost" + recorder.ServerAddress() + "/metrics")
    if err != nil {
        t.Fatal(err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode != http.StatusOK {
        t.Errorf("Expected status 200, got %d", resp.StatusCode)
    }
}

Next Steps

8.8 - Examples

Real-world examples of metrics collection patterns

This guide provides complete, real-world examples of using the metrics package.

Simple HTTP Server

Basic HTTP server with Prometheus metrics.

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "time"
    
    "rivaas.dev/metrics"
)

func main() {
    // Create lifecycle context
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create metrics recorder
    recorder, err := metrics.New(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("simple-api"),
        metrics.WithServiceVersion("v1.0.0"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Start metrics server
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    
    defer func() {
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        if err := recorder.Shutdown(shutdownCtx); err != nil {
            log.Printf("Metrics shutdown error: %v", err)
        }
    }()

    // Create HTTP handlers
    mux := http.NewServeMux()
    
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"message": "Hello, World!"}`))
    })
    
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    // Wrap with metrics middleware
    handler := metrics.Middleware(recorder,
        metrics.WithExcludePaths("/health", "/metrics"),
    )(mux)

    // Start HTTP server
    server := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }
    
    go func() {
        log.Printf("Server listening on :8080")
        log.Printf("Metrics available at http://localhost:9090/metrics")
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()
    
    // Wait for interrupt
    <-ctx.Done()
    log.Println("Shutting down gracefully...")
    
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    server.Shutdown(shutdownCtx)
}

Run and test:

# Start server
go run main.go

# Make requests
curl http://localhost:8080/

# View metrics
curl http://localhost:9090/metrics

Custom Metrics Example

Application with custom business metrics:

package main

import (
    "context"
    "log"
    "math/rand"
    "os"
    "os/signal"
    "time"
    
    "rivaas.dev/metrics"
    "go.opentelemetry.io/otel/attribute"
)

type OrderProcessor struct {
    recorder *metrics.Recorder
}

func NewOrderProcessor(recorder *metrics.Recorder) *OrderProcessor {
    return &OrderProcessor{recorder: recorder}
}

func (p *OrderProcessor) ProcessOrder(ctx context.Context, orderID string, amount float64) error {
    start := time.Now()
    
    // Simulate processing
    time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond)
    
    // Record processing duration
    duration := time.Since(start).Seconds()
    _ = p.recorder.RecordHistogram(ctx, "order_processing_duration_seconds", duration,
        attribute.String("order_id", orderID),
    )
    
    // Record order amount
    _ = p.recorder.RecordHistogram(ctx, "order_amount_usd", amount,
        attribute.String("currency", "USD"),
    )
    
    // Increment orders processed counter
    _ = p.recorder.IncrementCounter(ctx, "orders_processed_total",
        attribute.String("status", "success"),
    )
    
    log.Printf("Processed order %s: $%.2f in %.3fs", orderID, amount, duration)
    return nil
}

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create metrics recorder
    recorder := metrics.MustNew(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("order-processor"),
        metrics.WithDurationBuckets(0.01, 0.05, 0.1, 0.5, 1, 5),
    )
    
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())

    processor := NewOrderProcessor(recorder)
    
    log.Println("Processing orders... (metrics at http://localhost:9090/metrics)")
    
    // Simulate order processing
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    orderNum := 0
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            orderNum++
            orderID := fmt.Sprintf("ORD-%d", orderNum)
            amount := 10.0 + rand.Float64()*990.0
            
            if err := processor.ProcessOrder(ctx, orderID, amount); err != nil {
                log.Printf("Error processing order: %v", err)
            }
        }
    }
}

OTLP with OpenTelemetry Collector

Send metrics to OpenTelemetry collector:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "time"
    
    "rivaas.dev/metrics"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Get OTLP endpoint from environment
    endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
    if endpoint == "" {
        endpoint = "http://localhost:4318"
    }

    // Create recorder with OTLP
    recorder, err := metrics.New(
        metrics.WithOTLP(endpoint),
        metrics.WithServiceName(os.Getenv("SERVICE_NAME")),
        metrics.WithServiceVersion(os.Getenv("SERVICE_VERSION")),
        metrics.WithExportInterval(10 * time.Second),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Important: Start before recording metrics
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    
    defer func() {
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
        defer cancel()
        recorder.Shutdown(shutdownCtx)
    }()

    log.Printf("Sending metrics to OTLP endpoint: %s", endpoint)
    
    // Record metrics periodically
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()
    
    count := 0
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            count++
            _ = recorder.IncrementCounter(ctx, "app_ticks_total")
            _ = recorder.SetGauge(ctx, "app_counter", float64(count))
            log.Printf("Tick %d", count)
        }
    }
}

OpenTelemetry collector configuration:

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

exporters:
  prometheus:
    endpoint: "0.0.0.0:8889"
  logging:
    loglevel: debug

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [prometheus, logging]

Run collector:

otel-collector --config=otel-collector-config.yaml

Worker Pool with Gauges

Track worker pool metrics:

package main

import (
    "context"
    "log"
    "math/rand"
    "os"
    "os/signal"
    "sync"
    "time"
    
    "rivaas.dev/metrics"
    "go.opentelemetry.io/otel/attribute"
)

type WorkerPool struct {
    workers  int
    active   int
    idle     int
    mu       sync.Mutex
    recorder *metrics.Recorder
}

func NewWorkerPool(size int, recorder *metrics.Recorder) *WorkerPool {
    return &WorkerPool{
        workers:  size,
        idle:     size,
        recorder: recorder,
    }
}

func (p *WorkerPool) updateMetrics(ctx context.Context) {
    p.mu.Lock()
    active := p.active
    idle := p.idle
    p.mu.Unlock()
    
    _ = p.recorder.SetGauge(ctx, "worker_pool_active", float64(active))
    _ = p.recorder.SetGauge(ctx, "worker_pool_idle", float64(idle))
    _ = p.recorder.SetGauge(ctx, "worker_pool_total", float64(p.workers))
}

func (p *WorkerPool) DoWork(ctx context.Context, jobID string) {
    p.mu.Lock()
    p.active++
    p.idle--
    p.mu.Unlock()
    
    p.updateMetrics(ctx)
    
    start := time.Now()
    
    // Simulate work
    time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
    
    duration := time.Since(start).Seconds()
    _ = p.recorder.RecordHistogram(ctx, "job_duration_seconds", duration,
        attribute.String("job_id", jobID),
    )
    _ = p.recorder.IncrementCounter(ctx, "jobs_completed_total")
    
    p.mu.Lock()
    p.active--
    p.idle++
    p.mu.Unlock()
    
    p.updateMetrics(ctx)
}

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    recorder := metrics.MustNew(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("worker-pool"),
    )
    
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())

    pool := NewWorkerPool(10, recorder)
    
    log.Println("Worker pool started (metrics at http://localhost:9090/metrics)")
    
    // Submit jobs
    var wg sync.WaitGroup
    for i := 0; i < 50; i++ {
        wg.Add(1)
        jobID := fmt.Sprintf("job-%d", i)
        
        go func(id string) {
            defer wg.Done()
            pool.DoWork(ctx, id)
        }(jobID)
        
        time.Sleep(100 * time.Millisecond)
    }
    
    wg.Wait()
    log.Println("All jobs completed")
}

Environment-Based Configuration

Load metrics configuration from environment:

package main

import (
    "context"
    "log"
    "os"
    "strconv"
    "time"
    
    "rivaas.dev/metrics"
)

func createRecorder() (*metrics.Recorder, error) {
    var opts []metrics.Option
    
    // Service metadata
    opts = append(opts, metrics.WithServiceName(getEnv("SERVICE_NAME", "my-service")))
    
    if version := os.Getenv("SERVICE_VERSION"); version != "" {
        opts = append(opts, metrics.WithServiceVersion(version))
    }
    
    // Provider selection
    provider := getEnv("METRICS_PROVIDER", "prometheus")
    switch provider {
    case "prometheus":
        addr := getEnv("METRICS_ADDR", ":9090")
        path := getEnv("METRICS_PATH", "/metrics")
        opts = append(opts, metrics.WithPrometheus(addr, path))
        
        if getBoolEnv("METRICS_STRICT_PORT", true) {
            opts = append(opts, metrics.WithStrictPort())
        }
        
        // Optional: Reduce label cardinality for simple deployments
        if getBoolEnv("METRICS_WITHOUT_SCOPE_INFO", false) {
            opts = append(opts, metrics.WithoutScopeInfo())
        }
        
        if getBoolEnv("METRICS_WITHOUT_TARGET_INFO", false) {
            opts = append(opts, metrics.WithoutTargetInfo())
        }
        
    case "otlp":
        endpoint := getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
        opts = append(opts, metrics.WithOTLP(endpoint))
        
        if interval := getDurationEnv("METRICS_EXPORT_INTERVAL", 30*time.Second); interval > 0 {
            opts = append(opts, metrics.WithExportInterval(interval))
        }
        
    case "stdout":
        opts = append(opts, metrics.WithStdout())
        
    default:
        log.Printf("Unknown provider %s, using stdout", provider)
        opts = append(opts, metrics.WithStdout())
    }
    
    // Custom metrics limit
    if limit := getIntEnv("METRICS_MAX_CUSTOM", 1000); limit > 0 {
        opts = append(opts, metrics.WithMaxCustomMetrics(limit))
    }
    
    return metrics.New(opts...)
}

func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

func getBoolEnv(key string, defaultValue bool) bool {
    if value := os.Getenv(key); value != "" {
        b, err := strconv.ParseBool(value)
        if err == nil {
            return b
        }
    }
    return defaultValue
}

func getIntEnv(key string, defaultValue int) int {
    if value := os.Getenv(key); value != "" {
        i, err := strconv.Atoi(value)
        if err == nil {
            return i
        }
    }
    return defaultValue
}

func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
    if value := os.Getenv(key); value != "" {
        d, err := time.ParseDuration(value)
        if err == nil {
            return d
        }
    }
    return defaultValue
}

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    recorder, err := createRecorder()
    if err != nil {
        log.Fatal(err)
    }
    
    if err := recorder.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())

    log.Println("Service started with metrics")
    
    // Your application code...
    <-ctx.Done()
}

Example .env file:

SERVICE_NAME=my-api
SERVICE_VERSION=v1.2.3
METRICS_PROVIDER=prometheus
METRICS_ADDR=:9090
METRICS_PATH=/metrics
METRICS_STRICT_PORT=true
METRICS_MAX_CUSTOM=2000
# Optional: Reduce label cardinality
METRICS_WITHOUT_SCOPE_INFO=false
METRICS_WITHOUT_TARGET_INFO=false

Microservices Pattern

Shared metrics setup for microservices:

// pkg/telemetry/metrics.go
package telemetry

import (
    "context"
    "fmt"
    "os"
    
    "rivaas.dev/metrics"
)

type Config struct {
    ServiceName    string
    ServiceVersion string
    MetricsAddr    string
}

func NewMetricsRecorder(cfg Config) (*metrics.Recorder, error) {
    opts := []metrics.Option{
        metrics.WithPrometheus(cfg.MetricsAddr, "/metrics"),
        metrics.WithStrictPort(),
        metrics.WithServiceName(cfg.ServiceName),
    }
    
    if cfg.ServiceVersion != "" {
        opts = append(opts, metrics.WithServiceVersion(cfg.ServiceVersion))
    }
    
    return metrics.New(opts...)
}

// Service-specific metrics helpers
type ServiceMetrics struct {
    recorder *metrics.Recorder
}

func NewServiceMetrics(recorder *metrics.Recorder) *ServiceMetrics {
    return &ServiceMetrics{recorder: recorder}
}

func (m *ServiceMetrics) RecordAPICall(ctx context.Context, endpoint string, duration float64, err error) {
    status := "success"
    if err != nil {
        status = "error"
    }
    
    _ = m.recorder.RecordHistogram(ctx, "api_call_duration_seconds", duration,
        attribute.String("endpoint", endpoint),
        attribute.String("status", status),
    )
    
    _ = m.recorder.IncrementCounter(ctx, "api_calls_total",
        attribute.String("endpoint", endpoint),
        attribute.String("status", status),
    )
}

Use in service:

// cmd/user-service/main.go
package main

import (
    "context"
    "log"
    
    "myapp/pkg/telemetry"
)

func main() {
    cfg := telemetry.Config{
        ServiceName:    "user-service",
        ServiceVersion: os.Getenv("VERSION"),
        MetricsAddr:    ":9090",
    }
    
    recorder, err := telemetry.NewMetricsRecorder(cfg)
    if err != nil {
        log.Fatal(err)
    }
    
    if err := recorder.Start(context.Background()); err != nil {
        log.Fatal(err)
    }
    defer recorder.Shutdown(context.Background())
    
    metrics := telemetry.NewServiceMetrics(recorder)
    
    // Use metrics in your service
    // ...
}

Complete Production Example

Full production-ready setup:

package main

import (
    "context"
    "log"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
    
    "rivaas.dev/metrics"
)

func main() {
    // Setup structured logging
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    slog.SetDefault(logger)
    
    // Create application context
    ctx, cancel := signal.NotifyContext(
        context.Background(),
        os.Interrupt,
        syscall.SIGTERM,
    )
    defer cancel()

    // Create metrics recorder with production settings
    recorder, err := metrics.New(
        // Provider
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithStrictPort(),
        
        // Service metadata
        metrics.WithServiceName("production-api"),
        metrics.WithServiceVersion(os.Getenv("VERSION")),
        
        // Configuration
        metrics.WithDurationBuckets(0.01, 0.1, 0.5, 1, 5, 10, 30),
        metrics.WithSizeBuckets(100, 1000, 10000, 100000, 1000000),
        metrics.WithMaxCustomMetrics(2000),
        
        // Observability
        metrics.WithLogger(slog.Default()),
    )
    if err != nil {
        slog.Error("Failed to create metrics recorder", "error", err)
        os.Exit(1)
    }
    
    // Start metrics server
    if err := recorder.Start(ctx); err != nil {
        slog.Error("Failed to start metrics", "error", err)
        os.Exit(1)
    }
    
    slog.Info("Metrics server started", "address", recorder.ServerAddress())
    
    // Ensure graceful shutdown
    defer func() {
        shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        
        if err := recorder.Shutdown(shutdownCtx); err != nil {
            slog.Error("Metrics shutdown error", "error", err)
        } else {
            slog.Info("Metrics shut down successfully")
        }
    }()

    // Create HTTP server
    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)
    mux.HandleFunc("/api/v1/users", usersHandler)
    mux.HandleFunc("/health", healthHandler)
    mux.HandleFunc("/ready", readyHandler)

    // Configure middleware
    handler := metrics.Middleware(recorder,
        metrics.WithExcludePaths("/health", "/ready", "/metrics"),
        metrics.WithExcludePrefixes("/debug/", "/_/"),
        metrics.WithHeaders("X-Request-ID", "X-Correlation-ID"),
    )(mux)

    server := &http.Server{
        Addr:              ":8080",
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       10 * time.Second,
        WriteTimeout:      10 * time.Second,
        IdleTimeout:       60 * time.Second,
    }
    
    // Start HTTP server
    go func() {
        slog.Info("HTTP server starting", "address", server.Addr)
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            slog.Error("HTTP server error", "error", err)
            cancel()
        }
    }()
    
    // Wait for shutdown signal
    <-ctx.Done()
    slog.Info("Shutdown signal received")
    
    // Graceful shutdown
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer shutdownCancel()
    
    if err := server.Shutdown(shutdownCtx); err != nil {
        slog.Error("Server shutdown error", "error", err)
    } else {
        slog.Info("Server shut down successfully")
    }
}

func homeHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"status": "ok"}`))
}

func usersHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"users": []}`))
}

func healthHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
}

func readyHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
}

Next Steps

9 - Distributed Tracing

Learn how to implement distributed tracing with Rivaas tracing package

The Rivaas Tracing package provides OpenTelemetry-based distributed tracing. Supports various exporters and integrates with HTTP frameworks. Enables observability best practices with minimal configuration.

Features

  • OpenTelemetry Integration: Full OpenTelemetry tracing support
  • Context Propagation: Automatic trace context propagation across services
  • Span Management: Easy span creation and management with lifecycle hooks
  • HTTP Middleware: Standalone middleware for any HTTP framework
  • Multiple Providers: Stdout, OTLP (gRPC and HTTP), and Noop exporters
  • Path Filtering: Exclude specific paths from tracing via middleware options
  • Consistent API: Same design patterns as the metrics package
  • Thread-Safe: All operations safe for concurrent use

Quick Start

package main

import (
    "context"
    "log"
    "os/signal"
    
    "rivaas.dev/tracing"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    tracer, err := tracing.New(
        tracing.WithServiceName("my-service"),
        tracing.WithServiceVersion("v1.0.0"),
        tracing.WithOTLP("localhost:4317"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    if err := tracer.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer tracer.Shutdown(context.Background())

    // Traces exported via OTLP gRPC
    ctx, span := tracer.StartSpan(ctx, "operation")
    defer tracer.FinishSpan(span, 200)
}
package main

import (
    "context"
    "log"
    "os/signal"
    
    "rivaas.dev/tracing"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    tracer, err := tracing.New(
        tracing.WithServiceName("my-service"),
        tracing.WithServiceVersion("v1.0.0"),
        tracing.WithOTLPHTTP("http://localhost:4318"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    if err := tracer.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer tracer.Shutdown(context.Background())

    // Traces exported via OTLP HTTP
    ctx, span := tracer.StartSpan(ctx, "operation")
    defer tracer.FinishSpan(span, 200)
}
package main

import (
    "context"
    
    "rivaas.dev/tracing"
)

func main() {
    tracer := tracing.MustNew(
        tracing.WithServiceName("my-service"),
        tracing.WithStdout(),
    )
    defer tracer.Shutdown(context.Background())

    ctx := context.Background()
    
    // Traces printed to stdout
    ctx, span := tracer.StartSpan(ctx, "operation")
    defer tracer.FinishSpan(span, 200)
}

How It Works

  • Providers determine where traces are exported (Stdout, OTLP, Noop)
  • Lifecycle management ensures proper initialization and graceful shutdown
  • HTTP middleware creates spans for requests automatically
  • Custom spans can be created for detailed operation tracing
  • Context propagation enables distributed tracing across services

Learning Path

Follow these guides to learn distributed tracing with Rivaas:

  1. Installation - Get started with the tracing package
  2. Basic Usage - Learn tracer creation and span management
  3. Providers - Understand Stdout, OTLP, and Noop exporters
  4. Configuration - Configure service metadata, sampling, and hooks
  5. Middleware - Integrate HTTP tracing with your application
  6. Context Propagation - Propagate traces across services
  7. Testing - Test your tracing with provided utilities
  8. Examples - See real-world usage patterns

Next Steps

9.1 - Installation

Install and set up the tracing package

Get started with the Rivaas tracing package by installing it in your Go project.

Requirements

  • Go 1.25 or higher - The tracing package uses modern Go features
  • OpenTelemetry dependencies - Automatically installed via go get

Install the Package

Add the tracing package to your Go module:

go get rivaas.dev/tracing

This will download the package and its OpenTelemetry dependencies.

Verify Installation

Create a simple test file to verify the installation:

package main

import (
    "context"
    "log"
    
    "rivaas.dev/tracing"
)

func main() {
    tracer, err := tracing.New(
        tracing.WithServiceName("test-service"),
        tracing.WithStdout(),
    )
    if err != nil {
        log.Fatal(err)
    }
    defer tracer.Shutdown(context.Background())
    
    log.Println("Tracing initialized successfully!")
}

Run the test:

go run main.go

You should see a success message with no errors.

Dependencies

The tracing package depends on:

  • go.opentelemetry.io/otel - OpenTelemetry API
  • go.opentelemetry.io/otel/sdk - OpenTelemetry SDK
  • go.opentelemetry.io/otel/exporters/stdout/stdouttrace - Stdout exporter
  • go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc - OTLP gRPC exporter
  • go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp - OTLP HTTP exporter

These are automatically installed when you run go get rivaas.dev/tracing.

Module Setup

If you’re starting a new project, initialize a Go module first:

mkdir my-traced-app
cd my-traced-app
go mod init example.com/my-traced-app
go get rivaas.dev/tracing

Next Steps

Now that you have the package installed:

9.2 - Basic Usage

Learn the fundamentals of creating tracers and managing spans

Learn how to create tracers, manage spans, and add tracing to your Go applications.

Creating a Tracer

The Tracer is the main entry point for distributed tracing. Create one using functional options:

With Error Handling

tracer, err := tracing.New(
    tracing.WithServiceName("my-service"),
    tracing.WithServiceVersion("v1.0.0"),
    tracing.WithStdout(),
)
if err != nil {
    log.Fatalf("Failed to create tracer: %v", err)
}
defer tracer.Shutdown(context.Background())

Panic on Error

For convenience, use MustNew which panics if initialization fails:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithServiceVersion("v1.0.0"),
    tracing.WithStdout(),
)
defer tracer.Shutdown(context.Background())

Tracer Lifecycle

Starting the Tracer

For OTLP providers (gRPC and HTTP), you must call Start() before tracing:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317"),
)

// Start is required for OTLP providers
if err := tracer.Start(context.Background()); err != nil {
    log.Fatal(err)
}

defer tracer.Shutdown(context.Background())

Shutting Down

Always shut down the tracer to flush pending spans:

defer func() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    if err := tracer.Shutdown(ctx); err != nil {
        log.Printf("Error shutting down tracer: %v", err)
    }
}()

Manual Span Management

Create and manage spans manually for detailed tracing:

Basic Span Creation

func processData(ctx context.Context, tracer *tracing.Tracer) {
    // Start a span
    ctx, span := tracer.StartSpan(ctx, "process-data")
    defer tracer.FinishSpan(span, http.StatusOK)
    
    // Your code here...
}

Adding Attributes

Add attributes to provide context about the operation:

ctx, span := tracer.StartSpan(ctx, "database-query")
defer tracer.FinishSpan(span, http.StatusOK)

// Add attributes
tracer.SetSpanAttribute(span, "db.system", "postgresql")
tracer.SetSpanAttribute(span, "db.query", "SELECT * FROM users")
tracer.SetSpanAttribute(span, "db.rows_returned", 42)

Supported attribute types:

  • string
  • int, int64
  • float64
  • bool
  • Other types (converted to string)

Adding Events

Record significant moments in a span’s lifetime:

import "go.opentelemetry.io/otel/attribute"

ctx, span := tracer.StartSpan(ctx, "cache-lookup")
defer tracer.FinishSpan(span, http.StatusOK)

// Add an event
tracer.AddSpanEvent(span, "cache_hit",
    attribute.String("key", "user:123"),
    attribute.Int("ttl_seconds", 300),
)

Error Handling

Use the status code to indicate span success or failure:

func fetchUser(ctx context.Context, tracer *tracing.Tracer, userID string) error {
    ctx, span := tracer.StartSpan(ctx, "fetch-user")
    defer func() {
        if err != nil {
            tracer.FinishSpan(span, http.StatusInternalServerError)
        } else {
            tracer.FinishSpan(span, http.StatusOK)
        }
    }()
    
    tracer.SetSpanAttribute(span, "user.id", userID)
    
    // Fetch user logic...
    return nil
}

Context Helpers

Work with spans through the context without direct span references:

Set Attributes from Context

func handleRequest(ctx context.Context) {
    // Add attribute to the current span in context
    tracing.SetSpanAttributeFromContext(ctx, "user.role", "admin")
    tracing.SetSpanAttributeFromContext(ctx, "user.id", 12345)
}

Add Events from Context

func processEvent(ctx context.Context) {
    // Add event to the current span in context
    tracing.AddSpanEventFromContext(ctx, "event_processed",
        attribute.String("event_type", "user_login"),
        attribute.String("ip_address", "192.168.1.1"),
    )
}

Get Trace Information

func logWithTraceInfo(ctx context.Context) {
    traceID := tracing.TraceID(ctx)
    spanID := tracing.SpanID(ctx)
    
    log.Printf("Processing request [trace=%s, span=%s]", traceID, spanID)
}

Complete Example

Here’s a complete example showing manual span management:

package main

import (
    "context"
    "log"
    "time"
    
    "go.opentelemetry.io/otel/attribute"
    "rivaas.dev/tracing"
)

func main() {
    tracer := tracing.MustNew(
        tracing.WithServiceName("example-service"),
        tracing.WithStdout(),
    )
    defer tracer.Shutdown(context.Background())
    
    ctx := context.Background()
    
    // Parent span
    ctx, parentSpan := tracer.StartSpan(ctx, "process-order")
    defer tracer.FinishSpan(parentSpan, 200)
    
    tracer.SetSpanAttribute(parentSpan, "order.id", "12345")
    
    // Child span 1
    validateOrder(ctx, tracer)
    
    // Child span 2
    chargePayment(ctx, tracer)
    
    log.Println("Order processed successfully")
}

func validateOrder(ctx context.Context, tracer *tracing.Tracer) {
    ctx, span := tracer.StartSpan(ctx, "validate-order")
    defer tracer.FinishSpan(span, 200)
    
    tracer.SetSpanAttribute(span, "validation.status", "passed")
    tracer.AddSpanEvent(span, "validation_complete")
    
    time.Sleep(10 * time.Millisecond) // Simulate work
}

func chargePayment(ctx context.Context, tracer *tracing.Tracer) {
    ctx, span := tracer.StartSpan(ctx, "charge-payment")
    defer tracer.FinishSpan(span, 200)
    
    tracer.SetSpanAttribute(span, "payment.amount", 99.99)
    tracer.SetSpanAttribute(span, "payment.method", "credit_card")
    
    tracer.AddSpanEvent(span, "payment_authorized",
        attribute.String("authorization_code", "AUTH123"),
    )
    
    time.Sleep(20 * time.Millisecond) // Simulate work
}

Best Practices

Always Close Spans

Use defer to ensure spans are finished even if errors occur:

ctx, span := tracer.StartSpan(ctx, "operation")
defer tracer.FinishSpan(span, http.StatusOK) // Always close

Propagate Context

Always pass the context returned by StartSpan to child operations:

ctx, span := tracer.StartSpan(ctx, "parent")
defer tracer.FinishSpan(span, http.StatusOK)

// Pass the new context to children
childOperation(ctx) // ✓ Correct
childOperation(oldCtx) // ✗ Wrong - breaks trace chain

Use Descriptive Names

Choose clear, consistent span names:

// Good
tracer.StartSpan(ctx, "database-query")
tracer.StartSpan(ctx, "validate-user-input")
tracer.StartSpan(ctx, "send-email")

// Bad
tracer.StartSpan(ctx, "query")
tracer.StartSpan(ctx, "func1")
tracer.StartSpan(ctx, "DoStuff")

Add Meaningful Attributes

Include relevant information as attributes:

ctx, span := tracer.StartSpan(ctx, "api-call")
defer tracer.FinishSpan(span, statusCode)

tracer.SetSpanAttribute(span, "http.method", "POST")
tracer.SetSpanAttribute(span, "http.url", "/api/users")
tracer.SetSpanAttribute(span, "api.endpoint", "create_user")
tracer.SetSpanAttribute(span, "user.role", "admin")

Next Steps

9.3 - Tracing Providers

Choose and configure trace exporters for your application

The tracing package supports multiple providers for exporting traces. Choose the provider that best fits your environment and infrastructure.

Available Providers

ProviderUse CaseNetwork RequiredBest For
NoopDefault, no tracesNoTesting, disabled tracing
StdoutConsole outputNoDevelopment, debugging
OTLP (gRPC)OpenTelemetry collectorYesProduction (preferred)
OTLP (HTTP)OpenTelemetry collectorYesProduction (alternative)

Basic Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithNoop(),
)
defer tracer.Shutdown(context.Background())
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithStdout(),
)
defer tracer.Shutdown(context.Background())
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithServiceVersion("v1.0.0"),
    tracing.WithOTLP("localhost:4317"),
)
if err := tracer.Start(context.Background()); err != nil {
    log.Fatal(err)
}
defer tracer.Shutdown(context.Background())
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithServiceVersion("v1.0.0"),
    tracing.WithOTLPHTTP("http://localhost:4318"),
)
if err := tracer.Start(context.Background()); err != nil {
    log.Fatal(err)
}
defer tracer.Shutdown(context.Background())

Noop Provider

The noop provider doesn’t export any traces. It’s the default when no provider is configured.

When to Use

  • Testing environments where tracing isn’t needed
  • Temporarily disabling tracing without code changes
  • Safe default for new projects

Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithNoop(),
)
defer tracer.Shutdown(context.Background())

Or simply omit the provider option (noop is the default):

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    // No provider = Noop
)

Behavior

  • Spans are created but not recorded
  • No network calls or file I/O
  • Minimal performance overhead
  • Safe for production if tracing is disabled

Stdout Provider

The stdout provider prints traces to standard output in a human-readable format.

When to Use

  • Local development and debugging
  • Troubleshooting span creation and attributes
  • Testing trace propagation
  • Quick validation of tracing logic

Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithStdout(),
)
defer tracer.Shutdown(context.Background())

Output Format

Traces are printed as pretty-printed JSON to stdout:

{
  "Name": "GET /api/users",
  "SpanContext": {
    "TraceID": "3f3c5e4d...",
    "SpanID": "a1b2c3d4...",
    "TraceFlags": "01"
  },
  "Parent": {
    "TraceID": "3f3c5e4d...",
    "SpanID": "e5f6g7h8..."
  },
  "SpanKind": "Server",
  "StartTime": "2025-01-18T10:15:30.123Z",
  "EndTime": "2025-01-18T10:15:30.456Z",
  "Attributes": [
    {
      "Key": "http.method",
      "Value": {"Type": "STRING", "Value": "GET"}
    }
  ]
}

Limitations

  • Not for production: Output can be noisy and slow
  • No persistence: Traces are only printed, not stored
  • No visualization: Use an actual backend for trace visualization

OTLP Provider (gRPC)

The OTLP gRPC provider exports traces to an OpenTelemetry collector using the gRPC protocol.

When to Use

  • Production environments
  • OpenTelemetry collector infrastructure
  • Jaeger, Zipkin, or other OTLP-compatible backends
  • Best performance and reliability

Basic Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithServiceVersion("v1.0.0"),
    tracing.WithOTLP("localhost:4317"),
)

// Start is required for OTLP providers
if err := tracer.Start(context.Background()); err != nil {
    log.Fatal(err)
}

defer tracer.Shutdown(context.Background())

Secure Connection (TLS)

By default, OTLP uses TLS:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("collector.example.com:4317"),
    // TLS is enabled by default
)

Insecure Connection (Development)

For local development without TLS:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317", tracing.OTLPInsecure()),
)

Configuration Options

import "rivaas.dev/tracing"

// Secure (production)
tracing.WithOTLP("collector.example.com:4317")

// Insecure (development)
tracing.WithOTLP("localhost:4317", tracing.OTLPInsecure())

OTLP Provider (HTTP)

The OTLP HTTP provider exports traces to an OpenTelemetry collector using the HTTP protocol.

When to Use

  • Alternative to gRPC when firewalls block gRPC
  • Simpler infrastructure without gRPC support
  • HTTP-only environments
  • Debugging with curl/httpie

Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithServiceVersion("v1.0.0"),
    tracing.WithOTLPHTTP("http://localhost:4318"),
)

// Start is required for OTLP providers
if err := tracer.Start(context.Background()); err != nil {
    log.Fatal(err)
}

defer tracer.Shutdown(context.Background())

HTTPS Endpoint

Use HTTPS for secure connections:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLPHTTP("https://collector.example.com:4318"),
)

Endpoint Format

The endpoint should include the protocol:

// HTTP (insecure - development only)
tracing.WithOTLPHTTP("http://localhost:4318")

// HTTPS (secure - production)
tracing.WithOTLPHTTP("https://collector.example.com:4318")

Provider Comparison

Performance

ProviderLatencyThroughputCPUMemory
Noop~10nsUnlimitedMinimalMinimal
Stdout~100µsLowLowLow
OTLP (gRPC)~1-2msHighLowMedium
OTLP (HTTP)~2-3msMediumLowMedium

Use Case Matrix

// Development
tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithStdout(), // ← See traces in console
)

// Testing
tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithNoop(), // ← No tracing overhead
)

// Production (recommended)
tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithOTLP("collector:4317"), // ← gRPC to collector
)

// Production (HTTP alternative)
tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithOTLPHTTP("https://collector:4318"), // ← HTTP to collector
)

Switching Providers

Only one provider can be configured at a time. Attempting to configure multiple providers results in a validation error:

// ✗ Error: multiple providers configured
tracer, err := tracing.New(
    tracing.WithServiceName("my-service"),
    tracing.WithStdout(),
    tracing.WithOTLP("localhost:4317"), // Error!
)
// Returns: "validation errors: provider: multiple providers configured"

To switch providers, use environment variables or configuration:

func createTracer(env string) *tracing.Tracer {
    opts := []tracing.Option{
        tracing.WithServiceName("my-service"),
        tracing.WithServiceVersion("v1.0.0"),
    }
    
    switch env {
    case "production":
        opts = append(opts, tracing.WithOTLP("collector:4317"))
    case "development":
        opts = append(opts, tracing.WithStdout())
    default:
        opts = append(opts, tracing.WithNoop())
    }
    
    return tracing.MustNew(opts...)
}

OpenTelemetry Collector Setup

For OTLP providers, you need an OpenTelemetry collector.

Docker Compose Example

version: '3.8'
services:
  otel-collector:
    image: otel/opentelemetry-collector:latest
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
      - "13133:13133" # health_check

Collector Configuration

Basic otel-collector-config.yaml:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:

exporters:
  logging:
    loglevel: debug
  # Add your backend (Jaeger, Zipkin, etc.)
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [logging, jaeger]

Provider Selection Guide

Choose Noop When:

  • Tracing is disabled via feature flags
  • Running in CI/CD without trace backend
  • Performance testing without observability overhead

Choose Stdout When:

  • Developing locally and need to see traces
  • Debugging span creation and attributes
  • Quick validation of tracing setup

Choose OTLP (gRPC) When:

  • Deploying to production
  • Need high throughput and low latency
  • Using OpenTelemetry collector
  • Standard production setup

Choose OTLP (HTTP) When:

  • gRPC is blocked by firewalls
  • Simpler infrastructure requirements
  • Need HTTP-friendly debugging
  • Backend only supports HTTP

Next Steps

  • Learn Configuration options for service metadata and sampling
  • Set up Middleware for automatic HTTP tracing
  • Explore Examples for production-ready configurations

9.4 - Configuration

Configure service metadata, sampling, hooks, and logging

Configure your tracer with service information, sampling rates, lifecycle hooks, and logging integration.

Service Configuration

Set service metadata that appears in every span.

Service Name

The service name identifies your application in traces:

tracer := tracing.MustNew(
    tracing.WithServiceName("user-api"),
    tracing.WithStdout(),
)

Best practices:

  • Use descriptive, consistent names across services.
  • Use kebab-case: user-api, order-service, payment-gateway.
  • Avoid generic names like api or service.

Service Version

Track which version of your service created traces:

tracer := tracing.MustNew(
    tracing.WithServiceName("user-api"),
    tracing.WithServiceVersion("v1.2.3"),
    tracing.WithStdout(),
)

Best practices:

  • Use semantic versioning: v1.2.3.
  • Include in CI/CD builds.
  • Track version across deployments.

Combined Example

tracer := tracing.MustNew(
    tracing.WithServiceName("user-api"),
    tracing.WithServiceVersion("v1.2.3"),
    tracing.WithOTLP("collector:4317"),
)

These attributes appear in every span:

  • service.name: "user-api"
  • service.version: "v1.2.3"

Sampling Configuration

Control which requests are traced to reduce overhead and costs.

Sample Rate

Set the percentage of requests to trace (0.0 to 1.0):

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSampleRate(0.1), // Trace 10% of requests
    tracing.WithOTLP("collector:4317"),
)

Sample rates:

  • 1.0: 100% sampling. All requests traced.
  • 0.5: 50% sampling.
  • 0.1: 10% sampling.
  • 0.01: 1% sampling.
  • 0.0: 0% sampling (no traces)

Sampling Examples

// Development: trace everything
tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSampleRate(1.0),
    tracing.WithStdout(),
)

// Production: trace 10% of requests
tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSampleRate(0.1),
    tracing.WithOTLP("collector:4317"),
)

// High-traffic: trace 1% of requests
tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSampleRate(0.01),
    tracing.WithOTLP("collector:4317"),
)

Sampling Behavior

  • Probabilistic: Uses deterministic hashing for consistent sampling
  • Request-level: Decision made once per request, all child spans included
  • Zero overhead: Non-sampled requests skip span creation entirely

When to Sample

Traffic LevelRecommended Sample Rate
< 100 req/s1.0 (100%)
100-1000 req/s0.5 (50%)
1000-10000 req/s0.1 (10%)
> 10000 req/s0.01 (1%)

Adjust based on:

  • Trace backend capacity
  • Storage costs
  • Desired trace coverage
  • Debug vs production needs

Span Lifecycle Hooks

Add custom logic when spans start or finish.

Span Start Hook

Execute code when a request span is created:

import (
    "context"
    "net/http"
    
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/trace"
    "rivaas.dev/tracing"
)

startHook := func(ctx context.Context, span trace.Span, req *http.Request) {
    // Add custom attributes
    if tenantID := req.Header.Get("X-Tenant-ID"); tenantID != "" {
        span.SetAttributes(attribute.String("tenant.id", tenantID))
    }
    
    // Add user information
    if userID := req.Header.Get("X-User-ID"); userID != "" {
        span.SetAttributes(attribute.String("user.id", userID))
    }
    
    // Record custom business context
    span.SetAttributes(
        attribute.String("request.region", getRegionFromIP(req)),
        attribute.Bool("request.is_mobile", isMobileRequest(req)),
    )
}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSpanStartHook(startHook),
    tracing.WithOTLP("collector:4317"),
)

Use cases:

  • Add tenant/user identifiers
  • Record business context
  • Integrate with feature flags
  • Custom sampling decisions
  • APM tool integration

Span Finish Hook

Execute code when a request span completes:

import (
    "go.opentelemetry.io/otel/trace"
    "rivaas.dev/tracing"
)

finishHook := func(span trace.Span, statusCode int) {
    // Record custom metrics
    if statusCode >= 500 {
        metrics.IncrementServerErrors()
    }
    
    // Log slow requests
    if span.SpanContext().IsValid() {
        // Calculate duration and log if > threshold
    }
    
    // Send alerts for errors
    if statusCode >= 500 {
        alerting.SendAlert("Server error", statusCode)
    }
}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSpanFinishHook(finishHook),
    tracing.WithOTLP("collector:4317"),
)

Use cases:

  • Record custom metrics
  • Log slow requests
  • Send error alerts
  • Update counters
  • Cleanup resources

Combined Hooks Example

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSpanStartHook(func(ctx context.Context, span trace.Span, req *http.Request) {
        // Enrich span with business context
        span.SetAttributes(
            attribute.String("tenant.id", extractTenant(req)),
            attribute.String("feature.flags", getFeatureFlags(req)),
        )
    }),
    tracing.WithSpanFinishHook(func(span trace.Span, statusCode int) {
        // Record completion metrics
        recordRequestMetrics(statusCode)
    }),
    tracing.WithOTLP("collector:4317"),
)

Logging Integration

Integrate tracing with your logging infrastructure.

Using slog

Use Go’s standard log/slog package:

import (
    "log/slog"
    "os"
    
    "rivaas.dev/tracing"
)

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithLogger(logger),
    tracing.WithOTLP("collector:4317"),
)

The logger receives internal tracing events:

  • Tracer initialization
  • Provider startup/shutdown
  • Configuration warnings
  • Error conditions

Event Levels

Events are logged at appropriate levels:

Event TypeLog LevelExample
ErrorERROR“Failed to export spans”
WarningWARN“OTLP endpoint not specified”
InfoINFO“Tracing initialized”
DebugDEBUG“Request not sampled”

Custom Event Handler

For non-slog logging or custom event handling:

import "rivaas.dev/tracing"

eventHandler := func(e tracing.Event) {
    switch e.Type {
    case tracing.EventError:
        // Send to error tracking (e.g., Sentry)
        sentry.CaptureMessage(e.Message)
        myLogger.Error(e.Message, e.Args...)
    case tracing.EventWarning:
        myLogger.Warn(e.Message, e.Args...)
    case tracing.EventInfo:
        myLogger.Info(e.Message, e.Args...)
    case tracing.EventDebug:
        myLogger.Debug(e.Message, e.Args...)
    }
}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithEventHandler(eventHandler),
    tracing.WithOTLP("collector:4317"),
)

Use cases:

  • Integrate with non-slog loggers (zap, zerolog, logrus)
  • Send errors to Sentry/Rollbar
  • Custom alerting
  • Audit logging
  • Metrics from events

No Logging

To disable all internal logging:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    // No WithLogger or WithEventHandler = no logging
    tracing.WithOTLP("collector:4317"),
)

Advanced Configuration

Custom Propagator

Use a custom trace context propagation format:

import (
    "go.opentelemetry.io/otel/propagation"
    "rivaas.dev/tracing"
)

// Use B3 propagation format (Zipkin)
b3Propagator := propagation.NewCompositeTextMapPropagator(
    propagation.B3{},
)

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithCustomPropagator(b3Propagator),
    tracing.WithOTLP("collector:4317"),
)

Custom Tracer Provider

Provide your own OpenTelemetry tracer provider:

import (
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    "rivaas.dev/tracing"
)

// Create custom tracer provider
tp := sdktrace.NewTracerProvider(
    // Your custom configuration
    sdktrace.WithSampler(sdktrace.AlwaysSample()),
)

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithTracerProvider(tp),
)

// You manage tp.Shutdown() yourself
defer tp.Shutdown(context.Background())

Note: When using WithTracerProvider, you’re responsible for shutting down the provider.

Global Tracer Provider

Register as the global OpenTelemetry tracer provider:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithOTLP("collector:4317"),
    tracing.WithGlobalTracerProvider(), // Register globally
)

By default, tracers are not registered globally. Use this option when:

  • You want otel.GetTracerProvider() to return your tracer
  • Integrating with libraries that use the global tracer
  • Single tracer for entire application

Configuration Patterns

Development Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithServiceVersion("dev"),
    tracing.WithStdout(),
    tracing.WithSampleRate(1.0), // Trace everything
    tracing.WithLogger(slog.Default()),
)

Production Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("user-api"),
    tracing.WithServiceVersion(version), // From build
    tracing.WithOTLP(otlpEndpoint),      // From env
    tracing.WithSampleRate(0.1),         // 10% sampling
    tracing.WithSpanStartHook(enrichSpan),
    tracing.WithSpanFinishHook(recordMetrics),
)

Environment-Based Configuration

func createTracer(env string) *tracing.Tracer {
    opts := []tracing.Option{
        tracing.WithServiceName("my-api"),
        tracing.WithServiceVersion(getVersion()),
    }
    
    switch env {
    case "production":
        opts = append(opts,
            tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),
            tracing.WithSampleRate(0.1),
        )
    case "staging":
        opts = append(opts,
            tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),
            tracing.WithSampleRate(0.5),
        )
    default: // development
        opts = append(opts,
            tracing.WithStdout(),
            tracing.WithSampleRate(1.0),
            tracing.WithLogger(slog.Default()),
        )
    }
    
    return tracing.MustNew(opts...)
}

Next Steps

9.5 - HTTP Middleware

Automatically trace HTTP requests with middleware

The tracing package provides HTTP middleware for automatic request tracing with any HTTP framework.

Basic Usage

Wrap your HTTP handler with tracing middleware:

import (
    "net/http"
    "rivaas.dev/tracing"
)

func main() {
    tracer := tracing.MustNew(
        tracing.WithServiceName("my-api"),
        tracing.WithOTLP("localhost:4317"),
    )
    tracer.Start(context.Background())
    defer tracer.Shutdown(context.Background())

    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", handleUsers)
    
    // Wrap with middleware
    handler := tracing.Middleware(tracer)(mux)
    
    http.ListenAndServe(":8080", handler)
}

Middleware Functions

Two functions are available for creating middleware:

Middleware (Panics on Error)

handler := tracing.Middleware(tracer,
    tracing.WithExcludePaths("/health"),
)(mux)

Panics if middleware options are invalid (e.g., invalid regex pattern).

MustMiddleware (Alias)

handler := tracing.MustMiddleware(tracer,
    tracing.WithExcludePaths("/health"),
)(mux)

Identical to Middleware() - provided for API consistency with MustNew().

What Gets Traced

The middleware automatically:

  1. Extracts trace context from incoming request headers.
  2. Creates a span for the request with standard attributes.
  3. Propagates context to downstream handlers.
  4. Records HTTP method, URL, status code, and duration.
  5. Finishes the span when the request completes.

Standard Attributes

Every traced request includes:

AttributeDescriptionExample
http.methodHTTP method"GET"
http.urlFull URL"http://localhost:8080/api/users"
http.schemeURL scheme"http"
http.hostHost header"localhost:8080"
http.routeRequest path"/api/users"
http.user_agentUser agent"Mozilla/5.0..."
http.status_codeResponse status200
service.nameService name"my-api"
service.versionService version"v1.0.0"

Path Exclusion

Exclude specific paths from tracing to reduce noise and overhead.

Exact Path Matching

Exclude specific paths exactly:

handler := tracing.Middleware(tracer,
    tracing.WithExcludePaths("/health", "/metrics", "/ready"),
)(mux)

Requests to /health, /metrics, or /ready won’t create spans.

Prefix Matching

Exclude all paths with a given prefix:

handler := tracing.Middleware(tracer,
    tracing.WithExcludePrefixes("/debug/", "/internal/", "/.well-known/"),
)(mux)

Excludes:

  • /debug/pprof
  • /debug/vars
  • /internal/health
  • /.well-known/acme-challenge

Regex Pattern Matching

Exclude paths matching regex patterns:

handler := tracing.Middleware(tracer,
    tracing.WithExcludePatterns(
        `^/v[0-9]+/internal/.*`,  // Version-prefixed internal routes
        `^/api/health.*`,          // Any health-related endpoint
    ),
)(mux)

Important: Invalid regex patterns cause the middleware to panic during initialization.

Combined Exclusions

Use multiple exclusion types together:

handler := tracing.Middleware(tracer,
    // Exact paths
    tracing.WithExcludePaths("/health", "/metrics"),
    
    // Prefixes
    tracing.WithExcludePrefixes("/debug/", "/internal/"),
    
    // Patterns
    tracing.WithExcludePatterns(`^/v[0-9]+/internal/.*`),
)(mux)

Performance

Path exclusion is highly efficient:

  • Exact paths: O(1) hash map lookup
  • Prefixes: O(n) where n = number of prefixes
  • Patterns: O(p) where p = number of patterns

Even with 100+ excluded paths, overhead is negligible (~9ns per request).

Header Recording

Record specific request headers as span attributes.

Basic Header Recording

handler := tracing.Middleware(tracer,
    tracing.WithHeaders("X-Request-ID", "X-Correlation-ID"),
)(mux)

Headers are recorded as: http.request.header.{name}

Example span attributes:

  • http.request.header.x-request-id: "abc123"
  • http.request.header.x-correlation-id: "xyz789"

Security

Sensitive headers are automatically filtered and never recorded:

  • Authorization
  • Cookie
  • Set-Cookie
  • X-API-Key
  • X-Auth-Token
  • Proxy-Authorization
  • WWW-Authenticate

This protects against accidental credential exposure in traces.

// This is safe - Authorization header is filtered
handler := tracing.Middleware(tracer,
    tracing.WithHeaders(
        "X-Request-ID",
        "Authorization", // ← Automatically filtered, won't be recorded
        "X-Correlation-ID",
    ),
)(mux)

Header Name Normalization

Header names are case-insensitive and normalized to lowercase:

tracing.WithHeaders("X-Request-ID", "x-correlation-id", "User-Agent")

All recorded as lowercase:

  • http.request.header.x-request-id
  • http.request.header.x-correlation-id
  • http.request.header.user-agent

Query Parameter Recording

Record URL query parameters as span attributes.

Default Behavior

By default, all query parameters are recorded:

handler := tracing.Middleware(tracer)(mux)
// All params recorded by default

Request: GET /api/users?page=2&limit=10&user_id=123

Span attributes:

  • http.request.param.page: ["2"]
  • http.request.param.limit: ["10"]
  • http.request.param.user_id: ["123"]

Whitelist Parameters

Record only specific parameters:

handler := tracing.Middleware(tracer,
    tracing.WithRecordParams("page", "limit", "user_id"),
)(mux)

Only page, limit, and user_id are recorded. Others are ignored.

Blacklist Parameters

Exclude sensitive parameters while recording all others:

handler := tracing.Middleware(tracer,
    tracing.WithExcludeParams("password", "token", "api_key", "secret"),
)(mux)

All parameters recorded except password, token, api_key, and secret.

Disable Parameter Recording

Don’t record any query parameters:

handler := tracing.Middleware(tracer,
    tracing.WithoutParams(),
)(mux)

Useful when parameters may contain sensitive data.

Combined Parameter Options

// Record only safe parameters, explicitly exclude sensitive ones
handler := tracing.Middleware(tracer,
    tracing.WithRecordParams("page", "limit", "sort"),
    tracing.WithExcludeParams("api_key", "token"), // Takes precedence
)(mux)

Behavior: Blacklist takes precedence. Even if api_key is in the whitelist, it won’t be recorded.

Complete Middleware Example

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    
    "rivaas.dev/tracing"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create tracer
    tracer := tracing.MustNew(
        tracing.WithServiceName("user-api"),
        tracing.WithServiceVersion("v1.2.3"),
        tracing.WithOTLP("localhost:4317"),
        tracing.WithSampleRate(0.1), // 10% sampling
    )
    
    if err := tracer.Start(ctx); err != nil {
        log.Fatal(err)
    }
    defer tracer.Shutdown(context.Background())

    // Create HTTP handlers
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", handleUsers)
    mux.HandleFunc("/api/orders", handleOrders)
    mux.HandleFunc("/health", handleHealth)
    mux.HandleFunc("/metrics", handleMetrics)
    
    // Wrap with tracing middleware
    handler := tracing.MustMiddleware(tracer,
        // Exclude health/metrics endpoints
        tracing.WithExcludePaths("/health", "/metrics", "/ready", "/live"),
        
        // Exclude debug and internal routes
        tracing.WithExcludePrefixes("/debug/", "/internal/"),
        
        // Record correlation headers
        tracing.WithHeaders("X-Request-ID", "X-Correlation-ID", "User-Agent"),
        
        // Whitelist safe parameters
        tracing.WithRecordParams("page", "limit", "sort", "filter"),
        
        // Blacklist sensitive parameters
        tracing.WithExcludeParams("password", "token", "api_key"),
    )(mux)

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", handler))
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"users": []}`))
}

func handleOrders(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"orders": []}`))
}

func handleHealth(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func handleMetrics(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain")
    w.Write([]byte("# Metrics"))
}

Integration with Custom Context

Access the span from within your handlers:

func handleUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Add custom attributes to the current span
    tracing.SetSpanAttributeFromContext(ctx, "user.action", "view_profile")
    tracing.SetSpanAttributeFromContext(ctx, "user.id", getUserID(r))
    
    // Add events
    tracing.AddSpanEventFromContext(ctx, "profile_viewed",
        attribute.String("profile_id", "123"),
    )
    
    // Your handler logic...
}

Comparison with Metrics Middleware

The tracing middleware follows the same pattern as the metrics middleware:

AspectMetricsTracing
Main Functionmetrics.Middleware()tracing.Middleware()
Panic Versionmetrics.MustMiddleware()tracing.MustMiddleware()
Path Exclusionmetrics.WithExcludePaths()tracing.WithExcludePaths()
Prefix Exclusionmetrics.WithExcludePrefixes()tracing.WithExcludePrefixes()
Regex Exclusion✗ Not availabletracing.WithExcludePatterns()
Header Recordingmetrics.WithHeaders()tracing.WithHeaders()
Parameter Recording✗ Not availabletracing.WithRecordParams()

Performance

OperationTimeMemoryAllocations
Request overhead (100% sampling)~1.6 µs2.3 KB23
Path exclusion (100 paths)~9 ns0 B0
Start/Finish span~160 ns240 B3
Set attribute~3 ns0 B0

Best Practices

Always Exclude Health Checks

tracing.WithExcludePaths("/health", "/metrics", "/ready", "/live")

Health checks are high-frequency and low-value for tracing.

Use Sampling for High Traffic

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSampleRate(0.1), // 10% sampling
    tracing.WithOTLP("collector:4317"),
)

Reduces overhead and trace storage costs.

Record Correlation IDs

tracing.WithHeaders("X-Request-ID", "X-Correlation-ID", "X-Trace-ID")

Helps correlate traces with logs and other observability data.

Blacklist Sensitive Parameters

tracing.WithExcludeParams("password", "token", "api_key", "secret", "credit_card")

Prevents accidental exposure of credentials in traces.

Combine with Span Hooks

startHook := func(ctx context.Context, span trace.Span, req *http.Request) {
    // Add business context from request
    if tenantID := extractTenant(req); tenantID != "" {
        span.SetAttributes(attribute.String("tenant.id", tenantID))
    }
}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithSpanStartHook(startHook),
    tracing.WithOTLP("collector:4317"),
)

Next Steps

9.6 - Context Propagation

Propagate traces across service boundaries

Learn how to propagate trace context across service boundaries for distributed tracing.

What is Context Propagation?

Context propagation transmits trace information between services. Related operations appear in the same trace, even across network boundaries.

Why It Matters

Without context propagation:

  • Each service creates independent traces.
  • No visibility into end-to-end request flow.
  • Can’t trace requests across microservices.

With context propagation:

  • All services contribute to the same trace.
  • Complete visibility of distributed transactions.
  • Track requests across service boundaries.

W3C Trace Context

The tracing package uses W3C Trace Context format by default. It is:

  • Standard: Widely supported across languages and tools.
  • Propagated via HTTP headers:
    • traceparent: Contains trace ID, span ID, trace flags.
    • tracestate: Contains vendor-specific trace data.
  • Compatible: Works with Jaeger, Zipkin, OpenTelemetry, and more.

Extracting Trace Context

Extract trace context from incoming HTTP requests.

Automatic Extraction (Middleware)

The middleware automatically extracts trace context:

handler := tracing.Middleware(tracer)(mux)
// Context extraction is automatic

No additional code needed - spans automatically become part of the parent trace.

Manual Extraction

For manual span creation or custom HTTP handlers:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // Extract trace context from request headers
    ctx := tracer.ExtractTraceContext(r.Context(), r.Header)
    
    // Create span with propagated context
    ctx, span := tracer.StartSpan(ctx, "process-request")
    defer tracer.FinishSpan(span, http.StatusOK)
    
    // Span is now part of the distributed trace
}

What Gets Extracted

GET /api/users HTTP/1.1
Host: api.example.com
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
tracestate: vendor1=value1,vendor2=value2

The ExtractTraceContext method reads these headers and links the new span to the parent trace.

Injecting Trace Context

Inject trace context into outgoing HTTP requests.

Manual Injection

When making HTTP calls to other services:

func callDownstreamService(ctx context.Context, tracer *tracing.Tracer) error {
    // Create outgoing request
    req, err := http.NewRequestWithContext(ctx, "GET", "http://downstream/api", nil)
    if err != nil {
        return err
    }
    
    // Inject trace context into request headers
    tracer.InjectTraceContext(ctx, req.Header)
    
    // Make the request
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    
    return nil
}

What Gets Injected

The InjectTraceContext method adds headers to propagate the trace:

// Before injection
req.Header: {}

// After injection
req.Header: {
    "Traceparent": ["00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"],
    "Tracestate": ["vendor1=value1"],
}

Complete Distributed Tracing Example

Here’s a complete example showing service-to-service tracing:

Service A (Frontend)

package main

import (
    "context"
    "io"
    "log"
    "net/http"
    
    "rivaas.dev/tracing"
)

func main() {
    tracer := tracing.MustNew(
        tracing.WithServiceName("frontend-api"),
        tracing.WithOTLP("localhost:4317"),
    )
    tracer.Start(context.Background())
    defer tracer.Shutdown(context.Background())

    mux := http.NewServeMux()
    
    // Handler that calls downstream service
    mux.HandleFunc("/api/process", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // Create span for this service's work
        ctx, span := tracer.StartSpan(ctx, "frontend-process")
        defer tracer.FinishSpan(span, http.StatusOK)
        
        // Call downstream service with trace propagation
        result, err := callBackendService(ctx, tracer)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        
        w.Write([]byte(result))
    })
    
    handler := tracing.Middleware(tracer)(mux)
    log.Fatal(http.ListenAndServe(":8080", handler))
}

func callBackendService(ctx context.Context, tracer *tracing.Tracer) (string, error) {
    // Create span for outgoing call
    ctx, span := tracer.StartSpan(ctx, "call-backend-service")
    defer tracer.FinishSpan(span, http.StatusOK)
    
    // Create HTTP request
    req, err := http.NewRequestWithContext(ctx, "GET", 
        "http://localhost:8081/api/data", nil)
    if err != nil {
        return "", err
    }
    
    // Inject trace context for propagation
    tracer.InjectTraceContext(ctx, req.Header)
    
    // Make the request
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    
    body, _ := io.ReadAll(resp.Body)
    return string(body), nil
}

Service B (Backend)

package main

import (
    "context"
    "log"
    "net/http"
    
    "rivaas.dev/tracing"
)

func main() {
    tracer := tracing.MustNew(
        tracing.WithServiceName("backend-api"),
        tracing.WithOTLP("localhost:4317"),
    )
    tracer.Start(context.Background())
    defer tracer.Shutdown(context.Background())

    mux := http.NewServeMux()
    
    // Handler automatically receives trace context via middleware
    mux.HandleFunc("/api/data", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // This span is automatically part of the distributed trace
        ctx, span := tracer.StartSpan(ctx, "fetch-data")
        defer tracer.FinishSpan(span, http.StatusOK)
        
        tracer.SetSpanAttribute(span, "data.source", "database")
        
        // Simulate work
        data := fetchFromDatabase(ctx, tracer)
        
        w.Write([]byte(data))
    })
    
    // Middleware automatically extracts trace context
    handler := tracing.Middleware(tracer)(mux)
    log.Fatal(http.ListenAndServe(":8081", handler))
}

func fetchFromDatabase(ctx context.Context, tracer *tracing.Tracer) string {
    // Nested span - all part of the same trace
    ctx, span := tracer.StartSpan(ctx, "database-query")
    defer tracer.FinishSpan(span, http.StatusOK)
    
    tracer.SetSpanAttribute(span, "db.system", "postgresql")
    tracer.SetSpanAttribute(span, "db.query", "SELECT * FROM data")
    
    return "data from database"
}

Resulting Trace

The trace will show the complete flow:

frontend-api: GET /api/process
├─ frontend-api: frontend-process
│  └─ frontend-api: call-backend-service
│     └─ backend-api: GET /api/data
│        └─ backend-api: fetch-data
│           └─ backend-api: database-query

Context Helper Functions

Work with trace context without direct span references.

Get Trace Information

Retrieve trace and span IDs from context:

func logWithTraceInfo(ctx context.Context) {
    traceID := tracing.TraceID(ctx)
    spanID := tracing.SpanID(ctx)
    
    log.Printf("[trace=%s span=%s] Processing request", traceID, spanID)
}

Returns empty string if no active span.

Set Attributes from Context

Add attributes to the current span:

func processOrder(ctx context.Context, orderID string) {
    // Add attributes to current span in context
    tracing.SetSpanAttributeFromContext(ctx, "order.id", orderID)
    tracing.SetSpanAttributeFromContext(ctx, "order.status", "processing")
}

No-op if no active span.

Add Events from Context

Add events to the current span:

import "go.opentelemetry.io/otel/attribute"

func validatePayment(ctx context.Context, amount float64) {
    // Add event to current span
    tracing.AddSpanEventFromContext(ctx, "payment_validated",
        attribute.Float64("amount", amount),
        attribute.String("currency", "USD"),
    )
}

Get Trace Context

The context already contains trace information:

func passContextToWorker(ctx context.Context) {
    // Context already has trace info - just pass it
    go processInBackground(ctx)
}

func processInBackground(ctx context.Context) {
    // Trace context is preserved
    traceID := tracing.TraceID(ctx)
    log.Printf("Background work [trace=%s]", traceID)
}

Custom Propagators

Use alternative trace context formats.

B3 Propagation (Zipkin)

import "go.opentelemetry.io/contrib/propagators/b3"

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithCustomPropagator(b3.New()),
    tracing.WithOTLP("localhost:4317"),
)

Uses Zipkin’s B3 headers:

  • X-B3-TraceId
  • X-B3-SpanId
  • X-B3-Sampled

Jaeger Propagation

import "go.opentelemetry.io/contrib/propagators/jaeger"

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithCustomPropagator(jaeger.Jaeger{}),
    tracing.WithOTLP("localhost:4317"),
)

Uses Jaeger’s uber-trace-id header.

Composite Propagator

Support multiple formats simultaneously:

import (
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/contrib/propagators/b3"
)

composite := propagation.NewCompositeTextMapPropagator(
    propagation.TraceContext{}, // W3C Trace Context
    propagation.Baggage{},      // W3C Baggage
    b3.New(),                   // B3 (Zipkin)
)

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithCustomPropagator(composite),
    tracing.WithOTLP("localhost:4317"),
)

Best Practices

Always Propagate Context

Pass context through the entire call chain:

// ✓ Good - context propagates
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    result := doWork(ctx)  // Pass context
}

func doWork(ctx context.Context) string {
    ctx, span := tracer.StartSpan(ctx, "do-work")
    defer tracer.FinishSpan(span, http.StatusOK)
    
    return doMoreWork(ctx)  // Pass context
}

// ✗ Bad - context lost
func handler(w http.ResponseWriter, r *http.Request) {
    result := doWork(context.Background())  // Lost trace context!
}

Use Context for HTTP Clients

Always use http.NewRequestWithContext:

// ✓ Good
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
tracer.InjectTraceContext(ctx, req.Header)

// ✗ Bad - no context
req, _ := http.NewRequest("GET", url, nil)
tracer.InjectTraceContext(ctx, req.Header)  // Won't have span info

Inject Before Making Requests

Always inject trace context before sending requests:

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)

// Inject trace context
tracer.InjectTraceContext(ctx, req.Header)

// Then make request
resp, _ := http.DefaultClient.Do(req)

Extract in Custom Handlers

If not using middleware, extract context manually:

func customHandler(w http.ResponseWriter, r *http.Request) {
    // Extract trace context
    ctx := tracer.ExtractTraceContext(r.Context(), r.Header)
    
    // Use propagated context
    ctx, span := tracer.StartSpan(ctx, "custom-handler")
    defer tracer.FinishSpan(span, http.StatusOK)
}

Troubleshooting

Traces Not Connected Across Services

Problem: Each service shows separate traces instead of one distributed trace.

Solutions:

  1. Ensure both services use the same propagator format (default: W3C Trace Context)
  2. Verify InjectTraceContext is called before making requests
  3. Verify ExtractTraceContext is called when receiving requests
  4. Check that context is passed through the call chain
  5. Verify both services send to the same OTLP collector

Missing Spans in Distributed Trace

Problem: Some spans appear but others are missing.

Solutions:

  1. Check sampling rate - non-sampled requests won’t create spans
  2. Verify all services have tracing enabled
  3. Ensure context is passed to all operations
  4. Check for errors in span creation

Context Lost in Goroutines

Problem: Background goroutines don’t have trace context.

Solution: Pass context explicitly to goroutines:

func handler(ctx context.Context) {
    // ✓ Good - pass context
    go func(ctx context.Context) {
        ctx, span := tracer.StartSpan(ctx, "background-work")
        defer tracer.FinishSpan(span, http.StatusOK)
    }(ctx)
    
    // ✗ Bad - lost context
    go func() {
        ctx := context.Background()  // Lost trace context!
        ctx, span := tracer.StartSpan(ctx, "background-work")
        defer tracer.FinishSpan(span, http.StatusOK)
    }()
}

Next Steps

9.7 - Testing

Test your tracing implementation with provided utilities

The tracing package provides testing utilities to help you write tests for traced applications.

Testing Utilities

Three helper functions are provided for testing:

FunctionPurposeProvider
TestingTracer()Create tracer for tests.Noop
TestingTracerWithStdout()Create tracer with output.Stdout
TestingMiddleware()Create test middleware.Noop

TestingTracer

Create a tracer configured for unit tests.

Basic Usage

import (
    "testing"
    "rivaas.dev/tracing"
)

func TestSomething(t *testing.T) {
    t.Parallel()
    
    tracer := tracing.TestingTracer(t)
    // Use tracer in test...
}

Features

  • Noop provider: No actual tracing, minimal overhead.
  • Automatic cleanup: Shutdown() called via t.Cleanup().
  • Safe for parallel tests: Each test gets its own tracer.
  • Default configuration:
    • Service name: "test-service".
    • Service version: "v1.0.0".
    • Sample rate: 1.0 (100%).

With Custom Options

Override defaults with your own options.

func TestWithCustomConfig(t *testing.T) {
    tracer := tracing.TestingTracer(t,
        tracing.WithServiceName("my-test-service"),
        tracing.WithSampleRate(0.5),
    )
    // Use tracer...
}

Complete Test Example

func TestProcessOrder(t *testing.T) {
    t.Parallel()
    
    tracer := tracing.TestingTracer(t)
    ctx := context.Background()
    
    // Test your traced function
    result, err := processOrder(ctx, tracer, "order-123")
    
    assert.NoError(t, err)
    assert.Equal(t, "success", result)
}

func processOrder(ctx context.Context, tracer *tracing.Tracer, orderID string) (string, error) {
    ctx, span := tracer.StartSpan(ctx, "process-order")
    defer tracer.FinishSpan(span, 200)
    
    tracer.SetSpanAttribute(span, "order.id", orderID)
    
    return "success", nil
}

TestingTracerWithStdout

Create a tracer that prints traces to stdout for debugging.

When to Use

  • Debugging test failures
  • Verifying span creation
  • Checking span attributes and events
  • Understanding trace structure

Basic Usage

func TestWithDebugOutput(t *testing.T) {
    tracer := tracing.TestingTracerWithStdout(t)
    
    ctx := context.Background()
    ctx, span := tracer.StartSpan(ctx, "test-operation")
    defer tracer.FinishSpan(span, 200)
    
    tracer.SetSpanAttribute(span, "test.value", "debug")
}

Output

When run, you’ll see pretty-printed JSON traces:

{
  "Name": "test-operation",
  "SpanContext": {
    "TraceID": "3f3c5e4d...",
    "SpanID": "a1b2c3d4..."
  },
  "Attributes": [
    {
      "Key": "test.value",
      "Value": {"Type": "STRING", "Value": "debug"}
    }
  ]
}

With Custom Options

func TestDebugWithOptions(t *testing.T) {
    tracer := tracing.TestingTracerWithStdout(t,
        tracing.WithServiceName("debug-service"),
        tracing.WithSampleRate(1.0),
    )
    // Use tracer...
}

TestingMiddleware

Create HTTP middleware for testing traced handlers.

Basic Usage

import (
    "net/http"
    "net/http/httptest"
    "testing"
    
    "rivaas.dev/tracing"
)

func TestHTTPHandler(t *testing.T) {
    t.Parallel()
    
    // Create test middleware
    middleware := tracing.TestingMiddleware(t)
    
    // Wrap your handler
    handler := middleware(http.HandlerFunc(myHandler))
    
    // Test the handler
    req := httptest.NewRequest("GET", "/api/users", nil)
    rec := httptest.NewRecorder()
    
    handler.ServeHTTP(rec, req)
    
    assert.Equal(t, http.StatusOK, rec.Code)
}

func myHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

With Middleware Options

func TestWithMiddlewareOptions(t *testing.T) {
    middleware := tracing.TestingMiddleware(t,
        tracing.WithExcludePaths("/health"),
        tracing.WithHeaders("X-Request-ID"),
    )
    
    handler := middleware(http.HandlerFunc(myHandler))
    // Test...
}

Testing Path Exclusion

func TestPathExclusion(t *testing.T) {
    middleware := tracing.TestingMiddleware(t,
        tracing.WithExcludePaths("/health"),
    )
    
    handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // This handler should not create a span for /health
        w.WriteHeader(http.StatusOK)
    }))
    
    // Request to excluded path
    req := httptest.NewRequest("GET", "/health", nil)
    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)
    
    assert.Equal(t, http.StatusOK, rec.Code)
}

TestingMiddlewareWithTracer

Use a custom tracer with test middleware.

When to Use

  • Need specific tracer configuration
  • Testing with stdout output
  • Custom sampling rates
  • Specific provider behavior

Basic Usage

func TestWithCustomTracer(t *testing.T) {
    // Create custom tracer
    tracer := tracing.TestingTracer(t,
        tracing.WithSampleRate(0.5),
    )
    
    // Create middleware with custom tracer
    middleware := tracing.TestingMiddlewareWithTracer(t, tracer,
        tracing.WithExcludePaths("/metrics"),
    )
    
    handler := middleware(http.HandlerFunc(myHandler))
    // Test...
}

With Stdout Output

func TestDebugMiddleware(t *testing.T) {
    // Create tracer with stdout
    tracer := tracing.TestingTracerWithStdout(t)
    
    // Create middleware with that tracer
    middleware := tracing.TestingMiddlewareWithTracer(t, tracer)
    
    handler := middleware(http.HandlerFunc(myHandler))
    
    // Test and see trace output
    req := httptest.NewRequest("GET", "/api/users", nil)
    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)
}

Testing Patterns

Table-Driven Tests

func TestHandlers(t *testing.T) {
    tests := []struct {
        name       string
        path       string
        wantStatus int
    }{
        {"users endpoint", "/api/users", http.StatusOK},
        {"orders endpoint", "/api/orders", http.StatusOK},
        {"health check", "/health", http.StatusOK},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
            middleware := tracing.TestingMiddleware(t)
            handler := middleware(http.HandlerFunc(myHandler))
            
            req := httptest.NewRequest("GET", tt.path, nil)
            rec := httptest.NewRecorder()
            
            handler.ServeHTTP(rec, req)
            
            assert.Equal(t, tt.wantStatus, rec.Code)
        })
    }
}

Testing Span Attributes

func TestSpanAttributes(t *testing.T) {
    t.Parallel()
    
    tracer := tracing.TestingTracer(t)
    ctx := context.Background()
    
    // Create span and add attributes
    ctx, span := tracer.StartSpan(ctx, "test-span")
    tracer.SetSpanAttribute(span, "user.id", "123")
    tracer.SetSpanAttribute(span, "user.role", "admin")
    tracer.FinishSpan(span, 200)
    
    // With noop provider, this doesn't record anything,
    // but ensures the code doesn't panic or error
}

Testing Context Propagation

func TestContextPropagation(t *testing.T) {
    t.Parallel()
    
    tracer := tracing.TestingTracer(t)
    ctx := context.Background()
    
    // Create parent span
    ctx, parentSpan := tracer.StartSpan(ctx, "parent")
    defer tracer.FinishSpan(parentSpan, 200)
    
    // Get trace ID
    traceID := tracing.TraceID(ctx)
    assert.NotEmpty(t, traceID)
    
    // Create child span - should have same trace ID
    ctx, childSpan := tracer.StartSpan(ctx, "child")
    defer tracer.FinishSpan(childSpan, 200)
    
    childTraceID := tracing.TraceID(ctx)
    assert.Equal(t, traceID, childTraceID, "child should have same trace ID")
}

Testing Trace Injection/Extraction

func TestTraceInjection(t *testing.T) {
    t.Parallel()
    
    tracer := tracing.TestingTracer(t)
    ctx := context.Background()
    
    // Create span
    ctx, span := tracer.StartSpan(ctx, "test")
    defer tracer.FinishSpan(span, 200)
    
    // Inject into headers
    headers := http.Header{}
    tracer.InjectTraceContext(ctx, headers)
    
    // Verify headers were set
    assert.NotEmpty(t, headers.Get("Traceparent"))
    
    // Extract from headers
    newCtx := context.Background()
    newCtx = tracer.ExtractTraceContext(newCtx, headers)
    
    // Both contexts should have the same trace ID
    originalTraceID := tracing.TraceID(ctx)
    extractedTraceID := tracing.TraceID(newCtx)
    assert.Equal(t, originalTraceID, extractedTraceID)
}

Integration Test Example

func TestAPIWithTracing(t *testing.T) {
    t.Parallel()
    
    // Create tracer
    tracer := tracing.TestingTracer(t)
    
    // Create test server with tracing
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // Add attributes from context
        tracing.SetSpanAttributeFromContext(ctx, "handler", "users")
        
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"users": []}`))
    })
    
    handler := tracing.TestingMiddlewareWithTracer(t, tracer)(mux)
    server := httptest.NewServer(handler)
    defer server.Close()
    
    // Make request
    resp, err := http.Get(server.URL + "/api/users")
    require.NoError(t, err)
    defer resp.Body.Close()
    
    assert.Equal(t, http.StatusOK, resp.StatusCode)
}

Benchmarking

Test tracing overhead in benchmarks:

func BenchmarkTracedHandler(b *testing.B) {
    tracer := tracing.TestingTracer(b)
    
    middleware := tracing.TestingMiddlewareWithTracer(b, tracer)
    handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))
    
    req := httptest.NewRequest("GET", "/", nil)
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        rec := httptest.NewRecorder()
        handler.ServeHTTP(rec, req)
    }
}

func BenchmarkUntracedHandler(b *testing.B) {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
    
    req := httptest.NewRequest("GET", "/", nil)
    
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        rec := httptest.NewRecorder()
        handler.ServeHTTP(rec, req)
    }
}

Best Practices

Use t.Parallel()

Enable parallel test execution:

func TestSomething(t *testing.T) {
    t.Parallel() // Safe - each test gets its own tracer
    
    tracer := tracing.TestingTracer(t)
    // Test...
}

Don’t Call Shutdown Manually

The test utilities handle cleanup automatically:

// ✓ Good - automatic cleanup
func TestGood(t *testing.T) {
    tracer := tracing.TestingTracer(t)
    // No need to call Shutdown()
}

// ✗ Bad - redundant manual cleanup
func TestBad(t *testing.T) {
    tracer := tracing.TestingTracer(t)
    defer tracer.Shutdown(context.Background()) // Unnecessary
}

Use Stdout for Debugging Only

Don’t use TestingTracerWithStdout for regular tests:

// ✓ Good - stdout only when debugging
func TestDebug(t *testing.T) {
    if testing.Verbose() {
        tracer := tracing.TestingTracerWithStdout(t)
    } else {
        tracer := tracing.TestingTracer(t)
    }
}

// ✗ Bad - noisy test output
func TestRegular(t *testing.T) {
    tracer := tracing.TestingTracerWithStdout(t) // Too verbose
}

Test Error Cases

func TestErrorHandling(t *testing.T) {
    t.Parallel()
    
    tracer := tracing.TestingTracer(t)
    ctx := context.Background()
    
    ctx, span := tracer.StartSpan(ctx, "test-error")
    defer tracer.FinishSpan(span, http.StatusInternalServerError)
    
    tracer.SetSpanAttribute(span, "error", true)
    tracer.SetSpanAttribute(span, "error.message", "test error")
}

Comparison with Other Packages

Testing utilities follow the same pattern:

PackageTesting FunctionProvider
Metricsmetrics.TestingRecorder()Noop
Metricsmetrics.TestingRecorderWithPrometheus()Prometheus
Tracingtracing.TestingTracer()Noop
Tracingtracing.TestingTracerWithStdout()Stdout

Next Steps

9.8 - Examples

Real-world tracing configurations and patterns

Explore complete examples and best practices for production-ready tracing configurations.

Production Configuration

A production-ready tracing setup with all recommended settings.

package main

import (
    "context"
    "log"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "time"
    
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/trace"
    "rivaas.dev/tracing"
)

func main() {
    // Create context for graceful shutdown
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    // Create logger for internal events
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    }))

    // Create tracer with production settings
    tracer, err := tracing.New(
        tracing.WithServiceName("user-api"),
        tracing.WithServiceVersion(os.Getenv("VERSION")),
        tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),
        tracing.WithSampleRate(0.1), // 10% sampling
        tracing.WithLogger(logger),
        tracing.WithSpanStartHook(enrichSpan),
        tracing.WithSpanFinishHook(recordMetrics),
    )
    if err != nil {
        log.Fatalf("Failed to initialize tracing: %v", err)
    }

    // Start tracer (required for OTLP)
    if err := tracer.Start(ctx); err != nil {
        log.Fatalf("Failed to start tracer: %v", err)
    }

    // Ensure graceful shutdown
    defer func() {
        shutdownCtx, shutdownCancel := context.WithTimeout(
            context.Background(), 5*time.Second)
        defer shutdownCancel()
        
        if err := tracer.Shutdown(shutdownCtx); err != nil {
            log.Printf("Error shutting down tracer: %v", err)
        }
    }()

    // Create HTTP handlers
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", handleUsers)
    mux.HandleFunc("/api/orders", handleOrders)
    mux.HandleFunc("/health", handleHealth)
    mux.HandleFunc("/metrics", handleMetrics)

    // Wrap with tracing middleware
    handler := tracing.MustMiddleware(tracer,
        // Exclude observability endpoints
        tracing.WithExcludePaths("/health", "/metrics", "/ready", "/live"),
        
        // Exclude debug endpoints
        tracing.WithExcludePrefixes("/debug/", "/internal/"),
        
        // Record correlation headers
        tracing.WithHeaders("X-Request-ID", "X-Correlation-ID"),
        
        // Whitelist safe parameters
        tracing.WithRecordParams("page", "limit", "sort"),
        
        // Blacklist sensitive parameters
        tracing.WithExcludeParams("password", "token", "api_key"),
    )(mux)

    // Start server
    log.Printf("Server starting on :8080")
    if err := http.ListenAndServe(":8080", handler); err != nil {
        log.Fatal(err)
    }
}

// enrichSpan adds custom business context to spans
func enrichSpan(ctx context.Context, span trace.Span, req *http.Request) {
    // Add tenant identifier
    if tenantID := req.Header.Get("X-Tenant-ID"); tenantID != "" {
        span.SetAttributes(attribute.String("tenant.id", tenantID))
    }
    
    // Add user information
    if userID := req.Header.Get("X-User-ID"); userID != "" {
        span.SetAttributes(attribute.String("user.id", userID))
    }
    
    // Add deployment information
    span.SetAttributes(
        attribute.String("deployment.region", os.Getenv("REGION")),
        attribute.String("deployment.environment", os.Getenv("ENVIRONMENT")),
    )
}

// recordMetrics records custom metrics based on span completion
func recordMetrics(span trace.Span, statusCode int) {
    // Record error metrics
    if statusCode >= 500 {
        // metrics.IncrementServerErrors()
    }
    
    // Record slow request metrics
    // Could calculate duration and record if above threshold
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    // Add custom span attributes
    tracing.SetSpanAttributeFromContext(ctx, "handler", "users")
    tracing.SetSpanAttributeFromContext(ctx, "operation", "list")
    
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"users": []}`))
}

func handleOrders(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    
    tracing.SetSpanAttributeFromContext(ctx, "handler", "orders")
    
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"orders": []}`))
}

func handleHealth(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func handleMetrics(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/plain")
    w.Write([]byte("# Metrics"))
}

Development Configuration

A development setup with verbose output for debugging.

package main

import (
    "context"
    "log"
    "log/slog"
    "net/http"
    "os"
    
    "rivaas.dev/tracing"
)

func main() {
    // Create logger with debug level
    logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelDebug,
    }))

    // Create tracer with development settings
    tracer := tracing.MustNew(
        tracing.WithServiceName("user-api"),
        tracing.WithServiceVersion("dev"),
        tracing.WithStdout(),          // Print traces to console
        tracing.WithSampleRate(1.0),   // Trace everything
        tracing.WithLogger(logger),    // Verbose logging
    )
    defer tracer.Shutdown(context.Background())

    // Create simple handler
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello, World!"))
    })

    // Minimal middleware - trace everything
    handler := tracing.MustMiddleware(tracer)(mux)

    log.Println("Development server on :8080")
    log.Fatal(http.ListenAndServe(":8080", handler))
}

Microservices Example

Complete distributed tracing across multiple services.

Service A (API Gateway)

package main

import (
    "context"
    "io"
    "log"
    "net/http"
    
    "rivaas.dev/tracing"
)

func main() {
    tracer := tracing.MustNew(
        tracing.WithServiceName("api-gateway"),
        tracing.WithServiceVersion("v1.0.0"),
        tracing.WithOTLP("localhost:4317"),
    )
    tracer.Start(context.Background())
    defer tracer.Shutdown(context.Background())

    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // Call user service
        users, err := callUserService(ctx, tracer)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(users))
    })

    handler := tracing.MustMiddleware(tracer,
        tracing.WithExcludePaths("/health"),
    )(mux)

    log.Fatal(http.ListenAndServe(":8080", handler))
}

func callUserService(ctx context.Context, tracer *tracing.Tracer) (string, error) {
    // Create span for outgoing call
    ctx, span := tracer.StartSpan(ctx, "call-user-service")
    defer tracer.FinishSpan(span, http.StatusOK)
    
    // Create request
    req, err := http.NewRequestWithContext(ctx, "GET", 
        "http://localhost:8081/users", nil)
    if err != nil {
        return "", err
    }
    
    // Inject trace context
    tracer.InjectTraceContext(ctx, req.Header)
    
    // Make request
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()
    
    body, _ := io.ReadAll(resp.Body)
    return string(body), nil
}

Service B (User Service)

package main

import (
    "context"
    "log"
    "net/http"
    
    "rivaas.dev/tracing"
)

func main() {
    tracer := tracing.MustNew(
        tracing.WithServiceName("user-service"),
        tracing.WithServiceVersion("v1.0.0"),
        tracing.WithOTLP("localhost:4317"),
    )
    tracer.Start(context.Background())
    defer tracer.Shutdown(context.Background())

    mux := http.NewServeMux()
    mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
        ctx := r.Context()
        
        // This span is part of the distributed trace
        ctx, span := tracer.StartSpan(ctx, "fetch-users")
        defer tracer.FinishSpan(span, http.StatusOK)
        
        tracer.SetSpanAttribute(span, "db.system", "postgresql")
        
        // Simulate database query
        users := `{"users": [{"id": 1, "name": "Alice"}]}`
        
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(users))
    })

    // Middleware automatically extracts trace context
    handler := tracing.MustMiddleware(tracer)(mux)

    log.Fatal(http.ListenAndServe(":8081", handler))
}

Environment-Based Configuration

Configure tracing based on environment.

package main

import (
    "context"
    "log"
    "log/slog"
    "net/http"
    "os"
    
    "rivaas.dev/tracing"
)

func main() {
    tracer := createTracer(os.Getenv("ENVIRONMENT"))
    defer tracer.Shutdown(context.Background())

    // If OTLP, start the tracer
    if tracer.GetProvider() == tracing.OTLPProvider || 
       tracer.GetProvider() == tracing.OTLPHTTPProvider {
        if err := tracer.Start(context.Background()); err != nil {
            log.Fatal(err)
        }
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Hello"))
    })

    handler := tracing.MustMiddleware(tracer)(mux)
    log.Fatal(http.ListenAndServe(":8080", handler))
}

func createTracer(env string) *tracing.Tracer {
    serviceName := os.Getenv("SERVICE_NAME")
    if serviceName == "" {
        serviceName = "my-api"
    }

    version := os.Getenv("VERSION")
    if version == "" {
        version = "dev"
    }

    opts := []tracing.Option{
        tracing.WithServiceName(serviceName),
        tracing.WithServiceVersion(version),
    }

    switch env {
    case "production":
        opts = append(opts,
            tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),
            tracing.WithSampleRate(0.1), // 10% sampling
        )
    case "staging":
        opts = append(opts,
            tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),
            tracing.WithSampleRate(0.5), // 50% sampling
        )
    default: // development
        logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
        opts = append(opts,
            tracing.WithStdout(),
            tracing.WithSampleRate(1.0), // 100% sampling
            tracing.WithLogger(logger),
        )
    }

    return tracing.MustNew(opts...)
}

Database Tracing Example

Trace database operations.

package main

import (
    "context"
    "database/sql"
    "net/http"
    
    "go.opentelemetry.io/otel/attribute"
    "rivaas.dev/tracing"
)

type UserRepository struct {
    db     *sql.DB
    tracer *tracing.Tracer
}

func (r *UserRepository) GetUser(ctx context.Context, userID int) (*User, error) {
    // Create span for database operation
    ctx, span := r.tracer.StartSpan(ctx, "db-get-user")
    defer r.tracer.FinishSpan(span, http.StatusOK)
    
    // Add database attributes
    r.tracer.SetSpanAttribute(span, "db.system", "postgresql")
    r.tracer.SetSpanAttribute(span, "db.operation", "SELECT")
    r.tracer.SetSpanAttribute(span, "db.table", "users")
    r.tracer.SetSpanAttribute(span, "user.id", userID)
    
    // Execute query
    query := "SELECT id, name, email FROM users WHERE id = $1"
    r.tracer.SetSpanAttribute(span, "db.query", query)
    
    var user User
    err := r.db.QueryRowContext(ctx, query, userID).Scan(
        &user.ID, &user.Name, &user.Email)
    if err != nil {
        r.tracer.SetSpanAttribute(span, "error", true)
        r.tracer.SetSpanAttribute(span, "error.message", err.Error())
        return nil, err
    }
    
    // Add event for successful query
    r.tracer.AddSpanEvent(span, "user_found",
        attribute.Int("user.id", user.ID),
    )
    
    return &user, nil
}

type User struct {
    ID    int
    Name  string
    Email string
}

Custom Span Events Example

Record significant events within spans.

func processOrder(ctx context.Context, tracer *tracing.Tracer, order *Order) error {
    ctx, span := tracer.StartSpan(ctx, "process-order")
    defer tracer.FinishSpan(span, http.StatusOK)
    
    tracer.SetSpanAttribute(span, "order.id", order.ID)
    tracer.SetSpanAttribute(span, "order.total", order.Total)
    
    // Event: Order validation started
    tracer.AddSpanEvent(span, "validation_started")
    
    if err := validateOrder(ctx, tracer, order); err != nil {
        tracer.AddSpanEvent(span, "validation_failed",
            attribute.String("error", err.Error()),
        )
        return err
    }
    
    tracer.AddSpanEvent(span, "validation_passed")
    
    // Event: Payment processing started
    tracer.AddSpanEvent(span, "payment_started",
        attribute.Float64("amount", order.Total),
    )
    
    if err := chargePayment(ctx, tracer, order); err != nil {
        tracer.AddSpanEvent(span, "payment_failed",
            attribute.String("error", err.Error()),
        )
        return err
    }
    
    tracer.AddSpanEvent(span, "payment_succeeded",
        attribute.String("transaction_id", "TXN123"),
    )
    
    // Event: Order completed
    tracer.AddSpanEvent(span, "order_completed")
    
    return nil
}

Performance Benchmarks

Actual performance measurements from the tracing package:

// Operation                              Time        Memory      Allocations
// Request overhead (100% sampling)       ~1.6 µs     2.3 KB      23
// Start/Finish span                      ~160 ns     240 B       3
// Set attribute                          ~3 ns       0 B         0
// Path exclusion (100 paths)             ~9 ns       0 B         0

Performance Tips

  1. Use sampling for high-traffic endpoints:

    tracing.WithSampleRate(0.1) // 10% sampling
    
  2. Exclude health checks:

    tracing.WithExcludePaths("/health", "/metrics", "/ready")
    
  3. Minimize attributes in hot paths:

    // Only add essential attributes in critical code paths
    tracer.SetSpanAttribute(span, "request.id", requestID)
    
  4. Use path prefixes over regex when possible:

    tracing.WithExcludePrefixes("/debug/") // Faster than regex
    

Docker Compose Setup

Complete tracing infrastructure with Jaeger:

version: '3.8'
services:
  # Your application
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - OTLP_ENDPOINT=otel-collector:4317
      - ENVIRONMENT=development
    depends_on:
      - otel-collector

  # OpenTelemetry Collector
  otel-collector:
    image: otel/opentelemetry-collector:latest
    command: ["--config=/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
    depends_on:
      - jaeger

  # Jaeger for trace visualization
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # Jaeger UI
      - "14250:14250" # Model.proto

OpenTelemetry Collector configuration (otel-collector-config.yaml):

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 10s
    send_batch_size: 1024

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger]

Next Steps