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

Return to the regular view of this page.

Documentation

Welcome to the Rivaas documentation! Rivaas is a web framework for Go. It includes high-performance routing, request binding and validation, automatic OpenAPI generation, and OpenTelemetry observability.

What is Rivaas?

Rivaas is a modular Go web framework for building production-ready APIs and web applications. The name comes from ریواس (Rivās), a wild rhubarb plant from the mountains of Iran. This plant grows in harsh conditions at high altitudes.

Like its namesake, Rivaas is:

  • 🛡️ Resilient — Built for production. Includes graceful shutdown, health checks, and panic recovery.
  • ⚡ Lightweight — Minimal overhead (low latency, zero allocations). No loss of features.
  • 🔧 Adaptive — Works locally, in containers, or across distributed systems.
  • 📦 Self-sufficient — Integrated observability. No external dependencies to add.

Key Features

  • High Performance — High throughput. Uses radix tree router and Bloom filter optimization. See Router Performance for benchmarks.
  • Production-Ready — Includes graceful shutdown, health endpoints, panic recovery, and mTLS support.
  • Cloud-Native — Built with OpenTelemetry. Supports Prometheus, OTLP, and Jaeger.
  • Modular Architecture — Each package works alone. No need for the full framework.
  • Developer-Friendly — Sensible defaults. Progressive disclosure. Functional options pattern.
  • Type-Safe — Request binding and validation with clear error messages.

Quick Start

Installation (requires Go 1.25+):

go get rivaas.dev/app

Hello World:

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

Documentation Structure

Getting Started

New to Rivaas? Start here. Learn the basics and get your first application running.

Guides

Step-by-step tutorials for common tasks. Learn how to set up observability, configure middleware, and deploy to production. Each package guide includes practical examples.

Reference

Detailed API documentation. Covers all packages, configuration options, and advanced features.

Package Overview

Rivaas is organized into independent, standalone packages:

Core Packages

App

Web framework with integrated observability, lifecycle management, and graceful shutdown.

Learn more →

Router

High-performance HTTP router with sub-microsecond latency. See Performance for current benchmarks.

Learn more →

Configuration

Config

Configuration management. Supports files, environment variables, Consul, and built-in validation.

Learn more →

Data Handling

Binding

Request binding from multiple sources. Supports JSON, XML, YAML, TOML, MessagePack, and Protocol Buffers.

Learn more →

Validation

Struct validation with tags, JSON Schema, and custom interfaces.

Learn more →

Observability

Logging

Structured logging with Go’s standard log/slog. Includes trace correlation and sensitive data redaction.

Learn more →

Metrics

OpenTelemetry metrics collection. Supports Prometheus, OTLP, and stdout exporters.

Learn more →

Tracing

Distributed tracing with OpenTelemetry. Supports OTLP, Jaeger, and stdout.

Learn more →

API & Errors

OpenAPI

Automatic OpenAPI 3.0/3.1 specification generation from Go code. Includes Swagger UI support.

Learn more →

Errors

Error formatting. Supports RFC 9457 (Problem Details) and JSON:API specifications.

Learn more →

Philosophy

Every package in Rivaas follows these design principles:

  1. Developer Experience First — Sensible defaults, discoverable APIs, clear errors
  2. Functional Options Pattern — Backward-compatible, self-documenting configuration
  3. Standalone Packages — Use any package without the full framework
  4. Separation of Concerns — Each package has a single, well-defined responsibility

Community & Support

Learn More

Next Steps

1 - Getting Started

Start using Rivaas in minutes

Learn how to install Rivaas and build your first web application. This guide shows you how to create a running API with production-ready features.

What You’ll Learn

This section shows you how to:

  • ✅ Install and verify Rivaas
  • ✅ Build a complete REST API with multiple routes
  • ✅ Configure your application for different environments
  • ✅ Add middleware for CORS, authentication, and more
  • ✅ Test your application
  • ✅ Deploy with confidence

Prerequisites

Before you begin, you need:

  • Go 1.25 or higher installed (Download Go)
  • Basic Go programming knowledge
  • Basic HTTP and REST API knowledge

Check your Go installation:

go version
# Should output: go version go1.25.x ...

Learning Path

Follow these steps in order:

1. Installation

Install the Rivaas framework and verify your setup.

What you’ll do:

  • Install the app package
  • Verify installation with a test program
  • Troubleshoot common issues

2. Your First Application

Build a complete Hello World API with multiple endpoints.

What you’ll do:

  • Create a new project
  • Define routes and handlers
  • Handle JSON requests and responses
  • Set up graceful shutdown
  • Test your API

3. Configuration

Learn essential configuration options for your application.

What you’ll do:

  • Set service metadata
  • Configure health endpoints
  • Enable observability (logging, metrics, tracing)
  • Set up environment-specific configuration
  • Understand server timeouts

4. Using Middleware

Add functionality that works across all routes.

What you’ll do:

  • Learn middleware concepts
  • Use built-in middleware (CORS, request ID, auth)
  • Create custom middleware
  • Apply middleware globally or to specific routes
  • Learn execution order

5. Next Steps

Continue your journey beyond the basics.

What you’ll explore:

  • Production deployment
  • Advanced routing patterns
  • Testing strategies
  • Example applications

Quick Start

Want to skip ahead? Here’s the minimal setup:

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

This creates a basic API server. Continue through the guide to learn about configuration, middleware, and production practices.

Need Help?

Ready?

Start with Installation →

1.1 - Installation

Install Rivaas and verify your setup

Installing Rivaas is easy. Use it as a complete framework with the app package or use individual packages as needed.

Install the Full Framework

The app package provides a complete web framework. It includes everything you need to build production-ready APIs:

go get rivaas.dev/app

This installs the main framework with all packages (router, logging, metrics, tracing, etc.).

Install Individual Packages

Rivaas packages work independently. Install only what you need:

Core
# High-performance router
go get rivaas.dev/router

# Full framework
go get rivaas.dev/app
Data
# Request binding
go get rivaas.dev/binding

# Validation
go get rivaas.dev/validation

# Configuration
go get rivaas.dev/config
Observability
# Structured logging
go get rivaas.dev/logging

# Metrics collection
go get rivaas.dev/metrics

# Distributed tracing
go get rivaas.dev/tracing
API & Errors
# OpenAPI generation
go get rivaas.dev/openapi

# Error formatting
go get rivaas.dev/errors

Verify Installation

Create a simple test file to verify your 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.Fatalf("Failed to create app: %v", err)
    }

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

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

    log.Println("Test server running on http://localhost:8080")
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

Run it:

go run main.go

Test it in another terminal:

curl http://localhost:8080
# Output: {"message":"✅ Rivaas installed successfully!"}

Press Ctrl+C to stop the server gracefully.

System Requirements

  • Go Version: 1.25 or higher
  • Operating Systems: Linux, macOS, Windows
  • Architecture: amd64, arm64

Updating Rivaas

To update to the latest version:

go get -u rivaas.dev/app

To update a specific package:

go get -u rivaas.dev/router

Development Dependencies

For development, you may want additional tools:

# Install Go tools (optional but recommended)
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

Troubleshooting

Go Version Issues

If you see an error about Go version:

go: module rivaas.dev/app requires go >= 1.25

Update your Go installation to 1.25 or higher from go.dev/dl.

Module Cache Issues

If installation fails, try cleaning the module cache:

go clean -modcache
go get rivaas.dev/app

Network Issues

If you’re behind a proxy or firewall:

# Set proxy (if needed)
export GOPROXY=https://proxy.golang.org,direct

# Or use a custom proxy
export GOPROXY=https://goproxy.io,direct

Next Steps

Now that you have Rivaas installed, build your first application:

Build Your First Application →

1.2 - Your First Application

Build a complete REST API quickly

Build a simple REST API to learn Rivaas basics. You’ll create a working application with multiple routes, JSON responses, and graceful shutdown.

Create Your Project

Create a new directory and initialize a Go module:

mkdir hello-rivaas
cd hello-rivaas
go mod init example.com/hello-rivaas

Install Rivaas

go get rivaas.dev/app

Write Your Application

Create a file named main.go:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    "rivaas.dev/app"
)

func main() {
    // Create a new Rivaas application
    a := app.MustNew(
        app.WithServiceName("hello-rivaas"),
        app.WithServiceVersion("v1.0.0"),
    )

    // Define routes
    a.GET("/", handleRoot)
    a.GET("/hello/:name", handleHello)
    a.POST("/echo", handleEcho)

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

    // Start the server
    log.Println("🚀 Starting server on http://localhost:8080")
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

// handleRoot returns a welcome message
func handleRoot(c *app.Context) {
    c.JSON(http.StatusOK, map[string]string{
        "message": "Welcome to Rivaas!",
        "version": "v1.0.0",
    })
}

// handleHello greets a user by name
func handleHello(c *app.Context) {
    name := c.Param("name")
    c.JSON(http.StatusOK, map[string]string{
        "message": "Hello, " + name + "!",
    })
}

// handleEcho echoes back the request body
func handleEcho(c *app.Context) {
    var body map[string]any
    if err := c.Bind(&body); err != nil {
        c.JSON(http.StatusBadRequest, map[string]string{
            "error": "Invalid JSON",
        })
        return
    }

    c.JSON(http.StatusOK, map[string]any{
        "echo": body,
    })
}

Run Your Application

Start the server:

go run main.go

You should see output like:

🚀 Starting server on http://localhost:8080

Test Your API

Open a new terminal and test the endpoints:

Test the root endpoint

curl http://localhost:8080/

Response:

{
  "message": "Welcome to Rivaas!",
  "version": "v1.0.0"
}

Test the greeting endpoint

curl http://localhost:8080/hello/World

Response:

{
  "message": "Hello, World!"
}

Test the echo endpoint

curl -X POST http://localhost:8080/echo \
  -H "Content-Type: application/json" \
  -d '{"name": "Rivaas", "type": "framework"}'

Response:

{
  "echo": {
    "name": "Rivaas",
    "type": "framework"
  }
}

Understanding the Code

Here’s what each part does:

1. Creating the Application

a := app.MustNew(
    app.WithServiceName("hello-rivaas"),
    app.WithServiceVersion("v1.0.0"),
)
  • MustNew() creates a new application. Panics on error. Use in main() functions.
  • WithServiceName() sets the service name.
  • WithServiceVersion() sets the version.

2. Defining Routes

a.GET("/", handleRoot)
a.GET("/hello/:name", handleHello)
a.POST("/echo", handleEcho)
  • GET() and POST() register route handlers.
  • :name is a path parameter. Access it with c.Param("name").
  • Handler functions receive an *app.Context with all request data.

3. Graceful Shutdown

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

if err := a.Start(ctx); err != nil {
    log.Fatal(err)
}
  • signal.NotifyContext() creates a context that cancels on SIGINT (Ctrl+C) or SIGTERM.
  • Start() starts the server and blocks until the context is canceled.
  • The server shuts down gracefully. It finishes active requests before stopping.

4. Handler Functions

func handleRoot(c *app.Context) {
    c.JSON(http.StatusOK, map[string]string{
        "message": "Welcome to Rivaas!",
    })
}
  • Handlers receive an *app.Context.
  • c.JSON() sends a JSON response.
  • c.Param() gets path parameters.
  • c.Bind() parses request bodies. It auto-detects JSON, form, and other formats.

Common Patterns

Path Parameters

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

Query Parameters

// Route: /search?q=rivaas&limit=10
a.GET("/search", func(c *app.Context) {
    query := c.Query("q")
    limit := c.QueryDefault("limit", "20")
    
    c.JSON(http.StatusOK, map[string]string{
        "query": query,
        "limit": limit,
    })
})

Request Headers

a.GET("/headers", func(c *app.Context) {
    userAgent := c.Request.Header.Get("User-Agent")
    
    c.JSON(http.StatusOK, map[string]string{
        "user_agent": userAgent,
    })
})

Different Status Codes

a.GET("/not-found", func(c *app.Context) {
    c.JSON(http.StatusNotFound, map[string]string{
        "error": "Resource not found",
    })
})

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

Testing Your Application

Rivaas provides testing utilities for integration tests:

package main

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

    "rivaas.dev/app"
)

func TestHelloEndpoint(t *testing.T) {
    // Create test app
    a, err := app.New()
    if err != nil {
        t.Fatalf("Failed to create app: %v", err)
    }

    a.GET("/hello/:name", handleHello)

    // Create test request
    req := httptest.NewRequest(http.MethodGet, "/hello/Gopher", nil)
    
    // Test the request
    resp, err := a.Test(req)
    if err != nil {
        t.Fatalf("Request failed: %v", err)
    }
    defer resp.Body.Close()

    // Check status code
    if resp.StatusCode != http.StatusOK {
        t.Errorf("Expected status 200, got %d", resp.StatusCode)
    }
}

Key Testing Methods:

  • a.Test(req) - Execute a request without starting the server
  • a.TestJSON(method, path, body) - Test JSON endpoints
  • app.ExpectJSON(t, resp, status, target) - Verify JSON responses

See the blog example for comprehensive testing patterns.

Common Mistakes

Forgetting Error Handling

// ❌ Bad: Ignoring errors
a := app.MustNew()  // Panics on error

// ✅ Good: Handle errors properly
a, err := app.New()
if err != nil {
    log.Fatalf("Failed to create app: %v", err)
}

Not Using Context for Shutdown

// ❌ Bad: No graceful shutdown
a.Start(context.Background())

// ✅ Good: Graceful shutdown with signals
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
a.Start(ctx)

Registering Routes After Start

// ❌ Bad: Routes registered after Start
a.Start(ctx)
a.GET("/late", handler)  // Won't work!

// ✅ Good: Routes before Start
a.GET("/early", handler)
a.Start(ctx)

Production Basics

Before deploying your first application:

  • ✅ Use environment-based configuration (see Configuration)
  • ✅ Add health endpoints for Kubernetes/Docker
  • ✅ Enable structured logging
  • ✅ Set appropriate timeouts
  • ✅ Add recovery middleware (included by default)

Quick Production Setup:

a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithServiceVersion("v1.0.0"),
    app.WithEnvironment("production"),
    app.WithHealthEndpoints(
        app.WithReadinessCheck("ready", func(ctx context.Context) error {
            return nil // Add real checks here
        }),
    ),
)

See the full-featured example for production patterns.

What’s Next?

You now have a working Rivaas application. Here are the next steps:

Complete Example

The complete code is available in the examples repository.

Troubleshooting

Port Already in Use

If you see “address already in use” (default port is 8080 for HTTP, 8443 for TLS/mTLS):

# Find what's using the port
lsof -i :8080
# Or for TLS/mTLS: lsof -i :8443

# Kill the process or use a different port

Change the port when creating the app:

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

JSON Binding Errors

If Bind() fails for JSON requests, ensure:

  1. Content-Type header is set to application/json
  2. Request body contains valid JSON
  3. JSON structure matches your Go struct

Ready to learn more? Continue to Configuration →

1.3 - Configuration

Configure your Rivaas application

Overview

Rivaas uses the functional options pattern for configuration. This provides a clean, self-documenting API. It’s backward-compatible. This guide covers basic configuration options.

💡 First Time? Focus on sections marked with ⭐. Skip advanced topics for now.

Configuration Philosophy

  • Sensible Defaults: Works out of the box.
  • Progressive Disclosure: Start simple. Add complexity as needed.
  • Type Safety: Configuration errors are caught at startup.
  • Environment Aware: Different defaults for dev and prod.

⭐ Basic Configuration

Service Metadata

Set your service name and version:

a := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithServiceVersion("v1.2.3"),
    app.WithEnvironment("production"),
)

These values are sent to all observability components. This includes logging, metrics, and tracing.

Advanced: Server Configuration

⚠️ Advanced Topic: Most applications don’t need custom server configuration. The defaults work for production.

Timeouts

Configure server timeouts to protect against slow clients:

a := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithServerConfig(
        app.WithReadTimeout(10 * time.Second),
        app.WithWriteTimeout(15 * time.Second),
        app.WithIdleTimeout(60 * time.Second),
        app.WithShutdownTimeout(30 * time.Second),
    ),
)

Timeout Options:

OptionDescriptionDefault
WithReadTimeoutMaximum time to read request10s
WithWriteTimeoutMaximum time to write response10s
WithIdleTimeoutMaximum idle connection time60s
WithReadHeaderTimeoutMaximum time to read headers2s
WithShutdownTimeoutGraceful shutdown timeout30s

Request Limits

Configure request size limits:

app.WithServerConfig(
    app.WithMaxHeaderBytes(1 << 20), // 1MB
)

⭐ Environment Modes

Rivaas supports two environment modes with different defaults:

Development Mode (Default)

a := app.MustNew(
    app.WithEnvironment("development"),
)

Features:

  • Verbose logging enabled
  • Access logging for all requests
  • Development-friendly error messages
  • Pretty-printed JSON logs

Production Mode

a := app.MustNew(
    app.WithEnvironment("production"),
)

Features:

  • Error-only logging
  • JSON structured logs
  • Minimal overhead
  • Production-ready defaults

Observability

💡 Note: This section covers observability setup. For detailed observability patterns, see the Observability Guide.

Enable logging, metrics, and tracing:

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

a := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
        app.WithMetrics(),
        app.WithTracing(tracing.WithStdout()),
    ),
)

Logging Options

app.WithLogging(
    logging.WithJSONHandler(),              // JSON output
    logging.WithLevel(logging.LevelInfo),   // Log level
)

// Or use console handler (development)
app.WithLogging(logging.WithConsoleHandler())

Metrics Options

// Prometheus metrics (default)
app.WithMetrics()

// Custom Prometheus endpoint
app.WithMetrics(
    metrics.WithPrometheus(":9090", "/metrics"),
)

// OTLP metrics
app.WithMetrics(
    metrics.WithOTLP("localhost:4317"),
)

Tracing Options

// Stdout tracing (development)
app.WithTracing(tracing.WithStdout())

// OTLP tracing (production)
app.WithTracing(
    tracing.WithOTLP("jaeger:4317"),
    tracing.WithSampleRate(0.1), // Sample 10% of requests
)

Exclude Paths from Observability

Exclude health checks and static paths from logging/metrics/tracing:

app.WithObservability(
    app.WithLogging(),
    app.WithMetrics(),
    app.WithTracing(),
    app.WithExcludePaths("/livez", "/readyz", "/metrics"),
    app.WithExcludePrefixes("/static", "/assets"),
)

⭐ Health Endpoints

Add Kubernetes-compatible health endpoints:

a := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithHealthEndpoints(
        app.WithLivenessCheck("process", func(ctx context.Context) error {
            return nil // Process is alive
        }),
        app.WithReadinessCheck("database", func(ctx context.Context) error {
            return db.PingContext(ctx) // Check DB connection
        }),
    ),
)

This registers:

  • GET /livez — Liveness probe
  • GET /readyz — Readiness probe

Custom Health Paths

app.WithHealthEndpoints(
    app.WithHealthPrefix("/_system"),       // Prefix: /_system/livez
    app.WithLivezPath("/live"),             // Custom path: /_system/live
    app.WithReadyzPath("/ready"),           // Custom path: /_system/ready
    app.WithHealthTimeout(500 * time.Millisecond),
)

Advanced: Debug Endpoints

⚠️ Security Critical: Only enable pprof in controlled environments.

Enable pprof for profiling (use with caution):

// Enable conditionally (recommended for production)
app.WithDebugEndpoints(
    app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
)

// Always enable (development only)
app.WithDebugEndpoints(
    app.WithPprof(),
)

⚠️ Security Warning: Never expose pprof endpoints in production without proper authentication.

Advanced: Middleware Configuration

Add middleware during initialization or after app creation:

import (
    "rivaas.dev/router/middleware/cors"
    "rivaas.dev/router/middleware/requestid"
)

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

// Add middleware after creation
a.Use(requestid.New())
a.Use(cors.New(
    cors.WithAllowedOrigins([]string{"https://example.com"}),
))

Or during initialization:

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

💡 Learn More: See the Middleware Guide for detailed middleware usage patterns.

Complete Example

Here’s a production-ready configuration:

package main

import (
    "context"
    "log"
    "os"
    "time"

    "rivaas.dev/app"
    "rivaas.dev/logging"
    "rivaas.dev/metrics"
    "rivaas.dev/tracing"
    "rivaas.dev/router/middleware/cors"
    "rivaas.dev/router/middleware/requestid"
)

func main() {
    a := app.MustNew(
        // Service metadata
        app.WithServiceName("my-api"),
        app.WithServiceVersion("v1.0.0"),
        app.WithEnvironment("production"),

        // Server configuration
        app.WithServerConfig(
            app.WithReadTimeout(10 * time.Second),
            app.WithWriteTimeout(15 * time.Second),
            app.WithShutdownTimeout(30 * time.Second),
        ),

        // Observability
        app.WithObservability(
            app.WithLogging(logging.WithJSONHandler()),
            app.WithMetrics(metrics.WithPrometheus(":9090", "/metrics")),
            app.WithTracing(tracing.WithOTLP("jaeger:4317")),
            app.WithExcludePaths("/livez", "/readyz", "/metrics"),
        ),

        // Health checks
        app.WithHealthEndpoints(
            app.WithReadinessCheck("database", checkDatabase),
        ),

        // Debug (conditional)
        app.WithDebugEndpoints(
            app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
        ),
    )

    // Add middleware
    a.Use(requestid.New())
    a.Use(cors.New(cors.WithAllowedOrigins([]string{
        "https://example.com",
    })))

    // Register routes
    a.GET("/", handleRoot)

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

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

func checkDatabase(ctx context.Context) error {
    // Implement your database check
    return nil
}

func handleRoot(c *app.Context) {
    c.JSON(200, map[string]string{"status": "ok"})
}

Advanced: Configuration Validation

Rivaas validates configuration at startup and returns clear errors:

a, err := app.New(
    app.WithServerConfig(
        app.WithReadTimeout(15 * time.Second),
        app.WithWriteTimeout(10 * time.Second), // ❌ Read > Write
    ),
)
if err != nil {
    // Error: "server.readTimeout: read timeout should not exceed write timeout"
}

Advanced: Environment Variables

While Rivaas doesn’t directly use environment variables, you can easily integrate them:

import "os"

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

a := app.MustNew(
    app.WithServiceName(getEnv("SERVICE_NAME", "my-api")),
    app.WithServiceVersion(getEnv("SERVICE_VERSION", "v1.0.0")),
    app.WithEnvironment(getEnv("ENVIRONMENT", "development")),
)

Next Steps

Now that you understand configuration, explore these topics:

Reference

For a complete list of all configuration options, see the App Options Reference.

1.4 - Using Middleware

Add functionality to your application with middleware

Middleware functions intercept HTTP requests. They add functionality like logging, authentication, and error recovery to your Rivaas application.

What is Middleware?

Middleware wraps your route handlers. It runs code before and after the handler. Think of it as layers around your core logic:

Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response

Common uses:

  • Log requests and responses
  • Authenticate and authorize users
  • Recover from errors
  • Modify requests and responses
  • Limit request rates
  • Add CORS headers

Built-in Middleware

Rivaas includes 12 production-ready middleware packages:

MiddlewarePurposeProduction-Ready
recoveryPanic recovery✅ Auto-included
requestidRequest ID tracking
corsCross-Origin Resource Sharing
timeoutRequest timeouts
accesslogAccess logging
ratelimitRate limiting⚠️ Single-instance only
basicauthHTTP Basic Auth
bodylimitRequest size limits
compressionResponse compression
securitySecurity headers
methodoverrideHTTP method override
trailingslashTrailing slash handling

Check the Middleware Reference for complete documentation.

Installation

Each middleware is a separate Go module. Add only the ones you use so your dependency set stays small:

# Examples: add the middleware you need
go get rivaas.dev/middleware/requestid
go get rivaas.dev/middleware/cors
go get rivaas.dev/middleware/recovery
go get rivaas.dev/middleware/timeout

See the Middleware Reference for the full list and a go get command for each package.

Adding Middleware

Global Middleware

Apply middleware to all routes:

import (
    "rivaas.dev/app"
    "rivaas.dev/middleware/requestid"
    "rivaas.dev/middleware/cors"
)

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

    // Add middleware before registering routes
    a.Use(requestid.New())
    a.Use(cors.New(cors.WithAllowAllOrigins(true)))

    // Register routes
    a.GET("/", handleRoot)
    
    // Start server
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()
    a.Start(ctx)
}

Group Middleware

Apply middleware to specific route groups:

// Public routes - no auth
a.GET("/", handlePublic)

// API routes - with auth
api := a.Group("/api", authMiddleware)
api.GET("/users", getUsers)
api.POST("/users", createUser)

// Admin routes - with admin auth
admin := a.Group("/admin", authMiddleware, adminMiddleware)
admin.GET("/dashboard", getDashboard)

Route-Specific Middleware

Apply middleware to individual routes:

a.GET("/public", publicHandler)
a.GET("/protected", protectedHandler, authMiddleware)

Common Middleware Patterns

Request ID

Track requests across distributed systems with unique, time-ordered IDs:

import "rivaas.dev/middleware/requestid"

// UUID v7 by default (36 chars, time-ordered, RFC 9562)
a.Use(requestid.New())

// Or use ULID for shorter IDs (26 chars)
a.Use(requestid.New(requestid.WithULID()))

// In your 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,
    })
})

Test it:

curl -i http://localhost:8080/
# X-Request-ID: 018f3e9a-1b2c-7def-8000-abcdef123456  (UUID v7)

Both UUID v7 and ULID are lexicographically sortable, making them ideal for debugging and log correlation.

CORS

Enable cross-origin requests:

import "rivaas.dev/middleware/cors"

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

// Production: Specific origins
a.Use(cors.New(
    cors.WithAllowedOrigins([]string{
        "https://example.com",
        "https://app.example.com",
    }),
    cors.WithAllowedMethods([]string{"GET", "POST", "PUT", "DELETE"}),
    cors.WithAllowCredentials(true),
))

Timeout

Prevent long-running requests:

import "rivaas.dev/middleware/timeout"

// Global timeout
a.Use(timeout.New(timeout.WithDuration(5 * time.Second)))

// Skip for streaming endpoints
a.Use(timeout.New(
    timeout.WithDuration(5 * time.Second),
    timeout.WithSkipPaths("/stream", "/sse"),
))

Recovery

Automatically recover from panics (included by default):

import "rivaas.dev/middleware/recovery"

// Custom recovery with stack traces
a.Use(recovery.New(
    recovery.WithStackTrace(true),
    recovery.WithHandler(func(c *router.Context, err any) {
        log.Printf("Panic recovered: %v", err)
        c.JSON(http.StatusInternalServerError, map[string]string{
            "error": "Internal server error",
        })
    }),
))

Rate Limiting

Limit request rate (single-instance only):

import "rivaas.dev/middleware/ratelimit"

// 100 requests per second with burst of 20
a.Use(ratelimit.New(
    ratelimit.WithRequestsPerSecond(100),
    ratelimit.WithBurst(20),
))

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) // Executes last (if all middleware calls Next())

Example Flow:

Request
  → middleware1 (before)
    → middleware2 (before)
      → middleware3 (before)
        → handler
      ← middleware3 (after)
    ← middleware2 (after)
  ← middleware1 (after)
Response

Best Practices:

  1. Recovery should be first (catches panics from other middleware)
  2. Logging early (captures all requests)
  3. Auth before business logic
  4. CORS early (handles preflight requests)

Example:

a.Use(recovery.New())    // 1. Panic recovery
a.Use(requestid.New())   // 2. Request tracking
a.Use(cors.New(...))     // 3. CORS handling
// App-level observability is automatic
// Route handlers execute last

Creating Custom Middleware

Simple middleware example:

func timingMiddleware() app.HandlerFunc {
    return func(c *app.Context) {
        start := time.Now()
        
        // Process request (call next middleware/handler)
        c.Next()
        
        // After handler executes
        duration := time.Since(start)
        log.Printf("%s %s - %v", c.Request.Method, c.Request.URL.Path, duration)
    }
}

// Use it
a.Use(timingMiddleware())

Authentication middleware example:

func authMiddleware(c *app.Context) {
    token := c.Request.Header.Get("Authorization")
    
    if token == "" {
        c.JSON(http.StatusUnauthorized, map[string]string{
            "error": "Missing authorization token",
        })
        return  // Don't call Next() - stop here
    }
    
    // Validate token (simplified)
    if !isValidToken(token) {
        c.JSON(http.StatusUnauthorized, map[string]string{
            "error": "Invalid token",
        })
        return
    }
    
    // Token is valid - continue to handler
    c.Next()
}

// Use it
api := a.Group("/api", authMiddleware)

Middleware with Configuration

Use functional options for configurable middleware:

type Config struct {
    MaxRequests int
    Window      time.Duration
}

type Option func(*Config)

func WithMaxRequests(max int) Option {
    return func(c *Config) {
        c.MaxRequests = max
    }
}

func New(opts ...Option) app.HandlerFunc {
    cfg := &Config{
        MaxRequests: 100,
        Window:      time.Minute,
    }
    
    for _, opt := range opts {
        opt(cfg)
    }
    
    return func(c *app.Context) {
        // Use cfg.MaxRequests, cfg.Window
        c.Next()
    }
}

// Use it
a.Use(New(
    WithMaxRequests(200),
))

Default Middleware

Rivaas automatically includes some middleware based on environment:

Development Mode:

  • ✅ Recovery middleware (panic recovery)
  • ✅ Access logging via observability recorder

Production Mode:

  • ✅ Recovery middleware
  • ✅ Error-only logging via observability recorder

Disable defaults:

a, err := app.New(
    app.WithMiddleware(), // Empty = no defaults
)
// Now add only what you need
a.Use(recovery.New())
a.Use(requestid.New())

Complete Example

Here’s a production-ready middleware setup:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "rivaas.dev/app"
    "rivaas.dev/middleware/cors"
    "rivaas.dev/middleware/recovery"
    "rivaas.dev/middleware/requestid"
    "rivaas.dev/middleware/timeout"
)

func main() {
    a, err := app.New(
        app.WithServiceName("my-api"),
        app.WithEnvironment("production"),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Global middleware (order matters!)
    a.Use(recovery.New(recovery.WithStackTrace(true)))
    a.Use(requestid.New())
    a.Use(cors.New(cors.WithAllowedOrigins([]string{"https://example.com"})))
    a.Use(timeout.New(timeout.WithDuration(30 * time.Second)))

    // Public routes
    a.GET("/", handlePublic)
    a.GET("/health", handleHealth)

    // Protected API
    api := a.Group("/api", authMiddleware)
    api.GET("/users", getUsers)
    api.POST("/users", createUser)

    // Admin routes
    admin := a.Group("/admin", authMiddleware, adminMiddleware)
    admin.GET("/dashboard", getDashboard)

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

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

func handlePublic(c *app.Context) {
    c.JSON(http.StatusOK, map[string]string{"status": "ok"})
}

func handleHealth(c *app.Context) {
    c.JSON(http.StatusOK, map[string]string{"status": "healthy"})
}

func authMiddleware(c *app.Context) {
    token := c.Request.Header.Get("Authorization")
    if token == "" || !isValidToken(token) {
        c.JSON(http.StatusUnauthorized, map[string]string{"error": "Unauthorized"})
        return
    }
    c.Next()
}

func adminMiddleware(c *app.Context) {
    // Check if user is admin (simplified)
    if !isAdmin(c.Request.Header.Get("Authorization")) {
        c.JSON(http.StatusForbidden, map[string]string{"error": "Forbidden"})
        return
    }
    c.Next()
}

func getUsers(c *app.Context) {
    c.JSON(http.StatusOK, []string{"user1", "user2"})
}

func createUser(c *app.Context) {
    c.JSON(http.StatusCreated, map[string]string{"status": "created"})
}

func getDashboard(c *app.Context) {
    c.JSON(http.StatusOK, map[string]string{"dashboard": "data"})
}

func isValidToken(token string) bool {
    // Implement your token validation
    return token != ""
}

func isAdmin(token string) bool {
    // Implement your admin check
    return true
}

Troubleshooting

Middleware Not Executing

Problem: Middleware doesn’t run.

Solutions:

  • Ensure middleware is added before routes: a.Use(...) then a.GET(...)
  • Check if middleware calls c.Next() to continue the chain
  • Verify middleware isn’t returning early without calling c.Next()

Middleware Running in Wrong Order

Problem: Authentication runs after handler.

Solution: Add middleware in the correct order - they execute top to bottom:

a.Use(recovery.New())  // First
a.Use(authMiddleware)  // Second
a.GET("/", handler)    // Last

CORS Preflight Failing

Problem: OPTIONS requests return 404.

Solution: Add CORS middleware before routes, and ensure it handles OPTIONS:

a.Use(cors.New(
    cors.WithAllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
))

Next Steps

Learn More

1.5 - Next Steps

Continue learning Rivaas

You’ve completed the Getting Started guide. You now know how to install Rivaas, build applications, configure them, and add middleware.

What You’ve Learned

Installation — Set up Rivaas and verified it works
First Application — Built a REST API with routes and JSON responses
Configuration — Configured service metadata, health checks, and observability
Middleware — Added functionality like CORS and authentication

Choose Your Path

🚀 Building Production APIs

Learn advanced routing, error handling, and API patterns:

Recommended Example: Blog API — Full-featured blog with CRUD operations, validation, and testing.

📊 Observability & Monitoring

Understand your application in production:

Key Pattern: The observability trinity (logs, metrics, traces) works together. They provide complete visibility into your application.

🔒 Security & Best Practices

Secure your APIs:

Security Checklist:

  • ✅ Use HTTPS in production
  • ✅ Validate all inputs
  • ✅ Implement authentication
  • ✅ Add rate limiting
  • ✅ Enable security headers

☁️ Deployment & Operations

Deploy your application to production:

Production Checklist:

  • ✅ Set up health endpoints
  • ✅ Configure timeouts
  • ✅ Enable observability
  • ✅ Use environment variables
  • ✅ Implement graceful shutdown

🎯 Advanced Topics

Deep dive into framework internals:

Example Applications

Learn from complete, production-ready examples:

Quick Start Example

Path: /app/examples/01-quick-start
Complexity: Beginner
Shows: Minimal setup, basic routing, health checks

cd app/examples/01-quick-start
go run main.go

Blog API Example

Path: /app/examples/02-blog
Complexity: Intermediate
Shows: CRUD operations, validation, OpenAPI, testing, configuration

cd app/examples/02-blog
go run main.go
# Visit http://localhost:8080/docs for Swagger UI

Features:

  • Complete REST API (posts, authors, comments)
  • Method-based validation
  • OpenAPI documentation
  • Comprehensive tests
  • Configuration management
  • Observability setup

More Examples

Framework Packages

Rivaas is modular — use any package independently:

Core Packages

PackageDescriptionGo Reference
appBatteries-included frameworkpkg.go.dev
routerHigh-performance HTTP routerpkg.go.dev

Data Handling

PackageDescriptionGo Reference
bindingRequest binding (JSON, XML, YAML, etc.)pkg.go.dev
validationStruct validation with JSON Schemapkg.go.dev

Observability

PackageDescriptionGo Reference
loggingStructured logging with slogpkg.go.dev
metricsOpenTelemetry metricspkg.go.dev
tracingDistributed tracingpkg.go.dev

API & Errors

PackageDescriptionGo Reference
openapiOpenAPI 3.0/3.1 generationpkg.go.dev
errorsError formatting (RFC 9457, JSON:API)pkg.go.dev

Learn More: Package Documentation

Reference Documentation

Quick access to API references:

Community & Support

Get Help

  • 💬 GitHub Discussions — Ask questions, share ideas
  • 🐛 GitHub Issues — Report bugs, request features
  • 📧 Emailsecurity@rivaas.dev (security issues only)

Contribute

Rivaas is open source and welcomes contributions:

Stay Updated

Quick Reference Card

Create Application

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

Register Routes

a.GET("/path", handler)
a.POST("/path", handler)
a.PUT("/path/:id", handler)
a.DELETE("/path/:id", handler)

Add Middleware

a.Use(middleware1, middleware2)
api := a.Group("/api", authMiddleware)

Start Server

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

Handle Requests

func handler(c *app.Context) {
    // Get path parameter
    id := c.Param("id")
    
    // Get query parameter
    filter := c.Query("filter")
    
    // Bind request body (auto-detects JSON, form, etc.)
    var req MyRequest
    if err := c.Bind(&req); err != nil {
        c.JSON(400, map[string]string{"error": "Invalid request"})
        return
    }
    
    // Send JSON response
    c.JSON(200, map[string]string{"status": "ok"})
}

What’s Next?

Pick the topic that interests you most. The documentation works for both linear reading and jumping to specific topics.

Happy building with Rivaas!

2 - 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 →

2.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 the router directly. See Router Performance for baseline numbers. 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?

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

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

2.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:

  • Access log scope defaults to errors-only when not set via WithAccessLogScope. 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

Configure server timeouts and (optionally) transport. Default port is 8080 for HTTP and 8443 for TLS/mTLS; override with WithPort. For HTTPS or mTLS, use WithTLS or WithMTLS at construction; see Server for examples.

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

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

These methods are part of app.Context. They use the metrics and tracing you set up with app.WithObservability(). If observability is not configured, the methods simply do nothing (no-ops).

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

Access Log Scope

Control which requests are logged. When you do not set a scope, production defaults to errors-only and development to full access logs.

Errors and slow requests only (explicit; also the production default when unset):

a, err := app.New(
    app.WithObservability(
        app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
    ),
)

Full access logs (including production)

To log every request in production, set scope explicitly. Consider log volume and cost before enabling.

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

Slow Request Threshold

Mark requests as slow and log them. Slow requests are always logged regardless of access log scope.

a, err := app.New(
    app.WithObservability(
        app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
        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 (explicit; also production default when unset)
            app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
            app.WithSlowThreshold(1 * time.Second),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Register routes...
    a.GET("/orders/:id", handleGetOrder)
    
    // Start server...
}

Next Steps

2.1.5 - 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

2.1.6 - 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"
}

Observability

When you enable observability with app.WithObservability(), your handlers can record custom metrics and add tracing data from app.Context:

Tracing: TraceID(), SpanID(), SetSpanAttribute(), AddSpanEvent(), TraceContext(), Span()

Metrics: RecordHistogram(), IncrementCounter(), SetGauge()

Example:

a.GET("/orders/:id", func(c *app.Context) {
    c.SetSpanAttribute("order.id", c.Param("id"))
    c.AddSpanEvent("order_lookup_started")
    c.IncrementCounter("order.lookups", attribute.String("order.id", c.Param("id")))
    // ...
})

If observability is not configured, these methods do nothing (no-ops). For full setup and options, see the observability guide.

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

2.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/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/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/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/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/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/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/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/middleware/bodylimit"

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

Security Headers

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

import "rivaas.dev/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/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/middleware/requestid"
    "rivaas.dev/middleware/cors"
    "rivaas.dev/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

2.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)

Only GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS are supported. Using any other method (including via internal or reflection-based registration) causes a panic with a clear error so mistakes are caught early (see Fail Fast with Clear Errors).

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

2.1.9 - Lifecycle

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

Overview

The app package provides lifecycle hooks for managing application state. Registering a hook after the router is frozen (e.g. after Start()) returns an error; register all hooks before calling Start().

  • 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()

if err := a.OnStart(func(ctx context.Context) error {
    log.Println("Connecting to database...")
    return db.Connect(ctx)
}); err != nil {
    log.Fatal(err)
}

if err := a.OnStart(func(ctx context.Context) error {
    log.Println("Running migrations...")
    return db.Migrate(ctx)
}); err != nil {
    log.Fatal(err)
}

// 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
    if err := 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
    }); err != nil {
        log.Fatalf("failed to register OnStart: %v", err)
    }
    
    if err := a.OnStart(func(ctx context.Context) error {
        log.Println("Running migrations...")
        return db.Migrate(ctx)
    }); err != nil {
        log.Fatalf("failed to register OnStart: %v", err)
    }
    
    // OnRoute: Log route registration
    if err := a.OnRoute(func(rt *route.Route) {
        log.Printf("Route registered: %s %s", rt.Method(), rt.Path())
    }); err != nil {
        log.Fatalf("failed to register OnRoute: %v", err)
    }
    
    // OnReady: Post-startup tasks
    if err := a.OnReady(func() {
        log.Println("Server is ready!")
        log.Println("Registering with service discovery...")
        consul.Register("api", ":8080")
    }); err != nil {
        log.Fatalf("failed to register OnReady: %v", err)
    }
    
    // OnShutdown: Graceful cleanup
    if err := a.OnShutdown(func(ctx context.Context) {
        log.Println("Deregistering from service discovery...")
        consul.Deregister("api")
    }); err != nil {
        log.Fatalf("failed to register OnShutdown: %v", err)
    }
    
    if err := a.OnShutdown(func(ctx context.Context) {
        log.Println("Closing database connection...")
        if err := db.Close(); err != nil {
            log.Printf("Error closing database: %v", err)
        }
    }); err != nil {
        log.Fatalf("failed to register OnShutdown: %v", err)
    }
    
    // OnStop: Final cleanup
    if err := a.OnStop(func() {
        log.Println("Cleanup complete")
    }); err != nil {
        log.Fatalf("failed to register OnStop: %v", err)
    }
    
    // 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

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

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

2.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 for HTTP and :8443 for TLS/mTLS:

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

Configure TLS at construction with WithTLS, then start the server (default port 8443; use WithPort(443) to override):

a := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithTLS("server.crt", "server.key"),
)
// ... register routes ...

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

if err := a.Start(ctx); 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

Configure mTLS at construction with WithMTLS, then start the server:

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

a := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithMTLS(serverCert,
        app.WithClientCAs(caCertPool),
        app.WithMinVersion(tls.VersionTLS13),
    ), // default port 8443; use WithPort(443) to override
)
// ... register routes ...

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

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

Client Authorization

Authorize clients based on certificate by adding WithAuthorize to WithMTLS:

a := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithPort(8443),
    app.WithMTLS(serverCert,
        app.WithClientCAs(caCertPool),
        app.WithAuthorize(func(cert *x509.Certificate) (string, bool) {
            principal := cert.Subject.CommonName
            if principal == "" {
                return "", false
            }
            return principal, true
        }),
    ),
)
// ...
if err := a.Start(ctx); err != nil { ... }

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() {
    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)
    
    a := app.MustNew(
        app.WithServiceName("secure-api"),
        app.WithMTLS(serverCert,
            app.WithClientCAs(caCertPool),
            app.WithMinVersion(tls.VersionTLS13),
        ), // default port 8443
    )
    a.GET("/", homeHandler)
    
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()
    
    log.Println("mTLS server starting on :8443 (default)")
    if err := a.Start(ctx); err != nil {
        log.Fatal(err)
    }
}

Next Steps

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

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

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

2.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.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
            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.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 offers sub-microsecond routing and high throughput. See Router Performance for current benchmark numbers. It includes content negotiation, API versioning, and OpenTelemetry support.

Key Features

Core Routing & Request Handling

  • Radix tree routing - Path matching with bloom filters for static route lookups.
  • Optional compiled route tables - For large APIs you can turn on pre-compiled routes to speed up lookups.
  • 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.

Works with: Request Binding

The router works well with the binding package. Use it to parse request data into 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.

Works with: Request Validation

The validation package offers multiple strategies you can use after binding:

  • 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

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

Performance

  • Sub-microsecond routing and high throughput — See Router Performance for current latency and throughput numbers.
  • Zero allocation — 0 allocs for routing and parameter extraction in the benchmarked scenarios (static, 1 param, 2 params). One small allocation only when a route has more than 8 path parameters.
  • Memory efficient — Context pooling and minimal allocations per request.
  • 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 and high throughput

Next Steps

Need Help?

2.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.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.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: Sub-microsecond per operation, 0 allocations.
  • >8 params: Sub-microsecond per operation, 1 small allocation.
  • Real-world impact: Negligible for most applications (<1% overhead). See Router Performance for current figures.

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.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.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 separate packages. Each one is its own Go module—add only what you need:

go get rivaas.dev/middleware/security
go get rivaas.dev/middleware/cors
go get rivaas.dev/middleware/accesslog
# ... and so on for each middleware you use

See the Middleware Reference for the full list and options.

Security

Security Headers

import "rivaas.dev/middleware/security"

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

CORS

import "rivaas.dev/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/middleware/basicauth"

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

Observability

Access Log

import (
    "log/slog"
    "rivaas.dev/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/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/middleware/recovery"

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

Timeout

import "rivaas.dev/middleware/timeout"

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

Rate Limit

import "rivaas.dev/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/middleware/bodylimit"

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

Performance

Compression

import "rivaas.dev/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/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/middleware/accesslog"
    "rivaas.dev/middleware/cors"
    "rivaas.dev/middleware/recovery"
    "rivaas.dev/middleware/requestid"
    "rivaas.dev/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.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.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.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.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.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.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.2.12 - Observability

OpenTelemetry support via the observability recorder interface, with zero overhead when disabled, plus diagnostic events.

The router provides OpenTelemetry support via the observability recorder interface 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"),
)

Handler-level tracing

When you use the router on its own, get the span from the request context and use the tracing package helpers:

import "rivaas.dev/tracing"

r.GET("/users/:id", func(c *router.Context) {
    tracing.SetSpanAttributeFromContext(c.RequestContext(), "user.id", c.Param("id"))
    tracing.AddSpanEventFromContext(c.RequestContext(), "fetching_user")
    // ...
})

When you use the app package, your handlers receive app.Context, which has built-in methods: c.TraceID(), c.SpanID(), c.SetSpanAttribute(), c.AddSpanEvent(), and more. See the app observability guide.

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. Disable parameter recording for sensitive data:

    router.WithTracingDisableParams()
    

Next Steps

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2.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!

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2.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 := router.NewResponseWriterWrapper(w)
            
            next.ServeHTTP(wrapped, r)
            
            logger.LogRequest(r,
                "status", wrapped.StatusCode(),
                "duration_ms", time.Since(start).Milliseconds(),
                "bytes", wrapped.Size(),
            )
        })
    }
}

Ensure you import rivaas.dev/router when using router.NewResponseWriterWrapper. The wrapper provides StatusCode(), Size(), and Written() for request logging.

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.

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

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

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

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

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

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

2.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"
    "rivaas.dev/router"
)

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 and size (router provides StatusCode, Size, Written)
        wrapped := router.NewResponseWriterWrapper(w)
        
        next(wrapped, r)
        
        logger.LogRequest(r,
            "status", wrapped.StatusCode(),
            "duration_ms", time.Since(start).Milliseconds(),
            "bytes", wrapped.Size(),
        )
    }
}

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.

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

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

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

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

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

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

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

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

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

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

2.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:

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

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

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

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

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

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

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

3 - Reference

Technical reference documentation for Rivaas packages and APIs

Complete API reference documentation for all Rivaas packages. Find detailed information about types, methods, options, and advanced usage.

Package Reference

Explore the complete package reference for detailed API documentation on all Rivaas packages including App, Router, Config, Binding, Validation, Logging, Metrics, Tracing, and OpenAPI.

3.1 - Package Reference

API reference documentation for Rivaas packages

Detailed API reference for all Rivaas packages. Each package reference includes complete documentation of types, methods, options, and technical details.

Available Packages

App (rivaas.dev/app)

A batteries-included web framework built on top of the Rivaas router. Includes integrated observability (metrics, tracing, logging), lifecycle management with hooks, graceful shutdown handling, health and debug endpoints, and request binding/validation.

View App Package Reference →

Router (rivaas.dev/router)

High-performance HTTP router with radix tree routing, bloom filters, and optional compiled route tables. Sub-microsecond routing, built-in middleware, OpenTelemetry support, API versioning, and content negotiation.

View Router Package Reference →

Config (rivaas.dev/config)

Powerful configuration management for Go applications with support for multiple sources (files, environment variables, remote sources), format-agnostic with built-in JSON/YAML/TOML support, hierarchical configuration merging, and automatic struct binding with validation.

View Config Package Reference →

Binding (rivaas.dev/binding)

High-performance request data binding for Go web applications. Maps values from various sources (query parameters, form data, JSON bodies, headers, cookies, path parameters) into Go structs using struct tags with type-safe generic API.

View Binding Package Reference →

Validation (rivaas.dev/validation)

Flexible, multi-strategy validation for Go structs with support for struct tags, JSON Schema, and custom interfaces. Features partial validation for PATCH requests, sensitive data redaction, and detailed field-level error reporting.

View Validation Package Reference →

Logging (rivaas.dev/logging)

Structured logging for Go applications using Go’s standard log/slog package. Features multiple output formats (JSON, Text, Console), context-aware logging with OpenTelemetry trace correlation, automatic sensitive data redaction, and log sampling.

View Logging Package Reference →

Metrics (rivaas.dev/metrics)

OpenTelemetry-based metrics collection for Go applications with support for Prometheus, OTLP, and stdout exporters. Includes built-in HTTP metrics middleware, custom metrics (counters, histograms, gauges), and automatic header filtering for security.

View Metrics Package Reference →

Tracing (rivaas.dev/tracing)

OpenTelemetry-based distributed tracing for Go applications with support for Stdout, OTLP (gRPC and HTTP), and Noop providers. Includes built-in HTTP middleware for request tracing, manual span management, and context propagation.

View Tracing Package Reference →

OpenAPI (rivaas.dev/openapi)

Automatic OpenAPI 3.0.4 and 3.1.2 specification generation from Go code using struct tags and reflection. Features fluent HTTP method constructors, automatic parameter discovery, schema generation, built-in validation, and Swagger UI configuration support.

View OpenAPI Package Reference →

3.1.1 - App

API reference for the Rivaas App package - a batteries-included web framework with integrated observability.

This is the API reference for the rivaas.dev/app package. For learning-focused documentation, see the App Guide.

Overview

The App package provides a high-level, opinionated framework built on top of the Rivaas router. It includes:

  • Integrated observability (metrics, tracing, logging)
  • Lifecycle management with hooks
  • Graceful shutdown handling
  • Health and debug endpoints
  • OpenAPI spec generation
  • Request binding and validation

Package Information

  • Import Path: rivaas.dev/app
  • Go Version: 1.25+
  • License: Apache 2.0

Architecture

┌─────────────────────────────────────────┐
│           Application Layer             │
│  (app package)                          │
│                                         │
│  • Configuration Management             │
│  • Lifecycle Hooks                      │
│  • Observability Integration            │
│  • Server Management                    │
│  • Request Binding/Validation           │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           Router Layer                  │
│  (router package)                       │
│                                         │
│  • HTTP Routing                         │
│  • Middleware Chain                     │
│  • Request Context                      │
│  • Path Parameters                      │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│        Standard Library                 │
│  (net/http)                             │
└─────────────────────────────────────────┘

Quick Reference

Core Types

  • App - Main application type
  • Context - Request context with app-level features
  • HandlerFunc - Handler function type

Key Functions

  • New() - Create a new app (returns error)
  • MustNew() - Create a new app (panics on error)

Configuration

API Reference

Resources

App Type

The main application type that wraps the router with app-level features.

type App struct {
    // contains filtered or unexported fields
}

Creating Apps

// Returns (*App, error) for error handling
a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithServiceVersion("v1.0.0"),
)
if err != nil {
    log.Fatal(err)
}

// Panics on error (like regexp.MustCompile)
a := app.MustNew(
    app.WithServiceName("my-api"),
)

HTTP Methods

Register routes for HTTP methods:

a.GET(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
a.POST(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
a.PUT(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
a.DELETE(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
a.PATCH(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
a.HEAD(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
a.OPTIONS(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
a.Any(path string, handler HandlerFunc, opts ...RouteOption) *route.Route

Only the listed methods are supported; unsupported method causes a panic. See API Reference for details.

Server Management

a.Start(ctx context.Context) error

Configure HTTP, HTTPS, or mTLS at construction with WithTLS or WithMTLS.

Lifecycle Hooks

a.OnStart(fn func(context.Context) error) error
a.OnReady(fn func()) error
a.OnShutdown(fn func(context.Context)) error
a.OnStop(fn func()) error
a.OnRoute(fn func(*route.Route)) error
a.OnReload(fn func(context.Context) error) error

See Lifecycle Hooks for details.

Context Type

Request context that extends router.Context with app-level features.

type Context struct {
    *router.Context
    // contains filtered or unexported fields
}

Request Binding

c.Bind(out any, opts ...BindOption) error
c.MustBind(out any, opts ...BindOption) bool
c.BindOnly(out any, opts ...BindOption) error
c.Validate(v any, opts ...validation.Option) error

Error Handling

c.Fail(err error)
c.FailStatus(status int, err error)
c.NotFound(err error)
c.BadRequest(err error)
c.Unauthorized(err error)
c.Forbidden(err error)
c.Conflict(err error)
c.Gone(err error)
c.UnprocessableEntity(err error)
c.TooManyRequests(err error)
c.InternalError(err error)
c.ServiceUnavailable(err error)

Logging

c.Logger() *slog.Logger

See Context API for complete reference.

HandlerFunc

Handler function type that receives an app Context.

type HandlerFunc func(*Context)

Example:

func handler(c *app.Context) {
    c.JSON(http.StatusOK, data)
}

a.GET("/", handler)

Next Steps

3.1.1.1 - API Reference

Complete API reference for the App package.

Core Functions

New

func New(opts ...Option) (*App, error)

Creates a new App instance with the given options. Returns an error if configuration is invalid.

Parameters:

  • opts - Configuration options

Returns:

  • *App - The app instance
  • error - Configuration validation errors

Example:

a, err := app.New(
    app.WithServiceName("my-api"),
    app.WithServiceVersion("v1.0.0"),
)
if err != nil {
    log.Fatal(err)
}

MustNew

func MustNew(opts ...Option) *App

Creates a new App instance or panics on error. Use for initialization in main() functions.

Parameters:

  • opts - Configuration options

Returns:

  • *App - The app instance

Panics: If configuration is invalid

Example:

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

App Methods

HTTP Method Shortcuts

func (a *App) GET(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
func (a *App) POST(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
func (a *App) PUT(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
func (a *App) DELETE(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
func (a *App) PATCH(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
func (a *App) HEAD(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
func (a *App) OPTIONS(path string, handler HandlerFunc, opts ...RouteOption) *route.Route
func (a *App) Any(path string, handler HandlerFunc, opts ...RouteOption) *route.Route

Register routes for HTTP methods. Supported methods are GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS. Registering a route with any other method (e.g. CONNECT, TRACE, or a typo) causes a panic with a clear message (fail-fast). The same applies to routes registered via Group and VersionGroup (e.g. Group.GET, VersionGroup.POST).

Middleware

func (a *App) Use(middleware ...HandlerFunc)

Adds middleware to the app. Middleware executes for all routes registered after Use().

Route Groups

func (a *App) Group(prefix string, middleware ...HandlerFunc) *Group
func (a *App) Version(version string) *VersionGroup

Create route groups and version groups.

Static Files

func (a *App) Static(prefix, root string)
func (a *App) File(path, filepath string)
func (a *App) StaticFS(prefix string, fs http.FileSystem)
func (a *App) NoRoute(handler HandlerFunc)

Serve static files and set custom 404 handler.

Server Management

func (a *App) Start(ctx context.Context) error

Starts the server with graceful shutdown. The server runs HTTP, HTTPS, or mTLS depending on configuration: use WithTLS or WithMTLS at construction to serve over TLS; otherwise plain HTTP is used.

Lifecycle Hooks

func (a *App) OnStart(fn func(context.Context) error)
func (a *App) OnReady(fn func())
func (a *App) OnShutdown(fn func(context.Context))
func (a *App) OnStop(fn func())
func (a *App) OnRoute(fn func(*route.Route))

Register lifecycle hooks. See Lifecycle Hooks for details.

Accessors

func (a *App) Router() *router.Router
func (a *App) Metrics() *metrics.Recorder
func (a *App) Tracing() *tracing.Tracer
func (a *App) Readiness() *ReadinessManager
func (a *App) ServiceName() string
func (a *App) ServiceVersion() string
func (a *App) Environment() string

Access underlying components and configuration.

Route Management

func (a *App) Route(name string) (*route.Route, bool)
func (a *App) Routes() []*route.Route
func (a *App) URLFor(routeName string, params map[string]string, query map[string][]string) (string, error)
func (a *App) MustURLFor(routeName string, params map[string]string, query map[string][]string) string

Route lookup and URL generation. Router must be frozen (after Start()).

Metrics

func (a *App) GetMetricsHandler() (http.Handler, error)
func (a *App) GetMetricsServerAddress() string

Access metrics handler and server address.

Logging

func (a *App) BaseLogger() *slog.Logger

Returns the application’s base logger. Never returns nil.

Testing

func (a *App) Test(req *http.Request, opts ...TestOption) (*http.Response, error)
func (a *App) TestJSON(method, path string, body any, opts ...TestOption) (*http.Response, error)

Test routes without starting a server.

Helper Functions

ExpectJSON

func ExpectJSON(t testingT, resp *http.Response, statusCode int, out any)

Test helper that asserts response status and decodes JSON.

Generic Binding

func Bind[T any](c *Context, opts ...BindOption) (T, error)
func MustBind[T any](c *Context, opts ...BindOption) (T, bool)
func BindOnly[T any](c *Context, opts ...BindOption) (T, error)
func BindPatch[T any](c *Context, opts ...BindOption) (T, error)
func MustBindPatch[T any](c *Context, opts ...BindOption) (T, bool)
func BindStrict[T any](c *Context, opts ...BindOption) (T, error)
func MustBindStrict[T any](c *Context, opts ...BindOption) (T, bool)

Type-safe binding with generics. These functions provide a more concise API compared to the Context methods.

Types

HandlerFunc

type HandlerFunc func(*Context)

Handler function that receives an app Context.

TestOption

type TestOption func(*testConfig)

func WithTimeout(d time.Duration) TestOption
func WithContext(ctx context.Context) TestOption

Options for testing.

Next Steps

3.1.1.2 - Configuration Options

App-level configuration options reference.

Service Configuration

WithServiceName

func WithServiceName(name string) Option

Sets the service name used in observability metadata. This includes metrics, traces, and logs. If empty, validation fails.

Default: "rivaas-app"

WithServiceVersion

func WithServiceVersion(version string) Option

Sets the service version used in observability and API documentation. Must be non-empty or validation fails.

Default: "1.0.0"

WithEnvironment

func WithEnvironment(env string) Option

Sets the environment mode. Valid values: "development", "production". Invalid values cause validation to fail. When access log scope is not set via WithAccessLogScope, production defaults to errors-only and development to full access logs.

Default: "development"

Server Configuration

WithPort

func WithPort(port int) Option

Sets the server listen port. Default is 8080 for HTTP; when using WithTLS or WithMTLS the default is 8443. Override with WithPort(n) in all cases. Can be overridden by RIVAAS_PORT when WithEnv is used.

WithServer

func WithServer(opts ...ServerOption) Option

Configures server settings. See Server Options for sub-options.

Server Transport

At most one of WithTLS or WithMTLS may be used. Configure transport at construction; Start then runs the server. Default listen port for TLS/mTLS is 8443 unless overridden by WithPort or RIVAAS_PORT.

WithTLS

func WithTLS(certFile, keyFile string) Option

Configures the server to serve HTTPS using the given certificate and key files. Both certFile and keyFile must be non-empty. Default port is 8443 unless overridden. See Server guide for examples.

WithMTLS

func WithMTLS(serverCert tls.Certificate, opts ...MTLSOption) Option

Configures the server to serve HTTPS with mutual TLS (mTLS). Requires a server certificate and typically WithClientCAs for client verification. Default port is 8443 unless overridden. See Server guide for mTLS options and examples.

Observability

WithObservability

func WithObservability(opts ...ObservabilityOption) Option

Configures all observability components (metrics, tracing, logging). See Observability Options for sub-options.

Endpoints

WithHealthEndpoints

func WithHealthEndpoints(opts ...HealthOption) Option

Enables health endpoints. See Health Options for sub-options.

WithDebugEndpoints

func WithDebugEndpoints(opts ...DebugOption) Option

Enables debug endpoints. See Debug Options for sub-options.

Middleware

WithMiddleware

func WithMiddleware(middlewares ...HandlerFunc) Option

Adds middleware during app initialization. Multiple calls accumulate.

WithoutDefaultMiddleware

func WithoutDefaultMiddleware() Option

Disables default middleware (recovery). Use when you want full control over middleware.

Router

WithRouter

func WithRouter(opts ...router.Option) Option

Passes router options to the underlying router. Multiple calls accumulate.

OpenAPI

WithOpenAPI

func WithOpenAPI(opts ...openapi.Option) Option

Enables OpenAPI specification generation. Service name and version are automatically injected from app-level configuration.

Error Formatting

WithErrorFormatter

func WithErrorFormatter(formatter errors.Formatter) Option

Configures a single error formatter for all error responses.

WithErrorFormatters

func WithErrorFormatters(formatters map[string]errors.Formatter) Option

Configures multiple error formatters with content negotiation based on Accept header.

WithDefaultErrorFormat

func WithDefaultErrorFormat(mediaType string) Option

Sets the default format when no Accept header matches. Only used with WithErrorFormatters.

Complete Example

a, err := app.New(
    // Service
    app.WithServiceName("orders-api"),
    app.WithServiceVersion("v2.0.0"),
    app.WithEnvironment("production"),
    
    // Server
    app.WithServer(
        app.WithReadTimeout(10 * time.Second),
        app.WithWriteTimeout(15 * time.Second),
        app.WithShutdownTimeout(30 * time.Second),
    ),
    
    // Observability
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
        app.WithMetrics(),
        app.WithTracing(tracing.WithOTLP("localhost:4317")),
    ),
    
    // Health endpoints
    app.WithHealthEndpoints(
        app.WithReadinessCheck("database", dbCheck),
    ),
    
    // OpenAPI
    app.WithOpenAPI(
        openapi.WithSwaggerUI(true, "/docs"),
    ),
)

Next Steps

3.1.1.3 - Server Options

Server configuration options reference.

Server Options

These options are used with WithServer():

app.WithServer(
    app.WithReadTimeout(10 * time.Second),
    app.WithWriteTimeout(15 * time.Second),
)

Timeout Options

WithReadTimeout

func WithReadTimeout(d time.Duration) ServerOption

Maximum time to read entire request (including body). Must be positive.

Default: 10s

WithWriteTimeout

func WithWriteTimeout(d time.Duration) ServerOption

Maximum time to write response. Must be positive. Should be >= ReadTimeout.

Default: 10s

WithIdleTimeout

func WithIdleTimeout(d time.Duration) ServerOption

Maximum time to wait for next request on keep-alive connection. Must be positive.

Default: 60s

WithReadHeaderTimeout

func WithReadHeaderTimeout(d time.Duration) ServerOption

Maximum time to read request headers. Must be positive.

Default: 2s

WithShutdownTimeout

func WithShutdownTimeout(d time.Duration) ServerOption

Graceful shutdown timeout. Must be at least 1 second.

Default: 30s

Size Options

WithMaxHeaderBytes

func WithMaxHeaderBytes(n int) ServerOption

Maximum request header size in bytes. Must be at least 1KB (1024 bytes).

Default: 1MB (1048576 bytes)

Validation

Configuration is automatically validated:

  • All timeouts must be positive
  • ReadTimeout should not exceed WriteTimeout
  • ShutdownTimeout must be at least 1 second
  • MaxHeaderBytes must be at least 1KB

Invalid configuration causes app.New() to return an error.

3.1.1.4 - Observability Options

Observability configuration options reference (metrics, tracing, logging).

Observability Options

These options are used with WithObservability():

app.WithObservability(
    app.WithLogging(logging.WithJSONHandler()),
    app.WithMetrics(),
    app.WithTracing(tracing.WithOTLP("localhost:4317")),
)

You can also configure observability using environment variables. See Environment Variables Guide for details.

Component Options

WithLogging

func WithLogging(opts ...logging.Option) ObservabilityOption

Enables structured logging with slog. Service name/version automatically injected.

Environment variable alternative:

export RIVAAS_LOG_LEVEL=info      # debug, info, warn, error
export RIVAAS_LOG_FORMAT=json     # json, text, console

WithMetrics

func WithMetrics(opts ...metrics.Option) ObservabilityOption

Enables metrics collection (Prometheus by default). Service name/version automatically injected.

Environment variable alternative:

export RIVAAS_METRICS_EXPORTER=prometheus  # or otlp, stdout
export RIVAAS_METRICS_ADDR=:9090          # Optional: custom Prometheus address
export RIVAAS_METRICS_PATH=/metrics        # Optional: custom Prometheus path

WithTracing

func WithTracing(opts ...tracing.Option) ObservabilityOption

Enables distributed tracing. Service name/version automatically injected.

Environment variable alternative:

export RIVAAS_TRACING_EXPORTER=otlp        # or otlp-http, stdout
export RIVAAS_TRACING_ENDPOINT=localhost:4317  # Required for otlp/otlp-http

Metrics Server Options

WithMetricsOnMainRouter

func WithMetricsOnMainRouter(path string) ObservabilityOption

Mounts metrics endpoint on the main HTTP server (default: separate server).

WithMetricsSeparateServer

func WithMetricsSeparateServer(addr, path string) ObservabilityOption

Configures separate metrics server address and path.

Default: :9090/metrics

Path Filtering

WithExcludePaths

func WithExcludePaths(paths ...string) ObservabilityOption

Excludes exact paths from observability.

WithExcludePrefixes

func WithExcludePrefixes(prefixes ...string) ObservabilityOption

Excludes path prefixes from observability.

WithExcludePatterns

func WithExcludePatterns(patterns ...string) ObservabilityOption

Excludes paths matching regex patterns from observability.

WithoutDefaultExclusions

func WithoutDefaultExclusions() ObservabilityOption

Disables default path exclusions (/health*, /metrics, /debug/*).

Access Logging

WithAccessLogging

func WithAccessLogging(enabled bool) ObservabilityOption

Enables or disables access logging.

Default: true

WithAccessLogScope

func WithAccessLogScope(scope AccessLogScope) ObservabilityOption

Sets which requests are logged as access logs. Valid values are app.AccessLogScopeAll and app.AccessLogScopeErrorsOnly. Invalid values cause validation to fail at startup.

Scope values:

  • AccessLogScopeAll — Log every request (including 2xx). Use in production only if you need full request logs; consider log volume and cost.
  • AccessLogScopeErrorsOnly — Log only errors (status >= 400) and slow requests. Reduces log volume.

Access log scope and environment defaults

When you do not call WithAccessLogScope, the effective scope is determined by environment:

User choiceProductionDevelopment
NoneErrors-only (default)Full access logs (default)
WithAccessLogScope(AccessLogScopeErrorsOnly)Errors-onlyErrors-only
WithAccessLogScope(AccessLogScopeAll)Full access logsFull access logs

Slow requests are always logged regardless of scope. See WithSlowThreshold.

WithSlowThreshold

func WithSlowThreshold(d time.Duration) ObservabilityOption

Marks requests as slow if they exceed this duration.

Default: 1s

Example

app.WithObservability(
    // Components
    app.WithLogging(logging.WithJSONHandler()),
    app.WithMetrics(metrics.WithPrometheus(":9090", "/metrics")),
    app.WithTracing(tracing.WithOTLP("localhost:4317")),
    
    // Path filtering
    app.WithExcludePaths("/livez", "/readyz"),
    app.WithExcludePrefixes("/internal/"),
    
    // Access logging
    app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
    app.WithSlowThreshold(500 * time.Millisecond),
)

3.1.1.5 - Health Options

Health endpoint configuration options reference.

Health Options

These options are used with WithHealthEndpoints():

app.WithHealthEndpoints(
    app.WithReadinessCheck("database", dbCheck),
    app.WithHealthTimeout(800 * time.Millisecond),
)

Path Configuration

WithHealthPrefix

func WithHealthPrefix(prefix string) HealthOption

Mounts health endpoints under a prefix.

Default: "" (root)

WithLivezPath

func WithLivezPath(path string) HealthOption

Custom liveness probe path.

Default: "/livez"

WithReadyzPath

func WithReadyzPath(path string) HealthOption

Custom readiness probe path.

Default: "/readyz"

Check Configuration

WithHealthTimeout

func WithHealthTimeout(d time.Duration) HealthOption

Timeout for each health check.

Default: 1s

WithLivenessCheck

func WithLivenessCheck(name string, fn CheckFunc) HealthOption

Adds a liveness check. Liveness checks should be dependency-free and fast.

WithReadinessCheck

func WithReadinessCheck(name string, fn CheckFunc) HealthOption

Adds a readiness check. Readiness checks verify external dependencies.

CheckFunc

type CheckFunc func(context.Context) error

Health check function that returns nil if healthy, error if unhealthy.

Example

app.WithHealthEndpoints(
    app.WithHealthPrefix("/_system"),
    app.WithHealthTimeout(800 * time.Millisecond),
    app.WithLivenessCheck("process", func(ctx context.Context) error {
        return nil
    }),
    app.WithReadinessCheck("database", func(ctx context.Context) error {
        return db.PingContext(ctx)
    }),
)

// Endpoints:
// GET /_system/livez - Liveness (200 if all checks pass)
// GET /_system/readyz - Readiness (204 if all checks pass)

3.1.1.6 - Debug Options

Debug endpoint configuration options reference.

Debug Options

These options are used with WithDebugEndpoints():

app.WithDebugEndpoints(
    app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
)

Path Configuration

WithDebugPrefix

func WithDebugPrefix(prefix string) DebugOption

Mounts debug endpoints under a custom prefix.

Default: "/debug"

pprof Options

WithPprof

func WithPprof() DebugOption

Enables pprof endpoints unconditionally.

WithPprofIf

func WithPprofIf(condition bool) DebugOption

Conditionally enables pprof endpoints based on a boolean condition.

Available Endpoints

When pprof is enabled:

  • GET /debug/pprof/ - Main pprof index
  • GET /debug/pprof/cmdline - Command line
  • GET /debug/pprof/profile - CPU profile
  • GET /debug/pprof/symbol - Symbol lookup
  • GET /debug/pprof/trace - Execution trace
  • GET /debug/pprof/allocs - Memory allocations
  • GET /debug/pprof/block - Block profile
  • GET /debug/pprof/goroutine - Goroutine profile
  • GET /debug/pprof/heap - Heap profile
  • GET /debug/pprof/mutex - Mutex profile
  • GET /debug/pprof/threadcreate - Thread creation profile

Security Warning

⚠️ Never enable pprof in production without proper authentication. Debug endpoints expose sensitive runtime information.

Example

// Development: enable unconditionally
app.WithDebugEndpoints(
    app.WithPprof(),
)

// Production: enable conditionally
app.WithDebugEndpoints(
    app.WithDebugPrefix("/_internal/debug"),
    app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
)

3.1.1.7 - Context API

Context methods for request handling.

Request Binding

Bind

func (c *Context) Bind(out any, opts ...BindOption) error

Binds request data and validates it. This is the main method for handling requests.

Reads data from all sources (path, query, headers, cookies, JSON, forms) based on struct tags. Then validates the data using the configured strategy.

Returns: Error if binding or validation fails.

MustBind

func (c *Context) MustBind(out any, opts ...BindOption) bool

Binds and validates, automatically sending error responses on failure.

Use this when you want simple error handling. If binding or validation fails, it sends the error response and returns false.

Returns: True if successful, false if error was sent.

BindOnly

func (c *Context) BindOnly(out any, opts ...BindOption) error

Binds request data without validation.

Use this when you need to process data before validating it.

Returns: Error if binding fails.

Validate

func (c *Context) Validate(v any, opts ...validation.Option) error

Validates a struct using the configured strategy.

Use this after BindOnly() when you need fine-grained control.

Returns: Validation error if validation fails.

Error Handling

All error handling methods automatically format the error response and abort the handler chain. No further handlers will run after calling these methods.

Fail

func (c *Context) Fail(err error)

Sends a formatted error response using the configured formatter. The HTTP status code is determined from the error (if it implements HTTPStatus() int) or defaults to 500.

Parameters:

  • err: The error to send. If nil, the method returns without doing anything.

Behavior:

  • Formats the error using content negotiation
  • Writes the HTTP response
  • Aborts the handler chain

FailStatus

func (c *Context) FailStatus(status int, err error)

Sends an error response with an explicit HTTP status code.

Parameters:

  • status: The HTTP status code to use
  • err: The error to send

Behavior:

  • Wraps the error with the specified status code
  • Formats and sends the response
  • Aborts the handler chain

NotFound

func (c *Context) NotFound(err error)

Sends a 404 Not Found error response.

Parameters:

  • err: The error to send, or nil for a generic “Not Found” message

BadRequest

func (c *Context) BadRequest(err error)

Sends a 400 Bad Request error response.

Parameters:

  • err: The error to send, or nil for a generic “Bad Request” message

Unauthorized

func (c *Context) Unauthorized(err error)

Sends a 401 Unauthorized error response.

Parameters:

  • err: The error to send, or nil for a generic “Unauthorized” message

Forbidden

func (c *Context) Forbidden(err error)

Sends a 403 Forbidden error response.

Parameters:

  • err: The error to send, or nil for a generic “Forbidden” message

Conflict

func (c *Context) Conflict(err error)

Sends a 409 Conflict error response.

Parameters:

  • err: The error to send, or nil for a generic “Conflict” message

Gone

func (c *Context) Gone(err error)

Sends a 410 Gone error response.

Parameters:

  • err: The error to send, or nil for a generic “Gone” message

UnprocessableEntity

func (c *Context) UnprocessableEntity(err error)

Sends a 422 Unprocessable Entity error response.

Parameters:

  • err: The error to send, or nil for a generic “Unprocessable Entity” message

TooManyRequests

func (c *Context) TooManyRequests(err error)

Sends a 429 Too Many Requests error response.

Parameters:

  • err: The error to send, or nil for a generic “Too Many Requests” message

InternalError

func (c *Context) InternalError(err error)

Sends a 500 Internal Server Error response.

Parameters:

  • err: The error to send, or nil for a generic “Internal Server Error” message

ServiceUnavailable

func (c *Context) ServiceUnavailable(err error)

Sends a 503 Service Unavailable error response.

Parameters:

  • err: The error to send, or nil for a generic “Service Unavailable” message

Logging

To log from a handler with trace correlation, pass the request context to the standard library’s context-aware logging functions. For example: slog.InfoContext(c.RequestContext(), "msg", ...) or slog.ErrorContext(c.RequestContext(), "msg", ...). When the app is configured with observability (logging and tracing), trace_id and span_id are injected automatically from the active OpenTelemetry span.

Presence

Presence

func (c *Context) Presence() validation.PresenceMap

Returns the presence map for the current request (tracks which fields were present in JSON).

ResetBinding

func (c *Context) ResetBinding()

Resets binding metadata (useful for testing).

Router Context

The app Context embeds router.Context, providing access to all router features:

  • c.Request - HTTP request
  • c.Response - HTTP response writer
  • c.Param(name) - Path parameter
  • c.Query(name) - Query parameter
  • c.JSON(status, data) - Send JSON response
  • c.String(status, text) - Send text response
  • c.HTML(status, html) - Send HTML response
  • And more…

See Router Context API for complete router context reference.

3.1.1.8 - Lifecycle Hooks

Lifecycle hook APIs and execution order.

Hook Methods

All hook registration methods return an error when called after the router is frozen (e.g. after Start() or Router().Freeze()). Register all hooks before starting the server. Use errors.Is(err, app.ErrRouterFrozen) to detect this case.

OnStart

func (a *App) OnStart(fn func(context.Context) error) error

Called before server starts. Hooks run sequentially and stop on first error.

Use for: Database connections, migrations, initialization that must succeed.

OnReady

func (a *App) OnReady(fn func()) error

Called after server starts listening. Hooks run asynchronously and don’t block startup.

Use for: Warmup tasks, service discovery registration.

OnShutdown

func (a *App) OnShutdown(fn func(context.Context)) error

Called during graceful shutdown. Hooks run in LIFO order with shutdown timeout.

Use for: Closing connections, flushing buffers, cleanup that must complete within timeout.

OnStop

func (a *App) OnStop(fn func()) error

Called after shutdown completes. Hooks run in best-effort mode and panics are caught.

Use for: Final cleanup that doesn’t need timeout.

OnRoute

func (a *App) OnRoute(fn func(*route.Route)) error

Called when a route is registered. Disabled after router freeze.

Use for: Route validation, logging, documentation generation.

OnReload

func (a *App) OnReload(fn func(context.Context) error) error

Called when the application receives a reload signal (SIGHUP) or when Reload() is called programmatically. SIGHUP signal handling is automatically enabled when you register this hook.

If no OnReload hooks are registered, SIGHUP is ignored on Unix so the process keeps running (e.g. kill -HUP does not terminate it).

Hooks run sequentially and stop on first error. Errors are logged but don’t crash the server.

Use for: Reloading configuration, rotating certificates, flushing caches, updating runtime settings.

Platform: SIGHUP works on Unix/Linux/macOS. On Windows, use programmatic Reload().

Reload

func (a *App) Reload(ctx context.Context) error

Manually triggers all registered OnReload hooks. Useful for admin endpoints or Windows where SIGHUP isn’t available.

Returns an error if any hook fails, but the server continues running with the old configuration.

Post-freeze registration

Registering any lifecycle hook after the router is frozen (e.g. after Start() or Router().Freeze()) returns an error instead of panicking. Register all hooks before starting the server. Use errors.Is(err, app.ErrRouterFrozen) to detect this case programmatically.

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

Hook Characteristics

HookOrderError HandlingTimeoutAsync
OnStartSequentialStop on first errorNoNo
OnReady-Panic caught and loggedNoYes
OnReloadSequentialStop on first error, loggedNoNo
OnShutdownLIFOErrors ignoredYes (shutdown timeout)No
OnStop-Panic caught and loggedNoNo
OnRouteSequential-NoNo

Example

a := app.MustNew()

// OnStart: Initialize (sequential, stops on error)
if err := a.OnStart(func(ctx context.Context) error {
    return db.Connect(ctx)
}); err != nil {
    log.Fatal(err)
}

// OnReady: Post-startup (async, non-blocking)
if err := a.OnReady(func() {
    consul.Register("my-service", ":8080")
}); err != nil {
    log.Fatal(err)
}

// OnReload: Reload configuration (sequential, logged on error)
if err := a.OnReload(func(ctx context.Context) error {
    cfg, err := loadConfig("config.yaml")
    if err != nil {
        return err
    }
    applyConfig(cfg)
    return nil
}); err != nil {
    log.Fatal(err)
}

// OnShutdown: Graceful cleanup (LIFO, with timeout)
if err := a.OnShutdown(func(ctx context.Context) {
    db.Close()
}); err != nil {
    log.Fatal(err)
}

// OnStop: Final cleanup (best-effort)
if err := a.OnStop(func() {
    cleanupTempFiles()
}); err != nil {
    log.Fatal(err)
}

3.1.1.9 - Troubleshooting

Common issues and solutions for the App package.

Configuration Errors

Validation Errors

Problem: app.New() returns validation errors.

Solution: Check error message for specific field. Common issues:

  • Empty service name or version.
  • Invalid environment. Must be “development” or “production”.
  • ReadTimeout greater than WriteTimeout.
  • ShutdownTimeout less than 1 second.
  • MaxHeaderBytes less than 1KB.

Example:

a, err := app.New(
    app.WithServiceName(""),  // ❌ Empty
)
// Error: "serviceName must not be empty"

Import Errors

Problem: Cannot import rivaas.dev/app.

Solution:

go get rivaas.dev/app
go mod tidy

Ensure Go 1.25+ is installed.

Server Issues

Port Already in Use

Problem: Server fails to start with “address already in use”.

Solution: Check if port is in use (default is 8080 for HTTP, 8443 for TLS/mTLS):

lsof -i :8080
# Or for TLS/mTLS
lsof -i :8443
# Or
netstat -an | grep 8080

Kill the process or use a different port with WithPort(n).

Routes Not Registering

Problem: Routes return 404 even though registered.

Solution:

  • Ensure routes registered before Start().
  • Check paths match exactly. They are case-sensitive.
  • Verify HTTP method matches.
  • Router freezes on startup. Can’t add routes after.
  • Lifecycle hook registration (OnStart, OnReady, OnShutdown, etc.) after freeze returns an error instead of panicking. Check and handle the error (e.g. in main) and register all hooks before Start().

Unsupported HTTP Method Panic

Problem: Panic with message like unsupported HTTP method "…" or supported: GET, POST, ....

Solution: Use only the provided method shortcuts: a.GET, a.POST, a.PUT, a.DELETE, a.PATCH, a.HEAD, a.OPTIONS, and the same on Group and VersionGroup. If the panic appears in tests or custom code that passes a method string, ensure that string is one of: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS.

Graceful Shutdown Not Working

Problem: Server doesn’t shut down cleanly.

Solution:

  • Increase shutdown timeout: WithShutdownTimeout(60 * time.Second).
  • Check OnShutdown hooks complete quickly.
  • Verify handlers respect context cancellation.

Observability Issues

Metrics Not Appearing

Problem: Metrics endpoint returns 404.

Solution:

  • Ensure metrics enabled: WithMetrics()
  • Check metrics address: a.GetMetricsServerAddress()
  • Default is separate server on :9090/metrics
  • Use WithMetricsOnMainRouter("/metrics") to mount on main router

Tracing Not Working

Problem: No traces appear in backend.

Solution:

  • Verify tracing enabled: WithTracing()
  • Check OTLP endpoint configuration
  • Ensure tracing backend is running and accessible
  • Verify network connectivity
  • Check logs for tracing initialization errors

Logs Not Appearing

Problem: No logs are written.

Solution:

  • Ensure logging enabled: WithLogging()
  • Check log level configuration
  • Verify logger handler is correct (JSON, Console, etc.)
  • Use c.Logger() in handlers, not package-level logger

Middleware Issues

Middleware Not Executing

Problem: Middleware functions aren’t being called.

Solution:

  • Ensure middleware added before routes
  • Check middleware calls c.Next()
  • Verify middleware isn’t returning early
  • Default recovery middleware is included automatically

Authentication Failing

Problem: Auth middleware not working correctly.

Solution:

  • Check header/token extraction logic
  • Verify middleware order (auth should run early)
  • Ensure c.Next() is called on success
  • Test middleware in isolation

Testing Issues

Test Hangs

Problem: a.Test() never returns.

Solution:

  • Set timeout: a.Test(req, app.WithTimeout(5*time.Second))
  • Check for infinite loops in handler
  • Verify middleware calls c.Next()

Test Fails with Panic

Problem: Test panics instead of returning error.

Solution:

  • Use recover() in test or
  • Check that handler doesn’t panic
  • Recovery middleware catches panics in real server

Health Check Issues

Health Checks Always Failing

Problem: /livez or /readyz always returns 503.

Solution:

  • Check health check functions return nil on success
  • Verify dependencies (database, cache) are accessible
  • Check health timeout is sufficient
  • Test health checks independently

Health Checks Never Complete

Problem: Health checks timeout.

Solution:

  • Increase timeout: WithHealthTimeout(2 * time.Second)
  • Check dependencies respond within timeout
  • Verify no deadlocks in check functions
  • Use context timeout in check functions

Debugging Tips

Enable Development Mode

app.WithEnvironment("development")

Enables verbose logging and route table display.

Check Observability Status

if a.Metrics() != nil {
    fmt.Println("Metrics:", a.GetMetricsServerAddress())
}
if a.Tracing() != nil {
    fmt.Println("Tracing enabled")
}

Use Test Helpers

resp, err := a.Test(req)  // Test without starting server

Enable GC Tracing

GODEBUG=gctrace=1 go run main.go

Getting Help

3.1.2 - Router Package

Complete API reference for the rivaas.dev/router package.

This is the API reference for the rivaas.dev/router package. For learning-focused documentation, see the Router Guide.

Overview

The rivaas.dev/router package provides a high-performance HTTP router with comprehensive features:

  • Radix tree routing with bloom filters
  • Optional compiled route tables for large route sets
  • Built-in middleware support
  • OpenTelemetry support
  • API versioning
  • Content negotiation

Package Structure

rivaas.dev/router/
├── router.go          # Core router and route registration
├── context.go         # Request context with pooling
├── serve.go           # Request serving and dispatch
├── routes.go          # Route tree and method dispatch
├── radix.go           # Radix tree and route matching
├── route_bridge.go    # Route groups and mounting
├── options.go         # Router options
├── route/             # Route definitions and constraints
│   ├── route.go
│   ├── constraint.go
│   ├── group.go
│   └── ...
├── compiler/          # Optional compiled route lookup
├── version/           # API versioning
└── ...

Middleware (accesslog, cors, recovery, etc.) lives in separate packages under rivaas.dev/middleware/, not inside the router package.

Quick API Index

Core Types

Route Registration

  • HTTP Methods: GET(), POST(), PUT(), DELETE(), PATCH(), OPTIONS(), HEAD()
  • Route Groups: Group(prefix), Version(version)
  • Middleware: Use(middleware...)
  • Static Files: Static(), StaticFile(), StaticFS()

Request Handling

  • Parameters: Param(), Query(), PostForm()
  • Headers: Header(), GetHeader()
  • Cookies: Cookie(), SetCookie()

Response Rendering

  • JSON: JSON(), PureJSON(), IndentedJSON(), SecureJSON()
  • Other: YAML(), String(), HTML(), Data()
  • Files: ServeFile(), Download(), DataFromReader()

Configuration

Performance

Routing Performance

  • Sub-microsecond routing — See Performance for current latency and throughput numbers.
  • Zero allocation — No allocations for routing and param extraction in typical cases (≤8 path params). See Performance for benchmark details.
  • Memory efficient — Context pooling and minimal allocations per request.
  • Context pooling: Automatic context reuse
  • 404 handling: A single pooled context and conditional dispatch for custom NoRoute handler vs default RFC 9457 response
  • Lock-free operations: Atomic operations for concurrent access

Optimization Features

  • Optional compiled routes: Pre-compiled lookups for large APIs (opt-in via WithRouteCompilation(true))
  • Bloom filters: Fast negative lookups when compiled routes are enabled
  • First-segment index: ASCII-only route narrowing (O(1) lookup)
  • Parameter storage: Array-based for ≤8 params, map for >8
  • Type caching: Reflection information cached per struct type

Thread Safety

All router operations are concurrent-safe:

  • Route registration can occur from multiple goroutines
  • Route trees use atomic operations for concurrent access
  • Context pooling is thread-safe
  • Middleware execution is goroutine-safe

Next Steps

3.1.2.1 - API Reference

Core types and methods for the router package.

Router

router.New(opts ...Option) *Router

Creates a new router instance.

r := router.New()

// With options
r := router.New(
    router.WithTracing(),
    router.WithTracingServiceName("my-api"),
)

HTTP Method Handlers

Register routes for HTTP methods:

r.GET(path string, handlers ...HandlerFunc) *Route
r.POST(path string, handlers ...HandlerFunc) *Route
r.PUT(path string, handlers ...HandlerFunc) *Route
r.DELETE(path string, handlers ...HandlerFunc) *Route
r.PATCH(path string, handlers ...HandlerFunc) *Route
r.OPTIONS(path string, handlers ...HandlerFunc) *Route
r.HEAD(path string, handlers ...HandlerFunc) *Route

Example:

r.GET("/users", listUsersHandler)
r.POST("/users", createUserHandler)
r.GET("/users/:id", getUserHandler)

Middleware

r.Use(middleware ...HandlerFunc)

Adds global middleware to the router.

r.Use(Logger(), Recovery())

Route Groups

r.Group(prefix string, middleware ...HandlerFunc) *Group

Creates a new route group with the specified prefix and optional middleware.

api := r.Group("/api/v1")
api.Use(Auth())
api.GET("/users", listUsers)

API Versioning

r.Version(version string) *Group

Creates a version-specific route group.

v1 := r.Version("v1")
v1.GET("/users", listUsersV1)

Static Files

r.Static(relativePath, root string)
r.StaticFile(relativePath, filepath string)
r.StaticFS(relativePath string, fs http.FileSystem)

Example:

r.Static("/assets", "./public")
r.StaticFile("/favicon.ico", "./static/favicon.ico")

Route Introspection

r.Routes() []RouteInfo

Returns all registered routes for inspection.

Route

Constraints

Apply validation constraints to route parameters:

route.WhereInt(param string) *Route
route.WhereFloat(param string) *Route
route.WhereUUID(param string) *Route
route.WhereDate(param string) *Route
route.WhereDateTime(param string) *Route
route.WhereEnum(param string, values ...string) *Route
route.WhereRegex(param, pattern string) *Route

Example:

r.GET("/users/:id", getUserHandler).WhereInt("id")
r.GET("/entities/:uuid", getEntityHandler).WhereUUID("uuid")
r.GET("/status/:state", getStatusHandler).WhereEnum("state", "active", "pending")

Group

Route groups support the same methods as Router, with the group’s prefix automatically prepended.

group.GET(path string, handlers ...HandlerFunc) *Route
group.POST(path string, handlers ...HandlerFunc) *Route
group.Use(middleware ...HandlerFunc)
group.Group(prefix string, middleware ...HandlerFunc) *Group

HandlerFunc

type HandlerFunc func(*Context)

Handler function signature for route handlers and middleware.

Example:

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

Next Steps

3.1.2.2 - Router Options

Configuration options for Router initialization.

Router options are passed to router.New() or router.MustNew() to configure the router.

Router Creation

// With error handling
r, err := router.New(opts...)
if err != nil {
    log.Fatalf("Failed to create router: %v", err)
}

// Panics on invalid configuration. Use at startup.
r := router.MustNew(opts...)

Versioning Options

WithVersioning(opts ...version.Option)

Configures API versioning support using functional options from the version package.

import "rivaas.dev/router/version"

r := router.MustNew(
    router.WithVersioning(
        version.WithHeaderDetection("X-API-Version"),
        version.WithDefault("v1"),
    ),
)

With multiple detection strategies:

r := router.MustNew(
    router.WithVersioning(
        version.WithPathDetection("/api/v{version}"),
        version.WithHeaderDetection("X-API-Version"),
        version.WithQueryDetection("v"),
        version.WithDefault("v2"),
        version.WithResponseHeaders(),
        version.WithSunsetEnforcement(),
    ),
)

Diagnostic Options

WithDiagnostics(handler DiagnosticHandler)

Sets a diagnostic handler for informational events like header injection attempts or configuration warnings.

import "log/slog"

handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    slog.Warn(e.Message, "kind", e.Kind, "fields", e.Fields)
})

r := router.MustNew(router.WithDiagnostics(handler))

With metrics:

handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    metrics.Increment("router.diagnostics", "kind", string(e.Kind))
})

Server Options

WithH2C(enable bool)

Enables HTTP/2 Cleartext (h2c) support.

r := router.MustNew(router.WithH2C(true))

WithServerTimeouts(readHeader, read, write, idle time.Duration)

Configures HTTP server timeouts to prevent slowloris attacks and resource exhaustion.

Defaults (if not set):

  • ReadHeaderTimeout: 5s
  • ReadTimeout: 15s
  • WriteTimeout: 30s
  • IdleTimeout: 60s
r := router.MustNew(router.WithServerTimeouts(
    10*time.Second,  // ReadHeaderTimeout
    30*time.Second,  // ReadTimeout
    60*time.Second,  // WriteTimeout
    120*time.Second, // IdleTimeout
))

Performance Options

WithRouteCompilation(enabled bool)

Turns compiled route matching on or off. By default it’s off: the router uses tree traversal, which is fast and works well for most apps. Turn it on when you have a lot of routes (for example hundreds of static routes). Then the router can use pre-compiled lookups and bloom filters to speed things up.

Default: false (tree traversal)

// Default: tree traversal (no need to set anything)
r := router.MustNew()

// Turn on compiled routes for large APIs
r := router.MustNew(router.WithRouteCompilation(true))

WithBloomFilterSize(size uint64)

Sets the bloom filter size when you use compiled routes. Larger sizes reduce false positives.

Default: 1000
Recommended: 2-3x the number of static routes

r := router.MustNew(router.WithBloomFilterSize(2000)) // For ~1000 routes

WithBloomFilterHashFunctions(numFuncs int)

Sets the number of hash functions for bloom filters.

Default: 3
Range: 1-10 (clamped)

r := router.MustNew(router.WithBloomFilterHashFunctions(4))

WithCancellationCheck(enabled bool) / WithoutCancellationCheck()

Controls context cancellation checking in the middleware chain. When enabled (default), the router checks for canceled contexts between handlers.

// Enabled by default
r := router.MustNew(router.WithCancellationCheck(true))

// Disable if you handle cancellation manually
r := router.MustNew(router.WithoutCancellationCheck())

Complete Example

package main

import (
    "log/slog"
    "net/http"
    "os"
    "time"
    
    "rivaas.dev/router"
    "rivaas.dev/router/version"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    // Diagnostic handler
    diagHandler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
        logger.Warn(e.Message, "kind", e.Kind, "fields", e.Fields)
    })
    
    // Create router with options
    r := router.MustNew(
        // Versioning
        router.WithVersioning(
            version.WithHeaderDetection("API-Version"),
            version.WithDefault("v1"),
        ),
        
        // Server configuration
        router.WithServerTimeouts(
            10*time.Second,
            30*time.Second,
            60*time.Second,
            120*time.Second,
        ),
        
        // Performance tuning
        router.WithBloomFilterSize(2000),
        
        // Diagnostics
        router.WithDiagnostics(diagHandler),
    )
    
    r.GET("/", func(c *router.Context) {
        c.JSON(200, map[string]string{"message": "Hello"})
    })
    
    http.ListenAndServe(":8080", r)
}

Observability Options

import (
    "rivaas.dev/app"
    "rivaas.dev/tracing"
    "rivaas.dev/metrics"
)

application := app.New(
    app.WithServiceName("my-api"),
    app.WithObservability(
        app.WithTracing(tracing.WithSampleRate(0.1)),
        app.WithMetrics(metrics.WithPrometheus()),
        app.WithExcludePaths("/health", "/metrics"),
    ),
)

Next Steps

3.1.2.3 - Context API

Complete reference for Context methods.

The Context provides access to request/response and utility methods.

Request Information

URL Parameters

c.Param(key string) string

Returns URL parameter value from the route path.

// Route: /users/:id
userID := c.Param("id")
c.AllParams() map[string]string

Returns all URL path parameters as a map.

Query Parameters

c.Query(key string) string
c.QueryDefault(key, defaultValue string) string
c.AllQueries() map[string]string
// GET /search?q=golang&page=2
query := c.Query("q")           // "golang"
page := c.QueryDefault("page", "1") // "2"
all := c.AllQueries()           // map[string]string{"q": "golang", "page": "2"}

Form Data

c.FormValue(key string) string
c.FormValueDefault(key, defaultValue string) string

Returns form parameter from POST request body.

// POST with form data
username := c.FormValue("username")
role := c.FormValueDefault("role", "user")

Headers

c.Request.Header.Get(key string) string
c.RequestHeaders() map[string]string
c.ResponseHeaders() map[string]string

Request Binding

Content Type Validation

c.RequireContentType(allowed ...string) bool
c.RequireContentTypeJSON() bool
if !c.RequireContentTypeJSON() {
    return // 415 Unsupported Media Type already sent
}

Streaming

// Stream JSON array items
router.StreamJSONArray[T](c *Context, each func(T) error, maxItems int) error

// Stream NDJSON (newline-delimited JSON)
router.StreamNDJSON[T](c *Context, each func(T) error) error
err := router.StreamJSONArray(c, func(item User) error {
    return processUser(item)
}, 10000) // Max 10k items

Response Methods

JSON Responses

c.JSON(code int, obj any) error
c.IndentedJSON(code int, obj any) error
c.PureJSON(code int, obj any) error      // No HTML escaping
c.SecureJSON(code int, obj any, prefix ...string) error
c.ASCIIJSON(code int, obj any) error     // All non-ASCII escaped

Other Formats

c.YAML(code int, obj any) error
c.String(code int, value string) error
c.Stringf(code int, format string, values ...any) error
c.HTML(code int, html string) error

Binary & Streaming

c.Data(code int, contentType string, data []byte) error
c.DataFromReader(code int, contentLength int64, contentType string, reader io.Reader, extraHeaders map[string]string) error

File Serving

c.ServeFile(filepath string)

Status & No Content

c.Status(code int)
c.NoContent()

Error Responses

c.WriteErrorResponse(status int, message string)
c.NotFound()
c.MethodNotAllowed(allowed []string)

Headers

c.Header(key, value string)

Sets a response header with automatic security sanitization (newlines stripped).

URL Information

c.Hostname() string    // Host without port
c.Port() string        // Port number
c.Scheme() string      // "http" or "https"
c.BaseURL() string     // scheme + host
c.FullURL() string     // Complete URL with query string

Client Information

c.ClientIP() string      // Real client IP (respects trusted proxies)
c.ClientIPs() []string   // All IPs from X-Forwarded-For chain
c.IsHTTPS() bool         // Request over HTTPS
c.IsLocalhost() bool     // Request from localhost
c.IsXHR() bool           // XMLHttpRequest (AJAX)
c.Subdomains(offset ...int) []string

Content Type Detection

c.IsJSON() bool      // Content-Type is application/json
c.IsXML() bool       // Content-Type is application/xml or text/xml
c.AcceptsJSON() bool // Accept header includes application/json
c.AcceptsHTML() bool // Accept header includes text/html

Content Negotiation

c.Accepts(offers ...string) string
c.AcceptsCharsets(offers ...string) string
c.AcceptsEncodings(offers ...string) string
c.AcceptsLanguages(offers ...string) string
// Accept: application/json, text/html;q=0.9
best := c.Accepts("json", "html", "xml") // "json"

// Accept-Language: en-US, fr;q=0.8
lang := c.AcceptsLanguages("en", "fr", "de") // "en"

Caching

c.IsFresh() bool  // Response still fresh in client cache
c.IsStale() bool  // Client cache is stale
if c.IsFresh() {
    c.Status(http.StatusNotModified) // 304
    return
}

Redirects

c.Redirect(code int, location string)
c.Redirect(http.StatusFound, "/login")
c.Redirect(http.StatusMovedPermanently, "https://newdomain.com")

Cookies

c.SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool)
c.GetCookie(name string) (string, error)

File Uploads

c.File(name string) (*File, error)
c.Files(name string) ([]*File, error)

File methods:

file.Bytes() ([]byte, error)
file.Open() (io.ReadCloser, error)
file.Save(dst string) error
file.Ext() string
file, err := c.File("avatar")
if err != nil {
    return c.JSON(400, map[string]string{"error": "avatar required"})
}
file.Save("./uploads/" + uuid.New().String() + file.Ext())

Middleware Control

c.Next()           // Execute next handler in chain
c.Abort()          // Stop handler chain
c.IsAborted() bool // Check if chain was aborted

Error Collection

c.Error(err error)      // Collect error without writing response
c.Errors() []error      // Get all collected errors
c.HasErrors() bool      // Check if errors were collected

Note: router.Context.Error() collects errors without writing a response or aborting the handler chain. This is useful for gathering multiple errors before deciding how to respond.

To send an error response immediately, use app.Context.Fail() which formats the error, writes the response, and stops the handler chain.

if err := validateUser(c); err != nil {
    c.Error(err)
}
if err := validateEmail(c); err != nil {
    c.Error(err)
}

if c.HasErrors() {
    c.JSON(400, map[string]any{"errors": c.Errors()})
    return
}

Context Access

c.RequestContext() context.Context  // Request's context.Context

For tracing and metrics in your handlers, use the app package. The app observability guide shows how to use app.Context methods such as TraceID(), SpanID(), SetSpanAttribute(), AddSpanEvent(), RecordHistogram(), IncrementCounter(), and SetGauge().

Versioning

c.Version() string           // Current API version ("v1", "v2", etc.)
c.IsVersion(version string) bool
c.RoutePattern() string      // Matched route pattern ("/users/:id")

Complete Example

func handler(c *router.Context) {
    // Parameters
    id := c.Param("id")
    query := c.Query("q")
    
    // Headers
    auth := c.Request.Header.Get("Authorization")
    c.Header("X-Custom", "value")
    
    // Strict binding (for full binding, use binding package)
    var req CreateRequest
    if err := c.BindStrict(&req, router.BindOptions{MaxBytes: 1 << 20}); err != nil {
        return // Error response already written
    }
    
    // Logging (pass request context for trace correlation)
    slog.InfoContext(c.RequestContext(), "processing request", "user_id", id)
    
    // Response
    if err := c.JSON(200, map[string]string{
        "id":    id,
        "query": query,
    }); err != nil {
        slog.ErrorContext(c.RequestContext(), "failed to write response", "error", err)
    }
}

Next Steps

3.1.2.4 - Router Performance

Comprehensive benchmark comparison between rivaas/router and other popular Go web frameworks, with methodology and reproduction instructions.

This page contains detailed performance benchmarks comparing rivaas/router against other popular Go web frameworks. The benchmarks measure pure routing dispatch overhead by using direct writes (via io.WriteString) in all handlers to eliminate string concatenation allocations.

Benchmark Methodology

Test Environment

  • Go Version: 1.26
  • CPU: AMD EPYC 7763 64-Core Processor
  • OS: linux/amd64
  • Last Updated: 2026-02-26

Frameworks Compared

The following frameworks are included in the comparison:

Test Scenarios

All frameworks are tested with the same three route patterns:

  1. Static route: GET /
  2. One parameter: GET /users/:id
  3. Two parameters: GET /users/:id/posts/:post_id

Handler Implementation

To ensure fair comparison and isolate routing overhead, all handlers use direct writes rather than string concatenation:

// Instead of this (causes one string allocation):
w.Write([]byte("User: " + id))

// Handlers do this (zero allocations for supported frameworks):
io.WriteString(w, "User: ")
io.WriteString(w, id)

This eliminates the handler allocation cost, so the measured time represents:

  • Route tree traversal and matching
  • Parameter extraction
  • Context setup
  • Response writer overhead (framework-specific)

Measurement Notes

  • Fiber v2/v3: Measured via net/http adaptor (fiberadaptor.FiberApp) for compatibility with httptest.ResponseRecorder. The adaptor adds overhead but is necessary for the standard test harness.
  • Hertz: Measured using ut.PerformRequest(h.Engine, ...) (Hertz’s native test API) because Hertz does not implement http.Handler. Numbers are not directly comparable to httptest-based frameworks due to different measurement approach.
  • Beego: May log “init global config instance failed” when conf/app.conf is missing; this is safe to ignore in benchmarks.

Benchmark Results

Static Route (/)

This scenario measures the overhead of dispatching a request to a static route with no parameters.

Frameworkns/opB/opallocs/opNotes
Rivaas47.400Zero alloc
Gin61.100Zero alloc
Echo78.281
StdMux80.000Zero alloc
Chi347.63682
Beego663.13604
Hertz1720.0344824via ut.PerformRequest
Fiber2034.0206620via http adaptor
FiberV37116.03358215via http adaptor

Scenario: / — Lower is better. All handlers use direct writes (io.WriteString) to minimize overhead and isolate routing cost.

Key Observations:

  • Rivaas, Gin, and StdMux achieve zero allocations with direct writes
  • Echo has 1 allocation from its internal context
  • Chi, Fiber, Hertz, and Beego have framework-specific overhead

One Parameter (/users/:id)

This scenario measures routing + parameter extraction for a single dynamic segment.

Frameworkns/opB/opallocs/opNotes
Rivaas82.200Zero alloc
Gin104.400Zero alloc
Echo149.6162
StdMux212.2161
Chi407.23682
Beego1017.04006
Hertz2035.0354427via ut.PerformRequest
Fiber2156.0206020via http adaptor
FiberV37410.03311216via http adaptor

Scenario: /users/:id — Lower is better. All handlers use direct writes (io.WriteString) to minimize overhead and isolate routing cost.

Key Observations:

  • Rivaas and Gin maintain zero allocations even with parameter extraction
  • StdMux has 1 allocation from r.PathValue()
  • Echo has 2 allocations (context + param storage)

Two Parameters (/users/:id/posts/:post_id)

This scenario tests routing with multiple dynamic segments.

Frameworkns/opB/opallocs/opNotes
Rivaas130.900Zero alloc
Gin165.200Zero alloc
Echo251.3324
StdMux350.3482
Chi507.33682
Beego1362.04488
Hertz2160.0366429via ut.PerformRequest
Fiber2346.0207720via http adaptor
FiberV37403.03312818via http adaptor

Scenario: /users/:id/posts/:post_id — Lower is better. All handlers use direct writes (io.WriteString) to minimize overhead and isolate routing cost.

Key Observations:

  • Rivaas and Gin continue to show zero allocations
  • StdMux scales linearly (2 allocs for 2 params)
  • Echo scales with each additional parameter

How to Reproduce

The benchmarks are located in the router/benchmarks directory of the rivaas repository.

Running All Benchmarks

cd router/benchmarks
go test -bench=. -benchmem

Running a Specific Scenario

# Static route only
go test -bench=BenchmarkStatic -benchmem

# One parameter only
go test -bench=BenchmarkOneParam -benchmem

# Two parameters only
go test -bench=BenchmarkTwoParams -benchmem

Running a Specific Framework

# Rivaas only
go test -bench='/(Rivaas)$' -benchmem

# Gin only
go test -bench='/(Gin)$' -benchmem

Multiple Runs for Statistical Analysis

Use -count to run benchmarks multiple times and benchstat to compare:

go test -bench=. -benchmem -count=5 > results.txt
go install golang.org/x/perf/cmd/benchstat@latest
benchstat results.txt

Understanding the Results

Metrics Explained

  • ns/op: Nanoseconds per operation (lower is better)
  • B/op: Bytes allocated per operation (lower is better)
  • allocs/op: Number of allocations per operation (lower is better)

Why Zero Allocations Matter

The router is zero allocation for the benchmarked scenarios: static route, one parameter, and two parameters.

Each allocation has a cost:

  • Time: Allocating memory takes time (~30-50ns for small allocations)
  • GC pressure: More allocations mean more garbage collection work
  • Scalability: At high request rates (millions/sec), eliminating allocations significantly reduces CPU and memory usage

Rivaas achieves zero allocations for routing and parameter extraction by:

  • Pre-allocating context pools
  • Using array-based parameter storage for ≤8 params
  • Avoiding string concatenation in hot paths
  • Efficient radix tree implementation with minimal allocations

Continuous Benchmarking

The rivaas repository uses continuous benchmarking to detect performance regressions:

  • Pull Requests: Every PR runs Rivaas-only benchmarks and compares against a baseline. If performance regresses beyond a threshold, the PR check fails.
  • Releases: Full framework comparison runs on every release tag and updates this page automatically.

See the benchmarks.yml workflow for implementation details.


See Also

3.1.2.5 - Route Constraints

Type-safe parameter validation with route constraints.

Route constraints provide parameter validation that maps to OpenAPI schema types.

Typed Constraints

WhereInt(param string) *Route

Validates parameter as integer (OpenAPI: type: integer, format: int64).

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

Matches:

  • /users/123
  • /users/abc

WhereFloat(param string) *Route

Validates parameter as float (OpenAPI: type: number, format: double).

r.GET("/prices/:amount", getPriceHandler).WhereFloat("amount")

Matches:

  • /prices/19.99
  • /prices/abc

WhereUUID(param string) *Route

Validates parameter as UUID (OpenAPI: type: string, format: uuid).

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

Matches:

  • /entities/550e8400-e29b-41d4-a716-446655440000
  • /entities/not-a-uuid

WhereDate(param string) *Route

Validates parameter as date (OpenAPI: type: string, format: date).

r.GET("/orders/:date", getOrderHandler).WhereDate("date")

Matches:

  • /orders/2024-01-18
  • /orders/invalid-date

WhereDateTime(param string) *Route

Validates parameter as date-time (OpenAPI: type: string, format: date-time).

r.GET("/events/:timestamp", getEventHandler).WhereDateTime("timestamp")

Matches:

  • /events/2024-01-18T10:30:00Z
  • /events/invalid

WhereEnum(param string, values ...string) *Route

Validates parameter against enum values (OpenAPI: enum).

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

Matches:

  • /status/active
  • /status/invalid

Regex Constraints

WhereRegex(param, pattern string) *Route

Custom regex validation (OpenAPI: pattern).

// Alphanumeric only
r.GET("/slugs/:slug", getSlugHandler).WhereRegex("slug", `[a-zA-Z0-9]+`)

// Email validation
r.GET("/users/:email", getUserByEmailHandler).WhereRegex("email", `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)

Multiple Constraints

Apply multiple constraints to the same route:

r.GET("/posts/:id/:slug", getPostHandler).
    WhereInt("id").
    WhereRegex("slug", `[a-zA-Z0-9-]+`)

Common Patterns

RESTful IDs

// Integer IDs
r.GET("/users/:id", getUserHandler).WhereInt("id")
r.PUT("/users/:id", updateUserHandler).WhereInt("id")
r.DELETE("/users/:id", deleteUserHandler).WhereInt("id")

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

Slugs and Identifiers

// Alphanumeric slugs
r.GET("/posts/:slug", getPostBySlugHandler).WhereRegex("slug", `[a-z0-9-]+`)

// Category identifiers
r.GET("/categories/:name", getCategoryHandler).WhereRegex("name", `[a-zA-Z0-9_-]+`)

Status and States

// Enum validation for states
r.GET("/orders/:status", getOrdersByStatusHandler).WhereEnum("status", "pending", "processing", "shipped", "delivered")

Complete Example

package main

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

func main() {
    r := router.New()
    
    // Integer constraint
    r.GET("/users/:id", getUserHandler).WhereInt("id")
    
    // UUID constraint
    r.GET("/entities/:uuid", getEntityHandler).WhereUUID("uuid")
    
    // Enum constraint
    r.GET("/status/:state", getStatusHandler).WhereEnum("state", "active", "inactive", "pending")
    
    // Regex constraint
    r.GET("/posts/:slug", getPostHandler).WhereRegex("slug", `[a-z0-9-]+`)
    
    // Multiple constraints
    r.GET("/articles/:id/:slug", getArticleHandler).
        WhereInt("id").
        WhereRegex("slug", `[a-z0-9-]+`)
    
    http.ListenAndServe(":8080", r)
}

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

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

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

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

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

Next Steps

3.1.2.6 - Middleware Reference

Built-in middleware catalog with configuration options.

The router includes production-ready middleware in separate packages. Each middleware is its own Go module, so you only add the ones you need and keep your dependency footprint small. All of them use functional options for configuration.

Security

Security Headers

Package: rivaas.dev/middleware/security

go get rivaas.dev/middleware/security
import "rivaas.dev/middleware/security"

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

CORS

Package: rivaas.dev/middleware/cors

go get rivaas.dev/middleware/cors
import "rivaas.dev/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),
    cors.WithMaxAge(3600),
))

Basic Auth

Package: rivaas.dev/middleware/basicauth

go get rivaas.dev/middleware/basicauth
import "rivaas.dev/middleware/basicauth"

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

Observability

Access Log

Package: rivaas.dev/middleware/accesslog

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

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

Request ID

Package: rivaas.dev/middleware/requestid

go get rivaas.dev/middleware/requestid

Generates unique, time-ordered request IDs for distributed tracing and log correlation.

import "rivaas.dev/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")))

// Get request ID in handlers
func handler(c *router.Context) {
    id := requestid.Get(c)
}

ID Formats:

  • UUID v7 (default): 018f3e9a-1b2c-7def-8000-abcdef123456
  • ULID: 01ARZ3NDEKTSV4RRFFQ69G5FAV

Reliability

Recovery

Package: rivaas.dev/middleware/recovery

go get rivaas.dev/middleware/recovery
import "rivaas.dev/middleware/recovery"

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

Timeout

Package: rivaas.dev/middleware/timeout

go get rivaas.dev/middleware/timeout
import "rivaas.dev/middleware/timeout"

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

Rate Limit

Package: rivaas.dev/middleware/ratelimit

go get rivaas.dev/middleware/ratelimit
import "rivaas.dev/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
    }),
    ratelimit.WithLogger(logger),
))

Body Limit

Package: rivaas.dev/middleware/bodylimit

go get rivaas.dev/middleware/bodylimit
import "rivaas.dev/middleware/bodylimit"

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

Performance

Compression

Package: rivaas.dev/middleware/compression

go get rivaas.dev/middleware/compression
import "rivaas.dev/middleware/compression"

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

Other

Method Override

Package: rivaas.dev/middleware/methodoverride

go get rivaas.dev/middleware/methodoverride
import "rivaas.dev/middleware/methodoverride"

r.Use(methodoverride.New(
    methodoverride.WithHeader("X-HTTP-Method-Override"),
))

Trailing Slash

Package: rivaas.dev/middleware/trailingslash

go get rivaas.dev/middleware/trailingslash
import "rivaas.dev/middleware/trailingslash"

r.Use(trailingslash.New(
    trailingslash.WithRedirectCode(301),
))

Middleware Ordering

Recommended middleware order:

r := router.New()

// 1. Request ID
r.Use(requestid.New())

// 2. AccessLog
r.Use(accesslog.New())

// 3. Recovery
r.Use(recovery.New())

// 4. Security/CORS
r.Use(security.New())
r.Use(cors.New())

// 5. Body Limit
r.Use(bodylimit.New())

// 6. Rate Limit
r.Use(ratelimit.New())

// 7. Timeout
r.Use(timeout.New())

// 8. Authentication
r.Use(auth.New())

// 9. Compression (last)
r.Use(compression.New())

Complete Example

package main

import (
    "log/slog"
    "net/http"
    "os"
    "time"
    
    "rivaas.dev/router"
    "rivaas.dev/middleware/accesslog"
    "rivaas.dev/middleware/cors"
    "rivaas.dev/middleware/recovery"
    "rivaas.dev/middleware/requestid"
    "rivaas.dev/middleware/security"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    r := router.New()
    
    // Observability
    r.Use(requestid.New())
    r.Use(accesslog.New(
        accesslog.WithLogger(logger),
        accesslog.WithExcludePaths("/health"),
    ))
    
    // Reliability
    r.Use(recovery.New())
    
    // Security
    r.Use(security.New())
    r.Use(cors.New(
        cors.WithAllowedOrigins("*"),
        cors.WithAllowedMethods("GET", "POST", "PUT", "DELETE"),
    ))
    
    r.GET("/", func(c *router.Context) {
        c.JSON(200, map[string]string{"message": "Hello"})
    })
    
    http.ListenAndServe(":8080", r)
}

Next Steps

3.1.2.7 - Diagnostics

Diagnostic event types and handling.

The router emits optional diagnostic events for security concerns and configuration issues.

Event Types

DiagXFFSuspicious

Suspicious X-Forwarded-For chain detected (>10 IPs).

Fields:

  • chain (string) - The full X-Forwarded-For header value
  • count (int) - Number of IPs in the chain
handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    if e.Kind == router.DiagXFFSuspicious {
        log.Printf("Suspicious XFF chain: %s (count: %d)", 
            e.Fields["chain"], e.Fields["count"])
    }
})

DiagHeaderInjection

Header injection attempt blocked and sanitized.

Fields:

  • header (string) - Header name
  • value (string) - Original value
  • sanitized (string) - Sanitized value

DiagInvalidProto

Invalid X-Forwarded-Proto value.

Fields:

  • proto (string) - Invalid protocol value

DiagHighParamCount

Route has >8 parameters (uses map storage instead of array).

Fields:

  • method (string) - HTTP method
  • path (string) - Route path
  • param_count (int) - Number of parameters

DiagH2CEnabled

H2C enabled (development warning).

Fields:

  • None

Enabling Diagnostics

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

Handler Examples

With Logging

import "log/slog"

handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    slog.Warn(e.Message, 
        "kind", e.Kind, 
        "fields", e.Fields,
    )
})

With Metrics

handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    metrics.Increment("router.diagnostics", 
        "kind", string(e.Kind),
    )
})

With OpenTelemetry

import (
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/trace"
)

handler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
    span := trace.SpanFromContext(ctx)
    if span.IsRecording() {
        attrs := []attribute.KeyValue{
            attribute.String("diagnostic.kind", string(e.Kind)),
        }
        for k, v := range e.Fields {
            attrs = append(attrs, attribute.String(k, fmt.Sprint(v)))
        }
        span.AddEvent(e.Message, trace.WithAttributes(attrs...))
    }
})

Complete Example

package main

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

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    // Diagnostic handler
    diagHandler := router.DiagnosticHandlerFunc(func(e router.DiagnosticEvent) {
        logger.Warn(e.Message,
            "kind", e.Kind,
            "fields", e.Fields,
        )
    })
    
    // Create router with diagnostics
    r := router.New(router.WithDiagnostics(diagHandler))
    
    r.GET("/", func(c *router.Context) {
        c.JSON(200, map[string]string{"message": "Hello"})
    })
    
    http.ListenAndServe(":8080", r)
}

Best Practices

  1. Log diagnostic events for security monitoring
  2. Track metrics for diagnostic event frequency
  3. Alert on suspicious patterns (e.g., repeated XFF warnings)
  4. Don’t ignore warnings - they indicate potential issues

Next Steps

3.1.2.8 - Troubleshooting

Common issues and solutions for the router package.

This guide helps you troubleshoot common issues with the Rivaas Router.

Quick Reference

IssueSolutionExample
404 Route Not FoundCheck route syntax and order.r.GET("/users/:id", handler)
Middleware Not RunningRegister before routes.r.Use(middleware); r.GET("/path", handler)
Parameters Not WorkingUse :param syntax.r.GET("/users/:id", handler)
CORS IssuesAdd CORS middleware.r.Use(cors.New())
Memory LeaksDon’t store context references.Extract data immediately.
Slow PerformanceUse route groups.api := r.Group("/api")

Common Issues

Route Not Found (404 errors)

Problem: Routes not matching as expected.

Solutions:

// ✅ Correct: Use :param syntax
r.GET("/users/:id", handler)

// ❌ Wrong: Don't use {param} syntax
r.GET("/users/{id}", handler)

// ✅ Correct: Static route
r.GET("/users/me", currentUserHandler)

// Check route registration order
r.GET("/users/me", currentUserHandler)      // Register specific routes first
r.GET("/users/:id", getUserHandler)         // Then parameter routes

Middleware Not Executing

Problem: Middleware doesn’t run for routes.

Solution: Register middleware before routes.

// ✅ Correct: Middleware before routes
r.Use(Logger())
r.GET("/api/users", handler)

// ❌ Wrong: Routes before middleware
r.GET("/api/users", handler)
r.Use(Logger()) // Too late!

// ✅ Correct: Group middleware
api := r.Group("/api")
api.Use(Auth())
api.GET("/users", handler)

Parameter Constraints Not Working

Problem: Invalid parameters still match routes.

Solution: Apply constraints to routes.

// ✅ Correct: Integer constraint
r.GET("/users/:id", handler).WhereInt("id")

// ✅ Correct: Custom regex
r.GET("/files/:name", handler).WhereRegex("name", `[a-zA-Z0-9.-]+`)

// ❌ Wrong: No constraint (matches anything)
r.GET("/users/:id", handler) // Matches "/users/abc"

Memory Leaks

Problem: Growing memory usage.

Solution: Never store Context references.

// ❌ Wrong: Storing context
var globalContext *router.Context
func handler(c *router.Context) {
    globalContext = c // Memory leak!
}

// ✅ Correct: Extract data immediately
func handler(c *router.Context) {
    userID := c.Param("id")
    // Use userID, not c
    processUser(userID)
}

// ✅ Correct: Copy data for async operations
func handler(c *router.Context) {
    userID := c.Param("id")
    go func(id string) {
        processAsync(id)
    }(userID)
}

CORS Issues

Problem: CORS errors in browser.

Solution: Add CORS middleware.

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

Slow Performance

Problem: Routes are slow.

Solutions:

// ✅ Use route groups
api := r.Group("/api")
api.GET("/users", handler)
api.GET("/posts", handler)

// ✅ Minimize middleware
r.Use(Recovery()) // Essential only

// ✅ Apply constraints for parameter validation
r.GET("/users/:id", handler).WhereInt("id")

// ❌ Don't parse parameters manually
func handler(c *router.Context) {
    // id, err := strconv.Atoi(c.Param("id")) // Slow
    id := c.Param("id") // Fast
}

Validation Errors

Problem: Validation not working.

Solutions:

// ✅ Register custom tags in init()
func init() {
    router.RegisterTag("custom", validatorFunc)
}

// ✅ Use app.Context for binding and validation
func createUser(c *app.Context) {
    var req CreateUserRequest
    if !c.MustBind(&req) {
        return
    }
}

// ✅ Partial validation for PATCH
func updateUser(c *app.Context) {
    req, ok := app.MustBindPatch[UpdateUserRequest](c)
    if !ok {
        return
    }
}

FAQ

Can I use standard HTTP middleware?

Yes! Adapt existing middleware:

func adaptMiddleware(next http.Handler) router.HandlerFunc {
    return func(c *router.Context) {
        next.ServeHTTP(c.Writer, c.Request)
    }
}

Is the router production-ready?

Yes. The router is production-ready with:

  • 84.8% code coverage
  • Comprehensive test suite
  • Zero race conditions
  • Zero allocation for routing and param extraction in typical use (≤8 path params)
  • High throughput (see Performance for current numbers)

How do I handle CORS?

Use the built-in CORS middleware:

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

r.Use(cors.New(
    cors.WithAllowedOrigins("*"),
    cors.WithAllowedMethods("GET", "POST", "PUT", "DELETE"),
))

Why are my parameters not working?

Check the parameter syntax:

// ✅ Correct
r.GET("/users/:id", handler)
id := c.Param("id")

// ❌ Wrong syntax
r.GET("/users/{id}", handler) // Use :id instead

How do I debug routing issues?

Use route introspection:

routes := r.Routes()
for _, route := range routes {
    fmt.Printf("%s %s -> %s\n", route.Method, route.Path, route.HandlerName)
}

Getting Help

Next Steps

3.1.3 - Binding Package

Complete API reference for the rivaas.dev/binding package

This is the API reference for the rivaas.dev/binding package. For learning-focused documentation, see the Binding Guide.

Package Information

Overview

The binding package provides a high-performance, type-safe way to bind request data from various sources (query parameters, JSON bodies, headers, etc.) into Go structs using struct tags.

import "rivaas.dev/binding"

type CreateUserRequest struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    Age      int    `json:"age"`
}

// Generic API (preferred)
user, err := binding.JSON[CreateUserRequest](body)

Key Features

  • Type-Safe Generic API: Compile-time type safety with zero runtime overhead
  • Multiple Sources: Query, path, form, header, cookie, JSON, XML, YAML, TOML, MessagePack, Protocol Buffers
  • Zero Allocation: Struct reflection info cached for optimal performance
  • Flexible Type Support: Primitives, time types, collections, nested structs, custom types
  • Detailed Errors: Field-level error information with context
  • Extensible: Custom type converters and value getters
  • Multi-Source Binding: Combine data from multiple sources with precedence control

Package Structure

graph TB
    A[binding]:::info --> B[Core API]:::warning
    A --> C[Sub-Packages]:::success
    
    B --> B1[JSON/XML/Form]
    B --> B2[Query/Header/Cookie]
    B --> B3[Multi-Source]
    B --> B4[Custom Binders]
    
    C --> C1[yaml]
    C --> C2[toml]
    C --> C3[msgpack]
    C --> C4[proto]
    
    classDef default fill:#F8FAF9,stroke:#1E6F5C,color:#1F2A27
    classDef info fill:#D1ECF1,stroke:#17A2B8,color:#1F2A27
    classDef success fill:#D4EDDA,stroke:#28A745,color:#1F2A27
    classDef warning fill:#FFF3CD,stroke:#FFC107,color:#1F2A27

Quick Navigation

API Reference

Core types, functions, and interfaces for request binding.

View →

Options

Configuration options and binding settings.

View →

Sub-Packages

YAML, TOML, MessagePack, and Protocol Buffers support.

View →

Troubleshooting

Common issues and solutions for binding problems.

View →

User Guide

Step-by-step tutorials and examples.

View →

Core API

Generic Functions

Type-safe binding with compile-time guarantees:

// JSON binding
func JSON[T any](data []byte, opts ...Option) (T, error)

// Query parameter binding
func Query[T any](values url.Values, opts ...Option) (T, error)

// Form data binding
func Form[T any](values url.Values, opts ...Option) (T, error)

// Header binding
func Header[T any](headers http.Header, opts ...Option) (T, error)

// Cookie binding
func Cookie[T any](cookies []*http.Cookie, opts ...Option) (T, error)

// Path parameter binding
func Path[T any](params map[string]string, opts ...Option) (T, error)

// XML binding
func XML[T any](data []byte, opts ...Option) (T, error)

// Multi-source binding
func Bind[T any](sources ...Source) (T, error)

Non-Generic Functions

For cases where type comes from a variable:

// JSON binding to pointer
func JSONTo(data []byte, target interface{}, opts ...Option) error

// Query binding to pointer
func QueryTo(values url.Values, target interface{}, opts ...Option) error

// ... similar for other sources

Reader Variants

Stream from io.Reader for large payloads:

func JSONReader[T any](r io.Reader, opts ...Option) (T, error)
func XMLReader[T any](r io.Reader, opts ...Option) (T, error)

Type System

Built-in Type Support

CategoryTypes
Primitivesstring, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, bool
Timetime.Time, time.Duration
Networknet.IP, net.IPNet, url.URL
Regexregexp.Regexp
Collections[]T, map[string]T
Pointers*T for any supported type
NestedNested structs with dot notation

Custom Types

Register custom converters for unsupported types:

import "github.com/google/uuid"

binder := binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
)

Struct Tags

Control binding behavior with struct tags:

TagPurposeExample
jsonJSON body fieldjson:"field_name"
queryQuery parameterquery:"param_name"
formForm dataform:"field_name"
headerHTTP headerheader:"X-Header-Name"
pathPath parameterpath:"param_name"
cookieHTTP cookiecookie:"cookie_name"
defaultDefault valuedefault:"value"
validateValidation rulesvalidate:"required,email"

Error Types

BindError

Field-specific binding error:

type BindError struct {
    Field  string // Field name
    Source string // Source ("query", "json", etc.)
    Value  string // Raw value
    Type   string // Expected type
    Reason string // Error reason
    Err    error  // Underlying error
}

UnknownFieldError

Unknown fields in strict mode:

type UnknownFieldError struct {
    Fields []string // List of unknown fields
}

MultiError

Multiple errors with WithAllErrors():

type MultiError struct {
    Errors []*BindError
}

Configuration Options

Common options for all binding functions:

// Security limits
binding.WithMaxDepth(16)        // Max struct nesting
binding.WithMaxSliceLen(1000)   // Max slice elements
binding.WithMaxMapSize(500)     // Max map entries

// Unknown fields
binding.WithStrictJSON()         // Fail on unknown fields
binding.WithUnknownFields(mode)  // UnknownError/UnknownWarn/UnknownIgnore

// Slice parsing
binding.WithSliceMode(mode)      // SliceRepeat or SliceCSV

// Error collection
binding.WithAllErrors()          // Collect all errors instead of failing on first

Reusable Binders

Create configured binder instances:

binder := binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
    binding.WithTimeLayouts("2006-01-02", "01/02/2006"),
    binding.WithMaxDepth(16),
)

// Use across handlers
user, err := binder.JSON[User](body)
params, err := binder.Query[Params](values)

Sub-Packages

Additional format support via sub-packages:

PackageFormatImport Path
yamlYAMLrivaas.dev/binding/yaml
tomlTOMLrivaas.dev/binding/toml
msgpackMessagePackrivaas.dev/binding/msgpack
protoProtocol Buffersrivaas.dev/binding/proto

Performance Characteristics

  • First binding: ~500ns overhead for reflection
  • Subsequent bindings: ~50ns overhead (cache lookup)
  • Query/Path/Form: Zero allocations for primitive types
  • JSON/XML: Allocations depend on encoding/json and encoding/xml
  • Thread-safe: All operations are safe for concurrent use

Integration

With net/http

func Handler(w http.ResponseWriter, r *http.Request) {
    req, err := binding.JSON[CreateUserRequest](r.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    // Process request...
}

With rivaas.dev/router

import "rivaas.dev/router"

func Handler(c *router.Context) error {
    req, err := binding.JSON[CreateUserRequest](c.Request().Body)
    if err != nil {
        return c.JSON(http.StatusBadRequest, err)
    }
    return c.JSON(http.StatusOK, processRequest(req))
}

With rivaas.dev/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
    }
    return c.JSON(http.StatusOK, processRequest(req))
}

Version Compatibility

The binding package follows semantic versioning:

  • v1.x: Stable API, backward compatible
  • v2.x: Major changes, may require code updates

See Also


For step-by-step guides and tutorials, see the Binding Guide.

For real-world examples, see the Examples page.

3.1.3.1 - API Reference

Complete API documentation for all types, functions, and interfaces

Detailed API reference for all exported types, functions, and interfaces in the rivaas.dev/binding package.

Core Binding Functions

Generic API

JSON

func JSON[T any](data []byte, opts ...Option) (T, error)

Binds JSON data to a struct of type T.

Parameters:

  • data: JSON bytes to parse.
  • opts: Optional configuration options.

Returns:

  • Populated struct of type T.
  • Error if binding fails.

Example:

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

user, err := binding.JSON[User](jsonData)

JSONReader

func JSONReader[T any](r io.Reader, opts ...Option) (T, error)

Binds JSON from an io.Reader. More memory-efficient for large payloads.

Example:

user, err := binding.JSONReader[User](r.Body)

Query

func Query[T any](values url.Values, opts ...Option) (T, error)

Binds URL query parameters to a struct.

Parameters:

  • values: URL query values. Use r.URL.Query().
  • opts: Optional configuration options.

Example:

type Params struct {
    Page  int      `query:"page" default:"1"`
    Limit int      `query:"limit" default:"20"`
    Tags  []string `query:"tags"`
}

params, err := binding.Query[Params](r.URL.Query())

Form

func Form[T any](values url.Values, opts ...Option) (T, error)

Binds form data to a struct.

Parameters:

  • values: Form values (r.Form or r.PostForm)
  • opts: Optional configuration options

Example:

type LoginForm struct {
    Username string `form:"username"`
    Password string `form:"password"`
}

form, err := binding.Form[LoginForm](r.PostForm)

Multipart

func Multipart[T any](form *multipart.Form, opts ...Option) (T, error)

Binds multipart form data including file uploads to a struct. Use *binding.File type for file fields.

Parameters:

  • form: Multipart form from r.MultipartForm after calling r.ParseMultipartForm()
  • opts: Optional configuration options

Returns:

  • Populated struct of type T with form fields and files
  • Error if binding fails

Example:

type UploadRequest struct {
    File        *binding.File `form:"file"`
    Title       string        `form:"title"`
    Description string        `form:"description"`
    Tags        []string      `form:"tags"`
}

// Parse multipart form first (32MB limit)
if err := r.ParseMultipartForm(32 << 20); err != nil {
    return err
}

req, err := binding.Multipart[UploadRequest](r.MultipartForm)
if err != nil {
    return err
}

// Save the uploaded file
if err := req.File.Save("/uploads/" + req.File.Name); err != nil {
    return err
}

Multiple files:

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

req, err := binding.Multipart[GalleryUpload](r.MultipartForm)
for _, photo := range req.Photos {
    photo.Save("/uploads/" + photo.Name)
}

JSON in form fields:

Multipart binding automatically parses JSON strings 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"`
    Settings Settings      `form:"settings"` // JSON automatically parsed
}

// Form field "settings" contains: {"theme":"dark","notifications":true}
req, err := binding.Multipart[ProfileUpdate](r.MultipartForm)
// req.Settings is now populated from the JSON string
func Header[T any](headers http.Header, opts ...Option) (T, error)

Binds HTTP headers to a struct.

Example:

type Headers struct {
    APIKey    string `header:"X-API-Key"`
    RequestID string `header:"X-Request-ID"`
}

headers, err := binding.Header[Headers](r.Header)
func Cookie[T any](cookies []*http.Cookie, opts ...Option) (T, error)

Binds HTTP cookies to a struct.

Example:

type Cookies struct {
    SessionID string `cookie:"session_id"`
    Theme     string `cookie:"theme" default:"light"`
}

cookies, err := binding.Cookie[Cookies](r.Cookies())

Path

func Path[T any](params map[string]string, opts ...Option) (T, error)

Binds URL path parameters to a struct.

Example:

type PathParams struct {
    UserID int `path:"user_id"`
}

// With gorilla/mux or chi
params, err := binding.Path[PathParams](mux.Vars(r))

XML

func XML[T any](data []byte, opts ...Option) (T, error)

Binds XML data to a struct.

Example:

type Document struct {
    Title string `xml:"title"`
    Body  string `xml:"body"`
}

doc, err := binding.XML[Document](xmlData)

XMLReader

func XMLReader[T any](r io.Reader, opts ...Option) (T, error)

Binds XML from an io.Reader.

Bind (Multi-Source)

func Bind[T any](sources ...Source) (T, error)

Binds from multiple sources with precedence.

Example:

type Request struct {
    UserID int    `query:"user_id" json:"user_id"`
    APIKey string `header:"X-API-Key"`
}

req, err := binding.Bind[Request](
    binding.FromQuery(r.URL.Query()),
    binding.FromJSON(r.Body),
    binding.FromHeader(r.Header),
)

Non-Generic API

JSONTo

func JSONTo(data []byte, target interface{}, opts ...Option) error

Binds JSON to a pointer. Use when type comes from a variable.

Example:

var user User
err := binding.JSONTo(jsonData, &user)

Similar non-generic functions exist for all sources:

  • QueryTo(values url.Values, target interface{}, opts ...Option) error
  • FormTo(values url.Values, target interface{}, opts ...Option) error
  • MultipartTo(form *multipart.Form, target interface{}, opts ...Option) error
  • HeaderTo(headers http.Header, target interface{}, opts ...Option) error
  • CookieTo(cookies []*http.Cookie, target interface{}, opts ...Option) error
  • PathTo(params map[string]string, target interface{}, opts ...Option) error
  • XMLTo(data []byte, target interface{}, opts ...Option) error

Source Constructors

For multi-source binding:

func FromJSON(r io.Reader) Source
func FromQuery(values url.Values) Source
func FromForm(values url.Values) Source
func FromMultipart(form *multipart.Form) Source
func FromHeader(headers http.Header) Source
func FromCookie(cookies []*http.Cookie) Source
func FromPath(params map[string]string) Source
func FromXML(r io.Reader) Source

Example with multipart:

type Request struct {
    UserID int           `path:"user_id"`
    File   *binding.File `form:"file"`
    Token  string        `header:"X-Token"`
}

req, err := binding.Bind[Request](
    binding.FromPath(pathParams),
    binding.FromMultipart(r.MultipartForm),
    binding.FromHeader(r.Header),
)

Binder Type

Constructor

func New(opts ...Option) (*Binder, error)
func MustNew(opts ...Option) *Binder

Creates a reusable binder with configuration.

Example:

binder := binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
    binding.WithMaxDepth(16),
)

user, err := binder.JSON[User](data)

Binder Methods

A Binder has the same methods as the package-level functions:

func (b *Binder) JSON[T any](data []byte, opts ...Option) (T, error)
func (b *Binder) Query[T any](values url.Values, opts ...Option) (T, error)
// ... etc for all binding functions

Error Types

BindError

Field-specific binding error with detailed context:

type BindError struct {
    Field  string // Field name that failed to bind
    Source string // Source ("query", "json", "header", etc.)
    Value  string // Raw value that failed to bind
    Type   string // Expected Go type
    Reason string // Human-readable reason
    Err    error  // Underlying error
}

func (e *BindError) Error() string
func (e *BindError) Unwrap() error
func (e *BindError) IsType() bool    // True if type conversion failed
func (e *BindError) IsMissing() bool // True if required field missing

Example:

user, err := binding.JSON[User](data)
if err != nil {
    var bindErr *binding.BindError
    if errors.As(err, &bindErr) {
        log.Printf("Field %s from %s failed: %v",
            bindErr.Field, bindErr.Source, bindErr.Err)
    }
}

UnknownFieldError

Returned in strict mode when unknown fields are encountered:

type UnknownFieldError struct {
    Fields []string // List of unknown field names
}

func (e *UnknownFieldError) Error() string

Example:

user, err := binding.JSON[User](data, binding.WithStrictJSON())
if err != nil {
    var unknownErr *binding.UnknownFieldError
    if errors.As(err, &unknownErr) {
        log.Printf("Unknown fields: %v", unknownErr.Fields)
    }
}

MultiError

Multiple errors collected with WithAllErrors():

type MultiError struct {
    Errors []*BindError
}

func (e *MultiError) Error() string
func (e *MultiError) Unwrap() []error

Example:

user, err := binding.JSON[User](data, binding.WithAllErrors())
if err != nil {
    var multi *binding.MultiError
    if errors.As(err, &multi) {
        for _, e := range multi.Errors {
            log.Printf("Field %s: %v", e.Field, e.Err)
        }
    }
}

Interfaces

ValueGetter

Interface for custom data sources:

type ValueGetter interface {
    Get(key string) string          // Get first value for key
    GetAll(key string) []string     // Get all values for key
    Has(key string) bool            // Check if key exists
}

Example Implementation:

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
}

ConverterFunc

Function type for custom type converters:

type ConverterFunc[T any] func(string) (T, error)

Example:

func ParseEmail(s string) (Email, error) {
    if !strings.Contains(s, "@") {
        return "", errors.New("invalid email")
    }
    return Email(s), nil
}

binder := binding.MustNew(
    binding.WithConverter[Email](ParseEmail),
)

Converter Factory Functions

Ready-to-use converter factories for common type patterns.

TimeConverter

func TimeConverter(layouts ...string) func(string) (time.Time, error)

Creates a converter that tries parsing time strings using the provided formats in order.

Example:

binder := binding.MustNew(
    binding.WithConverter(binding.TimeConverter(
        "2006-01-02",      // ISO format
        "01/02/2006",      // US format
        "02-Jan-2006",     // Short month
    )),
)

type Event struct {
    Date time.Time `query:"date"`
}

// Works with: ?date=2026-01-28 or ?date=01/28/2026 or ?date=28-Jan-2026
event, err := binder.Query[Event](values)

DurationConverter

func DurationConverter(aliases map[string]time.Duration) func(string) (time.Duration, error)

Creates a converter that parses duration strings. It supports both standard Go duration format (like "30m", "2h30m") and custom aliases you define.

Example:

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"`
}

// Works with: ?timeout=quick or ?timeout=30m or ?timeout=2h30m
config, err := binder.Query[Config](values)

EnumConverter

func EnumConverter[T ~string](allowed ...T) func(string) (T, error)

Creates a converter that checks if a string value is one of the allowed options. Matching is case-insensitive.

Example:

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"`
}

// Works with: ?status=active or ?status=ACTIVE (case-insensitive)
// Returns error for: ?status=invalid
user, err := binder.Query[User](values)

BoolConverter

func BoolConverter(truthy, falsy []string) func(string) (bool, error)

Creates a converter that parses boolean values using your custom truthy and falsy strings. Matching is case-insensitive.

Example:

binder := binding.MustNew(
    binding.WithConverter(binding.BoolConverter(
        []string{"yes", "on", "enabled", "1"},   // truthy
        []string{"no", "off", "disabled", "0"},  // falsy
    )),
)

type Settings struct {
    Notifications bool `query:"notifications"`
}

// Works with: ?notifications=yes, ?notifications=ON, ?notifications=off
settings, err := binder.Query[Settings](values)

Helper Functions

MapGetter

Converts a map[string]string to a ValueGetter:

func MapGetter(m map[string]string) ValueGetter

Example:

data := map[string]string{"name": "Alice", "age": "30"}
getter := binding.MapGetter(data)
result, err := binding.RawInto[User](getter, "custom")

MultiMapGetter

Converts a map[string][]string to a ValueGetter:

func MultiMapGetter(m map[string][]string) ValueGetter

Example:

data := map[string][]string{
    "tags": {"go", "rust"},
    "name": {"Alice"},
}
getter := binding.MultiMapGetter(data)
result, err := binding.RawInto[User](getter, "custom")

GetterFunc

Adapts a function to the ValueGetter interface:

type GetterFunc func(key string) ([]string, bool)

func (f GetterFunc) Get(key string) string
func (f GetterFunc) GetAll(key string) []string
func (f GetterFunc) Has(key string) bool

Example:

getter := binding.GetterFunc(func(key string) ([]string, bool) {
    if val, ok := myMap[key]; ok {
        return []string{val}, true
    }
    return nil, false
})

Raw/RawInto

Low-level binding from custom ValueGetter:

func Raw[T any](getter ValueGetter, source string, opts ...Option) (T, error)
func RawInto(getter ValueGetter, source string, target interface{}, opts ...Option) error

Events and Observability

Events Type

Hooks for observing binding operations:

type Events struct {
    FieldBound   func(name, tag string)
    UnknownField func(name string)
    Done         func(stats Stats)
}

Example:

binder := binding.MustNew(
    binding.WithEvents(binding.Events{
        FieldBound: func(name, tag string) {
            log.Printf("Bound field %s from %s", name, tag)
        },
        UnknownField: func(name string) {
            log.Printf("Unknown field: %s", name)
        },
        Done: func(stats binding.Stats) {
            log.Printf("Binding completed: %d fields, %d errors",
                stats.FieldsBound, stats.ErrorCount)
        },
    }),
)

Stats Type

Statistics from binding operation:

type Stats struct {
    FieldsBound int           // Number of fields successfully bound
    ErrorCount  int           // Number of errors encountered
    Duration    time.Duration // Time taken for binding
}

Constants

Slice Modes

const (
    SliceRepeat SliceMode = iota // Repeated params: ?tags=a&tags=b (default)
    SliceCSV                     // CSV params: ?tags=a,b,c
)

Unknown Field Handling

const (
    UnknownIgnore UnknownMode = iota // Ignore unknown fields (default)
    UnknownWarn                       // Log warning for unknown fields
    UnknownError                      // Error on unknown fields
)

Merge Strategies

const (
    MergeLastWins  MergeStrategy = iota // Last source wins (default)
    MergeFirstWins                       // First source wins
)

Default Values

Time Layouts

var DefaultTimeLayouts = []string{
    time.RFC3339,
    time.RFC3339Nano,
    time.RFC1123,
    time.RFC1123Z,
    time.RFC822,
    time.RFC822Z,
    time.RFC850,
    time.ANSIC,
    time.UnixDate,
    time.RubyDate,
    time.Kitchen,
    time.Stamp,
    time.StampMilli,
    time.StampMicro,
    time.StampNano,
    time.DateTime,
    time.DateOnly,
    time.TimeOnly,
    "2006-01-02",
    "01/02/2006",
    "2006/01/02",
}

Can be extended with WithTimeLayouts().

Type Constraints

Supported Interface Types

Types implementing these interfaces are automatically supported:

  • encoding.TextUnmarshaler: For custom text unmarshaling
  • json.Unmarshaler: For custom JSON unmarshaling
  • xml.Unmarshaler: For custom XML unmarshaling

Example:

type Status string

func (s *Status) UnmarshalText(text []byte) error {
    // Custom parsing logic
    *s = Status(string(text))
    return nil
}

type Request struct {
    Status Status `query:"status"` // Automatically uses UnmarshalText
}

Thread Safety

All package-level functions and Binder methods are safe for concurrent use. The struct reflection cache is thread-safe and has no size limit.

See Also

For usage examples, see the Binding Guide.

3.1.3.2 - Options

Complete reference for all configuration options

Comprehensive reference for all configuration options available in the binding package.

Option Type

type Option func(*Config)

Options configure binding behavior. They can be passed to:

  • Package-level functions like binding.JSON[T](data, opts...).
  • Binder constructor like binding.MustNew(opts...).
  • Binder methods like binder.JSON[T](data, opts...).

Security Limits

WithMaxDepth

func WithMaxDepth(depth int) Option

Sets maximum struct nesting depth to prevent stack overflow from deeply nested structures.

Default: 32

Example:

user, err := binding.JSON[User](data, binding.WithMaxDepth(16))

Use Cases:

  • Protect against malicious deeply nested JSON.
  • Limit resource usage.
  • Prevent stack overflow.

WithMaxSliceLen

func WithMaxSliceLen(length int) Option

Sets maximum slice length to prevent memory exhaustion from large arrays.

Default: 10,000

Example:

params, err := binding.Query[Params](values, binding.WithMaxSliceLen(1000))

Use Cases:

  • Protect against memory attacks
  • Limit array sizes
  • Control memory allocation

WithMaxMapSize

func WithMaxMapSize(size int) Option

Sets maximum map size to prevent memory exhaustion from large objects.

Default: 1,000

Example:

config, err := binding.JSON[Config](data, binding.WithMaxMapSize(500))

Use Cases:

  • Protect against memory attacks.
  • Limit object sizes.
  • Control memory allocation.

Unknown Field Handling

WithStrictJSON

func WithStrictJSON() Option

Convenience function that sets WithUnknownFields(UnknownError). Fails binding if JSON contains fields not in the struct.

Example:

user, err := binding.JSON[User](data, binding.WithStrictJSON())
if err != nil {
    var unknownErr *binding.UnknownFieldError
    if errors.As(err, &unknownErr) {
        log.Printf("Unknown fields: %v", unknownErr.Fields)
    }
}

Use Cases:

  • API versioning
  • Catch typos in field names
  • Enforce strict contracts

WithUnknownFields

func WithUnknownFields(mode UnknownMode) Option

// Modes
const (
    UnknownIgnore UnknownMode = iota // Ignore unknown fields (default)
    UnknownWarn                       // Log warnings
    UnknownError                      // Return error
)

Controls how unknown fields are handled.

Example:

user, err := binding.JSON[User](data,
    binding.WithUnknownFields(binding.UnknownWarn))

Modes:

  • UnknownIgnore: Silently ignore (default, most flexible)
  • UnknownWarn: Log warnings (for debugging)
  • UnknownError: Fail binding (strict contracts)

Slice Parsing

WithSliceMode

func WithSliceMode(mode SliceMode) Option

// Modes
const (
    SliceRepeat SliceMode = iota // ?tags=a&tags=b (default)
    SliceCSV                     // ?tags=a,b,c
)

Controls how slices are parsed from query/form values.

Example:

// URL: ?tags=go,rust,python
params, err := binding.Query[Params](values,
    binding.WithSliceMode(binding.SliceCSV))

Modes:

  • SliceRepeat: Repeated parameters (default, standard HTTP)
  • SliceCSV: Comma-separated values (more compact)

Error Handling

WithAllErrors

func WithAllErrors() Option

Collects all binding errors instead of failing on the first error.

Example:

user, err := binding.JSON[User](data, binding.WithAllErrors())
if err != nil {
    var multi *binding.MultiError
    if errors.As(err, &multi) {
        for _, e := range multi.Errors {
            log.Printf("Field %s: %v", e.Field, e.Err)
        }
    }
}

Use Cases:

  • Show all validation errors to user
  • Debugging
  • Comprehensive error reporting

Type Conversion

WithConverter

func WithConverter[T any](fn func(string) (T, error)) Option

Registers a custom type converter for type T.

Example:

import "github.com/google/uuid"

binder := binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
)

type User struct {
    ID uuid.UUID `query:"id"`
}

user, err := binder.Query[User](values)

Use Cases:

  • Custom types (UUID, decimal, etc.)
  • Domain-specific types
  • Third-party types

Converter Factories

The binding package provides ready-to-use converter factories for common patterns. These are functions that return converter functions you can use with WithConverter.

TimeConverter

func TimeConverter(layouts ...string) func(string) (time.Time, error)

Creates a converter that parses time strings using the provided date formats. Tries each format in order until one succeeds.

Example:

binder := binding.MustNew(
    // Try ISO format first, then US format
    binding.WithConverter(binding.TimeConverter(
        "2006-01-02",
        "01/02/2006",
    )),
)

type Event struct {
    Date time.Time `query:"date"`
}

// Works with: ?date=2026-01-28 or ?date=01/28/2026
event, err := binder.Query[Event](values)

Common layouts:

  • "2006-01-02" - ISO date (YYYY-MM-DD)
  • "01/02/2006" - US format (MM/DD/YYYY)
  • "02-Jan-2006" - Short month name
  • "2006-01-02 15:04:05" - DateTime with seconds

DurationConverter

func DurationConverter(aliases map[string]time.Duration) func(string) (time.Duration, error)

Creates a converter that parses duration strings. Supports both standard Go duration format (like "30m", "2h30m") and custom aliases you define.

Example:

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
// ?timeout=2h30m      → 2 hours 30 minutes
config, err := binder.Query[Config](values)

Use Cases:

  • User-friendly duration aliases
  • Cache TTL presets
  • Timeout configurations
  • Fallback to standard durations

EnumConverter

func EnumConverter[T ~string](allowed ...T) func(string) (T, error)

Creates a converter that validates string values against a set of allowed options. Matching is case-insensitive.

Example:

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"`
}

// ?status=active   ✓ OK
// ?status=ACTIVE   ✓ OK (case-insensitive)
// ?status=invalid  ✗ Error: must be one of: active, pending, disabled
user, err := binder.Query[User](values)

Use Cases:

  • String enums with validation
  • Status fields
  • Category/type fields
  • Prevent invalid values

BoolConverter

func BoolConverter(truthy, falsy []string) func(string) (bool, error)

Creates a converter that parses boolean values using custom truthy and falsy strings. Matching is case-insensitive.

Example:

binder := binding.MustNew(
    binding.WithConverter(binding.BoolConverter(
        []string{"yes", "on", "enabled", "1"},   // truthy
        []string{"no", "off", "disabled", "0"},  // falsy
    )),
)

type Settings struct {
    Notifications bool `query:"notifications"`
}

// ?notifications=yes       → true
// ?notifications=enabled   → true
// ?notifications=OFF       → false (case-insensitive)
// ?notifications=0         → false
settings, err := binder.Query[Settings](values)

Use Cases:

  • User-friendly boolean inputs
  • Feature flags
  • Toggle settings
  • Forms with yes/no options

Combining Converters

You can use multiple converters together, including both factories and custom converters:

binder := binding.MustNew(
    // Time with custom formats
    binding.WithConverter(binding.TimeConverter("01/02/2006", "2006-01-02")),
    
    // Duration with friendly names
    binding.WithConverter(binding.DurationConverter(map[string]time.Duration{
        "short": 5 * time.Minute,
        "long":  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),
    binding.WithConverter[decimal.Decimal](decimal.NewFromString),
)

WithTimeLayouts

func WithTimeLayouts(layouts ...string) Option

Sets custom time parsing layouts. Replaces default layouts.

Default Layouts: See binding.DefaultTimeLayouts

Example:

binder := binding.MustNew(
    binding.WithTimeLayouts(
        "2006-01-02",           // Date only
        "01/02/2006",           // US format
        "2006-01-02 15:04:05",  // DateTime
    ),
)

Tip: Extend defaults instead of replacing:

binder := binding.MustNew(
    binding.WithTimeLayouts(
        append(binding.DefaultTimeLayouts, "01/02/2006", "02-Jan-2006")...,
    ),
)

Observability

WithEvents

func WithEvents(events Events) Option

type Events struct {
    FieldBound   func(name, tag string)
    UnknownField func(name string)
    Done         func(stats Stats)
}

type Stats struct {
    FieldsBound int
    ErrorCount  int
    Duration    time.Duration
}

Registers event handlers for observing binding operations.

Example:

binder := binding.MustNew(
    binding.WithEvents(binding.Events{
        FieldBound: func(name, tag string) {
            metrics.Increment("binding.field.bound",
                "field:"+name, "source:"+tag)
        },
        UnknownField: func(name string) {
            log.Warn("Unknown field", "name", name)
        },
        Done: func(stats binding.Stats) {
            metrics.Histogram("binding.duration",
                stats.Duration.Milliseconds())
            metrics.Gauge("binding.fields", stats.FieldsBound)
        },
    }),
)

Use Cases:

  • Metrics collection
  • Debugging
  • Performance monitoring
  • Audit logging

Multi-Source Options

WithMergeStrategy

func WithMergeStrategy(strategy MergeStrategy) Option

// Strategies
const (
    MergeLastWins  MergeStrategy = iota // Last source wins (default)
    MergeFirstWins                       // First source wins
)

Controls precedence when binding from multiple sources.

Example:

// First source wins
req, err := binding.Bind[Request](
    binding.WithMergeStrategy(binding.MergeFirstWins),
    binding.FromHeader(r.Header),      // Highest priority
    binding.FromQuery(r.URL.Query()),  // Lower priority
)

Strategies:

  • MergeLastWins: Last source overwrites (default)
  • MergeFirstWins: First non-empty value wins

JSON-Specific Options

WithDisallowUnknownFields

func WithDisallowUnknownFields() Option

Equivalent to WithStrictJSON(). Provided for clarity when explicitly disallowing unknown fields.

Example:

user, err := binding.JSON[User](data,
    binding.WithDisallowUnknownFields())

WithMaxBytes

func WithMaxBytes(bytes int64) Option

Limits the size of JSON/XML data to prevent memory exhaustion.

Example:

user, err := binding.JSON[User](data,
    binding.WithMaxBytes(1024 * 1024)) // 1MB limit

Use Cases:

  • Protect against large payloads
  • API rate limiting
  • Resource management

Custom Options

WithTagHandler

func WithTagHandler(tagName string, handler TagHandler) Option

type TagHandler interface {
    Get(fieldName, tagValue string) (string, bool)
}

Registers a custom struct tag handler.

Example:

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
}

binder := binding.MustNew(
    binding.WithTagHandler("env", &EnvTagHandler{prefix: "APP_"}),
)

type Config struct {
    APIKey string `env:"API_KEY"`  // Looks up APP_API_KEY
}

Option Combinations

Production Configuration

var ProductionBinder = binding.MustNew(
    // Security
    binding.WithMaxDepth(16),
    binding.WithMaxSliceLen(1000),
    binding.WithMaxMapSize(500),
    binding.WithMaxBytes(10 * 1024 * 1024), // 10MB
    
    // Strict validation
    binding.WithStrictJSON(),
    
    // Custom types
    binding.WithConverter[uuid.UUID](uuid.Parse),
    binding.WithConverter[decimal.Decimal](decimal.NewFromString),
    
    // Time formats
    binding.WithTimeLayouts(append(
        binding.DefaultTimeLayouts,
        "2006-01-02",
        "01/02/2006",
    )...),
    
    // Observability
    binding.WithEvents(binding.Events{
        FieldBound:   logFieldBound,
        UnknownField: logUnknownField,
        Done:         recordMetrics,
    }),
)

Development Configuration

var DevBinder = binding.MustNew(
    // Lenient limits
    binding.WithMaxDepth(32),
    binding.WithMaxSliceLen(10000),
    
    // Warnings instead of errors
    binding.WithUnknownFields(binding.UnknownWarn),
    
    // Collect all errors for debugging
    binding.WithAllErrors(),
    
    // Verbose logging
    binding.WithEvents(binding.Events{
        FieldBound: func(name, tag string) {
            log.Printf("[DEBUG] Bound %s from %s", name, tag)
        },
        UnknownField: func(name string) {
            log.Printf("[WARN] Unknown field: %s", name)
        },
        Done: func(stats binding.Stats) {
            log.Printf("[DEBUG] Binding: %d fields, %d errors, %v",
                stats.FieldsBound, stats.ErrorCount, stats.Duration)
        },
    }),
)

Testing Configuration

var TestBinder = binding.MustNew(
    // Strict validation
    binding.WithStrictJSON(),
    
    // Fail fast
    // (don't use WithAllErrors in tests)
    
    // Smaller limits for test data
    binding.WithMaxDepth(8),
    binding.WithMaxSliceLen(100),
)

Option Precedence

When options are provided to both MustNew() and individual functions:

  1. Function-level options override binder-level options
  2. Options are applied in order (last wins for same option)

Example:

binder := binding.MustNew(
    binding.WithMaxDepth(32),  // Binder default
)

// This call uses maxDepth=16 (overrides binder default)
user, err := binder.JSON[User](data,
    binding.WithMaxDepth(16))

Best Practices

1. Use Binders for Shared Configuration

// Good - shared configuration
var AppBinder = binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
    binding.WithMaxDepth(16),
)

func Handler1(r *http.Request) {
    user, err := AppBinder.JSON[User](r.Body)
}

func Handler2(r *http.Request) {
    params, err := AppBinder.Query[Params](r.URL.Query())
}

2. Set Security Limits

// Good - protect against attacks
user, err := binding.JSON[User](data,
    binding.WithMaxDepth(16),
    binding.WithMaxSliceLen(1000),
    binding.WithMaxBytes(1024*1024),
)

3. Use Strict Mode for APIs

// Good - catch client errors early
user, err := binding.JSON[User](data, binding.WithStrictJSON())

4. Collect All Errors for Forms

// Good - show all validation errors to user
form, err := binding.Form[Form](r.PostForm, binding.WithAllErrors())
if err != nil {
    var multi *binding.MultiError
    if errors.As(err, &multi) {
        // Show all errors to user
        for _, e := range multi.Errors {
            addError(e.Field, e.Err.Error())
        }
    }
}

See Also

For usage examples, see the Binding Guide.

3.1.3.3 - Sub-Packages

YAML, TOML, MessagePack, and Protocol Buffers support

Reference for sub-packages that add support for additional data formats beyond the core package.

Package Overview

Sub-PackageFormatImport Path
yamlYAMLrivaas.dev/binding/yaml
tomlTOMLrivaas.dev/binding/toml
msgpackMessagePackrivaas.dev/binding/msgpack
protoProtocol Buffersrivaas.dev/binding/proto

YAML Package

Import

import "rivaas.dev/binding/yaml"

Functions

YAML

func YAML[T any](data []byte, opts ...Option) (T, error)

Binds YAML data to a struct.

Example:

type Config struct {
    Name  string `yaml:"name"`
    Port  int    `yaml:"port"`
    Debug bool   `yaml:"debug"`
}

config, err := yaml.YAML[Config](yamlData)

YAMLReader

func YAMLReader[T any](r io.Reader, opts ...Option) (T, error)

Binds YAML from an io.Reader.

Example:

config, err := yaml.YAMLReader[Config](r.Body)

YAMLTo

func YAMLTo(data []byte, target interface{}, opts ...Option) error

Non-generic variant.

Options

WithStrict

func WithStrict() Option

Enables strict YAML parsing. Fails on unknown fields or duplicate keys.

Example:

config, err := yaml.YAML[Config](data, yaml.WithStrict())

Struct Tags

Use yaml struct tags:

type Config struct {
    Name  string `yaml:"name"`
    Port  int    `yaml:"port"`
    Debug bool   `yaml:"debug,omitempty"`
    
    // Inline nested struct
    Database struct {
        Host string `yaml:"host"`
        Port int    `yaml:"port"`
    } `yaml:"database"`
    
    // Ignore field
    Internal string `yaml:"-"`
}

Example

# config.yaml
name: my-app
port: 8080
debug: true
database:
  host: localhost
  port: 5432
data, _ := os.ReadFile("config.yaml")
config, err := yaml.YAML[Config](data)

TOML Package

Import

import "rivaas.dev/binding/toml"

Functions

TOML

func TOML[T any](data []byte, opts ...Option) (T, error)

Binds TOML data to a struct.

Example:

type Config struct {
    Name  string `toml:"name"`
    Port  int    `toml:"port"`
    Debug bool   `toml:"debug"`
}

config, err := toml.TOML[Config](tomlData)

TOMLReader

func TOMLReader[T any](r io.Reader, opts ...Option) (T, error)

Binds TOML from an io.Reader.

TOMLTo

func TOMLTo(data []byte, target interface{}, opts ...Option) error

Non-generic variant.

Struct Tags

Use toml struct tags:

type Config struct {
    Title string `toml:"title"`
    
    Owner struct {
        Name string `toml:"name"`
        DOB  time.Time `toml:"dob"`
    } `toml:"owner"`
    
    Database struct {
        Server  string `toml:"server"`
        Ports   []int  `toml:"ports"`
        Enabled bool   `toml:"enabled"`
    } `toml:"database"`
}

Example

# config.toml
title = "TOML Example"

[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00

[database]
server = "192.168.1.1"
ports = [ 8000, 8001, 8002 ]
enabled = true
data, _ := os.ReadFile("config.toml")
config, err := toml.TOML[Config](data)

MessagePack Package

Import

import "rivaas.dev/binding/msgpack"

Functions

MsgPack

func MsgPack[T any](data []byte, opts ...Option) (T, error)

Binds MessagePack data to a struct.

Example:

type Message struct {
    ID   int    `msgpack:"id"`
    Data []byte `msgpack:"data"`
    Time time.Time `msgpack:"time"`
}

msg, err := msgpack.MsgPack[Message](msgpackData)

MsgPackReader

func MsgPackReader[T any](r io.Reader, opts ...Option) (T, error)

Binds MessagePack from an io.Reader.

Example:

msg, err := msgpack.MsgPackReader[Message](r.Body)

MsgPackTo

func MsgPackTo(data []byte, target interface{}, opts ...Option) error

Non-generic variant.

Struct Tags

Use msgpack struct tags:

type Message struct {
    ID      int       `msgpack:"id"`
    Type    string    `msgpack:"type"`
    Payload []byte    `msgpack:"payload"`
    Created time.Time `msgpack:"created"`
    
    // Omit if zero
    Metadata map[string]string `msgpack:"metadata,omitempty"`
    
    // Use as array (more compact)
    Points []int `msgpack:"points,as_array"`
}

Use Cases

  • High-performance binary serialization
  • Microservice communication
  • Event streaming
  • Cache serialization

Protocol Buffers Package

Import

import "rivaas.dev/binding/proto"
import pb "myapp/proto"  // Your generated proto files

Functions

Proto

func Proto[T proto.Message](data []byte, opts ...Option) (T, error)

Binds Protocol Buffer data to a proto message.

Example:

import pb "myapp/proto"

user, err := proto.Proto[*pb.User](protoData)

ProtoReader

func ProtoReader[T proto.Message](r io.Reader, opts ...Option) (T, error)

Binds Protocol Buffers from an io.Reader.

Example:

user, err := proto.ProtoReader[*pb.User](r.Body)

ProtoTo

func ProtoTo(data []byte, target proto.Message, opts ...Option) error

Non-generic variant.

Proto Definition

// user.proto
syntax = "proto3";

package example;
option go_package = "myapp/proto";

message User {
  int64 id = 1;
  string username = 2;
  string email = 3;
  int32 age = 4;
  repeated string tags = 5;
}

Example

import (
    "rivaas.dev/binding/proto"
    pb "myapp/proto"
)

func HandleProtoRequest(w http.ResponseWriter, r *http.Request) {
    user, err := proto.ProtoReader[*pb.User](r.Body)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Use user
    log.Printf("Received user: %s", user.Username)
}

Use Cases

  • gRPC services
  • High-performance APIs
  • Cross-language communication
  • Schema evolution

Common Patterns

Configuration Files

import (
    "rivaas.dev/binding/yaml"
    "rivaas.dev/binding/toml"
)

type Config struct {
    Name     string `yaml:"name" toml:"name"`
    Port     int    `yaml:"port" toml:"port"`
    Database struct {
        Host string `yaml:"host" toml:"host"`
        Port int    `yaml:"port" toml:"port"`
    } `yaml:"database" toml:"database"`
}

func LoadConfig(format, path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    
    switch format {
    case "yaml", "yml":
        return yaml.YAML[Config](data)
    case "toml":
        return toml.TOML[Config](data)
    default:
        return nil, fmt.Errorf("unsupported format: %s", format)
    }
}

Content Negotiation

func HandleRequest(w http.ResponseWriter, r *http.Request) {
    contentType := r.Header.Get("Content-Type")
    
    var req CreateUserRequest
    var err error
    
    switch {
    case strings.Contains(contentType, "application/json"):
        req, err = binding.JSON[CreateUserRequest](r.Body)
        
    case strings.Contains(contentType, "application/x-yaml"):
        req, err = yaml.YAMLReader[CreateUserRequest](r.Body)
        
    case strings.Contains(contentType, "application/toml"):
        req, err = toml.TOMLReader[CreateUserRequest](r.Body)
        
    case strings.Contains(contentType, "application/x-msgpack"):
        req, err = msgpack.MsgPackReader[CreateUserRequest](r.Body)
        
    default:
        http.Error(w, "Unsupported content type", http.StatusUnsupportedMediaType)
        return
    }
    
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    
    // Process request...
}

Multi-Format API

type API struct {
    yaml    *yaml.Binder
    toml    *toml.Binder
    msgpack *msgpack.Binder
}

func NewAPI() *API {
    return &API{
        yaml:    yaml.MustNew(yaml.WithStrict()),
        toml:    toml.MustNew(),
        msgpack: msgpack.MustNew(),
    }
}

func (a *API) Bind(r *http.Request, target interface{}) error {
    contentType := r.Header.Get("Content-Type")
    
    switch {
    case strings.Contains(contentType, "yaml"):
        return a.yaml.YAMLReaderTo(r.Body, target)
    case strings.Contains(contentType, "toml"):
        return a.toml.TOMLReaderTo(r.Body, target)
    case strings.Contains(contentType, "msgpack"):
        return a.msgpack.MsgPackReaderTo(r.Body, target)
    default:
        return binding.JSONReaderTo(r.Body, target)
    }
}

Dependencies

Sub-packages have external dependencies:

PackageDependency
yamlgopkg.in/yaml.v3
tomlgithub.com/BurntSushi/toml
msgpackgithub.com/vmihailenco/msgpack/v5
protogoogle.golang.org/protobuf

Install with:

# YAML
go get gopkg.in/yaml.v3

# TOML
go get github.com/BurntSushi/toml

# MessagePack
go get github.com/vmihailenco/msgpack/v5

# Protocol Buffers
go get google.golang.org/protobuf

Performance Comparison

Approximate performance for a typical struct (10 fields):

FormatSpeed (ns/op)AllocsUse Case
JSON8003Web APIs, human-readable
MessagePack5002High performance, binary
Protocol Buffers4002Strongly typed, cross-language
YAML1,2005Configuration files
TOML1,0004Configuration files

Best Practices

1. Use Appropriate Format

  • JSON: Web APIs, JavaScript clients
  • YAML: Configuration files, human-readable
  • TOML: Configuration files, less ambiguous than YAML
  • MessagePack: High-performance microservices
  • Protocol Buffers: gRPC, schema evolution

2. Validate Input

All sub-packages support the same options as core binding:

config, err := yaml.YAML[Config](data,
    binding.WithMaxDepth(16),
    binding.WithMaxSliceLen(1000),
)

3. Stream Large Files

Use Reader variants for large payloads:

// Good - streams from disk
file, _ := os.Open("large-config.yaml")
config, err := yaml.YAMLReader[Config](file)

// Bad - loads entire file into memory
data, _ := os.ReadFile("large-config.yaml")
config, err := yaml.YAML[Config](data)

See Also

For usage examples, see the Binding Guide.

3.1.3.4 - Troubleshooting

Common issues, solutions, and FAQs

Solutions to common issues, frequently asked questions, and debugging strategies for the binding package.

Common Issues

Field Not Binding

Problem: Field remains zero value after binding.

Possible Causes:

  1. Field is unexported

    // Wrong - unexported field
    type Request struct {
        name string `json:"name"`  // Won't bind
    }
    
    // Correct
    type Request struct {
        Name string `json:"name"`
    }
    
  2. Tag name doesn’t match source key

    // JSON: {"username": "alice"}
    type Request struct {
        Name string `json:"name"`  // Wrong tag name
    }
    
    // Correct
    type Request struct {
        Name string `json:"username"`  // Matches JSON key
    }
    
  3. Wrong tag type for source

    // Binding from query parameters
    type Request struct {
        Name string `json:"name"`  // Wrong - should be `query:"name"`
    }
    
    // Correct
    type Request struct {
        Name string `query:"name"`
    }
    
  4. Source doesn’t contain the key

    // URL: ?page=1
    type Params struct {
        Page  int    `query:"page"`
        Limit int    `query:"limit"`  // Missing in URL
    }
    
    // Solution: Use default
    type Params struct {
        Page  int `query:"page" default:"1"`
        Limit int `query:"limit" default:"20"`
    }
    

Type Conversion Errors

Problem: Error like “cannot unmarshal string into int”.

Solutions:

  1. Check source data type

    // JSON: {"age": "30"}  <- string instead of number
    type User struct {
        Age int `json:"age"`
    }
    
    // Error: cannot unmarshal string into int
    

    Fix: Ensure JSON sends number: {"age": 30}

  2. Use string type and convert manually

    type User struct {
        AgeStr string `json:"age"`
    }
    
    user, err := binding.JSON[User](data)
    age, _ := strconv.Atoi(user.AgeStr)
    
  3. Register custom converter

    binder := binding.MustNew(
        binding.WithConverter[MyType](parseMyType),
    )
    

Slice Not Parsing

Problem: Slice remains empty or has unexpected values

Cause: Wrong slice mode for input format

// URL: ?tags=go,rust,python
type Params struct {
    Tags []string `query:"tags"`
}

// With default mode (SliceRepeat)
params, _ := binding.Query[Params](values)
// Result: Tags = ["go,rust,python"]  <- Wrong!

Solution: Use CSV mode

params, err := binding.Query[Params](values,
    binding.WithSliceMode(binding.SliceCSV))
// Result: Tags = ["go", "rust", "python"]  <- Correct!

Or use repeated parameters:

// URL: ?tags=go&tags=rust&tags=python
params, _ := binding.Query[Params](values)  // Default mode works

JSON Parsing Errors

Problem: “unexpected end of JSON input” or “invalid character”

Causes:

  1. Malformed JSON

    {"name": "test"  // Missing closing brace
    

    Solution: Validate JSON syntax

  2. Empty body

    // Body is empty but expecting JSON
    user, err := binding.JSON[User](r.Body)
    // Error: unexpected end of JSON input
    

    Solution: Check if body is empty first

    body, err := io.ReadAll(r.Body)
    if len(body) == 0 {
        return errors.New("empty body")
    }
    user, err := binding.JSON[User](body)
    
  3. Body already consumed

    body, _ := io.ReadAll(r.Body)  // Consumes body
    // ... some code ...
    user, err := binding.JSON[User](r.Body)  // Error: body empty
    

    Solution: Restore body

    body, _ := io.ReadAll(r.Body)
    r.Body = io.NopCloser(bytes.NewReader(body))
    user, err := binding.JSON[User](body)
    

Unknown Field Errors

Problem: Error in strict mode for valid JSON

Cause: JSON contains fields not in struct

// JSON: {"name": "alice", "extra": "field"}
type User struct {
    Name string `json:"name"`
}

user, err := binding.JSON[User](data, binding.WithStrictJSON())
// Error: json: unknown field "extra"

Solutions:

  1. Add field to struct

    type User struct {
        Name  string `json:"name"`
        Extra string `json:"extra"`
    }
    
  2. Remove strict mode

    user, err := binding.JSON[User](data)  // Ignores extra fields
    
  3. Use interface{} for unknown fields

    type User struct {
        Name  string                 `json:"name"`
        Extra map[string]interface{} `json:"-"`
    }
    

Pointer vs Value Confusion

Problem: Can’t distinguish between “not provided” and “zero value”

Example:

type UpdateRequest struct {
    Age int `json:"age"`
}

// JSON: {"age": 0}
// Can't tell if: 1) User wants to set age to 0, or 2) Field not provided

Solution: Use pointers

type UpdateRequest struct {
    Age *int `json:"age"`
}

// JSON: {"age": 0}      -> Age = &0 (explicitly set to zero)
// JSON: {}              -> Age = nil (not provided)
// JSON: {"age": null}   -> Age = nil (explicitly null)

Default Values Not Applied

Problem: Default value doesn’t work

Cause: Defaults only apply when field is missing, not for zero values

type Params struct {
    Page int `query:"page" default:"1"`
}

// URL: ?page=0
params, _ := binding.Query[Params](values)
// Result: Page = 0 (not 1, because 0 was provided)

Solution: Use pointer to distinguish nil from zero

type Params struct {
    Page *int `query:"page" default:"1"`
}

// URL: ?page=0  -> Page = &0
// URL: (no page) -> Page = &1 (default applied)

Nested Struct Not Binding

Problem: Nested struct fields remain zero

Example:

// JSON: {"user": {"name": "alice", "age": 30}}
type Request struct {
    User struct {
        Name string `json:"name"`
        Age  int    `json:"age"`
    } `json:"user"`
}

req, err := binding.JSON[Request](data)
// Works correctly

For query parameters, use dot notation:

// URL: ?user.name=alice&user.age=30
type Request struct {
    User struct {
        Name string `query:"user.name"`
        Age  int    `query:"user.age"`
    }
}

Time Parsing Errors

Problem: “parsing time … as …: cannot parse”

Cause: Time format doesn’t match any default layouts

// JSON: {"created": "01/02/2006"}
type Request struct {
    Created time.Time `json:"created"`
}
// Error: parsing time "01/02/2006"

Solution: Add custom time layout

binder := binding.MustNew(
    binding.WithTimeLayouts(
        append(binding.DefaultTimeLayouts, "01/02/2006")...,
    ),
)

req, err := binder.JSON[Request](data)

Memory Issues

Problem: Out of memory or slow performance

Causes:

  1. Large payloads without limits

    // No limit - vulnerable to memory attack
    user, err := binding.JSON[User](r.Body)
    

    Solution: Set size limits

    user, err := binding.JSON[User](r.Body,
        binding.WithMaxBytes(1024*1024),  // 1MB limit
        binding.WithMaxSliceLen(1000),
        binding.WithMaxMapSize(500),
    )
    
  2. Not using streaming for large data

    // Bad - loads entire body into memory
    body, _ := io.ReadAll(r.Body)
    user, err := binding.JSON[User](body)
    

    Solution: Stream from reader

    user, err := binding.JSONReader[User](r.Body)
    

Header Case Sensitivity

Problem: Header not binding

Cause: HTTP headers are case-insensitive but tag must match exact case

// Header: x-api-key: secret
type Request struct {
    APIKey string `header:"X-API-Key"`  // Still works!
}

// Headers are matched case-insensitively

Note: The binding package handles case-insensitive header matching automatically.

Multi-Source Precedence Issues

Problem: Wrong source value used

Example:

// Query: ?user_id=1
// JSON: {"user_id": 2}
type Request struct {
    UserID int `query:"user_id" json:"user_id"`
}

req, err := binding.Bind[Request](
    binding.FromQuery(values),  // user_id = 1
    binding.FromJSON(body),     // user_id = 2 (overwrites!)
)
// Result: UserID = 2

Solutions:

  1. Change source order (last wins)

    req, err := binding.Bind[Request](
        binding.FromJSON(body),      // user_id = 2
        binding.FromQuery(values),   // user_id = 1 (overwrites!)
    )
    // Result: UserID = 1
    
  2. Use first-wins strategy

    req, err := binding.Bind[Request](
        binding.WithMergeStrategy(binding.MergeFirstWins),
        binding.FromQuery(values),  // user_id = 1 (wins!)
        binding.FromJSON(body),     // user_id = 2 (ignored)
    )
    // Result: UserID = 1
    

Frequently Asked Questions

Q: How do I validate required fields?

A: Use the rivaas.dev/validation package after binding:

import "rivaas.dev/validation"

type Request struct {
    Name string `json:"name" validate:"required"`
    Age  int    `json:"age" validate:"required,min=18"`
}

req, err := binding.JSON[Request](data)
if err != nil {
    return err
}

// Validate after binding
if err := validation.Validate(req); err != nil {
    return err
}

Q: Can I bind to non-struct types?

A: Yes, but only for certain types:

// Array
type Batch []CreateUserRequest
batch, err := binding.JSON[Batch](data)

// Map
type Config map[string]string
config, err := binding.JSON[Config](data)

// Primitive (less useful)
var count int
err := binding.JSONTo([]byte("42"), &count)

Q: How do I handle optional vs. required fields?

A: Combine binding with validation:

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

// Name is required (validation)
// Email is optional (pointer) but if provided must be valid (validation)

Q: Can I use custom JSON field names?

A: Yes, use the json tag:

type User struct {
    ID       int    `json:"user_id"`      // Maps to "user_id" in JSON
    FullName string `json:"full_name"`    // Maps to "full_name" in JSON
}

Q: How do I bind from multiple query parameters to one field?

A: Use tag aliases:

type Request struct {
    UserID int `query:"user_id,id,uid"`  // Accepts any of these
}

// Works with: ?user_id=123, ?id=123, or ?uid=123

Q: Can I use both JSON and form binding?

A: Yes, use multi-source binding:

type Request struct {
    Name string `json:"name" form:"name"`
}

req, err := binding.Bind[Request](
    binding.FromJSON(r.Body),
    binding.FromForm(r.Form),
)

Q: How do I debug binding issues?

A: Use event hooks:

binder := binding.MustNew(
    binding.WithEvents(binding.Events{
        FieldBound: func(name, tag string) {
            log.Printf("Bound %s from %s", name, tag)
        },
        UnknownField: func(name string) {
            log.Printf("Unknown field: %s", name)
        },
        Done: func(stats binding.Stats) {
            log.Printf("%d fields, %d errors, %v",
                stats.FieldsBound, stats.ErrorCount, stats.Duration)
        },
    }),
)

Q: Is binding thread-safe?

A: Yes, all operations are thread-safe. The struct cache uses lock-free reads and synchronized writes.

Q: How do I bind custom types?

A: Register a converter:

import "github.com/google/uuid"

binder := binding.MustNew(
    binding.WithConverter[uuid.UUID](uuid.Parse),
)

Or implement encoding.TextUnmarshaler:

type MyType string

func (m *MyType) UnmarshalText(text []byte) error {
    *m = MyType(string(text))
    return nil
}

Q: Can I bind from environment variables?

A: Not directly, but you can create a custom 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
}

// Use with RawInto
config, err := binding.RawInto[Config](&EnvGetter{}, "env")

Q: What’s the difference between JSON and JSONReader?

A:

  • JSON: Takes []byte, entire data in memory
  • JSONReader: Takes io.Reader, streams data

Use JSONReader for large payloads (>1MB) to reduce memory usage.

Q: How do I handle API versioning?

A: Use different struct types per version:

type CreateUserRequestV1 struct {
    Name string `json:"name"`
}

type CreateUserRequestV2 struct {
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
}

// Route to appropriate handler based on version header

Debugging Strategies

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

log.Printf("Raw body: %s", string(body))
log.Printf("Content-Type: %s", r.Header.Get("Content-Type"))

req, err := binding.JSON[Request](r.Body)

3. Use Curl to Test

# Test JSON binding
curl -X POST http://localhost:8080/users \
  -H "Content-Type: application/json" \
  -d '{"name":"alice","age":30}'

# Test query parameters
curl "http://localhost:8080/users?page=2&limit=50"

# Test headers
curl -H "X-API-Key: secret" http://localhost:8080/users

4. Write Unit Tests

func TestBinding(t *testing.T) {
    payload := `{"name":"test","age":30}`
    
    user, err := binding.JSON[User]([]byte(payload))
    if err != nil {
        t.Fatalf("binding failed: %v", err)
    }
    
    if user.Name != "test" {
        t.Errorf("expected name=test, got %s", user.Name)
    }
}

Getting Help

If you’re still stuck:

  1. Check the examples: Binding Guide
  2. Review API docs: API Reference
  3. Search GitHub issues: rivaas-dev/rivaas/issues
  4. Ask for help: Open a new issue with:
    • Minimal reproducible example
    • Expected vs. actual behavior
    • Relevant logs/errors

See Also


For more examples and patterns, see the Binding Guide.

3.1.4 - Validation Package

Complete API reference for the rivaas.dev/validation package

This is the API reference for the rivaas.dev/validation package. For learning-focused documentation, see the Validation Guide.

Package Information

Overview

The validation package provides flexible validation for Go structs with support for multiple strategies: struct tags, JSON Schema, and custom interfaces. It’s designed for web applications with features like partial validation for PATCH requests, sensitive data redaction, and detailed error reporting.

import "rivaas.dev/validation"

type User struct {
    Email string `validate:"required,email"`
    Age   int    `validate:"min=18"`
}

err := validation.Validate(ctx, &user)

Key Features

  • Multiple Validation Strategies: Struct tags, JSON Schema, custom interfaces
  • Partial Validation: PATCH request support with presence tracking
  • Thread-Safe: Safe for concurrent use
  • Security: Built-in redaction, nesting limits, memory protection
  • Structured Errors: Field-level errors with codes and metadata
  • Extensible: Custom tags, validators, and error messages

Package Architecture

graph TB
    User[User Code] --> API[Public API]:::info
    
    API --> Validate[Validate Functions]
    API --> Validator[Validator Type]
    API --> Presence[Presence Tracking]
    
    Validate --> Strategy[Strategy Selection]:::warning
    Validator --> Strategy
    
    Strategy --> Tags[Struct Tags]
    Strategy --> Schema[JSON Schema]
    Strategy --> Interface[Custom Interfaces]
    
    Tags --> Errors[Error Collection]
    Schema --> Errors
    Interface --> Errors
    
    Errors --> ErrorType[Error/FieldError]:::danger
    
    Presence --> Partial[Partial Validation]
    Partial --> Strategy
    
    classDef default fill:#F8FAF9,stroke:#1E6F5C,color:#1F2A27
    classDef info fill:#D1ECF1,stroke:#17A2B8,color:#1F2A27
    classDef warning fill:#FFF3CD,stroke:#FFC107,color:#1F2A27
    classDef danger fill:#F8D7DA,stroke:#DC3545,color:#1F2A27

Quick Navigation

API Reference

Core types, functions, and validation methods.

View →

Options

Configuration options and validator settings.

View →

Interfaces

Custom validation interfaces and providers.

View →

Strategies

Validation strategy selection and priority.

View →

Troubleshooting

Common validation issues and solutions.

View →

User Guide

Step-by-step tutorials and examples.

View →

Core API

Package-Level Functions

Simple validation without creating a validator instance:

// Validate with default configuration
func Validate(ctx context.Context, v any, opts ...Option) error

// Validate only present fields (PATCH requests)
func ValidatePartial(ctx context.Context, v any, pm PresenceMap, opts ...Option) error

// Compute which fields are present in JSON
func ComputePresence(rawJSON []byte) (PresenceMap, error)

Validator Type

Create configured validator instances for reuse:

// Create validator (returns error on invalid config)
func New(opts ...Option) (*Validator, error)

// Create validator (panics on invalid config)
func MustNew(opts ...Option) *Validator

// Validator methods
func (v *Validator) Validate(ctx context.Context, val any, opts ...Option) error
func (v *Validator) ValidatePartial(ctx context.Context, val any, pm PresenceMap, opts ...Option) error

Error Types

Structured validation errors:

// Main error type with multiple field errors
type Error struct {
    Fields    []FieldError
    Truncated bool
}

// Individual field error
type FieldError struct {
    Path    string         // JSON path (e.g., "items.2.price")
    Code    string         // Error code (e.g., "tag.required")
    Message string         // Human-readable message
    Meta    map[string]any // Additional metadata
}

Interfaces

Implement these for custom validation:

// Simple custom validation
type ValidatorInterface interface {
    Validate() error
}

// Context-aware custom validation
type ValidatorWithContext interface {
    ValidateContext(context.Context) error
}

// JSON Schema provider
type JSONSchemaProvider interface {
    JSONSchema() (id, schema string)
}

// Redactor for sensitive fields
type Redactor func(path string) bool

Validation Strategies

The package supports three strategies with automatic selection:

Priority Order:

  1. Interface methods (Validate() / ValidateContext())
  2. Struct tags (validate:"...")
  3. JSON Schema (JSONSchemaProvider)
// Automatic strategy selection
err := validation.Validate(ctx, &user)

// Explicit strategy
err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

// Run all strategies
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

Configuration Options

Validator Creation Options

validator := validation.MustNew(
    validation.WithMaxErrors(10),              // Limit errors returned
    validation.WithMaxCachedSchemas(2048),     // Schema cache size
    validation.WithRedactor(redactorFunc),     // Redact sensitive fields
    validation.WithCustomTag("phone", phoneValidator), // Custom tag
    validation.WithMessages(messageMap),       // Custom error messages
    validation.WithMessageFunc("min", minFunc), // Dynamic messages
)

Per-Call Options

err := validator.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
    validation.WithPartial(true),
    validation.WithPresence(presenceMap),
    validation.WithMaxErrors(5),
    validation.WithCustomValidator(customFunc),
)

Usage Patterns

Basic Validation

type User struct {
    Email string `validate:"required,email"`
    Age   int    `validate:"min=18"`
}

user := User{Email: "test@example.com", Age: 25}
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)
        }
    }
}

Partial Validation (PATCH)

rawJSON := []byte(`{"email": "new@example.com"}`)

presence, _ := validation.ComputePresence(rawJSON)
var req UpdateUserRequest
json.Unmarshal(rawJSON, &req)

err := validation.ValidatePartial(ctx, &req, presence)

Custom Validator

validator := validation.MustNew(
    validation.WithCustomTag("phone", func(fl validator.FieldLevel) bool {
        return phoneRegex.MatchString(fl.Field().String())
    }),
    validation.WithRedactor(func(path string) bool {
        return strings.Contains(path, "password")
    }),
)

err := validator.Validate(ctx, &user)

Performance Characteristics

  • First validation: ~500ns overhead for reflection
  • Subsequent validations: ~50ns overhead (cache lookup)
  • Schema caching: LRU with configurable size (default 1024)
  • Thread-safe: All operations safe for concurrent use
  • Zero allocation: Field paths cached per type

Integration

With net/http

func Handler(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    json.NewDecoder(r.Body).Decode(&req)
    
    if err := validation.Validate(r.Context(), &req); err != nil {
        http.Error(w, err.Error(), http.StatusUnprocessableEntity)
        return
    }
    // Process request
}

With rivaas.dev/router

func Handler(c *router.Context) error {
    var req CreateUserRequest
    c.BindJSON(&req)
    
    if err := validation.Validate(c.Request().Context(), &req); err != nil {
        return c.JSON(http.StatusUnprocessableEntity, err)
    }
    return c.JSON(http.StatusOK, processRequest(req))
}

With rivaas.dev/app

func Handler(c *app.Context) error {
    var req CreateUserRequest
    if err := c.Bind(&req); err != nil {
        return err // Automatically validated and handled
    }
    return c.JSON(http.StatusOK, processRequest(req))
}

Version Compatibility

The validation package follows semantic versioning:

  • v1.x: Stable API, backward compatible
  • v2.x: Major changes, may require code updates

See Also


For step-by-step guides and tutorials, see the Validation Guide.

For real-world examples, see the Examples page.

3.1.4.1 - API Reference

Core types, functions, and methods

Complete API reference for the validation package’s core types, functions, and methods.

Package-Level Functions

Validate

func Validate(ctx context.Context, v any, opts ...Option) error

Validates a value using the default validator. Returns nil if validation passes, or *Error if validation fails.

Parameters:

  • ctx - Context passed to ValidatorWithContext implementations.
  • v - The value to validate. Typically a pointer to a struct.
  • opts - Optional per-call configuration options.

Returns:

  • nil on success.
  • *Error with field-level errors on failure.

Example:

err := validation.Validate(ctx, &user)
if err != nil {
    var verr *validation.Error
    if errors.As(err, &verr) {
        // Handle validation errors
    }
}

ValidatePartial

func ValidatePartial(ctx context.Context, v any, pm PresenceMap, opts ...Option) error

Validates only fields present in the PresenceMap. Useful for PATCH requests where only provided fields should be validated.

Parameters:

  • ctx - Context for validation.
  • v - The value to validate.
  • pm - Map of present fields.
  • opts - Optional configuration options.

Example:

presence, _ := validation.ComputePresence(rawJSON)
err := validation.ValidatePartial(ctx, &req, presence)

ComputePresence

func ComputePresence(rawJSON []byte) (PresenceMap, error)

Analyzes raw JSON and returns a map of present field paths. Used for partial validation.

Parameters:

  • rawJSON - Raw JSON bytes.

Returns:

  • PresenceMap - Map of field paths to true.
  • error - If JSON is invalid.

Example:

rawJSON := []byte(`{"email": "test@example.com", "age": 25}`)
presence, err := validation.ComputePresence(rawJSON)
// presence = {"email": true, "age": true}

Validator Type

New

func New(opts ...Option) (*Validator, error)

Creates a new Validator with the given options. Returns an error if configuration is invalid.

Parameters:

  • opts - Configuration options

Returns:

  • *Validator - Configured validator instance
  • error - If configuration is invalid

Example:

validator, err := validation.New(
    validation.WithMaxErrors(10),
    validation.WithRedactor(redactor),
)
if err != nil {
    return fmt.Errorf("failed to create validator: %w", err)
}

MustNew

func MustNew(opts ...Option) *Validator

Creates a new Validator with the given options. Panics if configuration is invalid. Use in main() or init() where panic on startup is acceptable.

Parameters:

  • opts - Configuration options

Returns:

  • *Validator - Configured validator instance

Panics:

  • If configuration is invalid

Example:

var validator = validation.MustNew(
    validation.WithMaxErrors(10),
    validation.WithRedactor(redactor),
)

Validator.Validate

func (v *Validator) Validate(ctx context.Context, val any, opts ...Option) error

Validates a value using this validator’s configuration. Per-call options override the validator’s base configuration.

Parameters:

  • ctx - Context for validation
  • val - The value to validate
  • opts - Optional per-call configuration overrides

Returns:

  • nil on success
  • *Error on failure

Example:

err := validator.Validate(ctx, &user,
    validation.WithMaxErrors(5), // Override base config
)

Validator.ValidatePartial

func (v *Validator) ValidatePartial(ctx context.Context, val any, pm PresenceMap, opts ...Option) error

Validates only fields present in the PresenceMap using this validator’s configuration.

Parameters:

  • ctx - Context for validation
  • val - The value to validate
  • pm - Map of present fields
  • opts - Optional configuration overrides

Returns:

  • nil on success
  • *Error on failure

Error Types

Error

type Error struct {
    Fields    []FieldError
    Truncated bool
}

Main validation error type containing multiple field errors.

Fields:

  • Fields - Slice of field-level errors
  • Truncated - True if errors were truncated due to maxErrors limit

Methods:

func (e Error) Error() string
func (e Error) Unwrap() error                    // Returns ErrValidation
func (e Error) HTTPStatus() int                  // Returns 422
func (e Error) Code() string                     // Returns "validation_error"
func (e Error) Details() any                     // Returns Fields
func (e *Error) Add(path, code, message string, meta map[string]any)
func (e *Error) AddError(err error)
func (e Error) HasErrors() bool
func (e Error) HasCode(code string) bool
func (e Error) Has(path string) bool
func (e Error) GetField(path string) *FieldError
func (e *Error) Sort()

Example:

var verr *validation.Error
if errors.As(err, &verr) {
    fmt.Printf("Found %d errors\n", len(verr.Fields))
    
    if verr.Truncated {
        fmt.Println("(more errors exist)")
    }
    
    if verr.Has("email") {
        fmt.Println("Email field has an error")
    }
}

FieldError

type FieldError struct {
    Path    string
    Code    string
    Message string
    Meta    map[string]any
}

Individual field validation error.

Fields:

  • Path - JSON path to the field (e.g., "items.2.price")
  • Code - Stable error code (e.g., "tag.required", "schema.type")
  • Message - Human-readable error message
  • Meta - Additional metadata (tag, param, value, etc.)

Methods:

func (e FieldError) Error() string    // Returns "path: message"
func (e FieldError) Unwrap() error    // Returns ErrValidation
func (e FieldError) HTTPStatus() int  // Returns 422

Example:

for _, fieldErr := range verr.Fields {
    fmt.Printf("Field: %s\n", fieldErr.Path)
    fmt.Printf("Code: %s\n", fieldErr.Code)
    fmt.Printf("Message: %s\n", fieldErr.Message)
    
    if tag, ok := fieldErr.Meta["tag"].(string); ok {
        fmt.Printf("Tag: %s\n", tag)
    }
}

PresenceMap Type

type PresenceMap map[string]bool

Tracks which fields are present in a request body. Keys are JSON field paths.

Methods:

func (pm PresenceMap) Has(path string) bool
func (pm PresenceMap) HasPrefix(prefix string) bool
func (pm PresenceMap) LeafPaths() []string

Example:

presence := PresenceMap{
    "email": true,
    "address": true,
    "address.city": true,
}

if presence.Has("email") {
    // Email was provided
}

if presence.HasPrefix("address") {
    // At least one address field was provided
}

leaves := presence.LeafPaths()
// Returns: ["email", "address.city"]
// (address is excluded as it has children)

Strategy Type

type Strategy int

const (
    StrategyAuto Strategy = iota
    StrategyTags
    StrategyJSONSchema
    StrategyInterface
)

Defines the validation approach to use.

Constants:

  • StrategyAuto - Automatically select best strategy (default)
  • StrategyTags - Use struct tag validation
  • StrategyJSONSchema - Use JSON Schema validation
  • StrategyInterface - Use interface methods (Validate() / ValidateContext())

Example:

err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

Sentinel Errors

var (
    ErrValidation                 = errors.New("validation")
    ErrCannotValidateNilValue     = errors.New("cannot validate nil value")
    ErrCannotValidateInvalidValue = errors.New("cannot validate invalid value")
    ErrUnknownValidationStrategy  = errors.New("unknown validation strategy")
    ErrValidationFailed           = errors.New("validation failed")
    ErrInvalidType                = errors.New("invalid type")
)

Sentinel errors for error checking with errors.Is.

Example:

if errors.Is(err, validation.ErrValidation) {
    // This is a validation error
}

Type Definitions

Option

type Option func(*config)

Functional option for configuring validation. See Options for all available options.

Redactor

type Redactor func(path string) bool

Function that determines if a field should be redacted in error messages. Returns true if the field at the given path should have its value hidden.

Example:

redactor := func(path string) bool {
    return strings.Contains(path, "password") ||
           strings.Contains(path, "token")
}

validator := validation.MustNew(
    validation.WithRedactor(redactor),
)

MessageFunc

type MessageFunc func(param string, kind reflect.Kind) string

Generates dynamic error messages for parameterized validation tags. Receives the tag parameter and field’s reflect.Kind.

Example:

minMessage := 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)
}

validator := validation.MustNew(
    validation.WithMessageFunc("min", minMessage),
)

Constants

const (
    defaultMaxCachedSchemas = 1024
    maxRecursionDepth      = 100
)
  • defaultMaxCachedSchemas - Default JSON Schema cache size
  • maxRecursionDepth - Maximum nesting depth for ComputePresence

Thread Safety

All types and functions in the validation package are safe for concurrent use by multiple goroutines:

  • Validator instances are thread-safe
  • Package-level functions use a shared thread-safe default validator
  • PresenceMap is read-only after creation (safe for concurrent reads)

Performance

  • First validation of a type: ~500ns overhead for reflection
  • Subsequent validations: ~50ns overhead (cache lookup)
  • Schema compilation: Cached with LRU eviction
  • Path computation: Cached per type
  • Zero allocations: For cached types

Next Steps

3.1.4.2 - Options

Configuration options for validators

Complete reference for all configuration options (With* functions) available in the validation package.

Option Types

Options can be used in two ways:

  1. Validator Creation: Pass to New() or MustNew(). Applies to all validations.
  2. Per-Call: Pass to Validate() or ValidatePartial(). Applies to that call only.
// Validator creation options
validator := validation.MustNew(
    validation.WithMaxErrors(10),
    validation.WithRedactor(redactor),
)

// Per-call options (override validator config)
err := validator.Validate(ctx, &req,
    validation.WithMaxErrors(5), // Overrides the 10 from creation
    validation.WithStrategy(validation.StrategyTags),
)

Strategy Options

WithStrategy

func WithStrategy(strategy Strategy) Option

Sets the validation strategy to use.

Values:

  • StrategyAuto - Automatically select best strategy. This is the default.
  • StrategyTags - Use struct tags only.
  • StrategyJSONSchema - Use JSON Schema only.
  • StrategyInterface - Use interface methods only.

Example:

err := validation.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyTags),
)

WithRunAll

func WithRunAll(runAll bool) Option

Runs all applicable validation strategies and aggregates errors. By default, validation stops at the first successful strategy.

Example:

err := validation.Validate(ctx, &req,
    validation.WithRunAll(true),
)

WithRequireAny

func WithRequireAny(require bool) Option

When used with WithRunAll(true), succeeds if at least one strategy passes (OR logic).

Example:

// Pass if ANY strategy succeeds
err := validation.Validate(ctx, &req,
    validation.WithRunAll(true),
    validation.WithRequireAny(true),
)

Partial Validation Options

WithPartial

func WithPartial(partial bool) Option

Enables partial validation mode for PATCH requests. Validates only present fields and ignores “required” constraints for absent fields.

Example:

err := validation.Validate(ctx, &req,
    validation.WithPartial(true),
    validation.WithPresence(presenceMap),
)

WithPresence

func WithPresence(presence PresenceMap) Option

Sets the presence map for partial validation. Tracks which fields were provided in the request body.

Example:

presence, _ := validation.ComputePresence(rawJSON)
err := validation.Validate(ctx, &req,
    validation.WithPresence(presence),
    validation.WithPartial(true),
)

Error Limit Options

WithMaxErrors

func WithMaxErrors(maxErrors int) Option

Limits the number of errors returned. Set to 0 for unlimited errors (default).

Example:

// Return at most 5 errors
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")
    }
}

WithMaxFields

func WithMaxFields(maxFields int) Option

Sets the maximum number of fields to validate in partial mode. Prevents pathological inputs with extremely large presence maps. Set to 0 to use the default (10000).

Example:

validator := validation.MustNew(
    validation.WithMaxFields(5000),
)

Cache Options

WithMaxCachedSchemas

func WithMaxCachedSchemas(maxCachedSchemas int) Option

Sets the maximum number of JSON schemas to cache. Uses LRU eviction when limit is reached. Set to 0 to use the default (1024).

Example:

validator := validation.MustNew(
    validation.WithMaxCachedSchemas(2048),
)

Security Options

WithRedactor

func WithRedactor(redactor Redactor) Option

Sets a redactor function to hide sensitive values in error messages. The redactor returns true if the field at the given path should be redacted.

Example:

redactor := func(path string) bool {
    return strings.Contains(path, "password") ||
           strings.Contains(path, "token") ||
           strings.Contains(path, "secret")
}

validator := validation.MustNew(
    validation.WithRedactor(redactor),
)

WithDisallowUnknownFields

func WithDisallowUnknownFields(disallow bool) Option

Rejects JSON with unknown fields (typo detection). When enabled, causes strict JSON binding to reject requests with fields not defined in the struct.

Example:

err := validation.Validate(ctx, &req,
    validation.WithDisallowUnknownFields(true),
)

Context Options

WithContext

func WithContext(ctx context.Context) Option

Overrides the context used for validation. Useful when you need a different context than the one passed to Validate().

Note: In most cases, you should pass the context directly to Validate(). This option exists for advanced use cases.

Example:

err := validator.Validate(requestCtx, &req,
    validation.WithContext(backgroundCtx),
)

Custom Validation Options

WithCustomSchema

func WithCustomSchema(id, schema string) Option

Sets a custom JSON Schema for validation. This overrides any schema provided by the JSONSchemaProvider interface.

Example:

customSchema := `{
    "type": "object",
    "properties": {
        "email": {"type": "string", "format": "email"}
    }
}`

err := validation.Validate(ctx, &req,
    validation.WithCustomSchema("custom-user", customSchema),
)

WithCustomValidator

func WithCustomValidator(fn func(any) error) Option

Sets a custom validation function that runs before any other validation strategies.

Example:

err := validation.Validate(ctx, &req,
    validation.WithCustomValidator(func(v any) error {
        req := v.(*UserRequest)
        if req.Age < 18 {
            return errors.New("must be 18 or older")
        }
        return nil
    }),
)

WithCustomTag

func WithCustomTag(name string, fn validator.Func) Option

Registers a custom validation tag for use in struct tags. Custom tags are registered when the validator is created.

Example:

phoneValidator := func(fl validator.FieldLevel) bool {
    return phoneRegex.MatchString(fl.Field().String())
}

validator := validation.MustNew(
    validation.WithCustomTag("phone", phoneValidator),
)

type User struct {
    Phone string `validate:"phone"`
}

Error Message Options

WithMessages

func WithMessages(messages map[string]string) Option

Sets static error messages for validation tags. Messages override the default English messages for specified tags.

Example:

validator := validation.MustNew(
    validation.WithMessages(map[string]string{
        "required": "cannot be empty",
        "email":    "invalid email format",
        "min":      "value too small",
    }),
)

WithMessageFunc

func WithMessageFunc(tag string, fn MessageFunc) Option

Sets a dynamic message generator for a parameterized tag. Use for tags like “min”, “max”, “len” that include parameters.

Example:

minMessage := 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)
}

validator := validation.MustNew(
    validation.WithMessageFunc("min", minMessage),
)

Field Name Options

WithFieldNameMapper

func WithFieldNameMapper(mapper func(string) string) Option

Sets a function to transform field names in error messages. Useful for localization or custom naming conventions.

Example:

validator := validation.MustNew(
    validation.WithFieldNameMapper(func(name string) string {
        // Convert snake_case to Title Case
        return strings.Title(strings.ReplaceAll(name, "_", " "))
    }),
)

Options Summary

Validator Creation Options

Options that should be set when creating a validator (affect all validations):

OptionPurpose
WithMaxErrorsLimit total errors returned
WithMaxFieldsLimit fields in partial validation
WithMaxCachedSchemasSchema cache size
WithRedactorRedact sensitive fields
WithCustomTagRegister custom validation tag
WithMessagesCustom error messages
WithMessageFuncDynamic error messages
WithFieldNameMapperTransform field names

Per-Call Options

Options commonly used per-call (override validator config):

OptionPurpose
WithStrategyChoose validation strategy
WithRunAllRun all strategies
WithRequireAnyOR logic with WithRunAll
WithPartialEnable partial validation
WithPresenceSet presence map
WithMaxErrorsOverride error limit
WithCustomValidatorAdd custom validator
WithCustomSchemaOverride JSON Schema
WithDisallowUnknownFieldsReject unknown fields
WithContextOverride context

Usage Patterns

Creating Configured Validator

validator := validation.MustNew(
    // Security
    validation.WithRedactor(sensitiveRedactor),
    validation.WithMaxErrors(20),
    validation.WithMaxFields(5000),
    
    // Custom validation
    validation.WithCustomTag("phone", phoneValidator),
    validation.WithCustomTag("username", usernameValidator),
    
    // Error messages
    validation.WithMessages(map[string]string{
        "required": "is required",
        "email":    "must be a valid email",
    }),
)

Per-Call Overrides

// Use tags strategy only
err := validator.Validate(ctx, &req,
    validation.WithStrategy(validation.StrategyTags),
    validation.WithMaxErrors(5),
)

// Partial validation
err := validator.Validate(ctx, &req,
    validation.WithPartial(true),
    validation.WithPresence(presence),
)

// Custom validation
err := validator.Validate(ctx, &req,
    validation.WithCustomValidator(complexBusinessLogic),
)

Next Steps

3.1.4.3 - Interfaces

Custom validation interfaces

Complete reference for validation interfaces that can be implemented for custom validation logic.

ValidatorInterface

type ValidatorInterface interface {
    Validate() error
}

Implement this interface for simple custom validation without context.

When to Use

  • Simple validation rules that don’t need external data
  • Business logic validation
  • Cross-field validation within the struct

Implementation

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 must be at least 2 characters")
    }
    return nil
}

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
}

Pointer vs Value Receivers

Both are supported:

// Pointer receiver (can modify struct)
func (u *User) Validate() error {
    u.Email = strings.ToLower(u.Email) // Normalize
    return validateEmail(u.Email)
}

// Value receiver (read-only)
func (u User) Validate() error {
    return validateEmail(u.Email)
}

Use pointer receivers when you need to modify the struct during validation (normalization, etc.).

ValidatorWithContext

type ValidatorWithContext interface {
    ValidateContext(context.Context) error
}

Implement this interface for context-aware validation that needs access to request-scoped data or external services.

When to Use

  • Database lookups (uniqueness checks, existence validation)
  • Tenant-specific validation rules
  • Rate limiting or quota checks
  • External service calls
  • Request-scoped data access

Implementation

type User struct {
    Username string
    Email    string
    TenantID string
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Get services from context
    db := ctx.Value("db").(*sql.DB)
    tenant := ctx.Value("tenant").(string)
    
    // Tenant validation
    if u.TenantID != tenant {
        return errors.New("user does not belong to this tenant")
    }
    
    // Database validation
    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
}

Context Values

Access data from context:

func (u *User) ValidateContext(ctx context.Context) error {
    // Database connection
    db := ctx.Value("db").(*sql.DB)
    
    // Current user/tenant
    currentUser := ctx.Value("user_id").(string)
    tenant := ctx.Value("tenant").(string)
    
    // Request metadata
    requestID := ctx.Value("request_id").(string)
    
    // Use in validation logic
    return validateWithContext(db, u, tenant)
}

Cancellation Support

Respect context cancellation for long-running validations:

func (u *User) ValidateContext(ctx context.Context) error {
    // Check cancellation before expensive operation
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
    }
    
    // Expensive validation
    return checkUsernameUniqueness(ctx, u.Username)
}

JSONSchemaProvider

type JSONSchemaProvider interface {
    JSONSchema() (id, schema string)
}

Implement this interface to provide a JSON Schema for validation.

When to Use

  • Portable validation rules (shared with frontend/documentation)
  • Complex validation logic without code
  • RFC-compliant validation
  • Schema versioning

Implementation

type Product struct {
    Name     string  `json:"name"`
    Price    float64 `json:"price"`
    Category string  `json:"category"`
}

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"]
            }
        },
        "required": ["name", "price", "category"],
        "additionalProperties": false
    }`
}

Schema ID

The ID is used for caching:

func (p Product) JSONSchema() (id, schema string) {
    return "product-v1", schemaString
    //     ^^^^^^^^^^^ Used as cache key
}

Use versioned IDs (e.g., "product-v1", "product-v2") to invalidate cache when schema changes.

Schema Formats

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

Embedded Schemas

For complex schemas, consider embedding:

import _ "embed"

//go:embed user_schema.json
var userSchemaJSON string

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", userSchemaJSON
}

Redactor

type Redactor func(path string) bool

Function that determines if a field should be redacted in error messages.

When to Use

  • Protecting passwords, tokens, secrets
  • Hiding credit card numbers, SSNs
  • Redacting PII (personally identifiable information)
  • Compliance requirements (GDPR, PCI-DSS)

Implementation

func sensitiveFieldRedactor(path string) bool {
    pathLower := strings.ToLower(path)
    
    // Password fields
    if strings.Contains(pathLower, "password") {
        return true
    }
    
    // Tokens and secrets
    if strings.Contains(pathLower, "token") ||
       strings.Contains(pathLower, "secret") ||
       strings.Contains(pathLower, "key") {
        return true
    }
    
    // Payment information
    if strings.Contains(pathLower, "card") ||
       strings.Contains(pathLower, "cvv") ||
       strings.Contains(pathLower, "credit") {
        return true
    }
    
    return false
}

validator := validation.MustNew(
    validation.WithRedactor(sensitiveFieldRedactor),
)

Path-Based Redaction

Redact specific paths:

func pathRedactor(path string) bool {
    redactedPaths := map[string]bool{
        "user.password":          true,
        "payment.card_number":    true,
        "payment.cvv":            true,
        "auth.refresh_token":     true,
    }
    return redactedPaths[path]
}

Nested Field Redaction

func nestedRedactor(path string) bool {
    // Redact all fields under payment.*
    if strings.HasPrefix(path, "payment.") {
        return true
    }
    
    // Redact specific nested field
    if strings.HasPrefix(path, "user.credentials.") {
        return true
    }
    
    return false
}

Interface Priority

When multiple interfaces are implemented, they have different priorities:

Priority Order:

  1. ValidatorWithContext / ValidatorInterface (highest)
  2. Struct tags (validate:"...")
  3. JSONSchemaProvider (lowest)
type User struct {
    Email string `validate:"required,email"` // Priority 2
}

func (u User) JSONSchema() (id, schema string) {
    // Priority 3 (lowest)
    return "user-v1", `{...}`
}

func (u *User) Validate() error {
    // Priority 1 (highest) - this runs instead of tags/schema
    return customValidation(u.Email)
}

Override priority with explicit strategy:

// Skip Validate() method, use tags
err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

Combining Interfaces

Run all strategies with WithRunAll:

type User struct {
    Email string `validate:"required,email"` // Struct tags
}

func (u User) JSONSchema() (id, schema string) {
    // JSON Schema
    return "user-v1", `{...}`
}

func (u *User) Validate() error {
    // Interface method
    return businessLogic(u)
}

// Run all three strategies
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

Best Practices

1. Choose the Right Interface

// Simple validation - ValidatorInterface
func (u *User) Validate() error {
    return validateEmail(u.Email)
}

// Needs external data - ValidatorWithContext
func (u *User) ValidateContext(ctx context.Context) error {
    db := ctx.Value("db").(*sql.DB)
    return checkUniqueness(ctx, db, u.Email)
}

2. Return Structured Errors

// Good
func (u *User) Validate() error {
    var verr validation.Error
    verr.Add("email", "invalid", "must be valid email", nil)
    return &verr
}

// Bad
func (u *User) Validate() error {
    return errors.New("email invalid")
}

3. Use Context Safely

func (u *User) ValidateContext(ctx context.Context) error {
    db, ok := ctx.Value("db").(*sql.DB)
    if !ok {
        return errors.New("database not available in context")
    }
    return validateWithDB(ctx, db, u)
}

4. Document Custom Validation

// ValidateContext validates the user against business rules:
// - Username must be unique within tenant
// - Email domain must be allowed for tenant
// - User must not exceed account limits
func (u *User) ValidateContext(ctx context.Context) error {
    // Implementation
}

Testing

Testing ValidatorInterface

func TestUserValidation(t *testing.T) {
    tests := []struct {
        name    string
        user    User
        wantErr bool
    }{
        {"valid", User{Email: "test@example.com"}, false},
        {"invalid", User{Email: "invalid"}, 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)
            }
        })
    }
}

Testing ValidatorWithContext

func TestUserValidationWithContext(t *testing.T) {
    ctx := context.Background()
    ctx = context.WithValue(ctx, "db", mockDB)
    ctx = context.WithValue(ctx, "tenant", "test-tenant")
    
    user := User{Username: "testuser"}
    err := user.ValidateContext(ctx)
    
    if err != nil {
        t.Errorf("ValidateContext() error = %v", err)
    }
}

Next Steps

3.1.4.4 - Validation Strategies

Strategy selection and priority

Complete reference for validation strategies, automatic selection, and priority order.

Overview

The validation package supports three validation strategies that can be used individually or combined:

  1. Interface Methods - Validate() / ValidateContext()
  2. Struct Tags - validate:"..." tags
  3. JSON Schema - JSONSchemaProvider interface

Strategy Types

StrategyAuto

const StrategyAuto Strategy = iota

Automatically selects the best strategy based on the type (default behavior).

Priority Order:

  1. Interface methods (highest priority)
  2. Struct tags
  3. JSON Schema (lowest priority)

Example:

// Uses automatic strategy selection
err := validation.Validate(ctx, &user)

StrategyTags

const StrategyTags Strategy = ...

Uses struct tag validation with go-playground/validator.

Requirements:

  • Struct type
  • Fields with validate tags

Example:

type User struct {
    Email string `validate:"required,email"`
}

err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

StrategyJSONSchema

const StrategyJSONSchema Strategy = ...

Uses JSON Schema validation (RFC-compliant).

Requirements:

  • Type implements JSONSchemaProvider interface, OR
  • Custom schema provided with WithCustomSchema

Example:

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{...}`
}

err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyJSONSchema),
)

StrategyInterface

const StrategyInterface Strategy = ...

Uses custom interface methods (Validate() or ValidateContext()).

Requirements:

  • Type implements ValidatorInterface or ValidatorWithContext

Example:

func (u *User) Validate() error {
    return customValidation(u)
}

err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyInterface),
)

Automatic Strategy Selection

Selection Process

When StrategyAuto is used (default), the validator checks strategies in priority order:

graph TD
    Start[Start Validation] --> CheckInterface{Implements<br/>ValidatorInterface or<br/>ValidatorWithContext?}
    
    CheckInterface -->|Yes| UseInterface[Use Interface Strategy]:::success
    CheckInterface -->|No| CheckTags{Has validate<br/>tags?}
    
    CheckTags -->|Yes| UseTags[Use Tags Strategy]:::warning
    CheckTags -->|No| CheckSchema{Implements<br/>JSONSchemaProvider?}
    
    CheckSchema -->|Yes| UseSchema[Use JSON Schema Strategy]:::info
    CheckSchema -->|No| DefaultTags[Default to Tags]
    
    UseInterface --> Done[Validate]
    UseTags --> Done
    UseSchema --> Done
    DefaultTags --> Done
    
    classDef default fill:#F8FAF9,stroke:#1E6F5C,color:#1F2A27
    classDef info fill:#D1ECF1,stroke:#17A2B8,color:#1F2A27
    classDef success fill:#D4EDDA,stroke:#28A745,color:#1F2A27
    classDef warning fill:#FFF3CD,stroke:#FFC107,color:#1F2A27

Applicability Checks

A strategy is considered “applicable” if:

Interface Strategy:

  • Type implements ValidatorInterface or ValidatorWithContext
  • Checks both value and pointer receivers

Tags Strategy:

  • Type is a struct
  • At least one field has a validate tag

JSON Schema Strategy:

  • Type implements JSONSchemaProvider, OR
  • Custom schema provided with WithCustomSchema

Priority Examples

// Example 1: Only interface method
type User struct {
    Email string
}

func (u *User) Validate() error {
    return validateEmail(u.Email)
}

// Uses: StrategyInterface (highest priority)
validation.Validate(ctx, &user)
// Example 2: Both interface and tags
type User struct {
    Email string `validate:"required,email"`
}

func (u *User) Validate() error {
    return customLogic(u.Email)
}

// Uses: StrategyInterface (interface has priority over tags)
validation.Validate(ctx, &user)
// Example 3: All three strategies
type User struct {
    Email string `validate:"required,email"`
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{...}`
}

func (u *User) Validate() error {
    return customLogic(u.Email)
}

// Uses: StrategyInterface (highest priority)
validation.Validate(ctx, &user)

Explicit Strategy Selection

Override automatic selection with WithStrategy:

type User struct {
    Email string `validate:"required,email"` // Has tags
}

func (u *User) Validate() error {
    return customLogic(u.Email) // Has interface method
}

// Force use of tags (skip interface method)
err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

Running Multiple Strategies

WithRunAll

Run all applicable strategies and aggregate errors:

type User struct {
    Email string `validate:"required,email"` // Tags
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{...}` // JSON Schema
}

func (u *User) Validate() error {
    return customLogic(u.Email) // Interface
}

// Run all three strategies
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

// All errors from all strategies are collected
var verr *validation.Error
if errors.As(err, &verr) {
    // verr.Fields contains errors from all strategies
}

WithRequireAny

With WithRunAll, succeed if any one strategy passes (OR logic):

// Pass if ANY strategy succeeds
err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
    validation.WithRequireAny(true),
)

Use Cases:

  • Multiple validation approaches, any one is sufficient
  • Fallback validation strategies
  • Gradual migration between strategies

Strategy Comparison

StrategyAdvantagesDisadvantagesBest For
InterfaceMost flexible, full programmatic controlMore code, not declarativeComplex business logic, database checks
TagsConcise, declarative, well-documentedLimited to supported tagsStandard validation, simple rules
JSON SchemaPortable, language-independentVerbose, learning curveShared validation with frontend

Strategy Patterns

Pattern 1: Tags for Simple, Interface for Complex

type User struct {
    Email    string `validate:"required,email"`
    Username string `validate:"required,min=3,max=20"`
    Age      int    `validate:"required,min=18"`
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Complex validation (database checks, etc.)
    db := ctx.Value("db").(*sql.DB)
    return checkUsernameUnique(ctx, db, u.Username)
}

// Tags validate format, interface validates business rules
validation.Validate(ctx, &user)

Pattern 2: Schema for API, Interface for Internal

func (u User) JSONSchema() (id, schema string) {
    // For external API documentation/validation
    return "user-v1", apiSchema
}

func (u *User) ValidateContext(ctx context.Context) error {
    // Internal business rules
    return validateInternal(ctx, u)
}

// External API: use schema
validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyJSONSchema),
)

// Internal: use interface
validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyInterface),
)

Pattern 3: Progressive Enhancement

// Start with tags
type User struct {
    Email string `validate:"required,email"`
}

// Add interface for complex validation later
func (u *User) ValidateContext(ctx context.Context) error {
    // Complex validation added over time
    return additionalValidation(ctx, u)
}

// Automatically uses interface (higher priority)
validation.Validate(ctx, &user)

Performance Considerations

Strategy Performance

Fastest to Slowest:

  1. Tags - Cached reflection, zero allocation after first use
  2. Interface - Direct method call, user code performance
  3. JSON Schema - Schema compilation (cached), RFC validation

Optimization Tips

// Fast: Use tags for simple validation
type User struct {
    Email string `validate:"required,email"`
}

// Slower: JSON Schema (first time, then cached)
func (u User) JSONSchema() (id, schema string) {
    return "user-v1", complexSchema
}

// Variable: Depends on your implementation
func (u *User) ValidateContext(ctx context.Context) error {
    // Keep this fast - runs every time
    return quickValidation(u)
}

Caching

  • Tags: Struct reflection cached per type
  • JSON Schema: Schemas cached by ID (LRU eviction)
  • Interface: No caching (direct method call)

Error Aggregation

When running multiple strategies:

err := validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

var verr *validation.Error
if errors.As(err, &verr) {
    // Errors are aggregated from all strategies
    for _, fieldErr := range verr.Fields {
        fmt.Printf("%s: %s (from %s strategy)\n",
            fieldErr.Path,
            fieldErr.Message,
            inferStrategy(fieldErr.Code), // tag.*, schema.*, etc.
        )
    }
    
    // Sort for consistent output
    verr.Sort()
}

Error codes indicate strategy:

  • tag.* - From struct tags
  • schema.* - From JSON Schema
  • Custom codes - From interface methods

Best Practices

1. Use Automatic Selection

// Good - let validator choose
validation.Validate(ctx, &user)

// Only override when necessary
validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

2. Single Strategy Per Type

// Good - clear which strategy is used
type User struct {
    Email string `validate:"required,email"`
}

// Confusing - multiple strategies compete
type User struct {
    Email string `validate:"required,email"`
}

func (u User) JSONSchema() (id, schema string) { ... }
func (u *User) Validate() error { ... }

3. Document Strategy Choice

// User validation uses struct tags for simplicity and performance.
// Email format and length are validated declaratively.
type User struct {
    Email string `validate:"required,email,max=255"`
}

4. Use WithRunAll Sparingly

// Most cases: automatic selection is sufficient
validation.Validate(ctx, &user)

// Only when you need to validate with multiple strategies
validation.Validate(ctx, &user,
    validation.WithRunAll(true),
)

Next Steps

3.1.4.5 - Troubleshooting

Common issues and solutions

Common issues, solutions, and debugging tips for the validation package.

Validation Not Running

Issue: Validation passes when it should fail

Symptom:

type User struct {
    Email string `validate:"required,email"`
}

user := User{Email: ""} // Should fail
err := validation.Validate(ctx, &user) // err is nil (unexpected)

Possible Causes:

  1. Struct tags not being checked

Check if a higher-priority strategy is being used:

func (u *User) Validate() error {
    return nil // This runs instead of tags
}

Solution: Remove interface method or use WithStrategy:

err := validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)
  1. Wrong tag name
// Wrong
Email string `validation:"required"` // Should be "validate"

// Correct
Email string `validate:"required"`
  1. Validating value instead of pointer
// May not work with pointer receivers
user := User{} // value
user.Validate() // Method might not be found

// Use pointer
user := &User{} // pointer
validation.Validate(ctx, user)

Partial Validation Issues

Issue: Required fields failing in PATCH requests

Symptom:

type UpdateUser struct {
    Email string `validate:"required,email"`
}

// PATCH with only age
err := validation.ValidatePartial(ctx, &req, presence)
// Error: email is required (but it wasn't provided)

Solution: Use omitempty instead of required for PATCH:

type UpdateUser struct {
    Email string `validate:"omitempty,email"` // Not "required"
}

Issue: Presence map not being respected

Symptom:

presence, _ := validation.ComputePresence(rawJSON)
err := validation.Validate(ctx, &req, // Missing WithPresence!
    validation.WithPartial(true),
)

Solution: Always pass presence map:

err := validation.Validate(ctx, &req,
    validation.WithPartial(true),
    validation.WithPresence(presence), // Add this
)

Custom Validation Issues

Issue: Custom tag not working

Symptom:

validator := validation.MustNew(
    validation.WithCustomTag("phone", phoneValidator),
)

type User struct {
    Phone string `validate:"phone"` // Not recognized
}

Possible Causes:

  1. Tag registered on wrong validator
// Registered on custom validator
validator := validation.MustNew(
    validation.WithCustomTag("phone", phoneValidator),
)

// But using package-level function (different validator)
validation.Validate(ctx, &user) // Doesn't have custom tag

Solution: Use the same validator:

validator.Validate(ctx, &user) // Use custom validator
  1. Tag function signature wrong
// Wrong
func phoneValidator(val string) bool { ... }

// Correct
func phoneValidator(fl validator.FieldLevel) bool { ... }

Issue: ValidateContext not being called

Symptom:

func (u *User) ValidateContext(ctx context.Context) error {
    fmt.Println("Never prints")
    return nil
}

Possible Causes:

  1. Wrong receiver type
// Method defined on value
func (u User) ValidateContext(ctx context.Context) error { ... }

// But validating pointer
user := &User{}
validation.Validate(ctx, user) // Method not found

Solution: Use pointer receiver:

func (u *User) ValidateContext(ctx context.Context) error { ... }
  1. Struct tags have priority

If auto-selection chooses tags, interface method isn’t called.

Solution: Explicitly use interface strategy:

validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyInterface),
)

Error Handling Issues

Issue: Can’t access field errors

Symptom:

err := validation.Validate(ctx, &user)
// How do I get field-level errors?

Solution: Use errors.As:

var verr *validation.Error
if errors.As(err, &verr) {
    for _, fieldErr := range verr.Fields {
        fmt.Printf("%s: %s\n", fieldErr.Path, fieldErr.Message)
    }
}

Issue: Sensitive data visible in errors

Symptom:

// Error message contains password value
email: invalid email (value: "password123")

Solution: Use redactor:

validator := validation.MustNew(
    validation.WithRedactor(func(path string) bool {
        return strings.Contains(path, "password")
    }),
)

Performance Issues

Issue: Validation is slow

Possible Causes:

  1. Creating validator on every request
// Bad - creates validator every time
func Handler(w http.ResponseWriter, r *http.Request) {
    validator := validation.MustNew(...) // Slow
    validator.Validate(ctx, &req)
}

Solution: Create once, reuse:

var validator = validation.MustNew(...)

func Handler(w http.ResponseWriter, r *http.Request) {
    validator.Validate(ctx, &req) // Fast
}
  1. JSON Schema not cached
func (u User) JSONSchema() (id, schema string) {
    return "", `{...}` // Empty ID = no caching
}

Solution: Use stable ID:

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{...}` // Cached
}
  1. Expensive ValidateContext
func (u *User) ValidateContext(ctx context.Context) error {
    // Expensive operation on every validation
    return checkWithExternalAPI(u.Email)
}

Solution: Optimize or cache:

func (u *User) ValidateContext(ctx context.Context) error {
    // Fast checks first
    if !basicValidation(u.Email) {
        return errors.New("invalid format")
    }
    
    // Expensive check last
    return checkWithExternalAPI(u.Email)
}

JSON Schema Issues

Issue: Schema validation not working

Symptom:

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{...}`
}

// But validation doesn't use schema

Possible Causes:

  1. Higher priority strategy exists
type User struct {
    Email string `validate:"email"` // Tags have higher priority
}

func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{...}` // Not used
}

Solution: Use explicit strategy:

validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyJSONSchema),
)
  1. Invalid JSON Schema
func (u User) JSONSchema() (id, schema string) {
    return "user-v1", `{ invalid json }` // Parse error
}

Solution: Validate schema syntax:

// Use online validator: https://www.jsonschemavalidator.net/

Context Issues

Issue: Context values not available

Symptom:

func (u *User) ValidateContext(ctx context.Context) error {
    db := ctx.Value("db") // db is nil
    // ...
}

Solution: Ensure values are in context:

ctx = context.WithValue(ctx, "db", db)
err := validation.Validate(ctx, &user)

Issue: Wrong context being used

Symptom:

err := validation.Validate(ctx1, &user,
    validation.WithContext(ctx2), // Overrides ctx1
)

Solution: Don’t use WithContext unless necessary:

// Just pass the right context
err := validation.Validate(correctCtx, &user)

Module and Import Issues

Issue: Cannot find module

go: finding module for package rivaas.dev/validation

Solution:

go mod tidy
go get rivaas.dev/validation

Issue: Version conflicts

require rivaas.dev/validation v1.0.0
// +incompatible

Solution: Update to compatible version:

go get rivaas.dev/validation@latest
go mod tidy

Common Error Messages

“cannot validate nil value”

Cause: Passing nil to Validate:

var user *User
validation.Validate(ctx, user) // Error: cannot validate nil value

Solution: Ensure value is not nil:

user := &User{Email: "test@example.com"}
validation.Validate(ctx, user)

“cannot validate invalid value”

Cause: Passing invalid reflect.Value:

var v interface{}
validation.Validate(ctx, v) // Error: cannot validate invalid value

Solution: Pass actual struct:

user := &User{}
validation.Validate(ctx, user)

“unknown validation strategy”

Cause: Invalid strategy value:

validation.Validate(ctx, &user,
    validation.WithStrategy(999), // Invalid
)

Solution: Use valid strategy constants:

validation.Validate(ctx, &user,
    validation.WithStrategy(validation.StrategyTags),
)

Debugging Tips

1. Check which strategy is being used

// Temporarily force each strategy to see which works
strategies := []validation.Strategy{
    validation.StrategyInterface,
    validation.StrategyTags,
    validation.StrategyJSONSchema,
}

for _, strategy := range strategies {
    err := validation.Validate(ctx, &user,
        validation.WithStrategy(strategy),
    )
    fmt.Printf("%v: %v\n", strategy, err)
}

2. Enable all error reporting

err := validation.Validate(ctx, &user,
    validation.WithMaxErrors(0), // Unlimited
)

3. Check struct tags

import "reflect"

t := reflect.TypeOf(User{})
for i := 0; i < t.NumField(); i++ {
    field := t.Field(i)
    fmt.Printf("%s: %s\n", field.Name, field.Tag.Get("validate"))
}

4. Test interface implementation

var _ validation.ValidatorInterface = (*User)(nil) // Compile-time check
var _ validation.ValidatorWithContext = (*User)(nil)
var _ validation.JSONSchemaProvider = (*User)(nil)

Getting Help

If you’re still stuck:

  1. Check documentation: User Guide
  2. Review examples: Examples
  3. Check pkg.go.dev: API Documentation
  4. GitHub Issues: Report a bug
  5. Discussions: Ask a question

Next Steps

3.1.5 - Config Package

API reference for rivaas.dev/config - Configuration management for Go applications

This is the API reference for the rivaas.dev/config package. For learning-focused documentation, see the Config Guide.

Package Information

Package Overview

The config package provides powerful configuration management for Go applications with support for multiple sources, formats, and validation strategies.

Core Features

  • Multiple configuration sources (files, environment variables, remote sources)
  • Format-agnostic with built-in JSON, YAML, and TOML support
  • Hierarchical configuration merging
  • Automatic struct binding with type safety
  • Multiple validation strategies
  • Thread-safe operations
  • Nil-safe getter methods

Architecture

The package is organized into several key components:

Main Package (rivaas.dev/config)

Core configuration management including:

  • Config struct - Main configuration container
  • New() / MustNew() - Configuration initialization
  • Getter methods - Type-safe value retrieval
  • Load() / Dump() - Loading and saving configuration

Sub-packages

  • codec - Format encoding/decoding (JSON, YAML, TOML, etc.)
  • source - Configuration sources (file, environment, Consul, etc.)
  • dumper - Configuration output destinations

Quick API Index

Configuration Creation

cfg, err := config.New(options...)     // With error handling
cfg := config.MustNew(options...)      // Panics on error

Loading Configuration

err := cfg.Load(ctx context.Context)

Accessing Values

// Direct access (returns zero values for missing keys)
value := cfg.String("key")
value := cfg.Int("key")
value := cfg.Bool("key")

// With defaults
value := cfg.StringOr("key", "default")
value := cfg.IntOr("key", 8080)

// With error handling
value, err := config.GetE[Type](cfg, "key")

Dumping Configuration

err := cfg.Dump(ctx context.Context)

Reference Pages

API Reference

Complete documentation of the Config struct and all methods including:

  • Configuration lifecycle methods
  • All getter method signatures
  • Error types and handling
  • Nil-safety guarantees

Options

Comprehensive list of all configuration options:

  • Source options (WithFile, WithEnv, WithConsul, etc.)
  • Validation options (WithBinding, WithValidator, WithJSONSchema)
  • Dumper options (WithFileDumper, WithDumper)

Codecs

Built-in and custom codec documentation:

  • Format codecs (JSON, YAML, TOML, EnvVar)
  • Caster codecs (Int, Bool, Duration, Time, etc.)
  • Creating custom codecs
  • File extension auto-detection

Troubleshooting

Common issues and solutions:

  • Configuration not loading
  • Struct not populating
  • Environment variable mapping
  • Performance considerations
  • Thread-safety information

Type Reference

Config

type Config struct {
    // contains filtered or unexported fields
}

Main configuration container. Thread-safe for concurrent access.

ConfigError

type ConfigError struct {
    Source    string // Where the error occurred
    Field     string // Specific field with error
    Operation string // Operation being performed
    Err       error  // Underlying error
}

Error type for configuration operations with detailed context.

Option

type Option func(*Config) error

Configuration option function type used with New() and MustNew().

Common Patterns

Basic Usage

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
)
cfg.Load(context.Background())

port := cfg.Int("server.port")

With Struct Binding

type AppConfig struct {
    Server struct {
        Port int `config:"port"`
    } `config:"server"`
}

var appConfig AppConfig
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&appConfig),
)
cfg.Load(context.Background())

With Validation

func (c *AppConfig) Validate() error {
    if c.Server.Port <= 0 {
        return errors.New("port must be positive")
    }
    return nil
}

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&appConfig),  // Validation runs after binding
)

Thread Safety

The Config type is thread-safe for:

  • Concurrent Load() operations
  • Concurrent getter operations
  • Mixed Load() and getter operations

Not thread-safe for:

  • Concurrent modification of the same configuration instance during initialization

Performance Notes

  • Getter methods are O(1) for simple keys, O(n) for nested dot notation paths
  • Load performance depends on source count and data size
  • Struct binding uses reflection, minimal overhead for most applications
  • Validation overhead depends on validation complexity

Version Compatibility

The config package follows semantic versioning. The API is stable for the v1 series.

Minimum Go version: 1.25

Next Steps

For learning-focused guides, see the Configuration Guide.

3.1.5.1 - API Reference

Complete API documentation for the Config type and methods

Complete API reference for the Config struct and all its methods.

Types

Config

type Config struct {
    // contains filtered or unexported fields
}

Main configuration container. Thread-safe for concurrent read operations and loading.

Key properties:

  • Thread-safe for concurrent Load() and getter operations.
  • Nil-safe. All getter methods handle nil instances gracefully.
  • Hierarchical data storage with dot notation support.

ConfigError

type ConfigError struct {
    Source    string // Where the error occurred (e.g., "source[0]", "json-schema")
    Field     string // Specific field with the error (optional)
    Operation string // Operation being performed (e.g., "load", "validate")
    Err       error  // Underlying error
}

Error type providing detailed context about configuration errors.

Example error messages:

config error in source[0] during load: file not found: config.yaml
config error in json-schema during validate: server.port: must be >= 1
config error in binding during bind: failed to decode configuration

Initialization Functions

New

func New(options ...Option) (*Config, error)

Creates a new Config instance with the given options. Returns an error if any option fails.

Parameters:

  • options - Variable number of Option functions.

Returns:

  • *Config - Initialized configuration instance.
  • error - Error if initialization fails.

Example:

cfg, err := config.New(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
)
if err != nil {
    log.Fatalf("failed to create config: %v", err)
}

Use when: You need explicit error handling. Recommended for libraries.

MustNew

func MustNew(options ...Option) *Config

Creates a new Config instance with the given options. Panics if any option fails.

Parameters:

  • options - Variable number of Option functions

Returns:

  • *Config - Initialized configuration instance

Panics: If any option returns an error

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
)

Use when: In main() or initialization code where panic is acceptable.

Lifecycle Methods

Load

func (c *Config) Load(ctx context.Context) error

Loads configuration from all configured sources, merges them, and runs validation.

Parameters:

  • ctx - Context for cancellation and deadlines (must not be nil)

Returns:

  • error - ConfigError if loading, merging, or validation fails

Behavior:

  1. Loads data from all sources sequentially
  2. Merges data hierarchically (later sources override earlier ones)
  3. Runs JSON Schema validation (if configured)
  4. Runs custom validation functions (if configured)
  5. Binds to struct (if configured)
  6. Runs struct Validate() method (if implemented)

Example:

if err := cfg.Load(context.Background()); err != nil {
    log.Fatalf("failed to load config: %v", err)
}

Thread-safety: Safe for concurrent calls (uses internal locking).

Dump

func (c *Config) Dump(ctx context.Context) error

Writes the current configuration state to all configured dumpers.

Parameters:

  • ctx - Context for cancellation and deadlines (must not be nil)

Returns:

  • error - Error if any dumper fails

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFileDumper("output.yaml"),
)
cfg.Load(context.Background())
cfg.Dump(context.Background())  // Writes to output.yaml

Use cases: Debugging, configuration snapshots, generating configuration files.

Getter Methods

Get

func (c *Config) Get(key string) any

Retrieves the value at the given key path. Returns nil for missing keys.

Parameters:

  • key - Dot-separated path (e.g., “server.port”)

Returns:

  • any - Value at the key, or nil if not found

Nil-safe: Returns nil if Config instance is nil.

Example:

value := cfg.Get("server.port")
if port, ok := value.(int); ok {
    fmt.Printf("Port: %d\n", port)
}

String

func (c *Config) String(key string) string

Retrieves a string value at the given key.

Returns: Empty string "" if key not found or on nil instance.

Example:

host := cfg.String("server.host")  // "" if missing

Int

func (c *Config) Int(key string) int

Retrieves an integer value at the given key.

Returns: 0 if key not found or on nil instance.

Int64

func (c *Config) Int64(key string) int64

Retrieves an int64 value at the given key.

Returns: 0 if key not found or on nil instance.

Float64

func (c *Config) Float64(key string) float64

Retrieves a float64 value at the given key.

Returns: 0.0 if key not found or on nil instance.

Bool

func (c *Config) Bool(key string) bool

Retrieves a boolean value at the given key.

Returns: false if key not found or on nil instance.

Duration

func (c *Config) Duration(key string) time.Duration

Retrieves a time.Duration value at the given key. Supports duration strings like “30s”, “5m”, “1h”.

Returns: 0 if key not found or on nil instance.

Example:

timeout := cfg.Duration("server.timeout")  // Parses "30s" to 30 * time.Second

Time

func (c *Config) Time(key string) time.Time

Retrieves a time.Time value at the given key.

Returns: Zero time (time.Time{}) if key not found or on nil instance.

StringSlice

func (c *Config) StringSlice(key string) []string

Retrieves a string slice at the given key.

Returns: Empty slice []string{} (not nil) if key not found or on nil instance.

Example:

hosts := cfg.StringSlice("servers")  // []string{} if missing

IntSlice

func (c *Config) IntSlice(key string) []int

Retrieves an integer slice at the given key.

Returns: Empty slice []int{} (not nil) if key not found or on nil instance.

StringMap

func (c *Config) StringMap(key string) map[string]any

Retrieves a map at the given key.

Returns: Empty map map[string]any{} (not nil) if key not found or on nil instance.

Example:

metadata := cfg.StringMap("metadata")  // map[string]any{} if missing

Getter Methods with Defaults

StringOr

func (c *Config) StringOr(key, defaultVal string) string

Retrieves a string value or returns the default if not found.

Example:

host := cfg.StringOr("server.host", "localhost")

IntOr

func (c *Config) IntOr(key string, defaultVal int) int

Retrieves an integer value or returns the default if not found.

Example:

port := cfg.IntOr("server.port", 8080)

Int64Or

func (c *Config) Int64Or(key string, defaultVal int64) int64

Retrieves an int64 value or returns the default if not found.

Float64Or

func (c *Config) Float64Or(key string, defaultVal float64) float64

Retrieves a float64 value or returns the default if not found.

BoolOr

func (c *Config) BoolOr(key string, defaultVal bool) bool

Retrieves a boolean value or returns the default if not found.

Example:

debug := cfg.BoolOr("debug", false)

DurationOr

func (c *Config) DurationOr(key string, defaultVal time.Duration) time.Duration

Retrieves a duration value or returns the default if not found.

Example:

timeout := cfg.DurationOr("timeout", 30*time.Second)

TimeOr

func (c *Config) TimeOr(key string, defaultVal time.Time) time.Time

Retrieves a time.Time value or returns the default if not found.

StringSliceOr

func (c *Config) StringSliceOr(key string, defaultVal []string) []string

Retrieves a string slice or returns the default if not found.

IntSliceOr

func (c *Config) IntSliceOr(key string, defaultVal []int) []int

Retrieves an integer slice or returns the default if not found.

StringMapOr

func (c *Config) StringMapOr(key string, defaultVal map[string]any) map[string]any

Retrieves a map or returns the default if not found.

Generic Getter Functions

GetE

func GetE[T any](c *Config, key string) (T, error)

Generic getter that returns the value and an error. Useful for custom types and explicit error handling.

Type parameters:

  • T - Target type

Parameters:

  • c - Config instance
  • key - Dot-separated path

Returns:

  • T - Value at the key (zero value if error)
  • error - Error if key not found, type mismatch, or nil instance

Example:

port, err := config.GetE[int](cfg, "server.port")
if err != nil {
    log.Printf("invalid port: %v", err)
    port = 8080
}

// Custom type
type DatabaseConfig struct {
    Host string
    Port int
}

dbConfig, err := config.GetE[DatabaseConfig](cfg, "database")

GetOr

func GetOr[T any](c *Config, key string, defaultVal T) T

Generic getter that returns the value or a default if not found.

Example:

port := config.GetOr(cfg, "server.port", 8080)

Get

func Get[T any](c *Config, key string) T

Generic getter that returns the value or zero value if not found.

Example:

port := config.Get[int](cfg, "server.port")  // 0 if missing

Data Access Methods

Values

func (c *Config) Values() *map[string]any

Returns a pointer to the internal configuration map.

Returns: nil if Config instance is nil

Warning: Direct modification of the returned map is not recommended. Use for read-only operations.

Example:

values := cfg.Values()
if values != nil {
    fmt.Printf("Config data: %+v\n", *values)
}

Nil-Safety Guarantees

All getter methods handle nil Config instances gracefully:

var cfg *config.Config  // nil

// Short methods return zero values
cfg.String("key")       // Returns ""
cfg.Int("key")          // Returns 0
cfg.Bool("key")         // Returns false
cfg.StringSlice("key")  // Returns []string{}
cfg.StringMap("key")    // Returns map[string]any{}

// Error methods return errors
port, err := config.GetE[int](cfg, "key")
// err: "config instance is nil"

Thread Safety

Thread-safe operations:

  • Load() - Uses internal locking
  • All getter methods - Read-only operations are safe
  • Multiple goroutines can call Load() and getters concurrently

Not thread-safe:

  • Concurrent modification during initialization
  • Direct modification of values returned by Values()

Error Handling Patterns

Pattern 1: Simple Access

port := cfg.Int("server.port")  // Use zero value as implicit default

Pattern 2: Explicit Defaults

port := cfg.IntOr("server.port", 8080)  // Explicit default

Pattern 3: Error Handling

port, err := config.GetE[int](cfg, "server.port")
if err != nil {
    return fmt.Errorf("invalid port: %w", err)
}

Pattern 4: Load Errors

if err := cfg.Load(context.Background()); err != nil {
    var configErr *config.ConfigError
    if errors.As(err, &configErr) {
        log.Printf("Config error in %s during %s: %v",
            configErr.Source, configErr.Operation, configErr.Err)
    }
    return err
}

Performance Characteristics

OperationComplexityNotes
Get(key)O(n)n = depth of dot notation path
String(key), Int(key), etc.O(n)Uses Get() internally
Load()O(s × m)s = number of sources, m = data size
Dump()O(d × m)d = number of dumpers, m = data size

Next Steps

3.1.5.2 - Options Reference

Complete reference for all configuration option functions

Comprehensive documentation of all option functions used to configure Config instances.

Option Type

type Option func(*Config) error

Options are functions that configure a Config instance during initialization. They are passed to New() or MustNew().

Environment Variable Expansion

All path-based options (WithFile, WithFileAs, WithConsul, WithConsulAs, WithFileDumper, WithFileDumperAs) support environment variable expansion in paths. This makes it easy to use different paths based on your environment.

Supported syntax:

  • ${VAR} - Braced variable name
  • $VAR - Simple variable name

Note: Shell-style defaults like ${VAR:-default} are NOT supported. Set defaults in your code before calling the option.

Examples:

// Environment-based Consul path
config.WithConsul("${APP_ENV}/service.yaml")
// When APP_ENV=production, expands to: "production/service.yaml"

// Config directory from environment
config.WithFile("${CONFIG_DIR}/app.yaml")
// When CONFIG_DIR=/etc/myapp, expands to: "/etc/myapp/app.yaml"

// Multiple variables
config.WithFile("${REGION}/${ENV}/settings.yaml")
// When REGION=us-west and ENV=staging, expands to: "us-west/staging/settings.yaml"

// Output directory
config.WithFileDumper("${LOG_DIR}/effective-config.yaml")
// When LOG_DIR=/var/log, expands to: "/var/log/effective-config.yaml"

Handling unset variables:

If an environment variable is not set, it expands to an empty string:

// If APP_ENV is not set:
config.WithConsul("${APP_ENV}/service.yaml")  // Expands to: "/service.yaml"

To provide defaults, set them in your code:

if os.Getenv("APP_ENV") == "" {
    os.Setenv("APP_ENV", "development")
}
config.WithConsul("${APP_ENV}/service.yaml")  // Uses "development" if not set

Source Options

Source options specify where configuration data comes from.

WithFile

func WithFile(path string) Option

Loads configuration from a file with automatic format detection based on extension.

Parameters:

  • path - Path to configuration file.

Supported extensions:

  • .json - JSON format.
  • .yaml, .yml - YAML format.
  • .toml - TOML format.

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFile("config.json"),
)

Error conditions:

  • File does not exist (error occurs during Load(), not initialization)
  • Extension not recognized

WithFileAs

func WithFileAs(path string, codecType codec.Type) Option

Loads configuration from a file with explicit format specification.

Parameters:

  • path - Path to configuration file.
  • codecType - Codec type like codec.TypeYAML or codec.TypeJSON.

Example:

cfg := config.MustNew(
    config.WithFileAs("config.txt", codec.TypeYAML),
    config.WithFileAs("settings.conf", codec.TypeJSON),
)

Use when: File extension doesn’t match its format.

WithEnv

func WithEnv(prefix string) Option

Loads configuration from environment variables with the given prefix.

Parameters:

  • prefix - Prefix to filter environment variables (e.g., “APP_”, “MYAPP_”)

Naming convention:

  • PREFIX_KEYkey
  • PREFIX_SECTION_KEYsection.key
  • PREFIX_A_B_Ca.b.c

Example:

cfg := config.MustNew(
    config.WithEnv("MYAPP_"),
)

// Environment: MYAPP_SERVER_PORT=8080
// Maps to: server.port = 8080

See also: Environment Variables Guide

WithConsul

func WithConsul(path string) Option

Loads configuration from HashiCorp Consul. The format is detected from the file extension.

Works without Consul: If CONSUL_HTTP_ADDR isn’t set, this option does nothing. This means you can run your app locally without Consul. When you deploy to production, just set the environment variable and Consul will be used.

Parameters:

  • path - Consul key path (format detected from extension)

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithConsul("production/service.json"),  // Skipped in dev, used in prod
)

Environment variables:

  • CONSUL_HTTP_ADDR - Consul server address (required for Consul to work)
  • CONSUL_HTTP_TOKEN - Access token for authentication (optional)

WithConsulAs

func WithConsulAs(path string, codecType codec.Type) Option

Loads configuration from Consul with explicit format. Use this when the key path doesn’t have an extension.

Works without Consul: Like WithConsul, this option does nothing if CONSUL_HTTP_ADDR isn’t set. Your code works the same in dev and prod.

Parameters:

  • path - Consul key path
  • codecType - Codec type (like codec.TypeYAML or codec.TypeJSON)

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithConsulAs("config/app", codec.TypeYAML),  // No extension in key
)

Environment variables:

  • CONSUL_HTTP_ADDR - Consul server address (required for Consul to work)
  • CONSUL_HTTP_TOKEN - Access token for authentication (optional)

WithContent

func WithContent(data []byte, codecType codec.Type) Option

Loads configuration from a byte slice.

Parameters:

  • data - Configuration data as bytes
  • codecType - Codec type for decoding

Example:

configData := []byte(`{"server": {"port": 8080}}`)
cfg := config.MustNew(
    config.WithContent(configData, codec.TypeJSON),
)

Use cases:

  • Testing
  • Dynamic configuration
  • Embedded configuration

WithSource

func WithSource(loader Source) Option

Adds a custom configuration source.

Parameters:

  • loader - Custom source implementing the Source interface

Source interface:

type Source interface {
    Load(ctx context.Context) (map[string]any, error)
}

Example:

type CustomSource struct{}

func (s *CustomSource) Load(ctx context.Context) (map[string]any, error) {
    return map[string]any{"key": "value"}, nil
}

cfg := config.MustNew(
    config.WithSource(&CustomSource{}),
)

Validation Options

Validation options enable configuration validation.

WithBinding

func WithBinding(v any) Option

Binds configuration to a Go struct and optionally validates it.

Parameters:

  • v - Pointer to struct to bind configuration to

Example:

type Config struct {
    Port int `config:"port"`
}

var cfg Config
config := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithBinding(&cfg),
)

Validation: If the struct implements Validate() error, it will be called after binding.

Requirements:

  • Must pass a pointer to the struct
  • Struct fields must have config:"name" tags

See also: Struct Binding Guide

WithTag

func WithTag(tagName string) Option

Changes the struct tag name used for binding (default: “config”).

Parameters:

  • tagName - Tag name to use instead of “config”

Example:

type Config struct {
    Port int `yaml:"port"`
}

var cfg Config
config := config.MustNew(
    config.WithTag("yaml"),
    config.WithBinding(&cfg),
)

Use when: You want to reuse existing struct tags (e.g., json, yaml).

WithValidator

func WithValidator(fn func(map[string]any) error) Option

Registers a custom validation function for the configuration map.

Parameters:

  • fn - Validation function that receives the merged configuration

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithValidator(func(data map[string]any) error {
        port, ok := data["port"].(int)
        if !ok || port <= 0 {
            return errors.New("port must be a positive integer")
        }
        return nil
    }),
)

Timing: Validation runs after sources are merged, before struct binding.

Multiple validators: You can register multiple validators; all will be executed.

WithJSONSchema

func WithJSONSchema(schema []byte) Option

Validates configuration against a JSON Schema.

Parameters:

  • schema - JSON Schema as bytes

Example:

schemaBytes, _ := os.ReadFile("schema.json")
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithJSONSchema(schemaBytes),
)

Schema validation:

See also: Validation Guide

Dumper Options

Dumper options specify where to write configuration.

WithFileDumper

func WithFileDumper(path string) Option

Writes configuration to a file with automatic format detection.

Parameters:

  • path - Output file path (format detected from extension)

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
    config.WithFileDumper("effective-config.yaml"),
)

cfg.Load(context.Background())
cfg.Dump(context.Background())  // Writes to effective-config.yaml

Default permissions: 0644 (owner read/write, group/others read)

WithFileDumperAs

func WithFileDumperAs(path string, codecType codec.Type) Option

Writes configuration to a file with explicit format specification.

Parameters:

  • path - Output file path
  • codecType - Codec type for encoding

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFileDumperAs("output.json", codec.TypeJSON),
)

WithDumper

func WithDumper(dumper Dumper) Option

Adds a custom configuration dumper.

Parameters:

  • dumper - Custom dumper implementing the Dumper interface

Dumper interface:

type Dumper interface {
    Dump(ctx context.Context, data map[string]any) error
}

Example:

type CustomDumper struct{}

func (d *CustomDumper) Dump(ctx context.Context, data map[string]any) error {
    // Write data somewhere
    return nil
}

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithDumper(&CustomDumper{}),
)

Option Composition

Options are applied in the order they are passed to New() or MustNew():

cfg := config.MustNew(
    // 1. Load base config
    config.WithFile("config.yaml"),
    
    // 2. Load environment-specific config
    config.WithFile("config.prod.yaml"),
    
    // 3. Override with environment variables (highest priority)
    config.WithEnv("APP_"),
    
    // 4. Set up validation
    config.WithJSONSchema(schemaBytes),
    config.WithValidator(customValidation),
    
    // 5. Bind to struct
    config.WithBinding(&appConfig),
    
    // 6. Set up dumper
    config.WithFileDumper("effective-config.yaml"),
)

Source Precedence

When multiple sources are configured, later sources override earlier ones:

cfg := config.MustNew(
    config.WithFile("config.yaml"),      // Priority 1 (lowest)
    config.WithFile("config.prod.yaml"), // Priority 2
    config.WithEnv("APP_"),              // Priority 3 (highest)
)

Validation Order

Validation happens in this sequence during Load():

  1. Load and merge all sources
  2. JSON Schema validation (if configured)
  3. Custom validation functions (if configured)
  4. Struct binding (if configured)
  5. Struct Validate() method (if implemented)

Common Patterns

Pattern 1: Basic Configuration

cfg := config.MustNew(
    config.WithFile("config.yaml"),
)

Pattern 2: Environment Override

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
)

Pattern 3: Multi-Environment

env := os.Getenv("APP_ENV")
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithFile("config."+env+".yaml"),
    config.WithEnv("APP_"),
)

Pattern 4: With Validation

var appConfig AppConfig
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
    config.WithBinding(&appConfig),
)

Pattern 5: Production Setup

var appConfig AppConfig
schemaBytes, _ := os.ReadFile("schema.json")

cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
    config.WithJSONSchema(schemaBytes),
    config.WithBinding(&appConfig),
    config.WithFileDumper("effective-config.yaml"),
)

Next Steps

3.1.5.3 - Codecs Reference

Built-in codecs for configuration format support and type conversion

Complete reference for built-in codecs and guidance on creating custom codecs.

Codec Interface

type Codec interface {
    Encode(v any) ([]byte, error)
    Decode(data []byte, v any) error
}

Codecs handle encoding and decoding of configuration data between different formats.

Built-in Format Codecs

JSON Codec

Type: codec.TypeJSON
Import: rivaas.dev/config/codec

Handles JSON format encoding and decoding.

Capabilities:

  • ✅ Encode
  • ✅ Decode

File extensions:

  • .json

Example:

import "rivaas.dev/config/codec"

cfg := config.MustNew(
    config.WithFileAs("config.txt", codec.TypeJSON),
)

Features:

  • Standard Go encoding/json implementation
  • Preserves JSON types (numbers, strings, booleans, arrays, objects)
  • Pretty-printed output when encoding

YAML Codec

Type: codec.TypeYAML
Import: rivaas.dev/config/codec

Handles YAML format encoding and decoding.

Capabilities:

  • ✅ Encode
  • ✅ Decode

File extensions:

  • .yaml
  • .yml

Example:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
)

Features:

  • Uses gopkg.in/yaml.v3
  • Supports YAML 1.2 features
  • Handles anchors and aliases
  • Preserves indentation on encoding

Common YAML types:

string_value: "hello"
number_value: 42
boolean_value: true
duration_value: 30s
list_value:
  - item1
  - item2
map_value:
  key1: value1
  key2: value2

TOML Codec

Type: codec.TypeTOML
Import: rivaas.dev/config/codec

Handles TOML format encoding and decoding.

Capabilities:

  • ✅ Encode
  • ✅ Decode

File extensions:

  • .toml

Example:

cfg := config.MustNew(
    config.WithFile("config.toml"),
)

Features:

Sample TOML:

[server]
host = "localhost"
port = 8080

[database]
host = "db.example.com"
port = 5432

Environment Variable Codec

Type: codec.TypeEnvVar
Import: rivaas.dev/config/codec

Handles environment variable format.

Capabilities:

  • ❌ Encode (returns error)
  • ✅ Decode

Example:

cfg := config.MustNew(
    config.WithEnv("APP_"),
)

Format:

PREFIX_SECTION_KEY=value
PREFIX_A_B_C=nested

Transformation:

  • Strips prefix
  • Converts to lowercase
  • Splits by underscores
  • Creates nested structure

See: Environment Variables Guide

Built-in Caster Codecs

Caster codecs provide automatic type conversion for getter methods.

Boolean Caster

Type: codec.TypeCasterBool

Converts values to bool.

Supported inputs:

  • true, "true", "True", "TRUE", 1, "1"true
  • false, "false", "False", "FALSE", 0, "0"false

Example:

debug := cfg.Bool("debug")  // Uses BoolCaster internally

Integer Casters

Convert values to integer types.

TypeCodecTarget Type
codec.TypeCasterIntIntint
codec.TypeCasterInt8Int8int8
codec.TypeCasterInt16Int16int16
codec.TypeCasterInt32Int32int32
codec.TypeCasterInt64Int64int64

Supported inputs:

  • Integer values: 42, 100
  • String integers: "42", "100"
  • Float values: 42.042
  • String floats: "42.0"42

Example:

port := cfg.Int("server.port")      // Uses IntCaster
timeout := cfg.Int64("timeout_ms")  // Uses Int64Caster

Unsigned Integer Casters

Convert values to unsigned integer types.

TypeCodecTarget Type
codec.TypeCasterUintUintuint
codec.TypeCasterUint8Uint8uint8
codec.TypeCasterUint16Uint16uint16
codec.TypeCasterUint32Uint32uint32
codec.TypeCasterUint64Uint64uint64

Supported inputs:

  • Positive integers: 42, 100
  • String integers: "42", "100"

Float Casters

Convert values to floating-point types.

TypeCodecTarget Type
codec.TypeCasterFloat32Float32float32
codec.TypeCasterFloat64Float64float64

Supported inputs:

  • Float values: 3.14, 2.5
  • String floats: "3.14", "2.5"
  • Integer values: 4242.0
  • String integers: "42"42.0

Example:

ratio := cfg.Float64("ratio")

String Caster

Type: codec.TypeCasterString

Converts any value to string.

Supported inputs:

  • String values: "hello""hello"
  • Numbers: 42"42"
  • Booleans: true"true"
  • Any value with String() method

Example:

value := cfg.String("key")  // Uses StringCaster internally

Time Caster

Type: codec.TypeCasterTime

Converts values to time.Time.

Supported inputs:

  • RFC3339 strings: "2025-01-01T00:00:00Z"
  • ISO8601 strings: "2025-01-01T00:00:00+00:00"
  • Unix timestamps: 1672531200

Example:

createdAt := cfg.Time("created_at")

Formats tried (in order):

  1. time.RFC3339 - "2006-01-02T15:04:05Z07:00"
  2. time.RFC3339Nano - "2006-01-02T15:04:05.999999999Z07:00"
  3. "2006-01-02" - Date only
  4. Unix timestamp (integer)

Duration Caster

Type: codec.TypeCasterDuration

Converts values to time.Duration.

Supported inputs:

  • Duration strings: "30s", "5m", "1h", "2h30m"
  • Integer nanoseconds: 3000000000030s
  • Float seconds: 2.52.5s

Example:

timeout := cfg.Duration("timeout")  // "30s" → 30 * time.Second

Duration units:

  • ns - nanoseconds
  • us or µs - microseconds
  • ms - milliseconds
  • s - seconds
  • m - minutes
  • h - hours

Codec Capabilities Table

CodecEncodeDecodeAuto-DetectExtensions
JSON.json
YAML.yaml, .yml
TOML.toml
EnvVar-
Bool-
Int*-
Uint*-
Float*-
String-
Time-
Duration-

Format Auto-Detection

The config package automatically detects formats based on file extensions:

cfg := config.MustNew(
    config.WithFile("config.json"),  // Auto-detects JSON
    config.WithFile("config.yaml"),  // Auto-detects YAML
    config.WithFile("config.toml"),  // Auto-detects TOML
)

Detection rules:

  1. Check file extension
  2. Look up registered decoder for that extension
  3. Use codec if found, error if not

Override auto-detection:

cfg := config.MustNew(
    config.WithFileAs("settings.txt", codec.TypeYAML),
)

Custom Codecs

Registering Custom Codecs

import "rivaas.dev/config/codec"

func init() {
    codec.RegisterEncoder("myformat", MyCodec{})
    codec.RegisterDecoder("myformat", MyCodec{})
}

Registration functions:

func RegisterEncoder(name string, encoder Codec)
func RegisterDecoder(name string, decoder Codec)

Custom Codec Example

type MyCodec struct{}

func (c MyCodec) Encode(v any) ([]byte, error) {
    data, ok := v.(map[string]any)
    if !ok {
        return nil, fmt.Errorf("expected map[string]any, got %T", v)
    }
    
    // Your encoding logic
    var buf bytes.Buffer
    // ... write to buf ...
    
    return buf.Bytes(), nil
}

func (c MyCodec) Decode(data []byte, v any) error {
    target, ok := v.(*map[string]any)
    if !ok {
        return fmt.Errorf("expected *map[string]any, got %T", v)
    }
    
    // Your decoding logic
    result := make(map[string]any)
    // ... parse data into result ...
    
    *target = result
    return nil
}

func init() {
    codec.RegisterEncoder("myformat", MyCodec{})
    codec.RegisterDecoder("myformat", MyCodec{})
}

See: Custom Codecs Guide

Common Patterns

Pattern 1: Mixed Formats

cfg := config.MustNew(
    config.WithFile("config.yaml"),  // YAML
    config.WithFile("secrets.json"), // JSON
    config.WithFile("extra.toml"),   // TOML
)

Pattern 2: Explicit Format

cfg := config.MustNew(
    config.WithFileAs("config.txt", codec.TypeYAML),
)

Pattern 3: Content Source

yamlData := []byte(`server: {port: 8080}`)
cfg := config.MustNew(
    config.WithContent(yamlData, codec.TypeYAML),
)

Pattern 4: Custom Codec

import _ "yourmodule/xmlcodec"  // Registers custom codec

cfg := config.MustNew(
    config.WithFileAs("config.xml", "xml"),
)

Type Conversion Examples

String to Duration

timeout: "30s"
timeout := cfg.Duration("timeout")  // 30 * time.Second

String to Int

port: "8080"
port := cfg.Int("port")  // 8080

String to Bool

debug: "true"
debug := cfg.Bool("debug")  // true

String to Time

created: "2025-01-01T00:00:00Z"
created := cfg.Time("created")  // time.Time

Error Handling

Decode Errors

if err := cfg.Load(context.Background()); err != nil {
    // Error format:
    // "config error in source[0] during load: yaml: unmarshal error"
    log.Printf("Failed to decode: %v", err)
}

Encode Errors

if err := cfg.Dump(context.Background()); err != nil {
    // Error format:
    // "config error in dumper[0] during dump: json: unsupported type"
    log.Printf("Failed to encode: %v", err)
}

Type Conversion Errors

// For error-returning methods
port, err := config.GetE[int](cfg, "server.port")
if err != nil {
    log.Printf("Invalid port: %v", err)
}

Performance Notes

  • JSON: Fast, minimal overhead
  • YAML: Moderate overhead (parsing complexity)
  • TOML: Fast, strict typing
  • Casters: Minimal overhead, optimized for common cases

Next Steps

3.1.5.4 - Troubleshooting

Common issues, solutions, and frequently asked questions

Solutions to common problems and frequently asked questions about the config package.

Configuration Loading Issues

File Not Found

Problem: Configuration file cannot be found.

config error in source[0] during load: open config.yaml: no such file or directory

Solutions:

  1. Check file path: Ensure the path is correct relative to where your application runs.
// Use absolute path if needed
cfg := config.MustNew(
    config.WithFile("/absolute/path/to/config.yaml"),
)
  1. Check working directory: Verify your application’s working directory.
wd, _ := os.Getwd()
fmt.Printf("Working directory: %s\n", wd)
  1. Make file optional: Handle missing files gracefully.
cfg, err := config.New(
    config.WithFile("config.yaml"),
)
if err != nil {
    log.Printf("Config file not found, using defaults: %v", err)
    // Use defaults
}

Format Not Recognized

Problem: File extension doesn’t match a known format.

config error in source[0] during load: no decoder registered for extension .conf

Solutions:

  1. Use explicit format:
cfg := config.MustNew(
    config.WithFileAs("config.conf", codec.TypeYAML),
)
  1. Register custom codec:
import _ "yourmodule/mycodec"  // Registers .conf format

Parse Errors

Problem: Configuration file has syntax errors.

config error in source[0] during load: yaml: unmarshal error

Solutions:

  1. Validate YAML/JSON syntax: Use online validators or linters
  2. Check indentation: YAML is indentation-sensitive
  3. Quote strings: Quote values with special characters
# Bad
url: http://example.com:8080

# Good
url: "http://example.com:8080"

Struct Binding Issues

Struct Not Populating

Problem: Struct fields remain at zero values after loading.

Solutions:

  1. Pass pointer to struct:
// Wrong
cfg := config.MustNew(config.WithBinding(myConfig))

// Correct
cfg := config.MustNew(config.WithBinding(&myConfig))
  1. Check struct tags:
// Config file: server.port = 8080
type Config struct {
    // Wrong - doesn't match config structure
    Port int `config:"port"`
    
    // Correct - matches nested structure
    Server struct {
        Port int `config:"port"`
    } `config:"server"`
}
  1. Verify tag names match config keys:
# config.yaml
server:
  host: localhost
  port: 8080
type Config struct {
    Server struct {
        Host string `config:"host"`  // Must match "host" in YAML
        Port int    `config:"port"`  // Must match "port" in YAML
    } `config:"server"`  // Must match "server" in YAML
}
  1. Export struct fields: Fields must be exported (start with uppercase)
// Wrong - unexported fields won't be populated
type Config struct {
    port int `config:"port"`
}

// Correct - exported field
type Config struct {
    Port int `config:"port"`
}

Type Mismatch Errors

Problem: Configuration value type doesn’t match struct field type.

Solutions:

  1. Use compatible types: Ensure types can be converted
# config.yaml
port: "8080"  # String
type Config struct {
    Port int `config:"port"`  // Will be converted from string
}
  1. Check slice vs scalar: Don’t mix slice and scalar values
# Wrong - port is an array but struct expects int
ports:
  - 8080
  - 8081
type Config struct {
    Ports []int `config:"ports"`  // Correct - expects slice
}

Validation Errors

Problem: Struct validation fails.

config error in binding during validate: port must be positive

Solutions:

  1. Check validation logic:
func (c *Config) Validate() error {
    if c.Port <= 0 {
        return fmt.Errorf("port must be positive, got %d", c.Port)
    }
    return nil
}
  1. Provide helpful error messages: Include the actual value in error

  2. Check validation order: Validation runs after binding

Environment Variable Issues

Environment Variables Not Loading

Problem: Environment variables are not being picked up.

Solutions:

  1. Check prefix: Ensure environment variables have the correct prefix
# Wrong - missing prefix
export SERVER_PORT=8080

# Correct - with MYAPP_ prefix
export MYAPP_SERVER_PORT=8080
cfg := config.MustNew(
    config.WithEnv("MYAPP_"),  // Must match prefix
)
  1. Verify environment variables are set:
env | grep MYAPP_
  1. Check variable names: Use underscores for nesting
# Maps to server.port
export MYAPP_SERVER_PORT=8080

# Maps to database.primary.host
export MYAPP_DATABASE_PRIMARY_HOST=localhost

Environment Variable Mapping Issues

Problem: Environment variables aren’t mapping to the right config keys.

Solutions:

  1. Understand naming convention:
Environment VariableConfig Path
MYAPP_SERVER_PORTserver.port
MYAPP_FOO_BAR_BAZfoo.bar.baz
MYAPP_FOO__BARfoo.bar (double underscore)
  1. Check case sensitivity: Environment variables are converted to lowercase
export MYAPP_SERVER_PORT=8080  # Becomes: server.port
  1. Test mapping:
cfg := config.MustNew(
    config.WithEnv("MYAPP_"),
)
cfg.Load(context.Background())

// Print effective configuration
values := cfg.Values()
fmt.Printf("Config: %+v\n", *values)

Type Conflicts

Problem: Environment variable creates conflict between scalar and nested.

export MYAPP_FOO=scalar
export MYAPP_FOO_BAR=nested

Solution: Nested structures take precedence. Result is foo.bar = "nested", scalar foo is overwritten.

Best practice: Don’t create such conflicts; structure your configuration hierarchically.

Validation Issues

Schema Validation Failures

Problem: JSON Schema validation fails.

config error in json-schema during validate: server.port: must be >= 1

Solutions:

  1. Check schema requirements: Ensure configuration meets schema constraints

  2. Debug with schema validator: Use online JSON Schema validators

  3. Provide all required fields:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "required": ["server", "database"]
}

Custom Validation Errors

Problem: Custom validation function fails.

Solutions:

  1. Add detailed error messages:
config.WithValidator(func(data map[string]any) error {
    port, ok := data["port"].(int)
    if !ok {
        return fmt.Errorf("port must be an integer, got %T", data["port"])
    }
    if port < 1 || port > 65535 {
        return fmt.Errorf("port must be 1-65535, got %d", port)
    }
    return nil
})
  1. Check data types: Values in map might not be expected type
// Type assertion with check
if port, ok := data["port"].(int); ok {
    // Use port
}

Performance Issues

Slow Configuration Loading

Problem: Configuration loading takes too long.

Solutions:

  1. Reduce source count: Combine configuration files when possible

  2. Avoid remote sources in hot paths: Cache remote configuration

  3. Profile loading:

start := time.Now()
err := cfg.Load(context.Background())
log.Printf("Config load time: %v", time.Since(start))
  1. Load once: Load configuration during initialization, not per-request

Memory Usage

Problem: High memory usage.

Solutions:

  1. Don’t keep multiple Config instances: Reuse single instance

  2. Clear unnecessary dumpers: Only use dumpers when needed

// Development only
if debug {
    cfg = config.MustNew(
        config.WithFile("config.yaml"),
        config.WithFileDumper("debug-config.yaml"),
    )
}

Common Misconceptions

Q: Why don’t changes to config files take effect?

A: Configuration is loaded once during Load(). It’s not automatically reloaded when files change.

Solution: Reload configuration explicitly:

// Reload configuration
if err := cfg.Load(context.Background()); err != nil {
    log.Printf("Failed to reload: %v", err)
}

Q: Why does my config work locally but not in Docker?

A: Likely a path or working directory issue.

Solutions:

  1. Use absolute paths in Docker:
cfg := config.MustNew(
    config.WithFile("/app/config/config.yaml"),
)
  1. Set working directory in Dockerfile:
WORKDIR /app
COPY config.yaml .
  1. Use environment variables for container configuration:
cfg := config.MustNew(
    config.WithFile("config.yaml"),     // Defaults
    config.WithEnv("APP_"),             // Override in container
)

Q: Can I modify configuration at runtime?

A: The Config instance is read-only after loading. You need to reload to pick up changes.

Pattern for dynamic updates:

type ConfigManager struct {
    cfg *config.Config
    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(key string) any {
    cm.mu.RLock()
    defer cm.mu.RUnlock()
    return cm.cfg.Get(key)
}

FAQ

Q: Is Config thread-safe?

A: Yes, Load() and all getter methods are thread-safe.

Q: What happens with nil Config instances?

A: Getter methods return zero values, error methods return errors. No panics.

Q: Can I load from multiple sources?

A: Yes, sources are merged with later sources overriding earlier ones.

Q: How do I handle secrets?

A:

  1. Use environment variables for secrets (not config files)
  2. Use secret management systems (Vault, AWS Secrets Manager)
  3. Never commit secrets to version control

Q: Can I use the same struct tags for JSON and config?

A: Yes, using WithTag():

type Config struct {
    Port int `json:"port"`
}

cfg := config.MustNew(
    config.WithTag("json"),
    config.WithBinding(&myConfig),
)

Q: How do I debug configuration loading?

A:

  1. Use WithFileDumper() to see merged config
  2. Print values after loading
  3. Check error messages for source context
cfg := config.MustNew(
    config.WithFile("config.yaml"),
    config.WithEnv("APP_"),
    config.WithFileDumper("debug-config.yaml"),
)

if err := cfg.Load(context.Background()); err != nil {
    log.Printf("Load error: %v", err)
}

cfg.Dump(context.Background())  // Writes to debug-config.yaml

values := cfg.Values()
fmt.Printf("Loaded config: %+v\n", *values)

Q: What’s the difference between Get, GetE, and GetOr?

A:

  • Get[T]() - Returns value or zero value (no error)
  • GetE[T]() - Returns value and error
  • GetOr[T]() - Returns value or provided default

Q: Can I use config without struct binding?

A: Yes, use getter methods directly:

cfg := config.MustNew(
    config.WithFile("config.yaml"),
)
cfg.Load(context.Background())

port := cfg.Int("server.port")
host := cfg.String("server.host")

Q: How do I validate required fields?

A: Use struct validation:

func (c *Config) Validate() error {
    if c.Database.Host == "" {
        return errors.New("database.host is required")
    }
    return nil
}

Performance Notes

Configuration access:

  • Getter methods: O(n) where n = dot notation depth
  • Direct Get(): O(n)
  • No caching of individual keys

Best practices:

  • Load configuration once at startup
  • Cache frequently accessed values in local variables
  • Use struct binding for best performance

Thread safety overhead:

  • Minimal locking overhead
  • Read operations are concurrent
  • Write operations (Load) use exclusive lock

Getting Help

If you encounter issues not covered here:

  1. Check the Configuration Guide
  2. Review API Reference
  3. Search GitHub Issues
  4. Ask in the community forums

When reporting issues, include:

  • Go version
  • Config package version
  • Minimal reproducible example
  • Error messages
  • Expected vs actual behavior

3.1.6 - OpenAPI Package

API reference for rivaas.dev/openapi - Automatic OpenAPI specification generation

This is the API reference for the rivaas.dev/openapi package. For learning-focused documentation, see the OpenAPI Guide.

Package Information

Package Overview

The openapi package provides automatic OpenAPI 3.0.4 and 3.1.2 specification generation from Go code using struct tags and reflection.

Core Features

  • Automatic OpenAPI specification generation from Go code
  • Support for OpenAPI 3.0.4 and 3.1.2 specifications
  • Type-safe version selection with V30x and V31x constants
  • Fluent HTTP method constructors (GET, POST, PUT, etc.)
  • Automatic parameter discovery from struct tags
  • Schema generation from Go types
  • Built-in validation against official meta-schemas
  • Type-safe warning diagnostics via diag package
  • Swagger UI configuration support

Architecture

The package is organized into two main components:

Main Package (rivaas.dev/openapi)

Core specification generation including:

  • API struct - Configuration container
  • New() / MustNew() - API initialization
  • HTTP method constructors - GET(), POST(), PUT(), etc.
  • Operation options - WithRequest(), WithResponse(), WithSecurity(), etc.
  • Generate() - Specification generation

Sub-package (rivaas.dev/openapi/diag)

Type-safe warning diagnostics:

  • Warning interface - Individual warning
  • Warnings type - Warning collection
  • WarningCode type - Type-safe warning codes
  • WarningCategory type - Warning categories

Validator (rivaas.dev/openapi/validate)

Standalone specification validator:

  • Validator type - Validates OpenAPI specifications
  • Validate() - Validate against specific version
  • ValidateAuto() - Auto-detect version and validate

Quick API Index

API Creation

api, err := openapi.New(options...)     // With error handling
api := openapi.MustNew(options...)      // Panics on error

Specification Generation

result, err := api.Generate(ctx context.Context, operations...)

HTTP Method Constructors

openapi.GET(path, ...opts) Operation
openapi.POST(path, ...opts) Operation
openapi.PUT(path, ...opts) Operation
openapi.PATCH(path, ...opts) Operation
openapi.DELETE(path, ...opts) Operation
openapi.HEAD(path, ...opts) Operation
openapi.OPTIONS(path, ...opts) Operation
openapi.TRACE(path, ...opts) Operation

Result Access

result.JSON      // OpenAPI spec as JSON bytes
result.YAML      // OpenAPI spec as YAML bytes
result.Warnings  // Generation warnings

Reference Pages

API Reference

Core types, HTTP method constructors, and generation API.

View →

Options

API-level configuration for info, servers, and security.

View →

Operation Options

Operation-level configuration for endpoints.

View →

Swagger UI Options

Customize the Swagger UI interface.

View →

Diagnostics

Warning system and diagnostic codes.

View →

Troubleshooting

Common issues and solutions.

View →

User Guide

Step-by-step tutorials and examples.

View →

Type Reference

API

type API struct {
    // contains filtered or unexported fields
}

Main API configuration container. Created via New() or MustNew() with functional options.

Operation

type Operation struct {
    // contains filtered or unexported fields
}

Represents an HTTP operation with method, path, and metadata. Created via HTTP method constructors.

Result

type Result struct {
    JSON     []byte    // OpenAPI spec as JSON
    YAML     []byte    // OpenAPI spec as YAML
    Warnings Warnings  // Generation warnings
}

Result of specification generation containing the spec in multiple formats and any warnings.

Version

type Version int

const (
    V30x Version = iota  // OpenAPI 3.0.x (generates 3.0.4)
    V31x                 // OpenAPI 3.1.x (generates 3.1.2)
)

Type-safe OpenAPI version selection.

Option

type Option func(*API) error

Functional option for API configuration.

OperationOption

type OperationOption func(*Operation) error

Functional option for operation configuration.

Common Patterns

Basic Generation

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
)

result, err := api.Generate(context.Background(),
    openapi.GET("/users/:id",
        openapi.WithSummary("Get user"),
        openapi.WithResponse(200, User{}),
    ),
)

With Security

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
)

result, err := api.Generate(context.Background(),
    openapi.GET("/users/:id",
        openapi.WithSecurity("bearerAuth"),
        openapi.WithResponse(200, User{}),
    ),
)

With Validation

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithValidation(true),
)

result, err := api.Generate(context.Background(), operations...)
// Fails if spec is invalid

With Diagnostics

import "rivaas.dev/openapi/diag"

result, err := api.Generate(context.Background(), operations...)
if err != nil {
    log.Fatal(err)
}

if result.Warnings.Has(diag.WarnDownlevelInfoSummary) {
    log.Warn("info.summary was dropped")
}

Thread Safety

The API type is safe for concurrent use:

  • Multiple goroutines can call Generate() simultaneously
  • Configuration is immutable after creation

Not thread-safe:

  • Modifying API configuration during initialization

Performance Notes

  • Schema generation: First use per type ~500ns (reflection), subsequent uses ~50ns (cached)
  • Validation: Adds 10-20ms on first validation (schema compilation), 1-5ms subsequent
  • Generation: Depends on operation count and complexity

Version Compatibility

The package follows semantic versioning. The API is stable for the v1 series.

Minimum Go version: 1.25

Next Steps

For learning-focused guides, see the OpenAPI Guide.

3.1.6.1 - API Reference

Complete API reference for types, functions, and methods

Complete reference for all types, functions, and methods in the openapi package.

Key Types

API

type API struct {
    // contains filtered or unexported fields
}

Main API configuration container. Holds the OpenAPI specification metadata and configuration.

Created by:

  • New(...Option) (*API, error) - With error handling.
  • MustNew(...Option) *API - Panics on error.

Methods:

  • Generate(ctx context.Context, ...Operation) (*Result, error) - Generate OpenAPI specification.
  • Version() string - Get target OpenAPI version like “3.0.4” or “3.1.2”.

Operation

type Operation struct {
    // contains filtered or unexported fields
}

Represents an HTTP operation with method, path, and configuration.

Created by HTTP method constructors:

  • GET(path string, ...OperationOption) Operation
  • POST(path string, ...OperationOption) Operation
  • PUT(path string, ...OperationOption) Operation
  • PATCH(path string, ...OperationOption) Operation
  • DELETE(path string, ...OperationOption) Operation
  • HEAD(path string, ...OperationOption) Operation
  • OPTIONS(path string, ...OperationOption) Operation
  • TRACE(path string, ...OperationOption) Operation

Result

type Result struct {
    JSON     []byte
    YAML     []byte
    Warnings Warnings
}

Result of specification generation.

Fields:

  • JSON - OpenAPI specification as JSON bytes.
  • YAML - OpenAPI specification as YAML bytes.
  • Warnings - Collection of generation warnings. Check Diagnostics for details.

Version

type Version int

const (
    V30x Version = iota  // OpenAPI 3.0.x (generates 3.0.4)
    V31x                 // OpenAPI 3.1.x (generates 3.1.2)
)

Type-safe OpenAPI version selection. Use with WithVersion() option.

Constants:

  • V30x - Target OpenAPI 3.0.x family. Generates 3.0.4 specification.
  • V31x - Target OpenAPI 3.1.x family. Generates 3.1.2 specification.

Option

type Option func(*API) error

Functional option for configuring the API. See Options for all available options.

OperationOption

type OperationOption func(*Operation) error

Functional option for configuring operations. See Operation Options for all available options.

Functions

New

func New(opts ...Option) (*API, error)

Creates a new API configuration with error handling.

Parameters:

  • opts - Variable number of Option functions

Returns:

  • *API - Configured API instance
  • error - Configuration error if any

Example:

api, err := openapi.New(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithServer("http://localhost:8080", "Development"),
)
if err != nil {
    log.Fatal(err)
}

MustNew

func MustNew(opts ...Option) *API

Creates a new API configuration. Panics if configuration fails.

Parameters:

  • opts - Variable number of Option functions

Returns:

  • *API - Configured API instance

Panics:

  • If configuration fails

Example:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithServer("http://localhost:8080", "Development"),
)

HTTP Method Constructors

GET

func GET(path string, opts ...OperationOption) Operation

Creates a GET operation.

Parameters:

  • path - URL path (use :param syntax for path parameters)
  • opts - Variable number of OperationOption functions

Returns:

  • Operation - Configured operation

Example:

openapi.GET("/users/:id",
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
)

POST

func POST(path string, opts ...OperationOption) Operation

Creates a POST operation.

Parameters:

  • path - URL path
  • opts - Variable number of OperationOption functions

Returns:

  • Operation - Configured operation

Example:

openapi.POST("/users",
    openapi.WithSummary("Create user"),
    openapi.WithRequest(CreateUserRequest{}),
    openapi.WithResponse(201, User{}),
)

PUT

func PUT(path string, opts ...OperationOption) Operation

Creates a PUT operation.

PATCH

func PATCH(path string, opts ...OperationOption) Operation

Creates a PATCH operation.

DELETE

func DELETE(path string, opts ...OperationOption) Operation

Creates a DELETE operation.

Example:

openapi.DELETE("/users/:id",
    openapi.WithSummary("Delete user"),
    openapi.WithResponse(204, nil),
)
func HEAD(path string, opts ...OperationOption) Operation

Creates a HEAD operation.

OPTIONS

func OPTIONS(path string, opts ...OperationOption) Operation

Creates an OPTIONS operation.

TRACE

func TRACE(path string, opts ...OperationOption) Operation

Creates a TRACE operation.

Methods

API.Generate

func (api *API) Generate(ctx context.Context, operations ...Operation) (*Result, error)

Generates an OpenAPI specification from the configured API and operations.

Parameters:

  • ctx - Context for cancellation
  • operations - Variable number of Operation instances

Returns:

  • *Result - Generation result with JSON, YAML, and warnings
  • error - Generation or validation error if any

Errors:

  • Returns error if context is nil
  • Returns error if generation fails
  • Returns error if validation is enabled and spec is invalid

Example:

result, err := api.Generate(context.Background(),
    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)
}

// Use result.JSON or result.YAML
fmt.Println(string(result.JSON))

API.Version

func (api *API) Version() string

Returns the target OpenAPI version as a string.

Returns:

  • string - Version string (“3.0.4” or “3.1.2”)

Example:

api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithVersion(openapi.V31x),
)

fmt.Println(api.Version()) // "3.1.2"

Type Aliases and Constants

Parameter Locations

const (
    InHeader ParameterLocation = "header"
    InQuery  ParameterLocation = "query"
    InCookie ParameterLocation = "cookie"
)

Used with WithAPIKey() to specify where the API key is located.

OAuth2 Flow Types

const (
    FlowAuthorizationCode OAuthFlowType = "authorizationCode"
    FlowImplicit          OAuthFlowType = "implicit"
    FlowPassword          OAuthFlowType = "password"
    FlowClientCredentials OAuthFlowType = "clientCredentials"
)

Used with WithOAuth2() to specify the OAuth2 flow type.

Swagger UI Constants

// Document expansion
const (
    DocExpansionList DocExpansion = "list"
    DocExpansionFull DocExpansion = "full"
    DocExpansionNone DocExpansion = "none"
)

// Model rendering
const (
    ModelRenderingExample ModelRendering = "example"
    ModelRenderingModel   ModelRendering = "model"
)

// Operations sorting
const (
    OperationsSorterAlpha  OperationsSorter = "alpha"
    OperationsSorterMethod OperationsSorter = "method"
)

// Tags sorting
const (
    TagsSorterAlpha TagsSorter = "alpha"
)

// Validators (untyped string constants)
const (
    ValidatorLocal = "local"  // Use embedded meta-schema validation
    ValidatorNone  = "none"   // Disable validation
)

// Syntax themes
const (
    SyntaxThemeAgate        SyntaxTheme = "agate"
    SyntaxThemeArta         SyntaxTheme = "arta"
    SyntaxThemeMonokai      SyntaxTheme = "monokai"
    SyntaxThemeNord         SyntaxTheme = "nord"
    SyntaxThemeObsidian     SyntaxTheme = "obsidian"
    SyntaxThemeTomorrowNight SyntaxTheme = "tomorrow-night"
    SyntaxThemeIdea         SyntaxTheme = "idea"
)

// Request snippet languages
const (
    SnippetCurlBash       RequestSnippetLanguage = "curl_bash"
    SnippetCurlPowerShell RequestSnippetLanguage = "curl_powershell"
    SnippetCurlCmd        RequestSnippetLanguage = "curl_cmd"
)

See Swagger UI Options for usage.

Next Steps

3.1.6.2 - API Options

Complete reference for API-level configuration options

Complete reference for all API-level configuration options (functions passed to New() or MustNew()).

Info Options

WithTitle

func WithTitle(title, version string) Option

Sets the API title and version. Required.

Parameters:

  • title - API title.
  • version - API version like “1.0.0”.

Example:

openapi.WithTitle("My API", "1.0.0")

WithInfoDescription

func WithInfoDescription(description string) Option

Sets the API description.

Example:

openapi.WithInfoDescription("Comprehensive API for managing users and resources")

WithInfoSummary

func WithInfoSummary(summary string) Option

Sets a short summary for the API. OpenAPI 3.1 only. Generates warning if used with 3.0 target.

Example:

openapi.WithInfoSummary("User Management API")

WithTermsOfService

func WithTermsOfService(url string) Option

Sets the terms of service URL.

Example:

openapi.WithTermsOfService("https://example.com/terms")

WithContact

func WithContact(name, url, email string) Option

Sets contact information.

Parameters:

  • name - Contact name
  • url - Contact URL
  • email - Contact email

Example:

openapi.WithContact("API Support", "https://example.com/support", "support@example.com")

WithLicense

func WithLicense(name, url string) Option

Sets license information.

Parameters:

  • name - License name
  • url - License URL

Example:

openapi.WithLicense("Apache 2.0", "https://www.apache.org/licenses/LICENSE-2.0.html")

WithLicenseIdentifier

func WithLicenseIdentifier(name, identifier string) Option

Sets license with SPDX identifier. OpenAPI 3.1 only.

Parameters:

  • name - License name
  • identifier - SPDX license identifier

Example:

openapi.WithLicenseIdentifier("Apache 2.0", "Apache-2.0")

WithInfoExtension

func WithInfoExtension(key string, value any) Option

Adds a custom extension to the info object.

Parameters:

  • key - Extension key (must start with x-)
  • value - Extension value

Example:

openapi.WithInfoExtension("x-api-id", "user-service")

Version Options

WithVersion

func WithVersion(version Version) Option

Sets the target OpenAPI version. Default is V30x.

Parameters:

  • version - Either V30x or V31x

Example:

openapi.WithVersion(openapi.V31x)

Server Options

WithServer

func WithServer(url, description string) Option

Adds a server configuration.

Parameters:

  • url - Server URL
  • description - Server description

Example:

openapi.WithServer("https://api.example.com", "Production")
openapi.WithServer("http://localhost:8080", "Development")

WithServerVariable

func WithServerVariable(name, defaultValue string, enumValues []string, description string) Option

Adds a server variable for URL templating.

Parameters:

  • name - Variable name
  • defaultValue - Default value
  • enumValues - Allowed values
  • description - Variable description

Example:

openapi.WithServer("https://{environment}.example.com", "Environment-based"),
openapi.WithServerVariable("environment", "api", 
    []string{"api", "staging", "dev"},
    "Environment to use",
)

Security Scheme Options

WithBearerAuth

func WithBearerAuth(name, description string) Option

Adds Bearer (JWT) authentication scheme.

Parameters:

  • name - Security scheme name (used in WithSecurity())
  • description - Scheme description

Example:

openapi.WithBearerAuth("bearerAuth", "JWT authentication")

WithAPIKey

func WithAPIKey(name, paramName string, location ParameterLocation, description string) Option

Adds API key authentication scheme.

Parameters:

  • name - Security scheme name
  • paramName - Parameter name (e.g., “X-API-Key”, “api_key”)
  • location - Where the key is located: InHeader, InQuery, or InCookie
  • description - Scheme description

Example:

openapi.WithAPIKey("apiKey", "X-API-Key", openapi.InHeader, "API key for authentication")

WithOAuth2

func WithOAuth2(name, description string, flows ...OAuth2Flow) Option

Adds OAuth2 authentication scheme.

Parameters:

  • name - Security scheme name
  • description - Scheme description
  • flows - OAuth2 flow configurations

Example:

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

WithOpenIDConnect

func WithOpenIDConnect(name, openIDConnectURL, description string) Option

Adds OpenID Connect authentication scheme.

Parameters:

  • name - Security scheme name
  • openIDConnectURL - OpenID Connect discovery URL
  • description - Scheme description

Example:

openapi.WithOpenIDConnect("openId", "https://example.com/.well-known/openid-configuration", "OpenID Connect")

WithDefaultSecurity

func WithDefaultSecurity(scheme string, scopes ...string) Option

Sets default security requirement at API level (applies to all operations unless overridden).

Parameters:

  • scheme - Security scheme name
  • scopes - Optional OAuth2 scopes

Example:

openapi.WithDefaultSecurity("bearerAuth")
openapi.WithDefaultSecurity("oauth2", "read", "write")

Tag Options

WithTag

func WithTag(name, description string) Option

Adds a tag for organizing operations.

Parameters:

  • name - Tag name
  • description - Tag description

Example:

openapi.WithTag("users", "User management operations")
openapi.WithTag("posts", "Post management operations")

External Documentation

WithExternalDocs

func WithExternalDocs(url, description string) Option

Links to external documentation.

Parameters:

  • url - Documentation URL
  • description - Documentation description

Example:

openapi.WithExternalDocs("https://docs.example.com", "Full API Documentation")

Validation Options

WithValidation

func WithValidation(enabled bool) Option

Enables or disables specification validation. Default is false.

Parameters:

  • enabled - Whether to validate generated specs

Example:

openapi.WithValidation(true) // Enable validation

WithStrictDownlevel

func WithStrictDownlevel(enabled bool) Option

Enables strict downlevel mode. When enabled, using 3.1 features with a 3.0 target causes errors instead of warnings. Default is false.

Parameters:

  • enabled - Whether to error on downlevel issues

Example:

openapi.WithStrictDownlevel(true) // Error on 3.1 features with 3.0 target

WithSpecPath

func WithSpecPath(path string) Option

Sets the path where the OpenAPI specification will be served.

Parameters:

  • path - URL path for the spec (e.g., “/openapi.json”)

Example:

openapi.WithSpecPath("/api/openapi.json")

Swagger UI Options

WithSwaggerUI

func WithSwaggerUI(path string, opts ...UIOption) Option

Configures Swagger UI at the specified path.

Parameters:

  • path - URL path where Swagger UI is served
  • opts - Swagger UI configuration options (see Swagger UI Options)

Example:

openapi.WithSwaggerUI("/docs",
    openapi.WithUIExpansion(openapi.DocExpansionList),
    openapi.WithUITryItOut(true),
)

WithoutSwaggerUI

func WithoutSwaggerUI() Option

Disables Swagger UI.

Example:

openapi.WithoutSwaggerUI()

Extension Options

WithExtension

func WithExtension(key string, value interface{}) Option

Adds a custom x-* extension to the root of the specification.

Parameters:

  • key - Extension key (must start with x-)
  • value - Extension value (any JSON-serializable type)

Example:

openapi.WithExtension("x-api-version", "v2")
openapi.WithExtension("x-custom-config", map[string]interface{}{
    "feature": "enabled",
    "rate-limit": 100,
})

Next Steps

3.1.6.3 - Operation Options

Complete reference for operation-level configuration options

Complete reference for all operation-level configuration options (functions passed to HTTP method constructors).

Metadata Options

WithSummary

func WithSummary(summary string) OperationOption

Sets the operation summary (short description).

Example:

openapi.WithSummary("Get user by ID")

WithDescription

func WithDescription(description string) OperationOption

Sets the operation description (detailed explanation).

Example:

openapi.WithDescription("Retrieves a user by their unique identifier from the database")

WithOperationID

func WithOperationID(operationID string) OperationOption

Sets a custom operation ID. By default, operation IDs are auto-generated from method and path.

Example:

openapi.WithOperationID("getUserById")

Request and Response Options

WithRequest

func WithRequest(requestType interface{}, examples ...interface{}) OperationOption

Sets the request body type with optional examples.

Parameters:

  • requestType - Go type to convert to schema
  • examples - Optional example instances

Example:

exampleUser := CreateUserRequest{Name: "John", Email: "john@example.com"}
openapi.WithRequest(CreateUserRequest{}, exampleUser)

WithResponse

func WithResponse(statusCode int, responseType interface{}, examples ...interface{}) OperationOption

Adds a response type for a specific status code.

Parameters:

  • statusCode - HTTP status code
  • responseType - Go type to convert to schema (use nil for no body)
  • examples - Optional example instances

Example:

openapi.WithResponse(200, User{})
openapi.WithResponse(204, nil) // No response body
openapi.WithResponse(404, ErrorResponse{})

Organization Options

WithTags

func WithTags(tags ...string) OperationOption

Adds tags to the operation for organization.

Parameters:

  • tags - Tag names

Example:

openapi.WithTags("users")
openapi.WithTags("users", "admin")

Security Options

WithSecurity

func WithSecurity(scheme string, scopes ...string) OperationOption

Adds a security requirement to the operation.

Parameters:

  • scheme - Security scheme name (defined with WithBearerAuth, WithAPIKey, etc.)
  • scopes - Optional OAuth2 scopes

Example:

openapi.WithSecurity("bearerAuth")
openapi.WithSecurity("oauth2", "read", "write")

Multiple calls create alternative security requirements (OR logic):

openapi.GET("/users/:id",
    openapi.WithSecurity("bearerAuth"),  // Can use bearer auth
    openapi.WithSecurity("apiKey"),      // OR can use API key
    openapi.WithResponse(200, User{}),
)

Content Type Options

WithConsumes

func WithConsumes(contentTypes ...string) OperationOption

Sets accepted content types for the request.

Parameters:

  • contentTypes - MIME types

Example:

openapi.WithConsumes("application/json", "application/xml")

WithProduces

func WithProduces(contentTypes ...string) OperationOption

Sets returned content types for the response.

Parameters:

  • contentTypes - MIME types

Example:

openapi.WithProduces("application/json", "application/xml")

Deprecation

WithDeprecated

func WithDeprecated() OperationOption

Marks the operation as deprecated.

Example:

openapi.GET("/users/legacy",
    openapi.WithSummary("Legacy user list"),
    openapi.WithDeprecated(),
    openapi.WithResponse(200, []User{}),
)

Extension Options

WithOperationExtension

func WithOperationExtension(key string, value interface{}) OperationOption

Adds a custom x-* extension to the operation.

Parameters:

  • key - Extension key (must start with x-)
  • value - Extension value (any JSON-serializable type)

Example:

openapi.WithOperationExtension("x-rate-limit", 100)
openapi.WithOperationExtension("x-cache-ttl", 300)

Composable Options

WithOptions

func WithOptions(opts ...OperationOption) OperationOption

Combines multiple operation options into a single reusable option.

Parameters:

  • opts - Operation options to combine

Example:

CommonErrors := openapi.WithOptions(
    openapi.WithResponse(400, ErrorResponse{}),
    openapi.WithResponse(500, ErrorResponse{}),
)

UserEndpoint := openapi.WithOptions(
    openapi.WithTags("users"),
    openapi.WithSecurity("bearerAuth"),
    CommonErrors,
)

// Use in operations
openapi.GET("/users/:id",
    UserEndpoint,
    openapi.WithSummary("Get user"),
    openapi.WithResponse(200, User{}),
)

Option Summary Table

OptionDescription
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
WithOptions(opts...)Combine options into reusable set

Next Steps

3.1.6.4 - Swagger UI Options

Complete reference for Swagger UI configuration options

Complete reference for all Swagger UI configuration options (functions passed to WithSwaggerUI()).

Display Options

WithUIExpansion

func WithUIExpansion(expansion DocExpansion) UIOption

Controls initial document expansion.

Parameters:

  • expansion - DocExpansionList, DocExpansionFull, or DocExpansionNone

Values:

  • DocExpansionList - Show endpoints, hide details (default)
  • DocExpansionFull - Show endpoints and details
  • DocExpansionNone - Hide everything

Example:

openapi.WithUIExpansion(openapi.DocExpansionFull)

WithUIDefaultModelRendering

func WithUIDefaultModelRendering(rendering ModelRendering) UIOption

Controls how models/schemas are rendered.

Parameters:

  • rendering - ModelRenderingExample or ModelRenderingModel

Example:

openapi.WithUIDefaultModelRendering(openapi.ModelRenderingExample)

WithUIModelExpandDepth

func WithUIModelExpandDepth(depth int) UIOption

Controls how deeply a single model is expanded.

Parameters:

  • depth - Expansion depth (-1 to disable, 1 for shallow, higher for deeper)

Example:

openapi.WithUIModelExpandDepth(2)

WithUIModelsExpandDepth

func WithUIModelsExpandDepth(depth int) UIOption

Controls how deeply the models section is expanded.

Example:

openapi.WithUIModelsExpandDepth(1)

WithUIDisplayOperationID

func WithUIDisplayOperationID(display bool) UIOption

Shows operation IDs alongside summaries.

Example:

openapi.WithUIDisplayOperationID(true)

Try It Out Options

WithUITryItOut

func WithUITryItOut(enabled bool) UIOption

Enables “Try it out” functionality.

Example:

openapi.WithUITryItOut(true)

WithUIRequestSnippets

func WithUIRequestSnippets(enabled bool, languages ...RequestSnippetLanguage) UIOption

Shows code snippets for making requests.

Parameters:

  • enabled - Whether to show snippets
  • languages - Snippet languages to show

Languages:

  • SnippetCurlBash - curl for bash/sh shells
  • SnippetCurlPowerShell - curl for PowerShell
  • SnippetCurlCmd - curl for Windows CMD

Example:

openapi.WithUIRequestSnippets(true,
    openapi.SnippetCurlBash,
    openapi.SnippetCurlPowerShell,
    openapi.SnippetCurlCmd,
)

WithUIRequestSnippetsExpanded

func WithUIRequestSnippetsExpanded(expanded bool) UIOption

Expands request snippets by default.

Example:

openapi.WithUIRequestSnippetsExpanded(true)

WithUIDisplayRequestDuration

func WithUIDisplayRequestDuration(display bool) UIOption

Shows how long requests take.

Example:

openapi.WithUIDisplayRequestDuration(true)

Filtering and Sorting Options

WithUIFilter

func WithUIFilter(enabled bool) UIOption

Enables filter/search box.

Example:

openapi.WithUIFilter(true)

WithUIMaxDisplayedTags

func WithUIMaxDisplayedTags(max int) UIOption

Limits the number of tags displayed.

Example:

openapi.WithUIMaxDisplayedTags(10)

WithUIOperationsSorter

func WithUIOperationsSorter(sorter OperationsSorter) UIOption

Sets operation sorting method.

Parameters:

  • sorter - OperationsSorterAlpha or OperationsSorterMethod

Example:

openapi.WithUIOperationsSorter(openapi.OperationsSorterAlpha)

WithUITagsSorter

func WithUITagsSorter(sorter TagsSorter) UIOption

Sets tag sorting method.

Parameters:

  • sorter - TagsSorterAlpha

Example:

openapi.WithUITagsSorter(openapi.TagsSorterAlpha)

Syntax Highlighting Options

WithUISyntaxHighlight

func WithUISyntaxHighlight(enabled bool) UIOption

Enables syntax highlighting.

Example:

openapi.WithUISyntaxHighlight(true)

WithUISyntaxTheme

func WithUISyntaxTheme(theme SyntaxTheme) UIOption

Sets syntax highlighting theme.

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

Example:

openapi.WithUISyntaxTheme(openapi.SyntaxThemeMonokai)

Authentication Options

WithUIPersistAuth

func WithUIPersistAuth(persist bool) UIOption

Persists authentication across browser refreshes.

Example:

openapi.WithUIPersistAuth(true)

WithUIWithCredentials

func WithUIWithCredentials(withCredentials bool) UIOption

Includes credentials in requests.

Example:

openapi.WithUIWithCredentials(true)

Additional Options

WithUIDeepLinking

func WithUIDeepLinking(enabled bool) UIOption

Enables deep linking for tags and operations.

Example:

openapi.WithUIDeepLinking(true)

WithUIShowExtensions

func WithUIShowExtensions(show bool) UIOption

Shows vendor extensions (x-*) in the UI.

Example:

openapi.WithUIShowExtensions(true)

WithUIShowCommonExtensions

func WithUIShowCommonExtensions(show bool) UIOption

Shows common extensions in the UI.

Example:

openapi.WithUIShowCommonExtensions(true)

WithUISupportedMethods

func WithUISupportedMethods(methods ...HTTPMethod) UIOption

Configures which HTTP methods are supported for “Try it out”.

Parameters:

  • methods - HTTP method constants (MethodGet, MethodPost, MethodPut, etc.)

Example:

openapi.WithUISupportedMethods(
    openapi.MethodGet,
    openapi.MethodPost,
    openapi.MethodPut,
    openapi.MethodDelete,
)

Validation Options

WithUIValidator

func WithUIValidator(url string) UIOption

Sets specification validator.

Parameters:

  • url - ValidatorLocal, ValidatorNone, or custom validator URL

Example:

openapi.WithUIValidator(openapi.ValidatorLocal)
openapi.WithUIValidator("https://validator.swagger.io/validator")
openapi.WithUIValidator(openapi.ValidatorNone)

Complete Example

openapi.WithSwaggerUI("/docs",
    // Display
    openapi.WithUIExpansion(openapi.DocExpansionList),
    openapi.WithUIModelExpandDepth(1),
    openapi.WithUIDisplayOperationID(true),
    
    // Try it out
    openapi.WithUITryItOut(true),
    openapi.WithUIRequestSnippets(true,
        openapi.SnippetCurlBash,
        openapi.SnippetCurlPowerShell,
        openapi.SnippetCurlCmd,
    ),
    openapi.WithUIDisplayRequestDuration(true),
    
    // Filtering/Sorting
    openapi.WithUIFilter(true),
    openapi.WithUIOperationsSorter(openapi.OperationsSorterAlpha),
    
    // Syntax
    openapi.WithUISyntaxHighlight(true),
    openapi.WithUISyntaxTheme(openapi.SyntaxThemeMonokai),
    
    // Auth
    openapi.WithUIPersistAuth(true),
    
    // Validation
    openapi.WithUIValidator(openapi.ValidatorLocal),
)

Next Steps

3.1.6.5 - Diagnostics

Warning system reference with codes and categories

Complete reference for the warning diagnostics system in rivaas.dev/openapi/diag.

Package Import

import "rivaas.dev/openapi/diag"

Warning Interface

type Warning interface {
    Code() WarningCode
    Message() string
    Path() string
    Category() WarningCategory
}

Individual warning with diagnostic information.

Methods:

  • Code() - Returns type-safe warning code
  • Message() - Returns human-readable message
  • Path() - Returns location in spec (e.g., “info.summary”)
  • Category() - Returns warning category

Warnings Collection

type Warnings []Warning

Collection of warnings with helper methods.

Has

func (w Warnings) Has(code WarningCode) bool

Checks if collection contains a specific warning code.

Example:

if result.Warnings.Has(diag.WarnDownlevelInfoSummary) {
    log.Warn("info.summary was dropped")
}

HasAny

func (w Warnings) HasAny(codes ...WarningCode) bool

Checks if collection contains any of the specified warning codes.

Example:

if result.Warnings.HasAny(
    diag.WarnDownlevelMutualTLS,
    diag.WarnDownlevelWebhooks,
) {
    log.Warn("Some 3.1 security features were dropped")
}

Filter

func (w Warnings) Filter(code WarningCode) Warnings

Returns warnings matching the specified code.

Example:

licenseWarnings := result.Warnings.Filter(diag.WarnDownlevelLicenseIdentifier)

FilterCategory

func (w Warnings) FilterCategory(category WarningCategory) Warnings

Returns warnings in the specified category.

Example:

downlevelWarnings := result.Warnings.FilterCategory(diag.CategoryDownlevel)

Exclude

func (w Warnings) Exclude(codes ...WarningCode) Warnings

Returns warnings excluding the specified codes.

Example:

expected := []diag.WarningCode{
    diag.WarnDownlevelInfoSummary,
}
unexpected := result.Warnings.Exclude(expected...)

Warning Codes

WarningCode Type

type WarningCode string

Type-safe warning code constant.

Downlevel Warning Codes

Warnings generated when using 3.1 features with a 3.0 target:

ConstantCode ValueDescription
WarnDownlevelWebhooksDOWNLEVEL_WEBHOOKSWebhooks dropped (3.0 doesn’t support them)
WarnDownlevelInfoSummaryDOWNLEVEL_INFO_SUMMARYinfo.summary dropped (3.0 doesn’t support it)
WarnDownlevelLicenseIdentifierDOWNLEVEL_LICENSE_IDENTIFIERlicense.identifier dropped
WarnDownlevelMutualTLSDOWNLEVEL_MUTUAL_TLSmutualTLS security scheme dropped
WarnDownlevelConstToEnumDOWNLEVEL_CONST_TO_ENUMJSON Schema const converted to enum
WarnDownlevelConstToEnumConflictDOWNLEVEL_CONST_TO_ENUM_CONFLICTconst conflicted with existing enum
WarnDownlevelPathItemsDOWNLEVEL_PATH_ITEMS$ref in pathItems was expanded
WarnDownlevelPatternPropertiesDOWNLEVEL_PATTERN_PROPERTIESpatternProperties dropped
WarnDownlevelUnevaluatedPropertiesDOWNLEVEL_UNEVALUATED_PROPERTIESunevaluatedProperties dropped
WarnDownlevelContentEncodingDOWNLEVEL_CONTENT_ENCODINGcontentEncoding dropped
WarnDownlevelContentMediaTypeDOWNLEVEL_CONTENT_MEDIA_TYPEcontentMediaType dropped
WarnDownlevelMultipleExamplesDOWNLEVEL_MULTIPLE_EXAMPLESMultiple examples collapsed to one
const (
    WarnDownlevelWebhooks              WarningCode = "DOWNLEVEL_WEBHOOKS"
    WarnDownlevelInfoSummary           WarningCode = "DOWNLEVEL_INFO_SUMMARY"
    WarnDownlevelLicenseIdentifier     WarningCode = "DOWNLEVEL_LICENSE_IDENTIFIER"
    WarnDownlevelMutualTLS             WarningCode = "DOWNLEVEL_MUTUAL_TLS"
    WarnDownlevelConstToEnum           WarningCode = "DOWNLEVEL_CONST_TO_ENUM"
    WarnDownlevelConstToEnumConflict   WarningCode = "DOWNLEVEL_CONST_TO_ENUM_CONFLICT"
    WarnDownlevelPathItems             WarningCode = "DOWNLEVEL_PATH_ITEMS"
    WarnDownlevelPatternProperties     WarningCode = "DOWNLEVEL_PATTERN_PROPERTIES"
    WarnDownlevelUnevaluatedProperties WarningCode = "DOWNLEVEL_UNEVALUATED_PROPERTIES"
    WarnDownlevelContentEncoding       WarningCode = "DOWNLEVEL_CONTENT_ENCODING"
    WarnDownlevelContentMediaType      WarningCode = "DOWNLEVEL_CONTENT_MEDIA_TYPE"
    WarnDownlevelMultipleExamples      WarningCode = "DOWNLEVEL_MULTIPLE_EXAMPLES"
)

Deprecation Warning Codes

Warnings for deprecated feature usage:

ConstantCode ValueDescription
WarnDeprecationExampleSingularDEPRECATION_EXAMPLE_SINGULARUsing deprecated singular example field
const (
    WarnDeprecationExampleSingular WarningCode = "DEPRECATION_EXAMPLE_SINGULAR"
)

Warning Categories

WarningCategory Type

type WarningCategory string

Category grouping for warnings.

Category Constants

CategoryDescription
CategoryDownlevel3.1 to 3.0 conversion feature losses (spec is still valid)
CategoryDeprecationDeprecated feature usage (feature still works but is discouraged)
CategoryUnknownUnrecognized warning codes
const (
    CategoryDownlevel   WarningCategory = "downlevel"
    CategoryDeprecation WarningCategory = "deprecation"
    CategoryUnknown     WarningCategory = "unknown"
)

Usage Examples

Check for Specific Warning

import "rivaas.dev/openapi/diag"

result, err := api.Generate(context.Background(), ops...)
if err != nil {
    log.Fatal(err)
}

if result.Warnings.Has(diag.WarnDownlevelInfoSummary) {
    log.Warn("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 at %s\n", 
            warn.Code(), 
            warn.Message(), 
            warn.Path(),
        )
    }
}

Check for Unexpected Warnings

expected := []diag.WarningCode{
    diag.WarnDownlevelInfoSummary,
    diag.WarnDownlevelLicenseIdentifier,
}

unexpected := result.Warnings.Exclude(expected...)
if len(unexpected) > 0 {
    log.Fatalf("Unexpected warnings: %d", len(unexpected))
}

Iterate All Warnings

for _, warn := range result.Warnings {
    fmt.Printf("[%s] %s\n", warn.Code(), warn.Message())
    fmt.Printf("  Location: %s\n", warn.Path())
    fmt.Printf("  Category: %s\n", warn.Category())
}

Complete Example

package main

import (
    "context"
    "fmt"
    "log"
    
    "rivaas.dev/openapi"
    "rivaas.dev/openapi/diag"
)

func main() {
    api := openapi.MustNew(
        openapi.WithTitle("My API", "1.0.0"),
        openapi.WithVersion(openapi.V30x),
        openapi.WithInfoSummary("API Summary"), // 3.1 feature
    )
    
    result, err := api.Generate(context.Background(), operations...)
    if err != nil {
        log.Fatal(err)
    }
    
    // Handle specific warnings
    if result.Warnings.Has(diag.WarnDownlevelInfoSummary) {
        fmt.Println("Note: info.summary was dropped")
    }
    
    // Filter by category
    downlevelWarnings := result.Warnings.FilterCategory(diag.CategoryDownlevel)
    fmt.Printf("Downlevel warnings: %d\n", len(downlevelWarnings))
    
    // Check for unexpected
    expected := []diag.WarningCode{
        diag.WarnDownlevelInfoSummary,
    }
    unexpected := result.Warnings.Exclude(expected...)
    
    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())
        }
        log.Fatal("Unexpected warnings found")
    }
    
    fmt.Println("Generation complete")
}

Next Steps

3.1.6.6 - Troubleshooting

Common issues and solutions

Common issues and solutions for the openapi package.

Schema Name Collisions

Problem

Types with the same name in different packages may collide in the generated specification.

Solution

The package automatically uses pkgname.TypeName format for schema names:

// In package "api"
type User struct { ... }  // Becomes "api.User"

// In package "models"  
type User struct { ... }  // Becomes "models.User"

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"`
}

Extension Validation

Problem

Custom extensions are rejected or filtered out.

Solution

Extensions must follow OpenAPI rules:

Valid:

openapi.WithExtension("x-custom", "value")
openapi.WithExtension("x-api-version", "v2")

Invalid:

// Missing x- prefix
openapi.WithExtension("custom", "value") // Error

// Reserved prefix in 3.1.x
openapi.WithExtension("x-oai-custom", "value") // Filtered out in 3.1.x
openapi.WithExtension("x-oas-custom", "value") // Filtered out in 3.1.x

Version Compatibility

Problem

Using OpenAPI 3.1 features with a 3.0 target generates warnings or errors.

Solution

When using OpenAPI 3.0.x target, some 3.1.x features are automatically down-leveled:

Feature3.0 Behavior
info.summaryDropped (warning)
license.identifierDropped (warning)
const in schemasConverted to enum with single value
examples (multiple)Converted to single example
webhooksDropped (warning)
mutualTLS securityDropped (warning)

Options:

  1. Accept warnings (default):
api := openapi.MustNew(
    openapi.WithVersion(openapi.V30x),
    openapi.WithInfoSummary("Summary"), // Generates warning
)

result, err := api.Generate(context.Background(), ops...)
// Check result.Warnings
  1. Enable strict mode (error on 3.1 features):
api := openapi.MustNew(
    openapi.WithVersion(openapi.V30x),
    openapi.WithStrictDownlevel(true), // Error on 3.1 features
    openapi.WithInfoSummary("Summary"), // Causes error
)
  1. Use 3.1 target:
api := openapi.MustNew(
    openapi.WithVersion(openapi.V31x), // All features available
    openapi.WithInfoSummary("Summary"), // No warning
)

Parameters Not Discovered

Problem

Parameters are not appearing in the generated specification.

Solution

Ensure struct tags are correct:

Common Issues:

// Wrong tag name
type Request struct {
    ID int `params:"id"` // Should be "path", "query", "header", or "cookie"
}

// Missing tag
type Request struct {
    ID int // No tag - won't be discovered
}

// Wrong location
type Request struct {
    ID int `query:"id"` // Should be "path" for path parameters
}

Correct:

type Request struct {
    // Path parameters
    ID int `path:"id" doc:"User ID"`
    
    // Query parameters
    Page int `query:"page" doc:"Page number"`
    
    // Header parameters
    Auth string `header:"Authorization" doc:"Auth token"`
    
    // Cookie parameters
    Session string `cookie:"session_id" doc:"Session ID"`
}

Validation Errors

Problem

Generated specification fails validation.

Solution

Enable validation to get detailed error messages:

api := openapi.MustNew(
    openapi.WithTitle("My API", "1.0.0"),
    openapi.WithValidation(true), // Enable validation
)

result, err := api.Generate(context.Background(), ops...)
if err != nil {
    log.Printf("Validation failed: %v\n", err)
}

Common validation errors:

  1. Missing required fields:
// Missing version
openapi.MustNew(
    openapi.WithTitle("My API", ""), // Version required
)
  1. Invalid URLs:
// Invalid server URL
openapi.WithServer("not-a-url", "Server")
  1. Invalid enum values:
type Request struct {
    Status string `json:"status" enum:"active"` // Missing comma-separated values
}

Performance Issues

Problem

Specification generation is slow.

Solution

Typical performance:

  • First generation per type: ~500ns (reflection)
  • Subsequent generations: ~50ns (cached)
  • Validation overhead: 10-20ms first time, 1-5ms subsequent

Optimization tips:

  1. Disable validation in production:
api := openapi.MustNew(
    openapi.WithTitle("API", "1.0.0"),
    openapi.WithValidation(false), // Disable for production
)
  1. Generate once, cache result:
var cachedSpec []byte
var once sync.Once

func getSpec() []byte {
    once.Do(func() {
        result, _ := api.Generate(context.Background(), ops...)
        cachedSpec = result.JSON
    })
    return cachedSpec
}
  1. Pre-generate at build time:
# generate-spec.go
go run generate-spec.go > openapi.json

Schema Generation Issues

Problem

Go types are not converted correctly to OpenAPI schemas.

Solution

Supported types:

type Example struct {
    // Primitives
    String  string  `json:"string"`
    Int     int     `json:"int"`
    Bool    bool    `json:"bool"`
    Float   float64 `json:"float"`
    
    // Pointers (nullable)
    Optional *string `json:"optional,omitempty"`
    
    // Slices
    Tags []string `json:"tags"`
    
    // Maps
    Metadata map[string]string `json:"metadata"`
    
    // Nested structs
    Address Address `json:"address"`
    
    // Time
    CreatedAt time.Time `json:"created_at"`
}

Unsupported types:

type Unsupported struct {
    Channel chan int        // Not supported
    Func    func()          // Not supported
    Complex complex64       // Not supported
}

Workaround for unsupported types:

Use custom types or JSON marshaling:

type CustomType struct {
    data interface{}
}

func (c CustomType) MarshalJSON() ([]byte, error) {
    // Custom marshaling logic
}

Context Errors

Problem

Generate() returns “context is nil” error.

Solution

Always provide a valid context:

// Wrong
result, err := api.Generate(nil, ops...) // Error

// Correct
result, err := api.Generate(context.Background(), ops...)
result, err := api.Generate(ctx, ops...) // With existing context

Common FAQ

Q: How do I make a parameter optional?

A: For query/header/cookie parameters, omit validate:"required" tag. For request body fields, use pointer types or omitempty:

type Request struct {
    Required string  `json:"required" validate:"required"`
    Optional *string `json:"optional,omitempty"`
}

Q: How do I add multiple examples?

A: Pass multiple example instances to WithRequest() or WithResponse():

example1 := User{ID: 1, Name: "Alice"}
example2 := User{ID: 2, Name: "Bob"}

openapi.WithResponse(200, User{}, example1, example2)

Q: Can I generate specs for existing handlers?

A: Yes, define types that match your handlers and pass them to operations:

// Handler
func GetUser(id int) (*User, error) { ... }

// OpenAPI
openapi.GET("/users/:id",
    openapi.WithResponse(200, User{}),
)

Q: How do I document error responses?

A: Use multiple WithResponse() calls:

openapi.GET("/users/:id",
    openapi.WithResponse(200, User{}),
    openapi.WithResponse(400, ErrorResponse{}),
    openapi.WithResponse(404, ErrorResponse{}),
    openapi.WithResponse(500, ErrorResponse{}),
)

Q: Can I use this with existing OpenAPI specs?

A: Use the validate package to validate external specs:

import "rivaas.dev/openapi/validate"

validator := validate.New()
err := validator.ValidateAuto(context.Background(), specJSON)

Getting Help

If you encounter issues not covered here:

  1. Check the pkg.go.dev documentation
  2. Review examples
  3. Search GitHub issues
  4. Open a new issue with a minimal reproduction

Next Steps

3.1.7 - Logging Package

API reference for rivaas.dev/logging - Structured logging for Go applications

This is the API reference for the rivaas.dev/logging package. For learning-focused documentation, see the Logging Guide.

Package Information

Package Overview

The logging package provides structured logging for Rivaas applications using Go’s standard log/slog package, with additional features for production environments.

Core Features

  • Multiple output formats (JSON, Text, Console)
  • Context-aware logging with OpenTelemetry trace correlation
  • Automatic sensitive data redaction
  • Log sampling for high-traffic scenarios
  • Dynamic log level changes at runtime
  • Convenience methods for common patterns
  • Comprehensive testing utilities
  • Zero external dependencies (except OpenTelemetry for tracing)

Architecture

The package is organized around key components:

Main Types

Logger - Main logging type with structured logging methods

type Logger struct {
    // contains filtered or unexported fields
}

ContextLogger - Context-aware logger with automatic trace correlation

type ContextLogger struct {
    // contains filtered or unexported fields
}

Option - Functional option for logger configuration

type Option func(*Logger)

Quick API Index

Logger Creation

logger, err := logging.New(options...)     // With error handling
logger := logging.MustNew(options...)      // Panics on error

Logging Methods

logger.Debug(msg string, args ...any)
logger.Info(msg string, args ...any)
logger.Warn(msg string, args ...any)
logger.Error(msg string, args ...any)

Convenience Methods

logger.LogRequest(r *http.Request, extra ...any)
logger.LogError(err error, msg string, extra ...any)
logger.LogDuration(msg string, start time.Time, extra ...any)
logger.ErrorWithStack(msg string, err error, includeStack bool, extra ...any)

Context-Aware Logging

cl := logging.NewContextLogger(ctx context.Context, logger *Logger)
cl.Info(msg string, args ...any)  // Includes trace_id and span_id

Configuration Methods

logger.SetLevel(level Level) error
logger.Level() Level
logger.Shutdown(ctx context.Context) error

Reference Pages

API Reference

Logger and ContextLogger types with all methods.

View →

Options

Configuration options for handlers and output.

View →

Testing Utilities

Test helpers and mocking utilities.

View →

Troubleshooting

Common logging issues and solutions.

View →

User Guide

Step-by-step tutorials and examples.

View →

Type Reference

Logger

type Logger struct {
    // contains filtered or unexported fields
}

Main logging type. Thread-safe for concurrent access.

Creation:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
)

Key Methods:

  • Debug, Info, Warn, Error - Logging at different levels
  • LogRequest, LogError, LogDuration - Convenience methods
  • SetLevel, Level - Dynamic level management
  • Shutdown - Graceful shutdown

ContextLogger

type ContextLogger struct {
    // contains filtered or unexported fields
}

Context-aware logger with automatic trace correlation.

Creation:

cl := logging.NewContextLogger(ctx, logger)

Key Methods:

  • Debug, Info, Warn, Error - Logging with trace correlation
  • TraceID, SpanID - Access trace information
  • Logger - Get underlying slog.Logger

HandlerType

type HandlerType string

const (
    JSONHandler    HandlerType = "json"
    TextHandler    HandlerType = "text"
    ConsoleHandler HandlerType = "console"
)

Output format type.

Level

type Level = slog.Level

const (
    LevelDebug = slog.LevelDebug  // -4
    LevelInfo  = slog.LevelInfo   // 0
    LevelWarn  = slog.LevelWarn   // 4
    LevelError = slog.LevelError  // 8
)

Log level constants.

SamplingConfig

type SamplingConfig struct {
    Initial    int           // Log first N entries unconditionally
    Thereafter int           // After Initial, log 1 of every M entries
    Tick       time.Duration // Reset sampling counter every interval
}

Configuration for log sampling.

Error Types

The package defines sentinel errors for better error handling:

var (
    ErrNilLogger         = errors.New("custom logger is nil")
    ErrInvalidHandler    = errors.New("invalid handler type")
    ErrLoggerShutdown    = errors.New("logger is shut down")
    ErrInvalidLevel      = errors.New("invalid log level")
    ErrCannotChangeLevel = errors.New("cannot change level on custom logger")
)

Usage:

if err := logger.SetLevel(level); err != nil {
    if errors.Is(err, logging.ErrCannotChangeLevel) {
        // Handle immutable logger case
    }
}

Common Patterns

Basic Usage

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
)
defer logger.Shutdown(context.Background())

logger.Info("operation completed", "items", 100)

With Service Metadata

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithServiceName("payment-api"),
    logging.WithServiceVersion("v2.1.0"),
    logging.WithEnvironment("production"),
)

With Context and Tracing

cl := logging.NewContextLogger(ctx, logger)
cl.Info("processing request", "user_id", userID)
// Automatically includes trace_id and span_id

With Sampling

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    1000,
        Thereafter: 100,
        Tick:       time.Minute,
    }),
)

Thread Safety

The Logger type is thread-safe for:

  • Concurrent logging operations
  • Concurrent SetLevel calls (serialized internally)
  • Mixed logging and configuration operations

Not thread-safe for:

  • Concurrent modification during initialization (use synchronization)

Performance Notes

  • Logging overhead: ~500ns per log entry
  • Level checks: ~5ns per check
  • Sampling overhead: ~20ns per log entry
  • Zero allocations: Standard log calls with inline fields
  • Stack traces: ~150µs capture cost (only when requested)

Version Compatibility

The logging package follows semantic versioning. The API is stable for the v1 series.

Minimum Go version: 1.25

Next Steps

For learning-focused guides, see the Logging Guide.

3.1.7.1 - API Reference

Complete API reference for all types and methods in the logging package

Complete API reference for all public types and methods in the logging package.

Core Functions

New

func New(opts ...Option) (*Logger, error)

Creates a new Logger with the given options. Returns an error if configuration is invalid.

Parameters:

  • opts - Variadic list of configuration options.

Returns:

  • *Logger - Configured logger instance.
  • error - Configuration error, if any.

Example:

logger, err := logging.New(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
)
if err != nil {
    log.Fatalf("failed to create logger: %v", err)
}

MustNew

func MustNew(opts ...Option) *Logger

Creates a new Logger or panics on error. Use for initialization where errors are fatal.

Parameters:

  • opts - Variadic list of configuration options

Returns:

  • *Logger - Configured logger instance

Panics:

  • If configuration is invalid

Example:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
)

Automatic Trace Correlation

When you create a logger with this package (and optionally set it as the global logger with WithGlobalLogger()), trace correlation is automatic. You do not need a special logger type.

Any call to the standard library’s context-aware methods — slog.InfoContext(ctx, ...), slog.ErrorContext(ctx, ...), and so on — will automatically get trace_id and span_id added to the log record if the context contains an active OpenTelemetry span. The logging package wraps the handler with a context-aware layer that reads the span from the context and injects these fields.

Example (in an HTTP handler):

// Pass the request context when you log
slog.InfoContext(c.RequestContext(), "processing request", "order_id", orderID)
// Output includes trace_id and span_id when tracing is enabled

Use the same pattern with slog.DebugContext, slog.WarnContext, and slog.ErrorContext. No wrapper type or extra API is required.

Logger Type

Logging Methods

Debug

func (l *Logger) Debug(msg string, args ...any)

Logs a debug message with structured attributes.

Parameters:

  • msg - Log message
  • args - Key-value pairs (must be even number of arguments)

Example:

logger.Debug("cache lookup", "key", cacheKey, "hit", true)

Info

func (l *Logger) Info(msg string, args ...any)

Logs an informational message with structured attributes.

Parameters:

  • msg - Log message
  • args - Key-value pairs

Example:

logger.Info("server started", "port", 8080, "version", "v1.0.0")

Warn

func (l *Logger) Warn(msg string, args ...any)

Logs a warning message with structured attributes.

Parameters:

  • msg - Log message
  • args - Key-value pairs

Example:

logger.Warn("high memory usage", "used_mb", 8192, "threshold_mb", 10240)

Error

func (l *Logger) Error(msg string, args ...any)

Logs an error message with structured attributes. Errors bypass log sampling.

Parameters:

  • msg - Log message
  • args - Key-value pairs

Example:

logger.Error("database connection failed", "error", err, "retry_count", 3)

Convenience Methods

LogRequest

func (l *Logger) LogRequest(r *http.Request, extra ...any)

Logs an HTTP request with standard fields (method, path, remote, user_agent, query).

Parameters:

  • r - HTTP request
  • extra - Additional key-value pairs

Example:

logger.LogRequest(r, "status", 200, "duration_ms", 45)

LogError

func (l *Logger) LogError(err error, msg string, extra ...any)

Logs an error with automatic error field.

Parameters:

  • err - Error to log
  • msg - Log message
  • extra - Additional key-value pairs

Example:

logger.LogError(err, "operation failed", "operation", "INSERT", "table", "users")

LogDuration

func (l *Logger) LogDuration(msg string, start time.Time, extra ...any)

Logs operation duration with automatic duration_ms and duration fields.

Parameters:

  • msg - Log message
  • start - Operation start time
  • extra - Additional key-value pairs

Example:

start := time.Now()
// ... operation ...
logger.LogDuration("processing completed", start, "items", 100)

ErrorWithStack

func (l *Logger) ErrorWithStack(msg string, err error, includeStack bool, extra ...any)

Logs an error with optional stack trace.

Parameters:

  • msg - Log message
  • err - Error to log
  • includeStack - Whether to capture and include stack trace
  • extra - Additional key-value pairs

Example:

logger.ErrorWithStack("critical failure", err, true, "user_id", userID)

Context Methods

Logger

func (l *Logger) Logger() *slog.Logger

Returns the underlying slog.Logger for advanced usage.

Returns:

  • *slog.Logger - Underlying logger

Example:

slogger := logger.Logger()

With

func (l *Logger) With(args ...any) *slog.Logger

Returns a slog.Logger with additional attributes that persist across log calls.

Parameters:

  • args - Key-value pairs to add as persistent attributes

Returns:

  • *slog.Logger - Logger with added attributes

Example:

requestLogger := logger.With("request_id", "req-123", "user_id", "user-456")
requestLogger.Info("processing")  // Includes request_id and user_id

WithGroup

func (l *Logger) WithGroup(name string) *slog.Logger

Returns a slog.Logger with a group name for nested attributes.

Parameters:

  • name - Group name

Returns:

  • *slog.Logger - Logger with group

Example:

dbLogger := logger.WithGroup("database")
dbLogger.Info("query", "sql", "SELECT * FROM users")
// Output: {...,"database":{"sql":"SELECT * FROM users"}}

Configuration Methods

SetLevel

func (l *Logger) SetLevel(level Level) error

Dynamically changes the minimum log level at runtime.

Parameters:

  • level - New log level

Returns:

  • error - ErrCannotChangeLevel if using custom logger

Example:

if err := logger.SetLevel(logging.LevelDebug); err != nil {
    log.Printf("failed to change level: %v", err)
}

Level

func (l *Logger) Level() Level

Returns the current minimum log level.

Returns:

  • Level - Current log level

Example:

currentLevel := logger.Level()
fmt.Printf("Current level: %s\n", currentLevel)

Metadata Methods

ServiceName

func (l *Logger) ServiceName() string

Returns the configured service name.

Returns:

  • string - Service name, or empty if not configured

ServiceVersion

func (l *Logger) ServiceVersion() string

Returns the configured service version.

Returns:

  • string - Service version, or empty if not configured

Environment

func (l *Logger) Environment() string

Returns the configured environment.

Returns:

  • string - Environment, or empty if not configured

Lifecycle Methods

IsEnabled

func (l *Logger) IsEnabled() bool

Returns true if logging is enabled (not shut down).

Returns:

  • bool - Whether logger is active

DebugInfo

func (l *Logger) DebugInfo() map[string]any

Returns diagnostic information about logger state.

Returns:

  • map[string]any - Diagnostic information

Example:

info := logger.DebugInfo()
fmt.Printf("Handler: %s\n", info["handler_type"])
fmt.Printf("Level: %s\n", info["level"])

Shutdown

func (l *Logger) Shutdown(ctx context.Context) error

Gracefully shuts down the logger, flushing any buffered logs.

Parameters:

  • ctx - Context for timeout control

Returns:

  • error - Shutdown error, if any

Example:

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

Next Steps

For usage guides, see the Logging Guide.

3.1.7.2 - Options Reference

Complete reference for all logger configuration options

Complete reference for all configuration options available in the logging package.

Handler Options

Configure the output format for logs.

WithHandlerType

func WithHandlerType(t HandlerType) Option

Sets the logging handler type directly.

Parameters:

  • t - Handler type. Use JSONHandler, TextHandler, or ConsoleHandler.

Example:

logging.WithHandlerType(logging.JSONHandler)

WithJSONHandler

func WithJSONHandler() Option

Uses JSON structured logging. This is the default. Best for production and log aggregation.

Example:

logger := logging.MustNew(logging.WithJSONHandler())

Output format:

{"time":"2024-01-15T10:30:45.123Z","level":"INFO","msg":"test","key":"value"}

WithTextHandler

func WithTextHandler() Option

Uses text key=value logging. Good for systems that prefer this format.

Example:

logger := logging.MustNew(logging.WithTextHandler())

Output format:

time=2024-01-15T10:30:45.123Z level=INFO msg=test key=value

WithConsoleHandler

func WithConsoleHandler() Option

Uses human-readable console logging with colors. Best for development.

Example:

logger := logging.MustNew(logging.WithConsoleHandler())

Output format:

10:30:45.123 INFO  test key=value

Level Options

Configure the minimum log level.

WithLevel

func WithLevel(level Level) Option

Sets the minimum log level.

Parameters:

  • level - Minimum level (LevelDebug, LevelInfo, LevelWarn, LevelError)

Example:

logger := logging.MustNew(
    logging.WithLevel(logging.LevelInfo),
)

WithDebugLevel

func WithDebugLevel() Option

Convenience function to enable debug logging. Equivalent to WithLevel(LevelDebug).

Example:

logger := logging.MustNew(logging.WithDebugLevel())

Output Options

Configure where logs are written.

WithOutput

func WithOutput(w io.Writer) Option

Sets the output destination for logs.

Parameters:

  • w - io.Writer to write logs to

Default: os.Stdout

Example:

logFile, _ := os.OpenFile("app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
logger := logging.MustNew(
    logging.WithOutput(logFile),
)

Multiple outputs:

logger := logging.MustNew(
    logging.WithOutput(io.MultiWriter(os.Stdout, logFile)),
)

Service Metadata Options

Configure service identification fields automatically added to every log entry.

WithServiceName

func WithServiceName(name string) Option

Sets the service name, automatically added to all log entries as service field.

Parameters:

  • name - Service name

Example:

logger := logging.MustNew(
    logging.WithServiceName("payment-api"),
)

WithServiceVersion

func WithServiceVersion(version string) Option

Sets the service version, automatically added to all log entries as version field.

Parameters:

  • version - Service version

Example:

logger := logging.MustNew(
    logging.WithServiceVersion("v2.1.0"),
)

WithEnvironment

func WithEnvironment(env string) Option

Sets the environment, automatically added to all log entries as env field.

Parameters:

  • env - Environment name

Example:

logger := logging.MustNew(
    logging.WithEnvironment("production"),
)

Combined example:

logger := logging.MustNew(
    logging.WithServiceName("payment-api"),
    logging.WithServiceVersion("v2.1.0"),
    logging.WithEnvironment("production"),
)
// All logs include: "service":"payment-api","version":"v2.1.0","env":"production"

Feature Options

Enable additional logging features.

WithSource

func WithSource(enabled bool) Option

Enables source code location (file and line number) in logs.

Parameters:

  • enabled - Whether to include source location

Default: false

Example:

logger := logging.MustNew(
    logging.WithSource(true),
)
// Output includes: "source":{"file":"main.go","line":42}

Note: Source location adds overhead. Use only for debugging.

WithDebugMode

func WithDebugMode(enabled bool) Option

Enables verbose debugging mode. Automatically enables debug level and source location.

Parameters:

  • enabled - Whether to enable debug mode

Example:

logger := logging.MustNew(
    logging.WithDebugMode(true),
)
// Equivalent to:
// WithDebugLevel() + WithSource(true)

WithGlobalLogger

func WithGlobalLogger() Option

Registers this logger as the global slog default logger. Allows third-party libraries using slog to use your configured logger.

Example:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithGlobalLogger(),
)
// Now slog.Info() uses this logger

Default: Not registered globally (allows multiple independent loggers)

WithSampling

func WithSampling(cfg SamplingConfig) Option

Enables log sampling to reduce volume in high-traffic scenarios.

Parameters:

  • cfg - Sampling configuration

Example:

logger := logging.MustNew(
    logging.WithSampling(logging.SamplingConfig{
        Initial:    1000,         // First 1000 logs
        Thereafter: 100,          // Then 1% sampling
        Tick:       time.Minute,  // Reset every minute
    }),
)

SamplingConfig fields:

  • Initial (int) - Log first N entries unconditionally
  • Thereafter (int) - After Initial, log 1 of every M entries (0 = log all)
  • Tick (time.Duration) - Reset counter every interval (0 = never reset)

Note: Errors (level >= ERROR) always bypass sampling.

Advanced Options

Advanced configuration for specialized use cases.

WithReplaceAttr

func WithReplaceAttr(fn func(groups []string, a slog.Attr) slog.Attr) Option

Sets a custom attribute replacer function for transforming or filtering log attributes.

Parameters:

  • fn - Function to transform attributes

Example - Custom redaction:

logger := logging.MustNew(
    logging.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == "credit_card" {
            return slog.String(a.Key, "***REDACTED***")
        }
        return a
    }),
)

Example - Dropping attributes:

logger := logging.MustNew(
    logging.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == "internal_field" {
            return slog.Attr{}  // Drop this field
        }
        return a
    }),
)

Example - Transforming values:

logger := logging.MustNew(
    logging.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
        if a.Key == "time" {
            if t, ok := a.Value.Any().(time.Time); ok {
                return slog.String(a.Key, t.Format(time.RFC3339))
            }
        }
        return a
    }),
)

WithCustomLogger

func WithCustomLogger(customLogger *slog.Logger) Option

Uses a custom slog.Logger instead of creating one. For advanced use cases where you need full control over the logger.

Parameters:

  • customLogger - Pre-configured slog.Logger

Example:

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
  • Service metadata must be added to custom logger directly

Configuration Examples

Development Configuration

logger := logging.MustNew(
    logging.WithConsoleHandler(),
    logging.WithDebugLevel(),
    logging.WithSource(true),
)

Production Configuration

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
    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,
    }),
)

Testing Configuration

buf := &bytes.Buffer{}
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithOutput(buf),
    logging.WithLevel(logging.LevelDebug),
)

File Logging Configuration

logFile, _ := os.OpenFile("app.log",
    os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithOutput(logFile),
    logging.WithServiceName("myapp"),
)

Multiple Output Configuration

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

Next Steps

For usage guides, see the Configuration Guide.

3.1.7.3 - Testing Utilities

Complete reference for logging test utilities and helpers

Complete reference for testing utilities provided by the logging package.

Test Logger Creation

NewTestLogger

func NewTestLogger() (*Logger, *bytes.Buffer)

Creates a Logger for testing with an in-memory buffer. The logger is configured with JSON handler, debug level, and writes to the returned buffer.

Returns:

  • *Logger - Configured test logger
  • *bytes.Buffer - Buffer containing log output

Example:

func TestMyFunction(t *testing.T) {
    logger, buf := logging.NewTestLogger()
    
    myFunction(logger)
    
    entries, err := logging.ParseJSONLogEntries(buf)
    require.NoError(t, err)
    assert.Len(t, entries, 1)
}

ParseJSONLogEntries

func ParseJSONLogEntries(buf *bytes.Buffer) ([]LogEntry, error)

Parses JSON log entries from buffer into LogEntry slices. Creates a copy of the buffer so the original is not consumed.

Parameters:

  • buf - Buffer containing JSON log entries (one per line)

Returns:

  • []LogEntry - Parsed log entries
  • error - Parse error, if any

Example:

entries, err := logging.ParseJSONLogEntries(buf)
require.NoError(t, err)

for _, entry := range entries {
    fmt.Printf("%s: %s\n", entry.Level, entry.Message)
}

TestHelper

High-level testing utility with convenience methods.

NewTestHelper

func NewTestHelper(t *testing.T, opts ...Option) *TestHelper

Creates a TestHelper with in-memory logging and additional options.

Parameters:

  • t - Testing instance
  • opts - Optional configuration options

Returns:

  • *TestHelper - Test helper instance

Example:

func TestService(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    svc := NewService(th.Logger)
    svc.DoSomething()
    
    th.AssertLog(t, "INFO", "operation completed", map[string]any{
        "status": "success",
    })
}

With custom configuration:

th := logging.NewTestHelper(t,
    logging.WithLevel(logging.LevelWarn),  // Only warnings and errors
)

TestHelper.Logs

func (th *TestHelper) Logs() ([]LogEntry, error)

Returns all parsed log entries.

Returns:

  • []LogEntry - All log entries
  • error - Parse error, if any

Example:

logs, err := th.Logs()
require.NoError(t, err)
assert.Len(t, logs, 3)

TestHelper.LastLog

func (th *TestHelper) LastLog() (*LogEntry, error)

Returns the most recent log entry.

Returns:

  • *LogEntry - Most recent log entry
  • error - Error if no logs or parse error

Example:

last, err := th.LastLog()
require.NoError(t, err)
assert.Equal(t, "INFO", last.Level)

TestHelper.ContainsLog

func (th *TestHelper) ContainsLog(msg string) bool

Checks if any log entry contains the given message.

Parameters:

  • msg - Message to search for

Returns:

  • bool - True if message found

Example:

if !th.ContainsLog("user created") {
    t.Error("expected user created log")
}

TestHelper.ContainsAttr

func (th *TestHelper) ContainsAttr(key string, value any) bool

Checks if any log entry contains the given attribute.

Parameters:

  • key - Attribute key
  • value - Attribute value

Returns:

  • bool - True if attribute found

Example:

if !th.ContainsAttr("user_id", "123") {
    t.Error("expected user_id attribute")
}

TestHelper.CountLevel

func (th *TestHelper) CountLevel(level string) int

Returns the number of log entries at the given level.

Parameters:

  • level - Log level (“DEBUG”, “INFO”, “WARN”, “ERROR”)

Returns:

  • int - Count of logs at that level

Example:

errorCount := th.CountLevel("ERROR")
assert.Equal(t, 2, errorCount)

TestHelper.Reset

func (th *TestHelper) Reset()

Clears the buffer for fresh testing.

Example:

th.Reset()  // Start fresh for next test phase

TestHelper.AssertLog

func (th *TestHelper) AssertLog(t *testing.T, level, msg string, attrs map[string]any)

Checks that a log entry exists with the given properties. Fails the test if not found.

Parameters:

  • t - Testing instance
  • level - Expected log level
  • msg - Expected message
  • attrs - Expected attributes

Example:

th.AssertLog(t, "INFO", "user created", map[string]any{
    "username": "alice",
    "email":    "alice@example.com",
})

LogEntry Type

type LogEntry struct {
    Time    time.Time
    Level   string
    Message string
    Attrs   map[string]any
}

Represents a parsed log entry for testing.

Fields:

  • Time - Log timestamp
  • Level - Log level (“DEBUG”, “INFO”, “WARN”, “ERROR”)
  • Message - Log message
  • Attrs - All other fields as map

Example:

entry := logs[0]
assert.Equal(t, "INFO", entry.Level)
assert.Equal(t, "test message", entry.Message)
assert.Equal(t, "value", entry.Attrs["key"])

Mock Writers

MockWriter

Records all writes for inspection.

Type:

type MockWriter struct {
    // contains filtered or unexported fields
}

Methods:

Write

func (mw *MockWriter) Write(p []byte) (n int, err error)

Implements io.Writer. Records the write.

WriteCount

func (mw *MockWriter) WriteCount() int

Returns the number of write calls.

BytesWritten

func (mw *MockWriter) BytesWritten() int

Returns total bytes written.

LastWrite

func (mw *MockWriter) LastWrite() []byte

Returns the most recent write.

Reset

func (mw *MockWriter) Reset()

Clears all recorded writes.

Example:

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")
    
    assert.Equal(t, 3, mw.WriteCount())
    assert.Contains(t, string(mw.LastWrite()), "test 3")
    assert.Greater(t, mw.BytesWritten(), 0)
}

CountingWriter

Counts bytes written without storing content.

Type:

type CountingWriter struct {
    // contains filtered or unexported fields
}

Methods:

Write

func (cw *CountingWriter) Write(p []byte) (n int, err error)

Implements io.Writer. Counts bytes.

Count

func (cw *CountingWriter) Count() int64

Returns the total bytes written.

Example:

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)
    }
    
    bytesLogged := cw.Count()
    t.Logf("Total bytes: %d", bytesLogged)
}

SlowWriter

Simulates slow I/O for testing timeouts.

Type:

type SlowWriter struct {
    // contains filtered or unexported fields
}

Constructor:

NewSlowWriter

func NewSlowWriter(delay time.Duration, inner io.Writer) *SlowWriter

Creates a writer that delays each write.

Parameters:

  • delay - Delay duration for each write
  • inner - Optional inner writer to actually write to

Returns:

  • *SlowWriter - Slow writer instance

Example:

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)
    
    assert.GreaterOrEqual(t, duration, 100*time.Millisecond)
}

HandlerSpy

Implements slog.Handler and records all Handle calls.

Type:

type HandlerSpy struct {
    // contains filtered or unexported fields
}

Methods:

Enabled

func (hs *HandlerSpy) Enabled(_ context.Context, _ slog.Level) bool

Always returns true.

Handle

func (hs *HandlerSpy) Handle(_ context.Context, r slog.Record) error

Records the log record.

WithAttrs

func (hs *HandlerSpy) WithAttrs(_ []slog.Attr) slog.Handler

Returns the same handler (for compatibility).

WithGroup

func (hs *HandlerSpy) WithGroup(_ string) slog.Handler

Returns the same handler (for compatibility).

Records

func (hs *HandlerSpy) Records() []slog.Record

Returns all captured records.

RecordCount

func (hs *HandlerSpy) RecordCount() int

Returns the number of captured records.

Reset

func (hs *HandlerSpy) Reset()

Clears all captured records.

Example:

func TestHandlerBehavior(t *testing.T) {
    spy := &logging.HandlerSpy{}
    logger := slog.New(spy)
    
    logger.Info("test", "key", "value")
    
    assert.Equal(t, 1, spy.RecordCount())
    
    records := spy.Records()
    assert.Equal(t, "test", records[0].Message)
}

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)
    th.AssertLog(t, "ERROR", "operation failed", map[string]any{
        "error": "expected failure",
    })
}

Table-Driven Tests

func TestLogLevels(t *testing.T) {
    tests := []struct {
        name         string
        level        logging.Level
        expectLogged bool
    }{
        {"debug at info", logging.LevelInfo, false},
        {"info at info", logging.LevelInfo, true},
        {"error at warn", logging.LevelWarn, true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            th := logging.NewTestHelper(t,
                logging.WithLevel(tt.level),
            )
            
            th.Logger.Debug("test")
            
            logs, _ := th.Logs()
            if tt.expectLogged {
                assert.Len(t, logs, 1)
            } else {
                assert.Len(t, logs, 0)
            }
        })
    }
}

Next Steps

For complete testing patterns, see the Testing Guide.

3.1.7.4 - Troubleshooting

Common issues and solutions for the logging package

Common issues and solutions when using the logging package.

Logs Not Appearing

Debug Logs Not Showing

Problem: Debug logs don’t appear in output.

Cause: Log level is set higher than Debug.

Solution: Enable debug level:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithDebugLevel(),  // Enable debug logs
)

Or check current level:

currentLevel := logger.Level()
fmt.Printf("Current level: %s\n", currentLevel)

No Logs at All

Problem: No logs appear, even errors.

Possible causes:

  1. Logger shutdown: Check if logger was shut down.
if !logger.IsEnabled() {
    fmt.Println("Logger is shut down")
}
  1. Wrong output: Verify output destination.
logger := logging.MustNew(
    logging.WithOutput(os.Stdout),  // Not stderr
)
  1. Sampling too aggressive: Check sampling configuration.
info := logger.DebugInfo()
if sampling, ok := info["sampling"]; ok {
    fmt.Printf("Sampling: %+v\n", sampling)
}

Logs Disappear After Some Time

Problem: Logs stop appearing after initial burst.

Cause: Log sampling is dropping logs.

Solution: Adjust sampling or disable:

// Less aggressive sampling
logger := logging.MustNew(
    logging.WithSampling(logging.SamplingConfig{
        Initial:    1000,
        Thereafter: 10,  // 10% instead of 1%
        Tick:       time.Minute,
    }),
)

// Or disable sampling
logger := logging.MustNew(
    logging.WithJSONHandler(),
    // No WithSampling() call
)

Sensitive Data Issues

Sensitive Data Not Redacted

Problem: Custom sensitive fields not being redacted.

Cause: Only built-in fields are automatically redacted.

Solution: Add custom redaction:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithReplaceAttr(func(groups []string, a slog.Attr) slog.Attr {
        // Redact custom fields
        if a.Key == "credit_card" || a.Key == "ssn" {
            return slog.String(a.Key, "***REDACTED***")
        }
        return a
    }),
)

Built-in redacted fields:

  • password
  • token
  • secret
  • api_key
  • authorization

Too Much Redaction

Problem: Fields being redacted unnecessarily.

Cause: Field names match redaction patterns.

Solution: Rename fields to avoid keywords:

// Instead of "token" (redacted)
log.Info("processing", "request_token_id", tokenID)

// Instead of "secret" (redacted)
log.Info("config", "shared_secret_name", secretName)

Trace Correlation Issues

No Trace IDs in Logs

Problem: Logs don’t include trace_id and span_id.

Possible causes:

  1. Tracing not initialized:
// Initialize tracing
tracer := tracing.MustNew(
    tracing.WithOTLP("localhost:4317"),
)
defer tracer.Shutdown(context.Background())
  1. Not passing the request context when logging:
// Wrong - no context, so no trace_id/span_id
slog.Info("message")

// Right - pass context so trace_id and span_id are injected automatically
slog.InfoContext(ctx, "message")
  1. Context has no active span:
// Start a span so the context carries trace info
ctx, span := tracer.Start(context.Background(), "operation")
defer span.End()

slog.InfoContext(ctx, "message")  // Now includes trace_id and span_id

Wrong Trace IDs

Problem: Trace IDs don’t match distributed trace.

Cause: Context not properly propagated.

Solution: Ensure context flows through the call chain and pass it when you log:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()  // Context carries trace from middleware
    
    result := processRequest(ctx)
    w.Write(result)
}

func processRequest(ctx context.Context) []byte {
    slog.InfoContext(ctx, "processing")  // Uses same context, so same trace
    return data
}

Performance Issues

High CPU Usage

Problem: Logging causes high CPU usage.

Possible causes:

  1. Logging in tight loops:
// Bad - logs thousands of times
for _, item := range items {
    logger.Debug("processing", "item", item)
}

// Good - log summary
logger.Info("processing batch", "count", len(items))
  1. Source location enabled in production:
// Bad for production
logger := logging.MustNew(
    logging.WithSource(true),  // Adds overhead
)

// Good for production
logger := logging.MustNew(
    logging.WithJSONHandler(),
    // No source location
)
  1. Debug level in production:
// Bad - debug logs have overhead even if filtered
logger := logging.MustNew(
    logging.WithDebugLevel(),
)

// Good - appropriate level
logger := logging.MustNew(
    logging.WithLevel(logging.LevelInfo),
)

High Memory Usage

Problem: Memory usage grows over time.

Possible causes:

  1. No log rotation: Logs written to file without rotation.

Solution: Use external log rotation (logrotate) or rotate in code:

// Use external tool like logrotate
// Or implement rotation
  1. Buffered output not flushed: Buffers growing without flush.

Solution: Ensure proper shutdown:

defer func() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    logger.Shutdown(ctx)
}()

Configuration Issues

Cannot Change Log Level

Problem: SetLevel returns error.

Cause: Using custom logger.

Error:

err := logger.SetLevel(logging.LevelDebug)
if errors.Is(err, logging.ErrCannotChangeLevel) {
    // Custom logger doesn't support dynamic level changes
}

Solution: Control level in custom logger:

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)

Service Metadata Not Appearing

Problem: Service name, version, or environment not in logs.

Cause: Not configured or using custom logger.

Solution: Configure service metadata:

logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithServiceName("my-api"),
    logging.WithServiceVersion("v1.0.0"),
    logging.WithEnvironment("production"),
)

For custom logger, add metadata manually:

customLogger := slog.New(handler).With(
    "service", "my-api",
    "version", "v1.0.0",
    "env", "production",
)

Router Integration Issues

Access Log Not Working

Problem: HTTP requests not being logged.

Possible causes:

  1. Logger not set on router:
r := router.MustNew()
logger := logging.MustNew(logging.WithJSONHandler())
r.SetLogger(logger)  // Must set logger
  1. Middleware not applied:
import "rivaas.dev/router/middleware/accesslog"

r.Use(accesslog.New())  // Apply middleware
  1. Path excluded:
r.Use(accesslog.New(
    accesslog.WithExcludePaths("/health", "/metrics"),
))
// /health and /metrics won't be logged

No Trace IDs in Handler Logs

Problem: Handler logs have no trace_id or span_id.

Cause: Tracing not initialized, or you are not using the context when logging.

Solution: Initialize tracing with the app, and always pass the request context when you log:

a, _ := app.New(
    app.WithServiceName("my-api"),
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
        app.WithTracing(tracing.WithOTLP("localhost:4317")),
    ),
)
// In handlers, use: slog.InfoContext(c.RequestContext(), "message", ...)

Trace IDs are injected automatically for any slog.*Context(ctx, ...) call when the context has an active OpenTelemetry span.

Testing Issues

Test Logs Not Captured

Problem: Logs not appearing in test buffer.

Cause: Using wrong logger instance.

Solution: Use TestHelper or ensure buffer is captured:

func TestMyFunction(t *testing.T) {
    th := logging.NewTestHelper(t)
    
    myFunction(th.Logger)  // Pass test logger
    
    logs, _ := th.Logs()
    assert.Len(t, logs, 1)
}

Parse Errors

Problem: ParseJSONLogEntries returns error.

Cause: Non-JSON output or malformed JSON.

Solution: Ensure JSON handler:

th := logging.NewTestHelper(t,
    logging.WithJSONHandler(),  // Must be JSON
)

Error Types

ErrNilLogger

var ErrNilLogger = errors.New("custom logger is nil")

When: Providing nil custom logger.

Solution:

if customLogger != nil {
    logger := logging.MustNew(
        logging.WithCustomLogger(customLogger),
    )
}

ErrInvalidHandler

var ErrInvalidHandler = errors.New("invalid handler type")

When: Invalid handler type specified.

Solution: Use valid handler types:

logging.WithHandlerType(logging.JSONHandler)
logging.WithHandlerType(logging.TextHandler)
logging.WithHandlerType(logging.ConsoleHandler)

ErrLoggerShutdown

var ErrLoggerShutdown = errors.New("logger is shut down")

When: Operations after shutdown.

Solution: Don’t use logger after shutdown:

defer logger.Shutdown(context.Background())
// Don't log after this point

ErrInvalidLevel

var ErrInvalidLevel = errors.New("invalid log level")

When: Invalid log level provided.

Solution: Use valid levels:

logging.LevelDebug
logging.LevelInfo
logging.LevelWarn
logging.LevelError

ErrCannotChangeLevel

var ErrCannotChangeLevel = errors.New("cannot change level on custom logger")

When: Calling SetLevel on custom logger.

Solution: Control level in custom logger directly or don’t use custom logger.

Getting Help

If you encounter issues not covered here:

  1. Check the API Reference for method details
  2. Review Examples for patterns
  3. See Best Practices for recommendations
  4. Check the GitHub issues

Debugging Tips

Enable Debug Info

info := logger.DebugInfo()
fmt.Printf("Logger state: %+v\n", info)

Check Sampling State

info := logger.DebugInfo()
if sampling, ok := info["sampling"]; ok {
    fmt.Printf("Sampling config: %+v\n", sampling)
}

Verify Configuration

fmt.Printf("Service: %s\n", logger.ServiceName())
fmt.Printf("Version: %s\n", logger.ServiceVersion())
fmt.Printf("Environment: %s\n", logger.Environment())
fmt.Printf("Level: %s\n", logger.Level())
fmt.Printf("Enabled: %v\n", logger.IsEnabled())

Next Steps

3.1.8 - Metrics Package

API reference for rivaas.dev/metrics - Metrics collection for Go applications

This is the API reference for the rivaas.dev/metrics package. For learning-focused documentation, see the Metrics Guide.

Package Information

Package Overview

The metrics package provides OpenTelemetry-based metrics collection for Go applications with support for multiple exporters including Prometheus, OTLP, and stdout.

Core Features

  • Multiple metrics providers (Prometheus, OTLP, stdout)
  • Built-in HTTP metrics via middleware
  • Custom metrics (counters, histograms, gauges)
  • Thread-safe operations
  • Context-aware methods
  • Automatic header filtering for security
  • Testing utilities

Architecture

The package is built on OpenTelemetry and provides a simplified interface for common metrics use cases.

graph TD
    App[Application Code]
    Recorder[Recorder]
    Provider[Provider Layer]
    Prom[Prometheus]
    OTLP[OTLP]
    Stdout[Stdout]
    Middleware[HTTP Middleware]
    
    App -->|Record Metrics| Recorder
    Middleware -->|Auto-Collect| Recorder
    Recorder --> Provider
    Provider --> Prom
    Provider --> OTLP
    Provider --> Stdout

Components

Main Package (rivaas.dev/metrics)

Core metrics collection including:

  • Recorder - Main metrics recorder
  • New() / MustNew() - Recorder initialization
  • Custom metrics methods - Counters, histograms, gauges
  • Middleware() - HTTP metrics collection
  • Testing utilities

Quick API Index

Recorder Creation

recorder, err := metrics.New(options...)     // With error handling
recorder := metrics.MustNew(options...)      // Panics on error

Lifecycle Management

err := recorder.Start(ctx context.Context)   // Start metrics server/exporter
err := recorder.Shutdown(ctx context.Context) // Graceful shutdown
err := recorder.ForceFlush(ctx context.Context) // Force immediate export

Recording Metrics

// Counters
err := recorder.IncrementCounter(ctx, name, attributes...)
err := recorder.AddCounter(ctx, name, value, attributes...)

// Histograms
err := recorder.RecordHistogram(ctx, name, value, attributes...)

// Gauges
err := recorder.SetGauge(ctx, name, value, attributes...)

HTTP Middleware

handler := metrics.Middleware(recorder, options...)(httpHandler)

Provider-Specific Methods

address := recorder.ServerAddress()          // Prometheus: actual address
handler, err := recorder.Handler()           // Prometheus: metrics handler
count := recorder.CustomMetricCount()        // Number of custom metrics

Testing Utilities

recorder := metrics.TestingRecorder(t, serviceName)
recorder := metrics.TestingRecorderWithPrometheus(t, serviceName)
err := metrics.WaitForMetricsServer(t, address, timeout)

Reference Pages

API Reference

Recorder type, lifecycle methods, and custom metrics API.

View →

Options

Configuration options for providers and service metadata.

View →

Middleware Options

HTTP middleware configuration and path exclusion.

View →

Troubleshooting

Common metrics issues and solutions.

View →

User Guide

Step-by-step tutorials and examples.

View →

Type Reference

Recorder

type Recorder struct {
    // contains filtered or unexported fields
}

Main metrics recorder. Thread-safe for concurrent access.

Methods: See API Reference for complete method documentation.

Option

type Option func(*Recorder)

Configuration option function type used with New() and MustNew().

Available Options: See Options for all options.

EventType

type EventType int

const (
    EventError   EventType = iota // Error events
    EventWarning                   // Warning events
    EventInfo                      // Informational events
    EventDebug                     // Debug events
)

Event severity levels for internal operational events.

Event

type Event struct {
    Type    EventType
    Message string
    Args    []any // slog-style key-value pairs
}

Internal operational event from the metrics package.

EventHandler

type EventHandler func(Event)

Processes internal operational events. Used with WithEventHandler option.

Common Patterns

Basic Usage

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)
recorder.Start(context.Background())
defer recorder.Shutdown(context.Background())

_ = recorder.IncrementCounter(ctx, "requests_total")

With HTTP Middleware

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)
recorder.Start(context.Background())

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health"),
)(httpHandler)

http.ListenAndServe(":8080", handler)

With OTLP

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
)
recorder.Start(ctx) // Required before recording metrics
defer recorder.Shutdown(context.Background())

Thread Safety

The Recorder type is thread-safe for:

  • All metric recording methods
  • Concurrent Start() and Shutdown() operations
  • Mixed recording and lifecycle operations

Not thread-safe for:

  • Concurrent modification during initialization

Performance Notes

  • Metric recording: ~1-2 microseconds per operation
  • HTTP middleware: ~1-2 microseconds overhead per request
  • Memory usage: Scales with number of unique metric names and label combinations
  • Histogram overhead: Proportional to bucket count

Best Practices:

  • Use fire-and-forget pattern for most metrics (ignore errors)
  • Limit metric cardinality (avoid high-cardinality labels)
  • Customize histogram buckets for your use case
  • Exclude high-traffic paths from middleware when appropriate

Built-in Metrics

When using HTTP middleware, these metrics are automatically collected:

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
target_infoGaugeOpenTelemetry resource metadata (service name, version)

Version Compatibility

The metrics package follows semantic versioning. The API is stable for the v1 series.

Minimum Go version: 1.25

OpenTelemetry compatibility: Uses OpenTelemetry SDK v1.x

Next Steps

For learning-focused guides, see the Metrics Guide.

3.1.8.1 - API Reference

Complete API documentation for the Recorder type and methods

Complete API reference for the metrics package core types and methods.

Recorder Type

The Recorder is the main type for collecting metrics. It is thread-safe. You can use it concurrently.

type Recorder struct {
    // contains filtered or unexported fields
}

Creation Functions

New

func New(opts ...Option) (*Recorder, error)

Creates a new Recorder with the given options. Returns an error if configuration is invalid.

Parameters:

  • opts ...Option - Configuration options.

Returns:

  • *Recorder - Configured recorder.
  • error - Configuration error, if any.

Example:

recorder, err := metrics.New(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)
if err != nil {
    log.Fatal(err)
}

Errors:

  • Multiple provider options specified.
  • Invalid service name.
  • Invalid port or endpoint configuration.

MustNew

func MustNew(opts ...Option) *Recorder

Creates a new Recorder with the given options. Panics if configuration is invalid.

Parameters:

  • opts ...Option - Configuration options.

Returns:

  • *Recorder - Configured recorder.

Panics: If configuration is invalid.

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)

Use Case: Applications that should fail fast on invalid metrics configuration.

Lifecycle Methods

Start

func (r *Recorder) Start(ctx context.Context) error

Starts the metrics recorder. For Prometheus, starts the HTTP server. For OTLP, establishes connection. For stdout, this is a no-op but safe to call.

Parameters:

  • ctx context.Context - Lifecycle context for the recorder

Returns:

  • error - Startup error, if any

Example:

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

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

Errors:

  • Port already in use (Prometheus with WithStrictPort)
  • Cannot connect to OTLP endpoint
  • Context already canceled

Provider Behavior:

  • Prometheus: Starts HTTP server on configured port
  • OTLP: Establishes connection to collector
  • Stdout: No-op, safe to call

Shutdown

func (r *Recorder) Shutdown(ctx context.Context) error

Gracefully shuts down the metrics recorder, flushing any pending metrics.

Parameters:

  • ctx context.Context - Shutdown context with timeout

Returns:

  • error - Shutdown error, if any

Example:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := recorder.Shutdown(shutdownCtx); err != nil {
    log.Printf("Shutdown error: %v", err)
}

Behavior:

  • Stops accepting new metrics
  • Flushes pending metrics
  • Closes network connections
  • Stops HTTP server (Prometheus)
  • Idempotent (safe to call multiple times)

Best Practice: Always defer Shutdown with a timeout context.

ForceFlush

func (r *Recorder) ForceFlush(ctx context.Context) error

Forces immediate export of all pending metrics. Primarily useful for push-based providers (OTLP, stdout).

Parameters:

  • ctx context.Context - Flush context with timeout

Returns:

  • error - Flush error, if any

Example:

// Before critical operation
if err := recorder.ForceFlush(ctx); err != nil {
    log.Printf("Failed to flush metrics: %v", err)
}

Provider Behavior:

  • OTLP: Immediately exports all pending metrics
  • Stdout: Immediately prints all pending metrics
  • Prometheus: Typically a no-op (pull-based)

Use Cases:

  • Before deployment or shutdown
  • Checkpointing during long operations
  • Ensuring metrics visibility

Custom Metrics Methods

IncrementCounter

func (r *Recorder) IncrementCounter(ctx context.Context, name string, attrs ...attribute.KeyValue) error

Increments a counter metric by 1.

Parameters:

  • ctx context.Context - Context for the operation
  • name string - Metric name (must be valid)
  • attrs ...attribute.KeyValue - Optional metric attributes

Returns:

  • error - Error if metric name is invalid or limit reached

Example:

err := recorder.IncrementCounter(ctx, "requests_total",
    attribute.String("method", "GET"),
    attribute.String("status", "success"),
)

Naming Rules:

  • Must start with letter
  • Can contain letters, numbers, underscores, dots, hyphens
  • Cannot use reserved prefixes: __, http_, router_
  • Maximum 255 characters

AddCounter

func (r *Recorder) AddCounter(ctx context.Context, name string, value int64, attrs ...attribute.KeyValue) error

Adds a specific value to a counter metric.

Parameters:

  • ctx context.Context - Context for the operation
  • name string - Metric name (must be valid)
  • value int64 - Amount to add (must be non-negative)
  • attrs ...attribute.KeyValue - Optional metric attributes

Returns:

  • error - Error if metric name is invalid, value is negative, or limit reached

Example:

bytesProcessed := int64(1024)
err := recorder.AddCounter(ctx, "bytes_processed_total", bytesProcessed,
    attribute.String("direction", "inbound"),
)

RecordHistogram

func (r *Recorder) RecordHistogram(ctx context.Context, name string, value float64, attrs ...attribute.KeyValue) error

Records a value in a histogram metric.

Parameters:

  • ctx context.Context - Context for the operation
  • name string - Metric name (must be valid)
  • value float64 - Value to record
  • attrs ...attribute.KeyValue - Optional metric attributes

Returns:

  • error - Error if metric name is invalid or limit reached

Example:

start := time.Now()
// ... operation ...
duration := time.Since(start).Seconds()

err := recorder.RecordHistogram(ctx, "operation_duration_seconds", duration,
    attribute.String("operation", "create_user"),
)

Bucket Configuration: Use WithDurationBuckets or WithSizeBuckets to customize histogram boundaries.

SetGauge

func (r *Recorder) SetGauge(ctx context.Context, name string, value float64, attrs ...attribute.KeyValue) error

Sets a gauge metric to a specific value.

Parameters:

  • ctx context.Context - Context for the operation
  • name string - Metric name (must be valid)
  • value float64 - Value to set
  • attrs ...attribute.KeyValue - Optional metric attributes

Returns:

  • error - Error if metric name is invalid or limit reached

Example:

activeConnections := float64(pool.Active())
err := recorder.SetGauge(ctx, "active_connections", activeConnections,
    attribute.String("pool", "database"),
)

Provider-Specific Methods

ServerAddress

func (r *Recorder) ServerAddress() string

Returns the server address (port) for Prometheus provider. Returns empty string for other providers or if server is disabled.

Returns:

  • string - Server address in port format (e.g., :9090)

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)
recorder.Start(ctx)

address := recorder.ServerAddress()
log.Printf("Metrics at: http://localhost%s/metrics", address)

Use Cases:

  • Logging actual port (when not using strict mode)
  • Testing with dynamic port allocation
  • Health check registration

Note: Returns the port string (e.g., :9090), not a full hostname. Prepend localhost for local access.

Handler

func (r *Recorder) Handler() (http.Handler, error)

Returns the HTTP handler for metrics endpoint. Only works with Prometheus provider.

Returns:

  • http.Handler - Metrics endpoint handler
  • error - Error if not using Prometheus provider or server disabled

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServerDisabled(),
    metrics.WithServiceName("my-api"),
)

handler, err := recorder.Handler()
if err != nil {
    log.Fatal(err)
}

http.Handle("/metrics", handler)
http.ListenAndServe(":8080", nil)

Errors:

  • Not using Prometheus provider
  • Server not disabled (use WithServerDisabled)

CustomMetricCount

func (r *Recorder) CustomMetricCount() int

Returns the number of custom metrics created.

Returns:

  • int - Number of custom metrics

Example:

count := recorder.CustomMetricCount()
log.Printf("Custom metrics: %d/%d", count, maxLimit)

// Expose as a metric
_ = recorder.SetGauge(ctx, "custom_metrics_count", float64(count))

Use Cases:

  • Monitoring metric cardinality
  • Debugging metric limit issues
  • Capacity planning

Note: Built-in HTTP metrics do not count toward this total.

Middleware Function

Middleware

func Middleware(recorder *Recorder, opts ...MiddlewareOption) func(http.Handler) http.Handler

Returns HTTP middleware that automatically collects metrics for requests.

Parameters:

  • recorder *Recorder - Metrics recorder
  • opts ...MiddlewareOption - Middleware configuration options

Returns:

  • func(http.Handler) http.Handler - Middleware function

Example:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health", "/metrics"),
    metrics.WithHeaders("X-Request-ID"),
)(httpHandler)

Collected Metrics:

  • http_request_duration_seconds - Request duration histogram
  • http_requests_total - Request counter
  • http_requests_active - Active requests gauge
  • http_request_size_bytes - Request size histogram
  • http_response_size_bytes - Response size histogram
  • http_errors_total - Error counter

Middleware Options: See Middleware Options for details.

Testing Functions

TestingRecorder

func TestingRecorder(tb testing.TB, serviceName string, opts ...Option) *Recorder

Creates a test recorder with stdout provider. Automatically registers cleanup via t.Cleanup().

Parameters:

  • tb testing.TB - Test or benchmark instance
  • serviceName string - Service name for metrics
  • opts ...Option - Optional additional configuration options

Returns:

  • *Recorder - Test recorder

Example:

func TestHandler(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorder(t, "test-service")
    
    // Use recorder in tests...
    // Cleanup is automatic
}

// With additional options
func TestWithOptions(t *testing.T) {
    recorder := metrics.TestingRecorder(t, "test-service",
        metrics.WithMaxCustomMetrics(100),
    )
}

Features:

  • No port conflicts (uses stdout)
  • Automatic cleanup
  • Parallel test safe
  • Works with both *testing.T and *testing.B

TestingRecorderWithPrometheus

func TestingRecorderWithPrometheus(tb testing.TB, serviceName string, opts ...Option) *Recorder

Creates a test recorder with Prometheus provider and dynamic port allocation. Automatically registers cleanup via t.Cleanup().

Parameters:

  • tb testing.TB - Test or benchmark instance
  • serviceName string - Service name for metrics
  • opts ...Option - Optional additional configuration options

Returns:

  • *Recorder - Test recorder with Prometheus

Example:

func TestMetricsEndpoint(t *testing.T) {
    t.Parallel()
    
    recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")
    
    // Wait for server
    err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
    if err != nil {
        t.Fatal(err)
    }
    
    // Test metrics endpoint...
}

Features:

  • Dynamic port allocation
  • Real Prometheus endpoint
  • Automatic cleanup
  • Works with both *testing.T and *testing.B

WaitForMetricsServer

func WaitForMetricsServer(tb testing.TB, address string, timeout time.Duration) error

Waits for Prometheus metrics server to be ready.

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 - Error if server not ready within timeout

Example:

recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")

err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
if err != nil {
    t.Fatalf("Server not ready: %v", err)
}

// Server is ready, make requests

Event Types

EventType

type EventType int

const (
    EventError   EventType = iota // Error events
    EventWarning                   // Warning events
    EventInfo                      // Informational events
    EventDebug                     // Debug events
)

Severity levels for internal operational events.

Event

type Event struct {
    Type    EventType
    Message string
    Args    []any // slog-style key-value pairs
}

Internal operational event from the metrics package.

Example:

metrics.WithEventHandler(func(e metrics.Event) {
    switch e.Type {
    case metrics.EventError:
        sentry.CaptureMessage(e.Message)
    case metrics.EventWarning:
        log.Printf("WARN: %s", e.Message)
    case metrics.EventInfo:
        log.Printf("INFO: %s", e.Message)
    }
})

EventHandler

type EventHandler func(Event)

Function type for handling internal operational events.

Example:

handler := func(e metrics.Event) {
    slog.Default().Info(e.Message, e.Args...)
}

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithEventHandler(handler),
)

Error Handling

All metric recording methods return error. Common error types:

Invalid Metric Name

err := recorder.IncrementCounter(ctx, "__reserved")
// Error: metric name uses reserved prefix "__"

Metric Limit Reached

err := recorder.IncrementCounter(ctx, "new_metric_1001")
// Error: custom metric limit reached (1000/1000)

Provider Not Started

recorder := metrics.MustNew(metrics.WithOTLP("http://localhost:4318"))
err := recorder.IncrementCounter(ctx, "metric")
// Error: OTLP provider not started (call Start first)

Thread Safety

All 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")
}()

Next Steps

3.1.8.2 - Configuration Options

Complete reference of all configuration options

Complete reference for all Option functions used to configure the Recorder.

Provider Options

Only one provider option can be used per Recorder. Using multiple provider options results in a validation error.

WithPrometheus

func WithPrometheus(port, path string) Option

Configures Prometheus provider with HTTP endpoint.

Parameters:

  • port string - Listen address like :9090 or localhost:9090.
  • path string - Metrics path like /metrics.

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)

Behavior:

  • Initializes immediately in New().
  • Starts HTTP server when Start() is called.
  • Metrics available at http://localhost:9090/metrics.

Related Options:

  • WithStrictPort() - Fail if port unavailable.
  • WithServerDisabled() - Manage HTTP server manually.

WithOTLP

func WithOTLP(endpoint string) Option

Configures OTLP (OpenTelemetry Protocol) provider for sending metrics to a collector.

Parameters:

  • endpoint string - OTLP collector HTTP endpoint like http://localhost:4318.

Example:

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
)

Behavior:

  • Defers initialization until Start() is called.
  • Uses lifecycle context for network connections.
  • Important: Must call Start() before recording metrics.

Related Options:

  • WithExportInterval() - Configure export frequency.

WithStdout

func WithStdout() Option

Configures stdout provider for printing metrics to console.

Example:

recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("dev-service"),
)

Behavior:

  • Initializes immediately in New()
  • Works without calling Start() (but safe to call)
  • Prints metrics to stdout periodically

Use Cases:

  • Development and debugging
  • CI/CD pipelines
  • Unit tests

Related Options:

  • WithExportInterval() - Configure print frequency

Service Configuration Options

WithServiceName

func WithServiceName(name string) Option

Sets the service name for metrics identification.

Parameters:

  • name string - Service name

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("payment-api"),
)

Where It Appears:

The service name appears in the target_info metric, which holds resource-level information about your service:

target_info{service_name="payment-api",service_version="1.0.0"} 1

Individual metrics like http_requests_total do not include service_name as a label. This keeps label cardinality low, following Prometheus best practices.

Best Practices:

  • Use lowercase with hyphens: user-service, payment-api
  • Be consistent across services
  • Avoid changing names in production

WithServiceVersion

func WithServiceVersion(version string) Option

Sets the service version for metrics.

Parameters:

  • version string - Service version

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
    metrics.WithServiceVersion("v1.2.3"),
)

Best Practices:

  • Use semantic versioning: v1.2.3
  • Automate from CI/CD build information

Prometheus-Specific Options

WithStrictPort

func WithStrictPort() Option

Requires the metrics server to use the exact port specified. Fails if port is unavailable.

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithStrictPort(),  // Fail if 9090 unavailable
    metrics.WithServiceName("my-api"),
)

Default Behavior: Automatically searches up to 100 ports if requested port is unavailable.

With Strict Mode: Returns error if exact port is not available.

Production Recommendation: Always use WithStrictPort() for predictable behavior.

WithServerDisabled

func WithServerDisabled() Option

Disables automatic metrics server startup. Use Handler() to get metrics handler for manual serving.

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServerDisabled(),
    metrics.WithServiceName("my-api"),
)

handler, err := recorder.Handler()
if err != nil {
    log.Fatal(err)
}

// Serve on your own 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

WithoutScopeInfo

func WithoutScopeInfo() Option

Removes OpenTelemetry instrumentation scope labels from Prometheus metrics output.

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithoutScopeInfo(),
    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 point. These labels identify which instrumentation library produced each metric.

When to Use:

  • You only have one instrumentation scope (common case)
  • You want to reduce label cardinality
  • The scope information is not useful for your use case

Only Affects: Prometheus provider (OTLP and stdout ignore this option)

Default Behavior: Scope labels are included on all metrics

WithoutTargetInfo

func WithoutTargetInfo() Option

Disables the target_info metric in Prometheus output.

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithoutTargetInfo(),
    metrics.WithServiceName("my-api"),
)

What It Does:

By default, OpenTelemetry creates a target_info metric containing resource attributes like service_name and service_version. This metric helps identify and correlate metrics across your infrastructure.

When to Use:

  • You manage service identification through Prometheus external labels
  • You have your own service discovery mechanism
  • You don’t need the resource-level metadata

Only Affects: Prometheus provider (OTLP and stdout ignore this option)

Default Behavior: The target_info metric is created with service metadata

Histogram Bucket Options

WithDurationBuckets

func WithDurationBuckets(buckets ...float64) Option

Sets custom histogram bucket boundaries for duration metrics (in seconds).

Parameters:

  • buckets ...float64 - Bucket boundaries in seconds

Default: 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10

Example:

// Fast API (most requests < 100ms)
recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithDurationBuckets(0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.5, 1),
    metrics.WithServiceName("fast-api"),
)

// Slow operations (seconds to minutes)
recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithDurationBuckets(1, 5, 10, 30, 60, 120, 300, 600),
    metrics.WithServiceName("batch-processor"),
)

Trade-offs:

  • More buckets = better resolution, higher memory/storage
  • Fewer buckets = lower overhead, coarser resolution

WithSizeBuckets

func WithSizeBuckets(buckets ...float64) Option

Sets custom histogram bucket boundaries for size metrics (in bytes).

Parameters:

  • buckets ...float64 - Bucket boundaries in bytes

Default: 100, 1000, 10000, 100000, 1000000, 10000000

Example:

// Small JSON API (< 10KB)
recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithSizeBuckets(100, 500, 1000, 5000, 10000, 50000),
    metrics.WithServiceName("json-api"),
)

// File uploads (KB to MB)
recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithSizeBuckets(1024, 10240, 102400, 1048576, 10485760, 104857600),
    metrics.WithServiceName("file-service"),
)

Advanced Options

WithExportInterval

func WithExportInterval(interval time.Duration) Option

Sets export interval for push-based providers (OTLP and stdout).

Parameters:

  • interval time.Duration - Export interval

Default: 30 seconds

Example:

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithExportInterval(10 * time.Second),
    metrics.WithServiceName("my-service"),
)

Applies To:

  • OTLP (push-based)
  • Stdout (push-based)

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

WithMaxCustomMetrics

func WithMaxCustomMetrics(maxLimit int) Option

Sets the maximum number of custom metrics allowed.

Parameters:

  • maxLimit int - Maximum custom metrics

Default: 1000

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithMaxCustomMetrics(5000),
    metrics.WithServiceName("my-api"),
)

Purpose:

  • Prevent unbounded metric cardinality
  • Protect against memory exhaustion
  • Enforce metric discipline

Note: Built-in HTTP metrics do not count toward this limit.

Monitor Usage:

count := recorder.CustomMetricCount()
log.Printf("Custom metrics: %d/%d", count, maxLimit)

WithLogger

func WithLogger(logger *slog.Logger) Option

Sets the logger for internal operational events.

Parameters:

  • logger *slog.Logger - Logger instance

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithLogger(slog.Default()),
    metrics.WithServiceName("my-api"),
)

Events Logged:

  • Initialization events
  • Error messages (metric creation failures)
  • Warning messages (port conflicts, limits reached)

Alternative: Use WithEventHandler() for custom event handling.

WithEventHandler

func WithEventHandler(handler EventHandler) Option

Sets a custom event handler for internal operational events.

Parameters:

  • handler EventHandler - Event handler function

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithEventHandler(func(e metrics.Event) {
        switch e.Type {
        case metrics.EventError:
            sentry.CaptureMessage(e.Message)
        case metrics.EventWarning:
            log.Printf("WARN: %s", e.Message)
        case metrics.EventInfo:
            log.Printf("INFO: %s", e.Message)
        }
    }),
    metrics.WithServiceName("my-api"),
)

Use Cases:

  • Send errors to external monitoring (Sentry, etc.)
  • Custom logging formats
  • Metric collection about metric collection

Event Types:

  • EventError - Error events
  • EventWarning - Warning events
  • EventInfo - Informational events
  • EventDebug - Debug events

Advanced Provider Options

WithMeterProvider

func WithMeterProvider(provider metric.MeterProvider) Option

Provides a custom OpenTelemetry meter provider for complete control.

Parameters:

  • provider metric.MeterProvider - Custom meter provider

Example:

mp := sdkmetric.NewMeterProvider(...)
recorder := metrics.MustNew(
    metrics.WithMeterProvider(mp),
    metrics.WithServiceName("my-service"),
)
defer mp.Shutdown(context.Background())

Use Cases:

  • Manage meter provider lifecycle yourself
  • Multiple independent metrics configurations
  • Avoid global state

Note: When using WithMeterProvider, provider options (WithPrometheus, WithOTLP, WithStdout) are ignored.

WithGlobalMeterProvider

func WithGlobalMeterProvider() Option

Registers the meter provider as the global OpenTelemetry meter provider.

Example:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithGlobalMeterProvider(),  // Register globally
    metrics.WithServiceName("my-service"),
)

Default Behavior: Meter providers are NOT registered globally.

When to Use:

  • OpenTelemetry instrumentation libraries need global provider
  • Third-party libraries expect global meter provider
  • otel.GetMeterProvider() should return your provider

When NOT to Use:

  • Multiple services in same process
  • 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)

Option Validation

The following validation occurs during New() or MustNew():

  • Provider Conflicts: Only one provider option (WithPrometheus, WithOTLP, WithStdout) can be used
  • Service Name: Cannot be empty (default: "rivaas-service")
  • Service Version: Cannot be empty (default: "1.0.0")
  • Port Format: Must be valid address format for Prometheus
  • Custom Metrics Limit: Must be at least 1

Defaults: If no provider is specified, defaults to Prometheus on :9090/metrics.

Validation Errors:

// Multiple providers - ERROR
recorder, err := metrics.New(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithOTLP("http://localhost:4318"),  // Error: conflicting providers
)

// Empty service name - ERROR
recorder, err := metrics.New(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName(""),  // Error: service name cannot be empty
)

// No options - OK (uses defaults)
recorder, err := metrics.New()  // Uses default Prometheus on :9090/metrics

Next Steps

3.1.8.3 - Middleware Options

HTTP middleware configuration options reference

Complete reference for MiddlewareOption functions used to configure the HTTP metrics middleware.

Overview

Middleware options configure which paths to exclude from metrics collection and which headers to record.

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health", "/metrics"),
    metrics.WithExcludePrefixes("/debug/"),
    metrics.WithExcludePatterns(`^/admin/.*`),
    metrics.WithHeaders("X-Request-ID"),
)(httpHandler)

Path Exclusion Options

WithExcludePaths

func WithExcludePaths(paths ...string) MiddlewareOption

Excludes exact paths from metrics collection.

Parameters:

  • paths ...string - Exact paths to exclude

Example:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health", "/metrics", "/ready"),
)(mux)

Use Cases:

  • Health check endpoints.
  • Metrics endpoints.
  • Readiness and liveness probes.

Behavior:

  • Matches exact path only.
  • Case-sensitive.
  • Does not match path prefixes.

Examples:

// Excluded paths
/health           excluded
/metrics          excluded
/ready            excluded

// Not excluded (not exact matches)
/health/status    not excluded
/livez            not excluded
/api/metrics      not excluded

WithExcludePrefixes

func WithExcludePrefixes(prefixes ...string) MiddlewareOption

Excludes all paths with specific prefixes from metrics collection.

Parameters:

  • prefixes ...string - Path prefixes to exclude

Example:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePrefixes("/debug/", "/internal/", "/_/"),
)(mux)

Use Cases:

  • Debug endpoints (/debug/pprof/, /debug/vars/)
  • Internal APIs (/internal/)
  • Administrative paths (/_/)

Behavior:

  • Matches any path starting with prefix
  • Case-sensitive
  • Include trailing slash for directory prefixes

Examples:

// With prefix "/debug/"
/debug/pprof/heap       excluded
/debug/vars             excluded
/debug/                 excluded

// Not excluded
/debuginfo              not excluded (no slash)
/api/debug              not excluded (doesn't start with prefix)

WithExcludePatterns

func WithExcludePatterns(patterns ...string) MiddlewareOption

Excludes paths matching regex patterns from metrics collection.

Parameters:

  • patterns ...string - Regular expression patterns

Example:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePatterns(
        `^/v[0-9]+/internal/.*`,  // /v1/internal/*, /v2/internal/*
        `^/api/[0-9]+$`,           // /api/123, /api/456
        `^/admin/.*`,              // /admin/*
    ),
)(mux)

Use Cases:

  • Version-specific internal paths
  • High-cardinality routes (IDs in path)
  • Pattern-based exclusions

Behavior:

  • Uses Go’s regexp package
  • Matches full path
  • Case-sensitive (use (?i) for case-insensitive)

Examples:

// Pattern: `^/v[0-9]+/internal/.*`
/v1/internal/metrics    excluded
/v2/internal/debug      excluded

// Not excluded
/internal/api           not excluded (no version)
/api/v1/internal        not excluded (doesn't start with /v)

// Pattern: `^/api/[0-9]+$`
/api/123                excluded
/api/456                excluded

// Not excluded
/api/users              not excluded (not numeric)
/api/123/details        not excluded (has suffix)

Pattern Tips:

// Anchors
^      // Start of path
$      // End of path

// Character classes
[0-9]  // Any digit
[a-z]  // Any lowercase letter
.      // Any character
\d     // Any digit

// Quantifiers
*      // Zero or more
+      // One or more
?      // Zero or one
{n}    // Exactly n

// Grouping
(...)  // Group

// Case-insensitive
(?i)pattern  // Case-insensitive match

Combining Exclusions

Use multiple exclusion options together:

handler := metrics.Middleware(recorder,
    // Exact paths
    metrics.WithExcludePaths("/health", "/metrics", "/ready"),
    
    // Prefixes
    metrics.WithExcludePrefixes("/debug/", "/internal/", "/_/"),
    
    // Patterns
    metrics.WithExcludePatterns(
        `^/v[0-9]+/internal/.*`,
        `^/api/users/[0-9]+$`,  // User IDs in path
    ),
)(mux)

Evaluation Order:

  1. Exact paths (WithExcludePaths)
  2. Prefixes (WithExcludePrefixes)
  3. Patterns (WithExcludePatterns)

If any exclusion matches, the path is excluded.

Header Recording Options

WithHeaders

func WithHeaders(headers ...string) MiddlewareOption

Records specific HTTP headers as metric attributes.

Parameters:

  • headers ...string - Header names to record

Example:

handler := metrics.Middleware(recorder,
    metrics.WithHeaders("X-Request-ID", "X-Correlation-ID", "X-Client-Version"),
)(mux)

Behavior:

  • Headers recorded as metric attributes
  • Header names normalized (lowercase, hyphens to underscores)
  • Sensitive headers automatically filtered

Header Normalization:

// Original header → Metric attribute
X-Request-ID        x_request_id
X-Correlation-ID    x_correlation_id
Content-Type        content_type
User-Agent          user_agent

Example Metric:

http_requests_total{
    method="GET",
    path="/api/users",
    status="200",
    x_request_id="abc123",
    x_correlation_id="def456"
} 1

Sensitive Header Filtering

The middleware automatically filters sensitive headers, even if explicitly requested.

Always Filtered Headers:

  • Authorization
  • Cookie
  • Set-Cookie
  • X-API-Key
  • X-Auth-Token
  • Proxy-Authorization
  • WWW-Authenticate

Example:

// Only X-Request-ID will be recorded
// Authorization and Cookie are automatically filtered
handler := metrics.Middleware(recorder,
    metrics.WithHeaders(
        "Authorization",      // ✗ Filtered (sensitive)
        "X-Request-ID",       // ✓ Recorded
        "Cookie",             // ✗ Filtered (sensitive)
        "X-Correlation-ID",   // ✓ Recorded
    ),
)(mux)

Why Filter?

  • Prevent credential leaks in metrics
  • Avoid exposing API keys
  • Comply with security policies
  • Prevent compliance violations

Configuration Examples

Basic Health Check Exclusion

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health", "/ready"),
)(mux)

Development/Debug Exclusion

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health", "/metrics"),
    metrics.WithExcludePrefixes("/debug/", "/_/"),
)(mux)

High-Cardinality Path Exclusion

handler := metrics.Middleware(recorder,
    // Exclude paths with IDs to avoid high cardinality
    metrics.WithExcludePatterns(
        `^/api/users/[0-9]+$`,         // /api/users/123
        `^/api/orders/[a-z0-9-]+$`,    // /api/orders/abc-123
        `^/files/[^/]+$`,              // /files/{id}
    ),
)(mux)

Request Tracing

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health"),
    metrics.WithHeaders("X-Request-ID", "X-Correlation-ID", "X-Trace-ID"),
)(mux)

Production Configuration

handler := metrics.Middleware(recorder,
    // Exclude operational endpoints
    metrics.WithExcludePaths(
        "/health",
        "/ready",
        "/metrics",
        "/favicon.ico",
    ),
    
    // Exclude administrative paths
    metrics.WithExcludePrefixes(
        "/debug/",
        "/internal/",
        "/_/",
    ),
    
    // Exclude high-cardinality routes
    metrics.WithExcludePatterns(
        `^/api/v[0-9]+/internal/.*`,
        `^/api/users/[0-9]+$`,
        `^/api/orders/[a-z0-9-]+$`,
    ),
    
    // Record tracing headers
    metrics.WithHeaders(
        "X-Request-ID",
        "X-Correlation-ID",
        "X-Client-Version",
    ),
)(mux)

Best Practices

Path Exclusions

DO:

  • Exclude health and readiness checks
  • Exclude metrics endpoints
  • Exclude high-cardinality paths (IDs)
  • Exclude debug and administrative paths

DON’T:

  • Over-exclude (you need some metrics!)
  • Exclude business-critical endpoints
  • Use overly broad patterns

Header Recording

DO:

  • Record low-cardinality headers only
  • Use headers for request tracing
  • Consider privacy implications

DON’T:

  • Record sensitive headers (automatically filtered)
  • Record high-cardinality headers (user IDs, timestamps)
  • Record excessive headers (increases metric cardinality)

Cardinality Management

High cardinality leads to:

  • Excessive memory usage
  • Slow query performance
  • Storage bloat

Low Cardinality (Good):

// Headers with limited values
X-Client-Version: v1.0, v1.1, v2.0  (3 values)
X-Region: us-east-1, eu-west-1      (2 values)

High Cardinality (Bad):

// Headers with unbounded values
X-Request-ID: abc123, def456, ...   (millions of values)
X-Timestamp: 2025-01-18T10:30:00Z   (always unique)
X-User-ID: user123, user456, ...    (millions of values)

Performance Considerations

Path Evaluation Overhead

  • Exact paths: O(1) hash lookup
  • Prefixes: O(n) prefix checks (n = number of prefixes)
  • Patterns: O(n) regex matches (n = number of patterns)

Recommendation: Use exact paths when possible for best performance.

Header Recording Impact

Each header adds:

  • Additional metric attribute
  • Increased metric cardinality
  • Higher memory usage

Recommendation: Only record necessary headers.

Troubleshooting

Path Not Excluded

Check:

  1. Path is exact match (use WithExcludePaths)
  2. Prefix includes trailing slash
  3. Pattern uses correct regex syntax
  4. Pattern is anchored (^ and $)

Header Not Recorded

Check:

  1. Header name is correct (case-insensitive)
  2. Header is not in sensitive list
  3. Header is present in request

High Memory Usage

Check:

  1. Too many unique paths (exclude high-cardinality routes)
  2. Too many header combinations
  3. Recording high-cardinality headers

Next Steps

3.1.8.4 - Troubleshooting

Common issues and solutions for the metrics package

Solutions to common issues when using the metrics package.

Service Name Not Correct

Symptoms

  • service_name="rivaas-service" instead of your configured name
  • service_name="unknown_service:main" in target_info metric
  • Wrong service name in dashboards

Solutions

1. Use WithServiceName Option

Always specify your service name when creating the recorder:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-actual-service"),  // Set your service name
)

2. Check target_info Metric

The target_info metric shows OpenTelemetry resource information:

target_info{service_name="my-actual-service",service_version="1.0.0"} 1

If you see unknown_service:main, make sure you’re using the latest version of the metrics package.

3. Verify Configuration Order

Options can be passed in any order. The service name will be applied correctly:

// Both work the same
recorder := metrics.MustNew(
    metrics.WithServiceName("my-service"),
    metrics.WithPrometheus(":9090", "/metrics"),
)

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-service"),
)

4. Check Where Service Name Appears

The service name shows up in two places:

  1. Metric labels: Every metric has a service_name label

    http_requests_total{service_name="my-service",method="GET"} 42
    
  2. Target info: Resource metadata metric

    target_info{service_name="my-service",service_version="1.0.0"} 1
    

Metrics Not Appearing

OTLP Provider

Symptoms:

  • Metrics not visible in collector
  • No data in monitoring system
  • Silent failures

Solutions:

1. Call Start() Before Recording

The OTLP provider requires Start() to be called before recording metrics:

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
)

// IMPORTANT: Call Start() before recording
if err := recorder.Start(ctx); err != nil {
    log.Fatal(err)
}

// Now recording works
_ = recorder.IncrementCounter(ctx, "requests_total")

2. Check OTLP Collector Reachability

Verify the collector is accessible:

# Test connectivity
curl http://localhost:4318/v1/metrics

# Check collector logs
docker logs otel-collector

3. Wait for Export Interval

OTLP exports metrics periodically (default: 30s):

// Reduce interval for testing
recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithExportInterval(5 * time.Second),
    metrics.WithServiceName("my-service"),
)

Or force immediate export:

if err := recorder.ForceFlush(ctx); err != nil {
    log.Printf("Failed to flush: %v", err)
}

4. Enable Logging

Add logging to see what’s happening:

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithLogger(slog.Default()),
    metrics.WithServiceName("my-service"),
)

Prometheus Provider

Symptoms:

  • Metrics endpoint returns 404
  • Empty metrics output
  • Server not accessible

Solutions:

1. Call Start() to Start Server

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-service"),
)

// Start the HTTP server
if err := recorder.Start(ctx); err != nil {
    log.Fatal(err)
}

2. Check Actual Address

If not using strict mode, server may use different port:

address := recorder.ServerAddress()
log.Printf("Metrics at: http://%s/metrics", address)

3. Verify Firewall/Network

Check if port is accessible:

# Test locally
curl http://localhost:9090/metrics

# Check from another machine
curl http://<server-ip>:9090/metrics

Stdout Provider

Symptoms:

  • No output to console
  • Metrics not visible

Solutions:

1. Wait for Export Interval

Stdout exports periodically (default: 30s):

recorder := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithExportInterval(5 * time.Second),  // Shorter interval
    metrics.WithServiceName("my-service"),
)

2. Force Flush

if err := recorder.ForceFlush(ctx); err != nil {
    log.Printf("Failed to flush: %v", err)
}

Port Conflicts

Symptoms

  • Error: address already in use
  • Metrics server fails to start
  • Different port than expected

Solutions

1. Use Strict Port Mode (Production)

Fail explicitly if port unavailable:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithStrictPort(),  // Fail if 9090 unavailable
    metrics.WithServiceName("my-service"),
)

2. Check Port Usage

Find what’s using the port:

# Linux/macOS
lsof -i :9090
netstat -tuln | grep 9090

# Windows
netstat -ano | findstr :9090

3. Use Dynamic Port (Testing)

Let the system choose an available port:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":0", "/metrics"),  // :0 = any available port
    metrics.WithServiceName("test-service"),
)
recorder.Start(ctx)

// Get actual port
address := recorder.ServerAddress()
log.Printf("Using port: %s", address)

4. Use Testing Utilities

For tests, use the testing utilities with automatic port allocation:

func TestMetrics(t *testing.T) {
    t.Parallel()
    recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")
    // Automatically finds available port
}

Custom Metric Limit Reached

Symptoms

  • Error: custom metric limit reached
  • New metrics not created
  • Warning in logs

Solutions

1. Increase Limit

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithMaxCustomMetrics(5000),  // Increase from default 1000
    metrics.WithServiceName("my-service"),
)

2. Monitor Usage

Track how many custom metrics are created:

count := recorder.CustomMetricCount()
log.Printf("Custom metrics: %d/%d", count, maxLimit)

// Expose as a metric
_ = recorder.SetGauge(ctx, "custom_metrics_count", float64(count))

3. Review Metric Cardinality

Check if you’re creating too many unique metrics:

// BAD: High cardinality (unique per user)
_ = recorder.IncrementCounter(ctx, "user_"+userID+"_requests")

// GOOD: Low cardinality (use labels)
_ = recorder.IncrementCounter(ctx, "user_requests_total",
    attribute.String("user_type", userType),
)

4. Consolidate Metrics

Combine similar metrics:

// BAD: Many separate metrics
_ = recorder.IncrementCounter(ctx, "get_requests_total")
_ = recorder.IncrementCounter(ctx, "post_requests_total")
_ = recorder.IncrementCounter(ctx, "put_requests_total")

// GOOD: One metric with label
_ = recorder.IncrementCounter(ctx, "requests_total",
    attribute.String("method", "GET"),
)

What Counts as Custom Metric?

Counts:

  • Each unique metric name created with IncrementCounter, AddCounter, RecordHistogram, SetGauge

Does NOT count:

  • Built-in HTTP metrics
  • Different label combinations of same metric
  • Re-recording same metric name

Metrics Server Not Starting

Symptoms

  • Start() returns error
  • Server not accessible
  • No metrics endpoint

Solutions

1. Check Context

Ensure context is not canceled:

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

// Use context with Start
if err := recorder.Start(ctx); err != nil {
    log.Fatal(err)
}

2. Check Port Availability

See Port Conflicts section.

3. Enable Logging

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithLogger(slog.Default()),
    metrics.WithServiceName("my-service"),
)

4. Check Permissions

Ensure your process has permission to bind to the port (< 1024 requires root on Linux).

Invalid Metric Names

Symptoms

  • Error: invalid metric name
  • Metrics not recorded
  • Reserved prefix error

Solutions

1. Check Naming Rules

Metric names must:

  • Start with letter (a-z, A-Z)
  • Contain only: letters, numbers, underscores, dots, hyphens
  • Not use reserved prefixes: __, http_, router_
  • Maximum 255 characters

Valid:

_ = recorder.IncrementCounter(ctx, "orders_total")
_ = recorder.IncrementCounter(ctx, "api.v1.requests")
_ = recorder.IncrementCounter(ctx, "payment-success")

Invalid:

_ = recorder.IncrementCounter(ctx, "__internal")      // Reserved prefix
_ = recorder.IncrementCounter(ctx, "http_custom")     // Reserved prefix
_ = recorder.IncrementCounter(ctx, "router_gauge")    // Reserved prefix
_ = recorder.IncrementCounter(ctx, "1st_metric")      // Starts with number
_ = recorder.IncrementCounter(ctx, "my metric!")      // Invalid characters

2. Handle Errors

Check for naming errors:

if err := recorder.IncrementCounter(ctx, metricName); err != nil {
    log.Printf("Invalid metric name %q: %v", metricName, err)
}

High Memory Usage

Symptoms

  • Excessive memory consumption
  • Out of memory errors
  • Slow performance

Solutions

1. Reduce Metric Cardinality

Limit unique label combinations:

// BAD: High cardinality
_ = recorder.IncrementCounter(ctx, "requests_total",
    attribute.String("user_id", userID),        // Millions of values
    attribute.String("request_id", requestID),  // Always unique
)

// GOOD: Low cardinality
_ = recorder.IncrementCounter(ctx, "requests_total",
    attribute.String("user_type", userType),    // Few values
    attribute.String("region", region),          // Few values
)

2. Exclude High-Cardinality Paths

handler := metrics.Middleware(recorder,
    metrics.WithExcludePatterns(
        `^/api/users/[0-9]+$`,      // User IDs
        `^/api/orders/[a-z0-9-]+$`, // Order IDs
    ),
)(mux)

3. Reduce Histogram Buckets

// BAD: Too many buckets (15)
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,
)

// GOOD: Fewer buckets (7)
metrics.WithDurationBuckets(0.01, 0.1, 0.5, 1, 5, 10)

4. Monitor Custom Metrics

count := recorder.CustomMetricCount()
if count > 500 {
    log.Printf("WARNING: High custom metric count: %d", count)
}

Performance Issues

HTTP Middleware Overhead

Symptom: Slow request handling

Solution: Exclude high-traffic paths:

handler := metrics.Middleware(recorder,
    metrics.WithExcludePaths("/health"),  // Called frequently
    metrics.WithExcludePrefixes("/static/"),  // Static assets
)(mux)

Histogram Recording Slow

Symptom: High CPU usage

Solution: Reduce bucket count (see High Memory Usage).

Global State Issues

Symptoms

  • Multiple recorder instances conflict
  • Unexpected behavior with multiple services
  • Global meter provider issues

Solutions

By default, recorders do NOT set global meter provider:

// These work independently
recorder1 := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("service-1"),
)

recorder2 := metrics.MustNew(
    metrics.WithStdout(),
    metrics.WithServiceName("service-2"),
)

2. Avoid WithGlobalMeterProvider

Only use WithGlobalMeterProvider() if you need:

  • OpenTelemetry instrumentation libraries to use your provider
  • otel.GetMeterProvider() to return your provider
// Only if needed
recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithGlobalMeterProvider(),  // Explicit opt-in
    metrics.WithServiceName("my-service"),
)

Thread Safety

All Recorder methods are thread-safe. No special handling needed for concurrent access:

// Safe to call from multiple goroutines
go func() {
    _ = recorder.IncrementCounter(ctx, "worker_1")
}()

go func() {
    _ = recorder.IncrementCounter(ctx, "worker_2")
}()

Shutdown Issues

Graceful Shutdown Not Working

Solution: Use proper timeout context:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := recorder.Shutdown(shutdownCtx); err != nil {
    log.Printf("Shutdown error: %v", err)
}

Metrics Not Flushed on Exit

Solution: Always defer Shutdown():

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
)
recorder.Start(ctx)

defer func() {
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    recorder.Shutdown(shutdownCtx)
}()

Testing Issues

Port Conflicts in Parallel Tests

Solution: Use testing utilities with dynamic ports:

func TestHandler(t *testing.T) {
    t.Parallel()  // Safe with TestingRecorder
    
    // Uses stdout, no port needed
    recorder := metrics.TestingRecorder(t, "test-service")
    
    // Or with Prometheus (dynamic port)
    recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")
}

Server Not Ready

Solution: Wait for server:

recorder := metrics.TestingRecorderWithPrometheus(t, "test-service")

err := metrics.WaitForMetricsServer(t, recorder.ServerAddress(), 5*time.Second)
if err != nil {
    t.Fatal(err)
}

Getting Help

If you’re still experiencing issues:

  1. Check logs: Enable logging with WithLogger(slog.Default())
  2. Review configuration: Verify all options are correct
  3. Test connectivity: Ensure network access to endpoints
  4. Check version: Update to latest version
  5. File an issue: GitHub Issues

Quick Reference

Common Patterns

Production Setup:

recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithStrictPort(),
    metrics.WithServiceName("my-api"),
    metrics.WithServiceVersion(version),
    metrics.WithLogger(slog.Default()),
)

OTLP Setup:

recorder := metrics.MustNew(
    metrics.WithOTLP("http://localhost:4318"),
    metrics.WithServiceName("my-service"),
)
// IMPORTANT: Call Start() before recording
recorder.Start(ctx)

Testing Setup:

func TestMetrics(t *testing.T) {
    t.Parallel()
    recorder := metrics.TestingRecorder(t, "test-service")
    // Test code...
}

Next Steps

3.1.9 - Tracing Package

API reference for rivaas.dev/tracing - Distributed tracing for Go applications

This is the API reference for the rivaas.dev/tracing package. For learning-focused documentation, see the Tracing Guide.

Package Information

Package Overview

The tracing package provides OpenTelemetry-based distributed tracing for Go applications with support for multiple exporters including Stdout, OTLP (gRPC and HTTP), and Noop.

Core Features

  • Multiple tracing providers (Stdout, OTLP, Noop)
  • Built-in HTTP middleware for request tracing
  • Manual span management with attributes and events
  • Context propagation for distributed tracing
  • Thread-safe operations
  • Span lifecycle hooks
  • Testing utilities

Architecture

The package is built on OpenTelemetry and provides a simplified interface for distributed tracing.

graph TD
    App[Application Code]
    Tracer[Tracer]
    Provider[Provider Layer]
    Noop[Noop]
    Stdout[Stdout]
    OTLP[OTLP gRPC/HTTP]
    Middleware[HTTP Middleware]
    Context[Context Propagation]
    
    App -->|Create Spans| Tracer
    Middleware -->|Auto-Trace| Tracer
    Tracer --> Provider
    Provider --> Noop
    Provider --> Stdout
    Provider --> OTLP
    Tracer --> Context
    Context -->|Extract/Inject| Middleware

Components

Main Package (rivaas.dev/tracing)

Core tracing functionality including:

  • Tracer - Main tracer for creating and managing spans
  • New() / MustNew() - Tracer initialization
  • Span management - Create, finish, add attributes/events
  • Middleware() - HTTP request tracing
  • ContextTracing - Helper for router context integration
  • Context helpers - Extract, inject, get trace IDs
  • Testing utilities

Quick API Index

Tracer Creation

tracer, err := tracing.New(options...)     // With error handling
tracer := tracing.MustNew(options...)      // Panics on error

Lifecycle Management

err := tracer.Start(ctx context.Context)   // Start OTLP providers
err := tracer.Shutdown(ctx context.Context) // Graceful shutdown

Span Management

// Create spans
ctx, span := tracer.StartSpan(ctx, "operation-name")
tracer.FinishSpan(span, statusCode)

// Add attributes
tracer.SetSpanAttribute(span, "key", value)

// Add events
tracer.AddSpanEvent(span, "event-name", attrs...)

Context Propagation

// Extract from incoming requests
ctx := tracer.ExtractTraceContext(ctx, req.Header)

// Inject into outgoing requests
tracer.InjectTraceContext(ctx, req.Header)

HTTP Middleware

handler := tracing.Middleware(tracer, options...)(httpHandler)
handler := tracing.MustMiddleware(tracer, options...)(httpHandler)

Context Helpers

traceID := tracing.TraceID(ctx)
spanID := tracing.SpanID(ctx)
tracing.SetSpanAttributeFromContext(ctx, "key", value)
tracing.AddSpanEventFromContext(ctx, "event-name", attrs...)

Testing Utilities

tracer := tracing.TestingTracer(t, options...)
tracer := tracing.TestingTracerWithStdout(t, options...)
middleware := tracing.TestingMiddleware(t, middlewareOptions...)

ContextTracing Helper

ct := tracing.NewContextTracing(ctx, tracer, span)
ct.SetSpanAttribute("key", value)
ct.AddSpanEvent("event-name", attrs...)
traceID := ct.TraceID()

Reference Pages

API Reference

Tracer type, span management, and context propagation.

View →

Options

Configuration options for providers and sampling.

View →

Middleware Options

HTTP middleware configuration and path exclusion.

View →

Troubleshooting

Common tracing issues and solutions.

View →

User Guide

Step-by-step tutorials and examples.

View →

Type Reference

Tracer

type Tracer struct {
    // contains filtered or unexported fields
}

Main tracer for distributed tracing. Thread-safe for concurrent access.

Methods: See API Reference for complete method documentation.

Option

type Option func(*Tracer)

Configuration option function type used with New() and MustNew().

Available Options: See Options for all options.

MiddlewareOption

type MiddlewareOption func(*middlewareConfig)

HTTP middleware configuration option.

Available Options: See Middleware Options for all options.

Provider

type Provider string

const (
    NoopProvider     Provider = "noop"
    StdoutProvider   Provider = "stdout"
    OTLPProvider     Provider = "otlp"
    OTLPHTTPProvider Provider = "otlp-http"
)

Available tracing providers.

EventType

type EventType int

const (
    EventError   EventType = iota // Error events
    EventWarning                   // Warning events
    EventInfo                      // Informational events
    EventDebug                     // Debug events
)

Event severity levels for internal operational events.

Event

type Event struct {
    Type    EventType
    Message string
    Args    []any // slog-style key-value pairs
}

Internal operational event from the tracing package.

EventHandler

type EventHandler func(Event)

Processes internal operational events. Used with WithEventHandler option.

SpanStartHook

type SpanStartHook func(ctx context.Context, span trace.Span, req *http.Request)

Callback invoked when a request span is started.

SpanFinishHook

type SpanFinishHook func(span trace.Span, statusCode int)

Callback invoked when a request span is finished.

Common Patterns

Basic Usage

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithOTLP("localhost:4317"),
)
tracer.Start(context.Background())
defer tracer.Shutdown(context.Background())

ctx, span := tracer.StartSpan(ctx, "operation")
defer tracer.FinishSpan(span, http.StatusOK)

With HTTP Middleware

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithOTLP("localhost:4317"),
)
tracer.Start(context.Background())

handler := tracing.MustMiddleware(tracer,
    tracing.WithExcludePaths("/health"),
)(httpHandler)

http.ListenAndServe(":8080", handler)

Distributed Tracing

// Service A - inject trace context
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
tracer.InjectTraceContext(ctx, req.Header)
resp, _ := http.DefaultClient.Do(req)

// Service B - extract trace context
ctx = tracer.ExtractTraceContext(r.Context(), r.Header)
ctx, span := tracer.StartSpan(ctx, "operation")
defer tracer.FinishSpan(span, http.StatusOK)

Thread Safety

The Tracer type is thread-safe for:

  • All span management methods
  • Concurrent Start() and Shutdown() operations
  • Mixed tracing and lifecycle operations
  • Context propagation methods

Not thread-safe for:

  • Concurrent modification during initialization

Performance Notes

  • Request overhead (100% sampling): ~1.6 microseconds
  • Start/Finish span: ~160 nanoseconds
  • Set attribute: ~3 nanoseconds
  • Path exclusion (100 paths): ~9 nanoseconds

Best Practices:

  • Use sampling for high-traffic endpoints
  • Exclude health checks and metrics endpoints
  • Limit span attribute cardinality
  • Use path prefixes instead of regex when possible

Comparison with Metrics Package

The tracing package follows the same design pattern as the metrics package:

AspectMetrics PackageTracing Package
Main TypeRecorderTracer
Provider OptionsWithPrometheus(), WithOTLP()WithOTLP(), WithStdout(), WithNoop()
ConstructorNew(opts...) (*Recorder, error)New(opts...) (*Tracer, error)
Panic VersionMustNew(opts...) *RecorderMustNew(opts...) *Tracer
MiddlewareMiddleware(recorder, opts...)Middleware(tracer, opts...)
Panic MiddlewareMustMiddleware(recorder, opts...)MustMiddleware(tracer, opts...)
Path ExclusionMiddlewareOptionMiddlewareOption
Header RecordingMiddlewareOptionMiddlewareOption

Version Compatibility

The tracing package follows semantic versioning. The API is stable for the v1 series.

Minimum Go version: 1.25

OpenTelemetry compatibility: Uses OpenTelemetry SDK v1.x

Next Steps

For learning-focused guides, see the Tracing Guide.

3.1.9.1 - API Reference

Complete API documentation for the Tracer type and all methods

Complete API reference for the Tracer type and all tracing methods.

Tracer Type

type Tracer struct {
    // contains filtered or unexported fields
}

The main entry point for distributed tracing. Holds OpenTelemetry tracing configuration and runtime state. All operations on Tracer are thread-safe.

Important Notes

  • Immutable: Tracer is immutable after creation via New(). All configuration must be done through functional options.
  • Thread-safe: All methods are safe for concurrent use.
  • Global state: By default, does NOT set the global OpenTelemetry tracer provider. Use WithGlobalTracerProvider() option if needed.

Constructor Functions

New

func New(opts ...Option) (*Tracer, error)

Creates a new Tracer with the given options. Returns an error if the tracing provider fails to initialize.

Default configuration:

  • Service name: "rivaas-service".
  • Service version: "1.0.0".
  • Sample rate: 1.0 (100%).
  • Provider: NoopProvider.

Example:

tracer, err := tracing.New(
    tracing.WithServiceName("my-api"),
    tracing.WithOTLP("localhost:4317"),
    tracing.WithSampleRate(0.1),
)
if err != nil {
    log.Fatal(err)
}
defer tracer.Shutdown(context.Background())

MustNew

func MustNew(opts ...Option) *Tracer

Creates a new Tracer with the given options. Panics if the tracing provider fails to initialize. Use this when you want to panic on initialization errors.

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithStdout(),
)
defer tracer.Shutdown(context.Background())

Lifecycle Methods

Start

func (t *Tracer) Start(ctx context.Context) error

Initializes OTLP providers that require network connections. The context is used for the OTLP connection establishment. This method is idempotent; calling it multiple times is safe.

Required for: OTLP (gRPC and HTTP) providers
Optional for: Noop and Stdout providers (they initialize immediately in New())

Example:

tracer := tracing.MustNew(
    tracing.WithOTLP("localhost:4317"),
)

if err := tracer.Start(context.Background()); err != nil {
    log.Fatal(err)
}

Shutdown

func (t *Tracer) Shutdown(ctx context.Context) error

Gracefully shuts down the tracing system, flushing any pending spans. This should be called before the application exits to ensure all spans are exported. This method is idempotent - calling it multiple times is safe and will only perform shutdown once.

Example:

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

Span Management Methods

StartSpan

func (t *Tracer) StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span)

Starts a new span with the given name and options. Returns a new context with the span attached and the span itself.

If tracing is disabled, returns the original context and a non-recording span. The returned span should always be ended, even if tracing is disabled.

Parameters:

  • ctx: Parent context
  • name: Span name (should be descriptive)
  • opts: Optional OpenTelemetry span start options

Returns:

  • New context with span attached
  • The created span

Example:

ctx, span := tracer.StartSpan(ctx, "database-query")
defer tracer.FinishSpan(span, http.StatusOK)

tracer.SetSpanAttribute(span, "db.query", "SELECT * FROM users")

FinishSpan

func (t *Tracer) FinishSpan(span trace.Span, statusCode int)

Completes the span with the given status code. Sets the span status based on the HTTP status code:

  • 2xx-3xx: Success (codes.Ok)
  • 4xx-5xx: Error (codes.Error)

This method is safe to call multiple times; subsequent calls are no-ops.

Parameters:

  • span: The span to finish
  • statusCode: HTTP status code (e.g., http.StatusOK)

Example:

defer tracer.FinishSpan(span, http.StatusOK)

SetSpanAttribute

func (t *Tracer) SetSpanAttribute(span trace.Span, key string, value any)

Adds an attribute to the span with type-safe handling.

Supported types:

  • string, int, int64, float64, bool: native OpenTelemetry handling
  • Other types: converted to string using fmt.Sprintf

This is a no-op if tracing is disabled, span is nil, or span is not recording.

Parameters:

  • span: The span to add the attribute to
  • key: Attribute key
  • value: Attribute value

Example:

tracer.SetSpanAttribute(span, "user.id", 12345)
tracer.SetSpanAttribute(span, "user.premium", true)
tracer.SetSpanAttribute(span, "user.name", "Alice")

AddSpanEvent

func (t *Tracer) AddSpanEvent(span trace.Span, name string, attrs ...attribute.KeyValue)

Adds an event to the span with optional attributes. Events represent important moments in a span’s lifetime.

This is a no-op if tracing is disabled, span is nil, or span is not recording.

Parameters:

  • span: The span to add the event to
  • name: Event name
  • attrs: Optional event attributes

Example:

import "go.opentelemetry.io/otel/attribute"

tracer.AddSpanEvent(span, "cache_hit",
    attribute.String("key", "user:123"),
    attribute.Int("ttl_seconds", 300),
)

Context Propagation Methods

ExtractTraceContext

func (t *Tracer) ExtractTraceContext(ctx context.Context, headers http.Header) context.Context

Extracts trace context from HTTP request headers. Returns a new context with the extracted trace information.

If no trace context is found in headers, returns the original context. Uses W3C Trace Context format by default.

Parameters:

  • ctx: Base context
  • headers: HTTP headers to extract from

Returns:

  • Context with extracted trace information

Example:

ctx := tracer.ExtractTraceContext(r.Context(), r.Header)
ctx, span := tracer.StartSpan(ctx, "operation")
defer tracer.FinishSpan(span, http.StatusOK)

InjectTraceContext

func (t *Tracer) InjectTraceContext(ctx context.Context, headers http.Header)

Injects trace context into HTTP headers. This allows trace context to propagate across service boundaries.

Uses W3C Trace Context format by default. This is a no-op if tracing is disabled.

Parameters:

  • ctx: Context containing trace information
  • headers: HTTP headers to inject into

Example:

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
tracer.InjectTraceContext(ctx, req.Header)
resp, _ := http.DefaultClient.Do(req)

Request Span Methods

These methods are used internally by the middleware but can also be used for custom HTTP handling.

StartRequestSpan

func (t *Tracer) StartRequestSpan(ctx context.Context, req *http.Request, path string, isStatic bool) (context.Context, trace.Span)

Starts a span for an HTTP request. This is used by the middleware to create request spans with standard attributes.

Parameters:

  • ctx: Request context
  • req: HTTP request
  • path: Request path
  • isStatic: Whether this is a static route

Returns:

  • Context with span
  • The created span

FinishRequestSpan

func (t *Tracer) FinishRequestSpan(span trace.Span, statusCode int)

Completes the span for an HTTP request. Sets the HTTP status code attribute and invokes the span finish hook if configured.

Parameters:

  • span: The span to finish
  • statusCode: HTTP response status code

Accessor Methods

IsEnabled

func (t *Tracer) IsEnabled() bool

Returns true if tracing is enabled.

ServiceName

func (t *Tracer) ServiceName() string

Returns the service name.

ServiceVersion

func (t *Tracer) ServiceVersion() string

Returns the service version.

GetTracer

func (t *Tracer) GetTracer() trace.Tracer

Returns the OpenTelemetry tracer.

GetPropagator

func (t *Tracer) GetPropagator() propagation.TextMapPropagator

Returns the OpenTelemetry propagator.

GetProvider

func (t *Tracer) GetProvider() Provider

Returns the current tracing provider.

Context Helper Functions

These are package-level functions for working with spans through context.

TraceID

func TraceID(ctx context.Context) string

Returns the current trace ID from the active span in the context. Returns an empty string if no active span or span context is invalid.

Example:

traceID := tracing.TraceID(ctx)
log.Printf("Processing request [trace=%s]", traceID)

SpanID

func SpanID(ctx context.Context) string

Returns the current span ID from the active span in the context. Returns an empty string if no active span or span context is invalid.

Example:

spanID := tracing.SpanID(ctx)
log.Printf("Processing request [span=%s]", spanID)

SetSpanAttributeFromContext

func SetSpanAttributeFromContext(ctx context.Context, key string, value any)

Adds an attribute to the current span from context. This is a no-op if tracing is not active.

Example:

func handleRequest(ctx context.Context) {
    tracing.SetSpanAttributeFromContext(ctx, "user.role", "admin")
    tracing.SetSpanAttributeFromContext(ctx, "user.id", 12345)
}

AddSpanEventFromContext

func AddSpanEventFromContext(ctx context.Context, name string, attrs ...attribute.KeyValue)

Adds an event to the current span from context. This is a no-op if tracing is not active.

Example:

import "go.opentelemetry.io/otel/attribute"

tracing.AddSpanEventFromContext(ctx, "cache_miss",
    attribute.String("key", "user:123"),
)

TraceContext

func TraceContext(ctx context.Context) context.Context

Returns the context as-is (it should already contain trace information). Provided for API consistency.

Event Types

EventType

type EventType int

const (
    EventError   EventType = iota // Error events
    EventWarning                   // Warning events
    EventInfo                      // Informational events
    EventDebug                     // Debug events
)

Event severity levels for internal operational events.

Event

type Event struct {
    Type    EventType
    Message string
    Args    []any // slog-style key-value pairs
}

Internal operational event from the tracing package. Events are used to report errors, warnings, and informational messages about the tracing system’s operation.

EventHandler

type EventHandler func(Event)

Processes internal operational events from the tracing package. Implementations can log events, send them to monitoring systems, or take custom actions based on event type.

Example:

tracing.WithEventHandler(func(e tracing.Event) {
    if e.Type == tracing.EventError {
        sentry.CaptureMessage(e.Message)
    }
    slog.Default().Info(e.Message, e.Args...)
})

DefaultEventHandler

func DefaultEventHandler(logger *slog.Logger) EventHandler

Returns an EventHandler that logs events to the provided slog.Logger. This is the default implementation used by WithLogger.

If logger is nil, returns a no-op handler that discards all events.

Hook Types

SpanStartHook

type SpanStartHook func(ctx context.Context, span trace.Span, req *http.Request)

Called when a request span is started. It receives the context, span, and HTTP request. This can be used for custom attribute injection, dynamic sampling, or integration with APM tools.

Example:

hook := func(ctx context.Context, span trace.Span, req *http.Request) {
    if tenantID := req.Header.Get("X-Tenant-ID"); tenantID != "" {
        span.SetAttributes(attribute.String("tenant.id", tenantID))
    }
}
tracer := tracing.MustNew(
    tracing.WithSpanStartHook(hook),
)

SpanFinishHook

type SpanFinishHook func(span trace.Span, statusCode int)

Called when a request span is finished. It receives the span and the HTTP status code. This can be used for custom metrics, logging, or post-processing.

Example:

hook := func(span trace.Span, statusCode int) {
    if statusCode >= 500 {
        metrics.IncrementServerErrors()
    }
}
tracer := tracing.MustNew(
    tracing.WithSpanFinishHook(hook),
)

ContextTracing Type

type ContextTracing struct {
    // contains filtered or unexported fields
}

A helper type for router context integration that provides convenient access to tracing functionality within HTTP handlers.

NewContextTracing

func NewContextTracing(ctx context.Context, tracer *Tracer, span trace.Span) *ContextTracing

Creates a new context tracing helper. Panics if ctx is nil.

Parameters:

  • ctx: The request context
  • tracer: The Tracer instance
  • span: The current span

Example:

ct := tracing.NewContextTracing(ctx, tracer, span)

ContextTracing Methods

TraceID

func (ct *ContextTracing) TraceID() string

Returns the current trace ID. Returns an empty string if no valid span.

SpanID

func (ct *ContextTracing) SpanID() string

Returns the current span ID. Returns an empty string if no valid span.

SetSpanAttribute

func (ct *ContextTracing) SetSpanAttribute(key string, value any)

Adds an attribute to the current span. No-op if span is nil or not recording.

AddSpanEvent

func (ct *ContextTracing) AddSpanEvent(name string, attrs ...attribute.KeyValue)

Adds an event to the current span. No-op if span is nil or not recording.

TraceContext

func (ct *ContextTracing) TraceContext() context.Context

Returns the trace context.

GetSpan

func (ct *ContextTracing) GetSpan() trace.Span

Returns the current span.

GetTracer

func (ct *ContextTracing) GetTracer() *Tracer

Returns the underlying Tracer.

ContextTracing Example

func handleRequest(w http.ResponseWriter, r *http.Request, tracer *tracing.Tracer) {
    ctx := r.Context()
    span := trace.SpanFromContext(ctx)
    
    // Create context tracing helper
    ct := tracing.NewContextTracing(ctx, tracer, span)
    
    // Use helper methods
    ct.SetSpanAttribute("user.id", "123")
    ct.AddSpanEvent("processing_started")
    
    // Get trace info for logging
    log.Printf("Processing [trace=%s, span=%s]", ct.TraceID(), ct.SpanID())
}

Constants

Default Values

const (
    DefaultServiceName    = "rivaas-service"
    DefaultServiceVersion = "1.0.0"
    DefaultSampleRate     = 1.0
)

Default configuration values used when not explicitly set.

Provider Types

const (
    NoopProvider     Provider = "noop"
    StdoutProvider   Provider = "stdout"
    OTLPProvider     Provider = "otlp"
    OTLPHTTPProvider Provider = "otlp-http"
)

Available tracing providers.

Next Steps

3.1.9.2 - Tracer Options

All configuration options for Tracer initialization

Complete reference for all Option functions used to configure the Tracer.

Option Type

type Option func(*Tracer)

Configuration option function type used with New() and MustNew(). Options are applied during Tracer creation.

Service Configuration Options

WithServiceName

func WithServiceName(name string) Option

Sets the service name for tracing. This name appears in span attributes as service.name.

Parameters:

  • name: Service identifier like "user-api" or "order-service".

Default: "rivaas-service"

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("user-api"),
)

WithServiceVersion

func WithServiceVersion(version string) Option

Sets the service version for tracing. This version appears in span attributes as service.version.

Parameters:

  • version: Service version like "v1.2.3" or "dev".

Default: "1.0.0"

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("user-api"),
    tracing.WithServiceVersion("v1.2.3"),
)

Provider Options

Only one provider can be configured at a time. Configuring multiple providers results in a validation error.

WithNoop

func WithNoop() Option

Configures noop provider. This is the default. No traces are exported. Use for testing or when tracing is disabled.

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithNoop(),
)

WithStdout

func WithStdout() Option

Configures stdout provider for development/debugging. Traces are printed to standard output in pretty-printed JSON format.

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithStdout(),
)

WithOTLP

func WithOTLP(endpoint string, opts ...OTLPOption) Option

Configures OTLP gRPC provider with endpoint. Use this for production deployments with OpenTelemetry collectors.

Parameters:

  • endpoint: OTLP endpoint in format "host:port" (e.g., "localhost:4317")
  • opts: Optional OTLP-specific options (e.g., OTLPInsecure())

Requires: Call tracer.Start(ctx) before tracing

Example:

// Secure (TLS enabled by default)
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("collector.example.com:4317"),
)

// Insecure (local development)
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317", tracing.OTLPInsecure()),
)

WithOTLPHTTP

func WithOTLPHTTP(endpoint string) Option

Configures OTLP HTTP provider with endpoint. Use this when gRPC is not available or HTTP is preferred.

Parameters:

  • endpoint: OTLP HTTP endpoint with protocol (e.g., "http://localhost:4318", "https://collector:4318")

Requires: Call tracer.Start(ctx) before tracing

Example:

// HTTP (insecure - development)
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLPHTTP("http://localhost:4318"),
)

// HTTPS (secure - production)
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLPHTTP("https://collector.example.com:4318"),
)

OTLP Options

OTLPInsecure

func OTLPInsecure() OTLPOption

Enables insecure gRPC for OTLP. Default is false (uses TLS). Set to true for local development.

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317", tracing.OTLPInsecure()),
)

Sampling Options

WithSampleRate

func WithSampleRate(rate float64) Option

Sets the sampling rate (0.0 to 1.0). Values outside this range are clamped to valid bounds.

A rate of 1.0 samples all requests, 0.5 samples 50%, and 0.0 samples none. Sampling decisions are made per-request based on the configured rate.

Parameters:

  • rate: Sampling rate between 0.0 and 1.0

Default: 1.0 (100% sampling)

Example:

// Sample 10% of requests
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithSampleRate(0.1),
)

Hook Options

WithSpanStartHook

func WithSpanStartHook(hook SpanStartHook) Option

Sets a callback that is invoked when a request span is started. The hook receives the context, span, and HTTP request, allowing custom attribute injection, dynamic sampling decisions, or integration with APM tools.

Type:

type SpanStartHook func(ctx context.Context, span trace.Span, req *http.Request)

Example:

startHook := func(ctx context.Context, span trace.Span, req *http.Request) {
    if tenantID := req.Header.Get("X-Tenant-ID"); tenantID != "" {
        span.SetAttributes(attribute.String("tenant.id", tenantID))
    }
}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithSpanStartHook(startHook),
)

WithSpanFinishHook

func WithSpanFinishHook(hook SpanFinishHook) Option

Sets a callback that is invoked when a request span is finished. The hook receives the span and HTTP status code, allowing custom metrics recording, logging, or post-processing.

Type:

type SpanFinishHook func(span trace.Span, statusCode int)

Example:

finishHook := func(span trace.Span, statusCode int) {
    if statusCode >= 500 {
        metrics.IncrementServerErrors()
    }
}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithSpanFinishHook(finishHook),
)

Logging Options

WithLogger

func WithLogger(logger *slog.Logger) Option

Sets the logger for internal operational events using the default event handler. This is a convenience wrapper around WithEventHandler that logs events to the provided slog.Logger.

Parameters:

  • logger: *slog.Logger for logging internal events

Example:

import "log/slog"

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithLogger(logger),
)

WithEventHandler

func WithEventHandler(handler EventHandler) Option

Sets a custom event handler for internal operational events. Use this for advanced use cases like sending errors to Sentry, custom alerting, or integrating with non-slog logging systems.

Type:

type EventHandler func(Event)

Example:

eventHandler := func(e tracing.Event) {
    switch e.Type {
    case tracing.EventError:
        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-service"),
    tracing.WithEventHandler(eventHandler),
)

Advanced Options

WithTracerProvider

func WithTracerProvider(provider trace.TracerProvider) Option

Allows you to provide a custom OpenTelemetry TracerProvider. When using this option, the package will NOT set the global otel.SetTracerProvider() by default. Use WithGlobalTracerProvider() if you want global registration.

Use cases:

  • Manage tracer provider lifecycle yourself
  • Need multiple independent tracing configurations
  • Want to avoid global state in your application

Important: When using WithTracerProvider, provider options (WithOTLP, WithStdout, etc.) are ignored since you’re managing the provider yourself. You are also responsible for calling Shutdown() on your provider.

Example:

import sdktrace "go.opentelemetry.io/otel/sdk/trace"

tp := sdktrace.NewTracerProvider(
    // Your custom configuration
    sdktrace.WithSampler(sdktrace.AlwaysSample()),
)

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithTracerProvider(tp),
)

// You manage tp.Shutdown() yourself
defer tp.Shutdown(context.Background())

WithCustomTracer

func WithCustomTracer(tracer trace.Tracer) Option

Allows using a custom OpenTelemetry tracer. This is useful when you need specific tracer configuration or want to use a tracer from an existing OpenTelemetry setup.

Example:

tp := trace.NewTracerProvider(...)
customTracer := tp.Tracer("my-tracer")

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithCustomTracer(customTracer),
)

WithCustomPropagator

func WithCustomPropagator(propagator propagation.TextMapPropagator) Option

Allows using a custom OpenTelemetry propagator. This is useful for custom trace context propagation formats. By default, uses the global propagator from otel.GetTextMapPropagator() (W3C Trace Context).

Example:

import "go.opentelemetry.io/otel/propagation"

// Use W3C Trace Context explicitly
prop := propagation.TraceContext{}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithCustomPropagator(prop),
)

Using B3 propagation:

import "go.opentelemetry.io/contrib/propagators/b3"

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithCustomPropagator(b3.New()),
)

WithGlobalTracerProvider

func WithGlobalTracerProvider() Option

Registers the tracer provider as the global OpenTelemetry tracer provider via otel.SetTracerProvider(). By default, tracer providers are not registered globally to allow multiple tracing configurations to coexist in the same process.

Use when:

  • You want otel.GetTracerProvider() to return your tracer
  • Integrating with libraries that use the global tracer
  • Single tracer for entire application

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317"),
    tracing.WithGlobalTracerProvider(), // Register globally
)

Option Combinations

Development Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithServiceVersion("dev"),
    tracing.WithStdout(),
    tracing.WithSampleRate(1.0),
    tracing.WithLogger(slog.Default()),
)

Production Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("user-api"),
    tracing.WithServiceVersion(os.Getenv("VERSION")),
    tracing.WithOTLP(os.Getenv("OTLP_ENDPOINT")),
    tracing.WithSampleRate(0.1),
    tracing.WithSpanStartHook(enrichSpan),
    tracing.WithSpanFinishHook(recordMetrics),
)

Testing Configuration

tracer := tracing.MustNew(
    tracing.WithServiceName("test-service"),
    tracing.WithServiceVersion("v1.0.0"),
    tracing.WithNoop(),
    tracing.WithSampleRate(1.0),
)

Validation Errors

Configuration is validated when calling New() or MustNew(). Common validation errors:

Multiple Providers

// ✗ 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"

Solution: Only configure one provider.

Empty Service Name

// ✗ Error: service name cannot be empty
tracer, err := tracing.New(
    tracing.WithServiceName(""),
)
// Returns: "invalid configuration: serviceName: cannot be empty"

Solution: Always provide a service name.

Invalid Sample Rate

// Values are automatically clamped
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithSampleRate(1.5), // Clamped to 1.0
)

Sample rates outside 0.0-1.0 are automatically clamped to valid bounds.

Complete Option Reference

OptionDescriptionDefault
WithServiceName(name)Set service name"rivaas-service"
WithServiceVersion(version)Set service version"1.0.0"
WithNoop()Noop providerYes (default)
WithStdout()Stdout provider-
WithOTLP(endpoint, opts...)OTLP gRPC provider-
WithOTLPHTTP(endpoint)OTLP HTTP provider-
WithSampleRate(rate)Sampling rate (0.0-1.0)1.0
WithSpanStartHook(hook)Span start callback-
WithSpanFinishHook(hook)Span finish callback-
WithLogger(logger)Set slog logger-
WithEventHandler(handler)Custom event handler-
WithTracerProvider(provider)Custom tracer provider-
WithCustomTracer(tracer)Custom tracer-
WithCustomPropagator(prop)Custom propagatorW3C Trace Context
WithGlobalTracerProvider()Register globallyNo

Next Steps

3.1.9.3 - Middleware Options

All configuration options for HTTP middleware

Complete reference for all MiddlewareOption functions used to configure the HTTP tracing middleware.

MiddlewareOption Type

type MiddlewareOption func(*middlewareConfig)

Configuration option function type used with Middleware() and MustMiddleware(). These options control HTTP request tracing behavior.

Path Exclusion Options

Exclude specific paths from tracing to reduce noise and overhead.

WithExcludePaths

func WithExcludePaths(paths ...string) MiddlewareOption

Excludes specific paths from tracing. Excluded paths will not create spans or record any tracing data. This is useful for health checks, metrics endpoints, etc.

Maximum of 1000 paths can be excluded to prevent unbounded growth.

Parameters:

  • paths: Exact paths to exclude (e.g., "/health", "/metrics")

Performance: O(1) hash map lookup

Example:

handler := tracing.Middleware(tracer,
    tracing.WithExcludePaths("/health", "/metrics", "/ready", "/live"),
)(mux)

WithExcludePrefixes

func WithExcludePrefixes(prefixes ...string) MiddlewareOption

Excludes paths with the given prefixes from tracing. This is useful for excluding entire path hierarchies like /debug/, /internal/, etc.

Parameters:

  • prefixes: Path prefixes to exclude (e.g., "/debug/", "/internal/")

Performance: O(n) where n = number of prefixes

Example:

handler := tracing.Middleware(tracer,
    tracing.WithExcludePrefixes("/debug/", "/internal/", "/.well-known/"),
)(mux)

Matches:

  • /debug/pprof
  • /debug/vars
  • /internal/health
  • /.well-known/acme-challenge

WithExcludePatterns

func WithExcludePatterns(patterns ...string) MiddlewareOption

Excludes paths matching the given regex patterns from tracing. The patterns are compiled once during configuration. Returns a validation error if any pattern fails to compile.

Parameters:

  • patterns: Regular expression patterns (e.g., "^/v[0-9]+/internal/.*")

Performance: O(p) where p = number of patterns

Validation: Invalid regex patterns cause the middleware to panic during initialization.

Example:

handler := tracing.Middleware(tracer,
    tracing.WithExcludePatterns(
        `^/v[0-9]+/internal/.*`,  // Version-prefixed internal routes
        `^/api/health.*`,          // Any health-related endpoint
        `^/debug/.*`,              // All debug routes
    ),
)(mux)

Matches:

  • /v1/internal/status
  • /v2/internal/debug
  • /api/health
  • /api/health/db
  • /debug/pprof/heap

Header Recording Options

WithHeaders

func WithHeaders(headers ...string) MiddlewareOption

Records specific request headers as span attributes. Header names are case-insensitive. Recorded as http.request.header.{name}.

Security: Sensitive headers (Authorization, Cookie, etc.) are automatically filtered out to prevent accidental exposure of credentials in traces.

Parameters:

  • headers: Header names to record (case-insensitive)

Recorded as: Lowercase header names (http.request.header.x-request-id)

Example:

handler := tracing.Middleware(tracer,
    tracing.WithHeaders("X-Request-ID", "X-Correlation-ID", "User-Agent"),
)(mux)

Span attributes:

  • http.request.header.x-request-id: "abc123"
  • http.request.header.x-correlation-id: "xyz789"
  • http.request.header.user-agent: "Mozilla/5.0..."

Sensitive Header Filtering

The following headers are automatically filtered and will never be recorded, even if explicitly included:

  • Authorization
  • Cookie
  • Set-Cookie
  • X-API-Key
  • X-Auth-Token
  • Proxy-Authorization
  • WWW-Authenticate

Example:

// Authorization is automatically filtered
handler := tracing.Middleware(tracer,
    tracing.WithHeaders(
        "X-Request-ID",
        "Authorization", // ← Filtered, won't be recorded
        "X-Correlation-ID",
    ),
)(mux)

Query Parameter Recording Options

Default Behavior

By default, all query parameters are recorded as span attributes.

WithRecordParams

func WithRecordParams(params ...string) MiddlewareOption

Specifies which URL query parameters to record as span attributes. Only parameters in this list will be recorded. This provides fine-grained control over which parameters are traced.

If this option is not used, all query parameters are recorded by default (unless WithoutParams is used).

Parameters:

  • params: Parameter names to record

Recorded as: http.request.param.{name}

Example:

handler := tracing.Middleware(tracer,
    tracing.WithRecordParams("user_id", "request_id", "page", "limit"),
)(mux)

Request: GET /api/users?page=2&limit=10&user_id=123&secret=xyz

Span attributes:

  • http.request.param.page: ["2"]
  • http.request.param.limit: ["10"]
  • http.request.param.user_id: ["123"]
  • secret is not recorded (not in whitelist)

WithExcludeParams

func WithExcludeParams(params ...string) MiddlewareOption

Specifies which URL query parameters to exclude from tracing. This is useful for blacklisting sensitive parameters while recording all others.

Parameters in this list will never be recorded, even if WithRecordParams includes them (blacklist takes precedence).

Parameters:

  • params: Parameter names to exclude

Example:

handler := tracing.Middleware(tracer,
    tracing.WithExcludeParams("password", "token", "api_key", "secret"),
)(mux)

Request: GET /api/users?page=2&password=secret123&user_id=123

Span attributes:

  • http.request.param.page: ["2"]
  • http.request.param.user_id: ["123"]
  • password is not recorded (blacklisted)

WithoutParams

func WithoutParams() MiddlewareOption

Disables recording URL query parameters as span attributes. By default, all query parameters are recorded. Use this option if parameters may contain sensitive data.

Example:

handler := tracing.Middleware(tracer,
    tracing.WithoutParams(),
)(mux)

No query parameters will be recorded regardless of the request.

Parameter Recording Precedence

When multiple parameter options are used:

  1. WithoutParams() - If set, no parameters are recorded
  2. WithExcludeParams() - Blacklist takes precedence over whitelist
  3. WithRecordParams() - Only whitelisted parameters are recorded
  4. Default - All parameters are recorded

Example:

// Whitelist with blacklist
handler := tracing.Middleware(tracer,
    tracing.WithRecordParams("page", "limit", "sort", "api_key"),
    tracing.WithExcludeParams("api_key", "token"), // Blacklist overrides
)(mux)

Result: page, limit, and sort are recorded, but api_key is excluded (blacklist wins).

Middleware Functions

Middleware

func Middleware(tracer *Tracer, opts ...MiddlewareOption) func(http.Handler) http.Handler

Creates a middleware function for standalone HTTP integration. Panics if any middleware option is invalid (e.g., invalid regex pattern).

Parameters:

  • tracer: Tracer instance
  • opts: Middleware configuration options

Returns: HTTP middleware function

Panics: If middleware options are invalid

Example:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-api"),
    tracing.WithOTLP("localhost:4317"),
)

handler := tracing.Middleware(tracer,
    tracing.WithExcludePaths("/health", "/metrics"),
    tracing.WithHeaders("X-Request-ID"),
)(mux)

MustMiddleware

func MustMiddleware(tracer *Tracer, opts ...MiddlewareOption) func(http.Handler) http.Handler

Creates a middleware function for standalone HTTP integration. It panics if any middleware option is invalid (e.g., invalid regex pattern). This is a convenience wrapper around Middleware for consistency with MustNew.

Behavior: Identical to Middleware() - both panic on invalid options.

Example:

handler := tracing.MustMiddleware(tracer,
    tracing.WithExcludePaths("/health", "/metrics"),
    tracing.WithHeaders("X-Request-ID"),
)(mux)

Complete Examples

Minimal Middleware

// Trace everything with no filtering
handler := tracing.Middleware(tracer)(mux)

Production Middleware

handler := tracing.Middleware(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", "filter"),
    
    // Blacklist sensitive parameters
    tracing.WithExcludeParams("password", "token", "api_key"),
)(mux)

Development Middleware

handler := tracing.Middleware(tracer,
    // Only exclude metrics
    tracing.WithExcludePaths("/metrics"),
    
    // Record all headers (except sensitive ones)
    tracing.WithHeaders("X-Request-ID", "X-Correlation-ID", "User-Agent"),
)(mux)

High-Security Middleware

handler := tracing.Middleware(tracer,
    // Exclude health checks
    tracing.WithExcludePaths("/health"),
    
    // No headers recorded
    // No query parameters recorded
    tracing.WithoutParams(),
)(mux)

Performance Considerations

Path Exclusion Performance

MethodComplexityPerformance
WithExcludePaths()O(1)~9ns per request (hash lookup)
WithExcludePrefixes()O(n)~9ns per request (n prefixes)
WithExcludePatterns()O(p)~20ns per request (p patterns)

Recommendation: Use exact paths when possible for best performance.

Memory Usage

  • Path exclusion: ~100 bytes per path
  • Header recording: ~50 bytes per header
  • Parameter recording: ~30 bytes per parameter name

Limits

  • Maximum excluded paths: 1000 (enforced by WithExcludePaths)
  • No limit on: Prefixes, patterns, headers, parameters

Validation Errors

Configuration is validated when calling Middleware() or MustMiddleware(). Invalid options cause a panic.

Invalid Regex Pattern

// ✗ Panics: invalid regex
handler := tracing.Middleware(tracer,
    tracing.WithExcludePatterns(`[invalid regex`),
)(mux)
// Panics: "middleware validation errors: excludePatterns: invalid regex..."

Solution: Ensure regex patterns are valid.

Option Reference Table

OptionDescriptionDefault Behavior
WithExcludePaths(paths...)Exclude exact pathsAll paths traced
WithExcludePrefixes(prefixes...)Exclude by prefixAll paths traced
WithExcludePatterns(patterns...)Exclude by regexAll paths traced
WithHeaders(headers...)Record headersNo headers recorded
WithRecordParams(params...)Whitelist paramsAll params recorded
WithExcludeParams(params...)Blacklist paramsNo params excluded
WithoutParams()Disable paramsAll params recorded

Best Practices

Always Exclude Health Checks

tracing.WithExcludePaths("/health", "/metrics", "/ready", "/live")

Health checks are high-frequency and low-value for tracing.

Use Exact Paths for Common Exclusions

// ✓ Good - fastest
tracing.WithExcludePaths("/health", "/metrics")

// ✗ Less optimal - slower
tracing.WithExcludePatterns("^/(health|metrics)$")

Blacklist Sensitive Parameters

tracing.WithExcludeParams(
    "password", "token", "api_key", "secret",
    "credit_card", "ssn", "access_token",
)

Record Correlation Headers

tracing.WithHeaders("X-Request-ID", "X-Correlation-ID", "X-Trace-ID")

Helps correlate traces with logs and other observability data.

Combine Exclusion Methods

handler := tracing.Middleware(tracer,
    tracing.WithExcludePaths("/health", "/metrics"),      // Exact
    tracing.WithExcludePrefixes("/debug/", "/internal/"), // Prefix
    tracing.WithExcludePatterns(`^/v[0-9]+/internal/.*`), // Regex
)(mux)

Next Steps

3.1.9.4 - Troubleshooting

Common issues and solutions for the tracing package

Common issues and solutions when using the tracing package.

Traces Not Appearing

Symptom

No traces appear in your tracing backend (Jaeger, Zipkin, etc.) even though tracing is configured.

Possible Causes & Solutions

1. OTLP Provider Not Started

Problem: OTLP providers require calling Start(ctx) before tracing.

Solution:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317"),
)

// ✓ Required for OTLP providers
if err := tracer.Start(context.Background()); err != nil {
    log.Fatal(err)
}

2. Sampling Rate Too Low

Problem: Sample rate is set too low. For example, 1% sampling means 99% of requests aren’t traced.

Solution: Increase sample rate or remove sampling for testing.

// Development - trace everything
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithSampleRate(1.0), // 100% sampling
)

3. Wrong Provider Configured

Problem: Using Noop provider (no traces exported).

Solution: Verify provider configuration:

// ✗ Bad - no traces exported
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithNoop(), // No traces!
)

// ✓ Good - traces exported
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317"),
)

4. Paths Excluded from Tracing

Problem: Paths are excluded via middleware options.

Solution: Check middleware exclusions.

// Check if your paths are excluded
handler := tracing.Middleware(tracer,
    tracing.WithExcludePaths("/health", "/api/users"), // ← Is this excluding your endpoint?
)(mux)

5. Shutdown Called Too Early

Problem: Application exits before spans are exported.

Solution: Ensure proper shutdown with timeout:

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

6. OTLP Endpoint Unreachable

Problem: OTLP collector is not running or unreachable.

Solution: Verify collector is running:

# Check if collector is listening
nc -zv localhost 4317  # OTLP gRPC
nc -zv localhost 4318  # OTLP HTTP

Check logs for connection errors:

logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317"),
    tracing.WithLogger(logger), // See connection errors
)

Context Propagation Issues

Symptom

Services create separate traces instead of one distributed trace.

Possible Causes & Solutions

1. Context Not Propagated

Problem: Context is not passed through the call chain.

Solution: Always pass context:

// ✓ Good - context propagates
func handler(ctx context.Context) {
    result := doWork(ctx)  // Pass context
}

// ✗ Bad - context lost
func handler(ctx context.Context) {
    result := doWork(context.Background())  // Lost!
}

2. Trace Context Not Injected

Problem: Trace context not injected into outgoing requests.

Solution: Always inject before making requests:

req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)

// ✓ Required - inject trace context
tracer.InjectTraceContext(ctx, req.Header)

resp, _ := http.DefaultClient.Do(req)

3. Trace Context Not Extracted

Problem: Incoming requests don’t extract trace context.

Solution: Middleware automatically extracts, or do it manually:

// Automatic (with middleware)
handler := tracing.Middleware(tracer)(mux)

// Manual (without middleware)
func myHandler(w http.ResponseWriter, r *http.Request) {
    ctx := tracer.ExtractTraceContext(r.Context(), r.Header)
    // Use extracted context...
}

4. Different Propagators

Problem: Services use different propagation formats.

Solution: Ensure all services use the same propagator (default is W3C Trace Context):

// All services should use default (W3C) or same custom propagator
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    // Default propagator is W3C Trace Context
)

Performance Issues

Symptom

High CPU usage, increased latency, or memory consumption.

Possible Causes & Solutions

1. Too Much Sampling

Problem: Sampling 100% of high-traffic endpoints.

Solution: Reduce sample rate:

// For high-traffic services (> 1000 req/s)
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithSampleRate(0.1), // 10% sampling
)

2. Not Excluding High-Frequency Endpoints

Problem: Tracing health checks and metrics endpoints.

Solution: Exclude them:

handler := tracing.Middleware(tracer,
    tracing.WithExcludePaths("/health", "/metrics", "/ready", "/live"),
)(mux)

3. Too Many Span Attributes

Problem: Adding excessive attributes to every span.

Solution: Only add essential attributes:

// ✓ Good - essential attributes
tracer.SetSpanAttribute(span, "user.id", userID)
tracer.SetSpanAttribute(span, "request.id", requestID)

// ✗ Bad - too many attributes
for k, v := range req.Header {
    tracer.SetSpanAttribute(span, k, v) // Don't do this!
}

4. Using Regex for Path Exclusion

Problem: Regex patterns are slower than exact paths.

Solution: Prefer exact paths or prefixes:

// ✓ Faster - O(1) hash lookup
tracing.WithExcludePaths("/health", "/metrics")

// ✗ Slower - O(p) regex matching
tracing.WithExcludePatterns("^/(health|metrics)$")

Configuration Errors

Multiple Providers Configured

Error: "validation errors: provider: multiple providers configured"

Problem: Attempting to configure multiple providers.

Solution: Only configure one provider:

// ✗ Error - multiple providers
tracer, err := tracing.New(
    tracing.WithServiceName("my-service"),
    tracing.WithStdout(),
    tracing.WithOTLP("localhost:4317"), // Error!
)

// ✓ Good - one provider
tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317"),
)

Empty Service Name

Error: "invalid configuration: serviceName: cannot be empty"

Problem: Service name not provided or empty string.

Solution: Always provide a service name:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"), // Required
)

Invalid Regex Pattern

Error: Middleware panics with "middleware validation errors: excludePatterns: invalid regex..."

Problem: Invalid regex pattern in WithExcludePatterns.

Solution: Validate regex patterns:

// ✗ Invalid regex
tracing.WithExcludePatterns(`[invalid`)

// ✓ Valid regex
tracing.WithExcludePatterns(`^/v[0-9]+/internal/.*`)

OTLP Connection Issues

TLS Certificate Errors

Problem: TLS certificate verification fails.

Solution: Use insecure connection for local development:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317", tracing.OTLPInsecure()),
)

For production, ensure proper TLS certificates are configured.

Connection Refused

Problem: Cannot connect to OTLP endpoint.

Solution:

  1. Verify collector is running:

    docker ps | grep otel-collector
    
  2. Check endpoint is correct:

    // Correct format: "host:port"
    tracing.WithOTLP("localhost:4317")
    
    // Not: "http://localhost:4317" (no protocol for gRPC)
    
  3. Check network connectivity:

    telnet localhost 4317
    

Wrong Endpoint for HTTP

Problem: Using gRPC endpoint for HTTP or vice versa.

Solution: Use correct provider and endpoint:

// OTLP gRPC (port 4317)
tracing.WithOTLP("localhost:4317")

// OTLP HTTP (port 4318, include protocol)
tracing.WithOTLPHTTP("http://localhost:4318")

Middleware Issues

Spans Not Created

Problem: Middleware doesn’t create spans for requests.

Solution: Ensure middleware is applied:

mux := http.NewServeMux()
mux.HandleFunc("/api/users", handleUsers)

// ✓ Middleware applied
handler := tracing.Middleware(tracer)(mux)
http.ListenAndServe(":8080", handler)

// ✗ Middleware not applied
http.ListenAndServe(":8080", mux) // No tracing!

Context Lost in Handlers

Problem: Context doesn’t contain trace information.

Solution: Use context from request:

func handleUsers(w http.ResponseWriter, r *http.Request) {
    // ✓ Good - use request context
    ctx := r.Context()
    traceID := tracing.TraceID(ctx)
    
    // ✗ Bad - creates new context
    ctx := context.Background() // Lost trace context!
}

Testing Issues

Tests Fail to Clean Up

Problem: Tests hang or don’t complete cleanup.

Solution: Use testing utilities:

func TestSomething(t *testing.T) {
    // ✓ Good - automatic cleanup
    tracer := tracing.TestingTracer(t)
    
    // ✗ Bad - manual cleanup required
    tracer, _ := tracing.New(tracing.WithNoop())
    defer tracer.Shutdown(context.Background())
}

Race Conditions in Tests

Problem: Race detector reports issues in parallel tests.

Solution: Use t.Parallel() correctly:

func TestParallel(t *testing.T) {
    t.Parallel() // Each test gets its own tracer
    
    tracer := tracing.TestingTracer(t)
    // Use tracer...
}

Debugging Tips

Enable Debug Logging

See internal events:

logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelDebug,
}))

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithLogger(logger),
)

Use Stdout Provider

See traces immediately:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithStdout(), // Print traces to console
)

Check Trace IDs

Verify trace context is propagated:

func handleRequest(ctx context.Context) {
    traceID := tracing.TraceID(ctx)
    log.Printf("Processing request [trace=%s]", traceID)
    
    if traceID == "" {
        log.Printf("WARNING: No trace context!")
    }
}

Verify Sampling

Log sampling decisions:

startHook := func(ctx context.Context, span trace.Span, req *http.Request) {
    if span.SpanContext().IsValid() {
        log.Printf("Request sampled: %s", req.URL.Path)
    } else {
        log.Printf("Request not sampled: %s", req.URL.Path)
    }
}

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithSpanStartHook(startHook),
)

Getting Help

Check Documentation

Check Logs

Enable debug logging to see internal events:

tracing.WithLogger(slog.Default())

Verify Configuration

Print configuration at startup:

tracer := tracing.MustNew(
    tracing.WithServiceName("my-service"),
    tracing.WithOTLP("localhost:4317"),
    tracing.WithSampleRate(0.1),
)

log.Printf("Tracer configured:")
log.Printf("  Service: %s", tracer.ServiceName())
log.Printf("  Version: %s", tracer.ServiceVersion())
log.Printf("  Provider: %s", tracer.GetProvider())
log.Printf("  Enabled: %v", tracer.IsEnabled())

Common Pitfalls Checklist

  • Called Start() for OTLP providers?
  • Shutdown with proper timeout?
  • Context propagated through call chain?
  • Trace context injected into outgoing requests?
  • Sample rate high enough to see traces?
  • Paths not excluded from tracing?
  • OTLP collector running and reachable?
  • All services using same propagator?
  • Only one provider configured?

Version Compatibility

Go Version

Minimum required: Go 1.25+

Error: go: module requires Go 1.25 or later

Solution: Upgrade Go version:

go version  # Check current version
# Upgrade to Go 1.25+

OpenTelemetry Version

The tracing package uses OpenTelemetry SDK v1.x. If you have conflicts with other dependencies:

go mod tidy
go get -u rivaas.dev/tracing

Next Steps

4 - About Rivaas

Learn about the philosophy and principles behind Rivaas

Welcome to the About section! Here you can learn about the ideas and principles that guide Rivaas development.

What is Rivaas?

Rivaas is a web framework for Go. We built it to make creating web APIs easier and more enjoyable. The name comes from ریواس (Rivās), a wild rhubarb plant that grows in the mountains of Iran.

Just like this tough mountain plant, Rivaas is:

  • Strong — Built to handle production workloads
  • Light — Fast and uses little memory
  • Flexible — Works in many different environments
  • Independent — Each piece works on its own

Our Goals

We want Rivaas to be:

  1. Easy to use — You should understand it quickly
  2. Hard to misuse — Good defaults keep you safe
  3. Fun to work with — Clear APIs and helpful errors
  4. Ready for production — Works well from day one

Design Philosophy

Every decision we make follows a few key ideas:

  • Developer experience comes first — Your time is valuable
  • Simple things stay simple — Basic tasks need simple code
  • Advanced features are available — But they don’t get in your way
  • Each package stands alone — Use only what you need

Learn More

Want to understand how we built Rivaas? Read about our design principles:

Design Principles →

Join the Community

Rivaas is open source. We welcome your ideas and contributions!

4.1 - Design Principles

Core principles that guide how we build Rivaas

This page explains the core ideas behind Rivaas. Understanding these principles helps you use the framework better. If you want to contribute code, these principles guide your work.

Core Philosophy

Developer Experience First

We put your experience as a developer first. Every choice we make thinks about how it affects you.

What this means:

When you use Rivaas, you should feel like the framework helps you, not fights you. Good defaults mean you can start quickly. Clear errors help you fix problems fast. The API should feel natural.

In practice:

  • Everything works without configuration
  • Simple tasks use simple code
  • Error messages tell you what went wrong and how to fix it
  • Your IDE can show you all available options

Example: Sensible Defaults

// This works right away - no setup needed
app := app.MustNew()

// Add configuration when you need it
app := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithEnvironment("production"),
)

The first example works perfectly for getting started. The second example shows how to customize when you need to.

Progressive Disclosure

Simple use cases stay simple. Advanced features exist but don’t make basic tasks harder.

Three levels:

  1. Basic — Works immediately with good defaults
  2. Intermediate — Common changes are easy
  3. Advanced — Full control when you need it

Example:

// Level 1: Basic - just works
logger := logging.MustNew()

// Level 2: Common customization
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelDebug),
)

// Level 3: Advanced - full control
logger := logging.MustNew(
    logging.WithCustomLogger(myCustomLogger),
    logging.WithSampling(logging.SamplingConfig{
        Initial:    100,
        Thereafter: 100,
        Tick:       time.Minute,
    }),
)

Discoverable APIs

Your IDE should help you find what you need. When you type metrics.With..., your IDE shows all options.

metrics.MustNew(
    metrics.With...  // IDE shows: WithProvider, WithPort, WithPath, etc.
)

Fail Fast with Clear Errors

Configuration errors happen at startup, not during requests. This helps you catch problems early.

// Returns a clear error immediately
app, err := app.New(
    app.WithServerTimeout(-1 * time.Second), // Invalid
)
// Error: "server.readTimeout: must be positive"

Convenience Without Sacrificing Control

We provide two ways to create things:

  • MustNew() — Panics on error (good for main function)
  • New() — Returns error (good for tests and libraries)
// In main() - panic is fine
app := app.MustNew(...)

// In tests or libraries - handle errors
app, err := app.New(...)
if err != nil {
    return fmt.Errorf("failed to create app: %w", err)
}

Architectural Patterns

Functional Options Pattern

All Rivaas packages use the same configuration pattern. This keeps the API consistent across packages.

Benefits:

  • Backward compatible — Adding new options doesn’t break existing code
  • Good defaults — You only specify what you want to change
  • Self-documenting — Option names tell you what they do
  • Easy to combine — Options work together naturally
  • IDE-friendly — Autocomplete shows all options

How it works:

Every package follows this structure:

// Step 1: Define an Option type
type Option func(*Config)

// Step 2: Create constructor that accepts options
func New(opts ...Option) (*Config, error) {
    cfg := defaultConfig()  // Start with defaults
    
    for _, opt := range opts {
        opt(cfg)  // Apply each option
    }
    
    if err := cfg.validate(); err != nil {
        return nil, err
    }
    
    return cfg, nil
}

// Step 3: Convenience constructor that panics on error
func MustNew(opts ...Option) *Config {
    cfg, err := New(opts...)
    if err != nil {
        panic(err)
    }
    return cfg
}

Naming conventions:

  • With<Feature> — Enable or configure something
  • Without<Feature> — Disable something (when default is enabled)
// Enable features
metrics.WithPrometheus(":9090", "/metrics")
logging.WithJSONHandler()
app.WithServiceName("my-api")

// Disable features
metrics.WithServerDisabled()
app.WithoutDefaultMiddleware()

Examples across packages:

// Metrics package
recorder := metrics.MustNew(
    metrics.WithPrometheus(":9090", "/metrics"),
    metrics.WithServiceName("my-api"),
)

// Logging package
logger := logging.MustNew(
    logging.WithJSONHandler(),
    logging.WithLevel(logging.LevelInfo),
    logging.WithServiceName("my-api"),
)

// Router package
r := router.MustNew(
    router.WithNotFoundHandler(custom404),
    router.WithMethodNotAllowedHandler(custom405),
)

Separation of Concerns

Each package does one thing well. This makes the code easier to:

  • Test — Test each package alone
  • Maintain — Changes to one package don’t affect others
  • Use — Pick only what you need
  • Understand — Clear boundaries make the code clearer

Package responsibilities:

PackageWhat it does
routerRoutes HTTP requests to handlers
metricsCollects and exports metrics
tracingTracks requests across services
loggingWrites structured log messages
bindingConverts request data to Go structs
validationChecks if data is valid
errorsFormats error messages
openapiGenerates API documentation
appConnects everything together

Clear boundaries:

Packages talk through clean interfaces. They don’t know about each other’s internal details.

// metrics package has a clean interface
type Recorder struct { ... }
func (r *Recorder) RecordRequest(method, path string, status int, duration time.Duration)

// app package uses the interface without knowing how it works inside
app.metrics.RecordRequest(method, path, status, duration)

Package Architecture

Standalone Packages

Every Rivaas package works on its own. You can use any package without the full framework.

Benefits:

  • No lock-in — Use Rivaas packages with any Go framework
  • Gradual adoption — Start with one package, add more later
  • Easy testing — Test with minimal dependencies
  • Flexible — Different services can use different packages

Requirements for standalone packages:

Each package must:

  1. Work without the app package
  2. Have its own go.mod file
  3. Provide New() and MustNew() constructors
  4. Use functional options
  5. Have good defaults
  6. Include documentation and examples

Example: Using metrics with standard library

package main

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

func main() {
    // Use metrics without the app framework
    recorder := metrics.MustNew(
        metrics.WithPrometheus(":9090", "/metrics"),
        metrics.WithServiceName("my-api"),
    )
    defer recorder.Shutdown(context.Background())

    // Create middleware for standard http.Handler
    handler := metrics.Middleware(recorder)(myHandler)
    
    http.ListenAndServe(":8080", handler)
}

Example: Using logging standalone

package main

import "rivaas.dev/logging"

func main() {
    // Use logging anywhere - no framework needed
    logger := logging.MustNew(
        logging.WithJSONHandler(),
        logging.WithServiceName("background-worker"),
    )
    
    logger.Info("worker started", "queue", "emails")
}

Example: Using binding with any framework

package main

import "rivaas.dev/binding"

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

func handler(w http.ResponseWriter, r *http.Request) {
    // Use binding standalone
    var req CreateUserRequest
    if err := binding.JSON(r, &req); err != nil {
        // Handle error
    }
}

All standalone packages:

PackageImport PathWhat it does
routerrivaas.dev/routerHTTP routing
metricsrivaas.dev/metricsPrometheus/OTLP metrics
tracingrivaas.dev/tracingOpenTelemetry tracing
loggingrivaas.dev/loggingStructured logging
bindingrivaas.dev/bindingRequest binding
validationrivaas.dev/validationInput validation
errorsrivaas.dev/errorsError formatting
openapirivaas.dev/openapiAPI documentation

The App Package: Integration Layer

The app package is the glue that connects standalone packages into a complete framework.

What app does:

  1. Connects packages — Wires standalone packages together
  2. Manages lifecycle — Handles startup, shutdown, and cleanup
  3. Shares configuration — Passes service name and version to all packages
  4. Provides defaults — Sets up everything for production use
  5. Makes it easy — One entry point for common use cases
  6. Configures server transport — HTTP, HTTPS, or mTLS via WithTLS / WithMTLS at construction; a single Start(ctx) runs the server. Default port is 8080 for HTTP and 8443 for TLS/mTLS, overridable with WithPort.

How app connects packages:

// app/app.go imports and connects standalone packages
import (
    "rivaas.dev/errors"
    "rivaas.dev/logging"
    "rivaas.dev/metrics"
    "rivaas.dev/openapi"
    "rivaas.dev/router"
    "rivaas.dev/tracing"
)

type App struct {
    router  *router.Router
    metrics *metrics.Recorder
    tracing *tracing.Config
    logging *logging.Config
    openapi *openapi.Manager
    // ...
}

Automatic wiring:

When you use app, packages connect automatically:

app := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithObservability(
        app.WithLogging(logging.WithJSONHandler()),
        app.WithMetrics(), // Prometheus is default
        app.WithTracing(tracing.WithOTLP("localhost:4317")),
    ),
)

// Behind the scenes, app:
// 1. Creates logging with service name "my-api"
// 2. Creates metrics with service name "my-api"
// 3. Connects logger to metrics (for error reporting)
// 4. Connects logger to tracing (for error reporting)
// 5. Sets up unified observability
// 6. Configures graceful shutdown for all components

Choose your level:

Full framework (recommended for most):

// Use app for batteries-included experience
app := app.MustNew(
    app.WithServiceName("my-api"),
    app.WithObservability(
        app.WithLogging(),
        app.WithMetrics(),
        app.WithTracing(),
    ),
)
app.GET("/users", handlers.ListUsers)
app.Start(ctx)

Standalone packages (for advanced use):

// Use packages individually for maximum control
r := router.MustNew()
logger := logging.MustNew()
recorder := metrics.MustNew()

// Wire them yourself
r.Use(loggingMiddleware(logger))
r.Use(metricsMiddleware(recorder))

r.GET("/users", listUsers)
http.ListenAndServe(":8080", r)

Design Decisions

This section explains why we made certain choices.

Why functional options over config structs?

Decision: Use functional options instead of configuration structs.

Reason:

  • New options don’t break existing code
  • Defaults are built in, not set by you
  • Option names tell you what they do
  • Your IDE can show all options
  • Options can check values when you use them

Example of the benefit:

// With config struct: Adding new fields breaks code
type Config struct {
    ServiceName string
    Port        int
    // New field added - all code must be checked
    NewFeature  bool
}

// With functional options: Adding options doesn't break anything
metrics.MustNew(
    metrics.WithServiceName("api"),
    // New option added - old code still works
)

Why standalone packages?

Decision: Every package works independently.

Reason:

  • You can try packages one at a time
  • No vendor lock-in to the framework
  • Testing is easier with fewer dependencies
  • Library authors can use specific features
  • Follows Go’s philosophy of composition

Why a separate app package?

Decision: Provide an app package that connects standalone packages.

Reason:

  • Most users want everything to work together
  • Connection code doesn’t pollute standalone packages
  • Central place for lifecycle management
  • Single place for shared concerns
  • Consistent configuration across packages

Why New() and MustNew()?

Decision: Provide both error-returning and panic-on-error constructors.

Reason:

  • New() for libraries and code that needs error handling
  • MustNew() for main() where panic is acceptable
  • Follows standard library patterns (regexp.Compile vs regexp.MustCompile)
  • Less boilerplate for common cases while keeping flexibility

Summary

PrincipleHow we implement it
DX FirstGood defaults, clear errors, progressive disclosure
Functional OptionsAll packages use Option func(*Config)
Separation of ConcernsEach package does one thing
Standalone PackagesEvery package works without app
App as GlueConnects packages, manages lifecycle

These principles guide all our development work. When you contribute to Rivaas, make sure your changes follow these principles.

5 - Contributing

How to contribute to Rivaas

Thank you for your interest in contributing to Rivaas! We welcome contributions from everyone.

Ways to Contribute

You can help Rivaas in many ways:

  • Report bugs — Tell us what’s broken
  • Suggest features — Share your ideas
  • Write documentation — Help others understand Rivaas
  • Fix bugs — Submit pull requests
  • Add features — Build new functionality
  • Review code — Help us maintain quality

Getting Started

1. Find Something to Work On

Good first steps:

2. Set Up Your Environment

Fork and clone the repository:

git clone https://github.com/YOUR-USERNAME/rivaas.git
cd rivaas

Rivaas uses Nix for development. If you have Nix installed:

nix develop

This gives you all the tools you need.

3. Make Your Changes

Create a new branch:

git checkout -b my-feature

Make your changes and test them:

# Run tests
go test ./...

# Run tests with race detection
go test -race ./...

# Check code style
golangci-lint run

4. Submit Your Work

Push your changes and create a pull request:

git push origin my-feature

Then open a pull request on GitHub.

Development Standards

We have clear standards for code quality. Please follow these guides:

Documentation Standards

Learn how to write good documentation for Go code.

Documentation Standards →

Testing Standards

Learn how to test your code properly.

Testing Standards →

Code Review Process

When you submit a pull request:

  1. Automated checks run — Tests, linting, and coverage checks
  2. Maintainer reviews — A maintainer looks at your code
  3. Feedback loop — You address any comments
  4. Approval — Maintainer approves when ready
  5. Merge — Your code becomes part of Rivaas!

Pull Request Guidelines

Good pull requests:

  • Focus on one thing — Don’t mix unrelated changes
  • Include tests — Test your changes
  • Update documentation — Keep docs current
  • Follow style guides — Match existing code style
  • Write clear commit messages — Explain what and why

Commit messages:

Use clear, descriptive commit messages:

Add user authentication middleware

This adds JWT authentication middleware for protecting routes.
It validates tokens and adds user info to the context.

Fixes #123

Format:

  • First line: Brief summary (under 72 characters)
  • Blank line
  • Detailed description (if needed)
  • Reference issues with Fixes #123 or Closes #456

Code of Conduct

We want Rivaas to be welcoming to everyone. Please:

  • Be respectful — Treat others kindly
  • Be constructive — Give helpful feedback
  • Be patient — Everyone learns at different speeds
  • Be inclusive — Welcome diverse perspectives

Questions?

Not sure about something? Ask!

License

By contributing to Rivaas, you agree that your contributions will be licensed under the Apache License 2.0.

Thank You!

Your contributions make Rivaas better for everyone. Thank you for helping!

5.1 - Documentation Standards

How to write clear documentation for Go code

This page explains how to write documentation for Rivaas code. Good documentation helps everyone understand how the code works.

Main Goal

Write clear documentation that explains:

  • What the code does
  • How to use it
  • What inputs it needs and outputs it gives

What NOT to Write

Don’t mention these things in documentation:

Performance Details

Don’t use words like:

  • “fast”, “slow”, “efficient”
  • “optimized”, “quick”
  • “high-performance”
  • Any speed comparisons

Algorithm Details

Don’t include:

  • Big-O notation (like O(1), O(n))
  • Time or space complexity
  • Algorithm names used to show speed

Benchmark Results

Don’t mention:

  • “zero allocations”
  • “optimized for speed”
  • “50% faster”
  • Any performance numbers

Memory Usage

Don’t talk about:

  • “low memory usage”
  • “minimal allocations”
  • “memory-efficient”
  • Specific memory amounts

Visual Decorations

Don’t use:

  • Lines of equals signs or dashes
  • ASCII art
  • Empty comment lines for spacing
  • Comments that add no information

TODO Comments About Moving Code

Don’t write:

  • “TODO: move this to…”
  • “FIXME: this should be in…”
  • “NOTE: consider moving to…”

Why not? If code needs to move, move it now. Don’t leave a comment about it. Use version control (git) to track changes.

File History Comments

Don’t write:

  • “merged from…”
  • “moved from…”
  • “originally in…”
  • “Benchmarks from X file”

Why not? Git tracks file history. Comments should explain what code does now, not where it came from.

What You SHOULD Write

Your documentation must focus on:

Purpose

  • What the function, type, or method does
  • Why it exists
  • When to use it

Functionality

  • What it does in simple words
  • How it changes inputs to outputs
  • Step-by-step behavior (when helpful)

Usage

  • How to use it (with brief examples)
  • Common use cases
  • How to integrate it

Code Examples in Documentation

Public functions should include examples:

  • Use tab-indented code blocks with // Example: header
  • Show typical usage patterns
  • Keep examples short and focused
  • Use valid Go code that compiles
  • Put examples after main description

Important: GoDoc needs tab indentation (not spaces) for code blocks.

Inline example format:

// FunctionName does something useful.
// It processes the input and returns a result.
//
// Example:
//
//	result := FunctionName("input")
//	fmt.Println(result)
//
// Parameters:
//   - input: description
func FunctionName(input string) string { ... }

Runnable Example functions (preferred):

For public APIs, create Example functions in *_test.go files:

// In example_test.go
func ExampleFunctionName() {
	result := FunctionName("input")
	fmt.Println(result)
	// Output: expected output
}

Parameters and Return Values

  • What each parameter means
  • What values are returned
  • Error conditions and their meanings

Behavior and Edge Cases

  • Expected behavior normally
  • Edge cases and how they’re handled
  • Side effects (if any)
  • Thread safety (if relevant)

Constraints and Requirements

  • Requirements to use it
  • Limitations or known issues
  • Dependencies

Error Documentation

Document when errors happen:

// Parse parses the input string into a Result.
// It returns an error if parsing fails.
//
// Errors:
//   - [ErrInvalidFormat]: input string is malformed
//   - [ErrEmpty]: input is an empty string
//   - [ErrTooLong]: input exceeds maximum length
func Parse(input string) (Result, error) { ... }

Deprecation

Mark deprecated APIs clearly:

// Deprecated: Use [NewRouter] instead. This function will be removed in v2.0.
func OldRouter() *Router { ... }

// Deprecated: Use [Context.Value] with [RequestIDKey] instead.
func (c *Context) RequestID() string { ... }

Interface vs Implementation

Interfaces document the contract:

// Handler handles HTTP requests.
// Implementations must be safe for concurrent use.
// Handle should not modify the request after returning.
type Handler interface {
	Handle(ctx *Context) error
}

Implementations reference the interface:

// JSONHandler implements [Handler] for JSON request/response handling.
// It automatically parses JSON request bodies and encodes JSON responses.
type JSONHandler struct { ... }

Generic Types

Document type parameter requirements:

// BindInto binds values from a ValueGetter into a struct of type T.
// T must be a struct type; using non-struct types results in an error.
// T should have exported fields with appropriate struct tags.
//
// Example:
//
//	result, err := BindInto[UserRequest](getter, "query")
func BindInto[T any](getter ValueGetter, tag string) (T, error) { ... }

Thread Safety

Document concurrency behavior when relevant:

// Router is safe for concurrent use by multiple goroutines.
// Routes should be registered before calling [Router.ServeHTTP].
type Router struct { ... }

// Counter provides a thread-safe counter.
// All methods may be called concurrently from multiple goroutines.
type Counter struct { ... }

// Builder is NOT safe for concurrent use.
// Create separate Builder instances for each goroutine.
type Builder struct { ... }

Cross-References

Use bracket syntax [Symbol] to link to other symbols (Go 1.19+):

// Handle processes the request using the provided [Context].
// It returns a [Response] or an error.
// See [Router.Register] for how to register handlers.
func Handle(ctx *Context) (*Response, error) { ... }

Link targets:

  • [FunctionName] — links to function in same package
  • [TypeName] — links to type in same package
  • [TypeName.MethodName] — links to method
  • [pkg.Symbol] — links to symbol in other package (e.g., [http.Handler])

Style Rules

GoDoc Standards

  • Start with the name — Begin function comments with the function/type name

    • // Register adds a new route...
    • // This function registers...
  • Use third-person — Write “Handler creates…” not “I create…”

    • ✅ “Handler creates…”, “Router registers…”, “Context stores…”
    • ❌ “This creates…”, “We register…”, “I store…”

Clarity and Conciseness

  • Use full sentences
  • Keep comments short but meaningful
  • Avoid unnecessary words
  • Be direct and clear

Language Guidelines

  • No marketing language — Avoid adjectives like:
    • “simple”, “powerful”, “robust”, “amazing”
    • “best”, “perfect”, “ideal”
  • No superlatives — No “fastest” or “most reliable”
  • Focus on facts — Describe what code does

Code Examples

  • Public APIs need examples
  • Use tab indentation (not spaces) for code blocks
  • Prefer runnable Example functions in *_test.go files
  • Keep examples minimal and focused

Package Documentation Files (doc.go)

When package documentation is long (more than a few lines), use a doc.go file:

  • File name: Must be exactly doc.go (lowercase)
  • Location: In the package root directory
  • Content: Only package comment and package declaration
  • Purpose: Keeps package overview separate from code

Format requirements:

  • Start with // Package [name] and clear description
  • First sentence is summary (shown in listings)
  • Use markdown headers (#) for sections
  • Include code examples when helpful
  • Cover: purpose, main concepts, usage patterns

What to include:

  • Package overview and purpose
  • Key features
  • Architecture (when relevant)
  • Quick start examples
  • Common usage patterns
  • Links to examples or related packages

What NOT to include:

  • Performance details
  • Algorithm complexity
  • File organization history
  • Individual function documentation (put those in their files)

Example structure:

// Package router provides an HTTP router for Go.
//
// The router implements a routing system for cloud-native applications.
// It features path matching, parameter extraction, and comprehensive middleware support.
//
// # Key Features
//
//   - Path matching for static and parameterized routes
//   - Parameter extraction from URL paths
//   - Context pooling for request handling
//
// # Quick Start
//
//	package main
//
//	import "rivaas.dev/router"
//
//	func main() {
//	    r := router.New()
//	    r.GET("/", handler)
//	    r.Run(":8080")
//	}
//
// # Examples
//
// See the examples directory for complete working examples.
package router

When to use doc.go:

  • Use doc.go: Package documentation is long (multiple paragraphs, sections)
  • Use inline comments: Package documentation is brief (1-3 sentences)

Examples

Good Documentation

// Register adds a new route to the [Router] using the given method and pattern.
// It returns the created [Route], which can be further configured.
// Register should be called during application setup before the server starts.
func (r *Router) Register(method, pattern string) *Route { ... }

// Context represents an HTTP request context.
// It provides access to the request, response writer, and route parameters.
// Context instances are pooled and reused across requests.
// Context is NOT safe for use after the handler returns.
type Context struct { ... }

// Param returns the value of the named route parameter.
// It returns an empty string if the parameter is not found.
// Parameters are extracted from the URL path during route matching.
//
// Example:
//
//	userID := c.Param("id")
//	fmt.Println(userID)
func (c *Context) Param(name string) string { ... }

Bad Documentation

// Register is a highly optimized router method with zero allocations.
// Uses O(1) lookup for fast routing.
// Extremely efficient performance characteristics.
func (r *Router) Register(method, pattern string) *Route { ... }

// Context is a fast, memory-efficient request context.
// Uses minimal allocations and provides high-performance access.
// Benchmarks show 50% faster than alternatives.
type Context struct { ... }

// Param returns the value with O(1) lookup time.
// Optimized for speed with zero allocations.
func (c *Context) Param(name string) string { ... }

// ========================================
// HTTP Context Methods
// ========================================
func (c *Context) Param(name string) string { ... }

// TODO: move this to a separate file
// Param returns the value of the named route parameter.
func (c *Context) Param(name string) string { ... }

Review Checklist

When writing or reviewing documentation, check:

Content Rules

  • No performance words (fast, efficient, optimized)
  • No algorithm complexity (Big-O, O(1))
  • No benchmark claims
  • No memory usage details
  • No marketing language
  • No decorative comment lines
  • No TODO/FIXME about moving code
  • No file history comments (merged from, moved from)
  • Comments provide useful information

Style Rules

  • Comments start with function/type name
  • Third-person, descriptive language
  • Clear explanation of what code does

Documentation Completeness

  • Parameters and return values documented
  • Error conditions documented with specific types
  • Edge cases and constraints mentioned
  • Thread safety documented when relevant
  • Generic type constraints documented

Examples and References

  • Public APIs include examples
  • Code examples use tab indentation
  • Cross-references use [Symbol] syntax

Special Cases

  • Deprecated functions use // Deprecated: prefix
  • Interfaces document contract, implementations reference interface
  • Long package docs use doc.go file
  • doc.go files start with // Package [name]

Additional Resources

Summary

Remember: Documentation explains what code does and how to use it, not how well it performs. Focus on functionality, behavior, and usage patterns. If performance is implied by code, don’t mention it in documentation.

5.2 - Testing Standards

How to write tests for Rivaas code

This page explains how to write tests for Rivaas. Good tests help us keep the code working correctly.

Test File Structure

All packages must have these test files:

  1. *_test.go — Unit tests (same package)
  2. example_test.go — Examples for documentation (external package)
  3. *_bench_test.go — Performance benchmarks (same package)
  4. integration_test.go — Integration tests (external package)
  5. testing.go — Test helpers (if needed)

File Naming

Test TypeFile NamePackage
Unit tests{package}_test.go{package}
Benchmarks{package}_bench_test.go{package}
Examplesexample_test.go{package}_test
Integrationintegration_test.go{package}_test
Helperstesting.go{package}

Test Naming

Use clear, descriptive names:

PatternUse CaseExample
TestFunctionNameBasic testTestParseConfig
TestFunctionName_ScenarioSpecific scenarioTestParseConfig_EmptyInput
TestFunctionName_ErrorCaseError caseTestParseConfig_InvalidJSON
TestType_MethodNameMethod testTestRouter_ServeHTTP

Subtest Naming

For table-driven tests, use names that explain the scenario:

tests := []struct {
    name string
    // ...
}{
    {name: "valid email address"},           // ✅ Good - descriptive
    {name: "empty string returns error"},    // ✅ Good - explains behavior
    {name: "test1"},                         // ❌ Bad - not descriptive
    {name: "case 1"},                        // ❌ Bad - not helpful
}

Grouping with Subtests

Use nested t.Run() for related tests:

func TestUser(t *testing.T) {
    t.Parallel()

    t.Run("Create", func(t *testing.T) {
        t.Parallel()
        t.Run("valid input succeeds", func(t *testing.T) {
            t.Parallel()
            // test code
        })
        t.Run("invalid email returns error", func(t *testing.T) {
            t.Parallel()
            // test code
        })
    })

    t.Run("Delete", func(t *testing.T) {
        t.Parallel()
        t.Run("existing user succeeds", func(t *testing.T) {
            t.Parallel()
            // test code
        })
    })
}

Package Organization

Unit Tests

  • Package: Same as source (package router)
  • Access: Can test public and internal APIs
  • Use for: Testing individual functions, internal details, edge cases
  • Framework: Standard testing with testify/assert or testify/require

Integration Tests

  • Package: External (package router_test)
  • Access: Only public APIs (black-box testing)
  • Use for: Testing full request/response cycles, component interactions
  • Framework:
    • Standard testing for simple tests
    • Ginkgo/Gomega for complex scenarios

Example Tests

  • Package: External (package router_test)
  • Access: Only public APIs
  • Use for: Showing how to use public APIs in documentation

Test Data Management

The testdata Directory

Go has special handling for testdata/ directories:

  • Ignored by go build
  • Used for test fixtures and sample data
  • Accessible via relative path from tests
package/
├── handler.go
├── handler_test.go
└── testdata/
    ├── fixtures/
    │   ├── valid_request.json
    │   └── invalid_request.json
    └── golden/
        ├── expected_output.json
        └── expected_error.txt

Loading Test Data

func TestHandler(t *testing.T) {
    t.Parallel()

    // Load test fixture
    input, err := os.ReadFile("testdata/fixtures/valid_request.json")
    require.NoError(t, err)

    // Use in test
    result, err := ProcessRequest(input)
    require.NoError(t, err)

    // Compare with golden file
    expected, err := os.ReadFile("testdata/golden/expected_output.json")
    require.NoError(t, err)
    assert.JSONEq(t, string(expected), string(result))
}

Golden File Testing

Golden files store expected output. Use -update flag to regenerate:

var updateGolden = flag.Bool("update", false, "update golden files")

func TestOutput_Golden(t *testing.T) {
    result := GenerateOutput()
    goldenPath := "testdata/golden/output.txt"

    if *updateGolden {
        err := os.WriteFile(goldenPath, []byte(result), 0644)
        require.NoError(t, err)
        return
    }

    expected, err := os.ReadFile(goldenPath)
    require.NoError(t, err)
    assert.Equal(t, string(expected), result)
}

Update golden files:

go test -update ./...

Assertions

Important: Always use assertion libraries. Don’t use manual if statements with t.Errorf().

testify/assert vs testify/require

  • assert: Continues test after failure (checks multiple things)
  • require: Stops test after failure (when later checks depend on it)
// Use require when later code needs the value
result, err := FunctionThatShouldSucceed()
require.NoError(t, err)  // Must succeed to continue
assert.Equal(t, expected, result)

// Use assert for independent checks
assert.NoError(t, err)
assert.Equal(t, expected, result)
assert.Contains(t, message, "success")  // All run even if first fails

Error Checking

Always use testify error functions, not manual error checks.

Available Functions

  • assert.NoError(t, err) — Verify no error occurred
  • assert.Error(t, err) — Verify an error occurred
  • assert.ErrorIs(t, err, target) — Verify error wraps specific error
  • assert.ErrorAs(t, err, target) — Verify error is specific type
  • assert.ErrorContains(t, err, substring) — Verify error message contains text

When to Use Each

NoError / require.NoError:

result, err := FunctionThatShouldSucceed()
require.NoError(t, err)  // Use require if result is needed
assert.Equal(t, expected, result)

Error / assert.Error:

_, err := FunctionThatShouldFail()
assert.Error(t, err)  // Any error is fine

ErrorIs / assert.ErrorIs:

var ErrNotFound = errors.New("not found")

_, err := FunctionThatReturnsWrappedError()
assert.ErrorIs(t, err, ErrNotFound)  // Check for specific error

ErrorAs / require.ErrorAs:

type ValidationError struct {
    Field string
}

_, err := FunctionThatReturnsTypedError()
var validationErr *ValidationError
require.ErrorAs(t, err, &validationErr)  // Use require if you need validationErr
assert.Equal(t, "email", validationErr.Field)

ErrorContains / assert.ErrorContains:

_, err := FunctionThatReturnsDescriptiveError()
assert.ErrorContains(t, err, "invalid input")

When to Use require vs assert for Errors

Use require when:

  1. Setup must succeed:
tmpfile, err := os.CreateTemp("", "test-*.txt")
require.NoError(t, err)  // Must succeed to continue
defer os.Remove(tmpfile.Name())
  1. Need non-nil value:
db, err := sql.Open("postgres", dsn)
require.NoError(t, err)  // Must succeed
require.NotNil(t, db)    // Must not be nil

rows, err := db.Query("SELECT ...")  // Safe to use db
  1. Later assertions depend on it:
err := c.Format(200, data)
require.NoError(t, err)  // Must succeed for rest of test

// These assume Format succeeded
assert.Contains(t, w.Header().Get("Content-Type"), "application/xml")
assert.Contains(t, w.Body.String(), "<?xml")

Use assert when:

  1. Independent validations:
assert.NoError(t, err)
assert.Equal(t, expected, result)
assert.Contains(t, message, "success")  // All checked even if first fails
  1. Non-critical checks:
err := optionalOperation()
assert.NoError(t, err)  // Nice to have, but test can continue
assert.Equal(t, http.StatusOK, w.Code)

Table-Driven Tests

All tests with multiple cases should use table-driven pattern:

func TestFunctionName(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name    string
        input   any
        want    any
        wantErr bool
    }{
        {
            name:    "valid input",
            input:   "test",
            want:    "result",
            wantErr: false,
        },
        {
            name:    "invalid input",
            input:   "",
            want:    nil,
            wantErr: true,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            got, err := FunctionName(tt.input)
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            assert.NoError(t, err)
            assert.Equal(t, tt.want, got)
        })
    }
}

Example Tests

All public APIs must have example tests in example_test.go:

package package_test

import (
    "fmt"
    "rivaas.dev/package"
)

// ExampleFunctionName demonstrates basic usage.
func ExampleFunctionName() {
    result := package.FunctionName("input")
    fmt.Println(result)
    // Output: expected output
}

// ExampleFunctionName_withOptions demonstrates usage with options.
func ExampleFunctionName_withOptions() {
    result := package.FunctionName("input",
        package.WithOption("value"),
    )
    fmt.Println(result)
    // Output: expected output
}

Example Guidelines

  • Package must be {package}_test
  • Function names start with Example
  • Include // Output: comments for deterministic examples
  • Use log.Fatal(err) for error handling (acceptable in examples)

Benchmarks

Critical paths must have benchmarks in *_bench_test.go:

func BenchmarkFunctionName(b *testing.B) {
    setup := prepareTestData()
    b.ResetTimer()
    b.ReportAllocs()

    // Preferred: Go 1.23+ syntax
    for b.Loop() {
        FunctionName(setup)
    }
}

func BenchmarkFunctionName_Parallel(b *testing.B) {
    setup := prepareTestData()
    b.ResetTimer()
    b.ReportAllocs()

    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            FunctionName(setup)
        }
    })
}

Benchmark Guidelines

  • Use b.ResetTimer() after setup
  • Use b.ReportAllocs() to track memory
  • Prefer b.Loop() for Go 1.23+
  • Test both sequential and parallel execution
  • Use b.Context() instead of context.Background() (Go 1.24+)
  • Use b.Fatal(err) for setup failures (acceptable in benchmarks)

Integration Tests

Integration tests use the integration build tag:

//go:build integration

package package_test

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

    "rivaas.dev/package"
)

func TestIntegration(t *testing.T) {
    r := package.MustNew()
    // Integration test code
}

Build Tags for Test Separation

Test TypeBuild TagRun Command
Unit tests//go:build !integrationgo test ./...
Integration tests//go:build integrationgo test -tags=integration ./...

Why build tags?

  • Tests excluded at compile time, not skipped at runtime
  • Cleaner coverage reports
  • Faster unit test runs
  • Easy to run different suites in parallel

Ginkgo Integration Tests

For complex scenarios, use Ginkgo. Important: Only one RunSpecs call per package.

Suite file (one per package):

// {package}_integration_suite_test.go

//go:build integration

package package_test

import (
    "testing"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
)

func TestPackageIntegration(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "Package Integration Suite")
}

Test files (multiple allowed):

// integration_test.go

//go:build integration

package package_test

import (
    "net/http"
    "net/http/httptest"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"

    "rivaas.dev/package"
)

var _ = Describe("Feature Integration", func() {
    var r *package.Router

    BeforeEach(func() {
        r = package.MustNew()
    })

    Describe("Scenario A", func() {
        Context("with condition X", func() {
            It("should behave correctly", func() {
                req := httptest.NewRequest("GET", "/path", nil)
                w := httptest.NewRecorder()
                r.ServeHTTP(w, req)

                Expect(w.Code).To(Equal(http.StatusOK))
            })
        })
    })
})

Using Labels for Filtering

Use labels to organize tests:

var _ = Describe("Router Stress Tests", Label("stress", "slow"), func() {
    It("should handle high concurrent load", Label("stress"), func() {
        // Stress test
    })
})

Run with labels:

# Run only stress tests
ginkgo -label-filter=stress ./package

# Run everything except stress tests
ginkgo -label-filter='!stress' ./package

# Run tests with multiple labels (AND)
ginkgo -label-filter='integration && versioning' ./package

Test Helpers

Common utilities go in testing.go:

package package

import (
    "testing"
    
    "github.com/stretchr/testify/assert"
)

// testHelper creates a test instance with default configuration.
func testHelper(t *testing.T) *Config {
    t.Helper()
    return MustNew(WithTestDefaults())
}

// assertError checks if error matches expected.
func assertError(t *testing.T, err error, wantErr bool, msg string) {
    t.Helper()
    if wantErr {
        assert.Error(t, err, msg)
    } else {
        assert.NoError(t, err, msg)
    }
}

Always use t.Helper() in helper functions.

HTTP Testing Patterns

Testing Handlers

func TestHandler_GetUser(t *testing.T) {
    t.Parallel()

    handler := NewUserHandler(mockRepo)

    req := httptest.NewRequest(http.MethodGet, "/users/123", nil)
    req.Header.Set("Content-Type", "application/json")

    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)

    assert.Equal(t, http.StatusOK, w.Code)
    assert.Contains(t, w.Header().Get("Content-Type"), "application/json")

    var response User
    err := json.NewDecoder(w.Body).Decode(&response)
    require.NoError(t, err)
    assert.Equal(t, "123", response.ID)
}

Testing with Request Body

func TestHandler_CreateUser(t *testing.T) {
    t.Parallel()

    body := strings.NewReader(`{"name": "Test User", "email": "test@example.com"}`)
    req := httptest.NewRequest(http.MethodPost, "/users", body)
    req.Header.Set("Content-Type", "application/json")

    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)

    assert.Equal(t, http.StatusCreated, w.Code)
}

Testing Middleware

func TestAuthMiddleware(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name           string
        authHeader     string
        wantStatusCode int
    }{
        {
            name:           "valid token",
            authHeader:     "Bearer valid-token",
            wantStatusCode: http.StatusOK,
        },
        {
            name:           "missing header",
            authHeader:     "",
            wantStatusCode: http.StatusUnauthorized,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()

            nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
            })

            handler := AuthMiddleware(nextHandler)

            req := httptest.NewRequest(http.MethodGet, "/protected", nil)
            if tt.authHeader != "" {
                req.Header.Set("Authorization", tt.authHeader)
            }

            w := httptest.NewRecorder()
            handler.ServeHTTP(w, req)

            assert.Equal(t, tt.wantStatusCode, w.Code)
        })
    }
}

Context and Timeout Patterns

Testing with Context

func TestService_WithTimeout(t *testing.T) {
    t.Parallel()

    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    t.Cleanup(cancel)

    result, err := service.SlowOperation(ctx)

    require.NoError(t, err)
    assert.NotNil(t, result)
}

Using Test Context (Go 1.24+)

In Go 1.24+, use t.Context() instead of context.Background():

func TestWithContext(t *testing.T) {
    t.Parallel()

    // ✅ Preferred: Use t.Context()
    ctx := t.Context()
    
    // ❌ Avoid: context.Background()
    // ctx := context.Background()

    result, err := service.Operation(ctx)
    require.NoError(t, err)
    assert.NotNil(t, result)
}

Benefits: Automatically cancelled when test ends.

Mocking

Interface-Based Mocking (Preferred)

// Define interface
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    Save(ctx context.Context, user *User) error
}

// Test implementation (fake)
type fakeUserRepository struct {
    users map[string]*User
    err   error
}

func (f *fakeUserRepository) FindByID(ctx context.Context, id string) (*User, error) {
    if f.err != nil {
        return nil, f.err
    }
    return f.users[id], nil
}

// Test using the fake
func TestUserService_GetUser(t *testing.T) {
    t.Parallel()

    repo := &fakeUserRepository{
        users: map[string]*User{
            "123": {ID: "123", Name: "Test User"},
        },
    }
    service := NewUserService(repo)

    user, err := service.GetUser(context.Background(), "123")
    require.NoError(t, err)
    assert.Equal(t, "Test User", user.Name)
}

HTTP Client Mocking

func TestAPIClient_FetchData(t *testing.T) {
    t.Parallel()

    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "/api/data", r.URL.Path)
        assert.Equal(t, "Bearer token123", r.Header.Get("Authorization"))

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte(`{"id": "123", "name": "test"}`))
    }))
    t.Cleanup(server.Close)

    client := NewAPIClient(server.URL, "token123")
    data, err := client.FetchData(context.Background())

    require.NoError(t, err)
    assert.Equal(t, "123", data.ID)
}

Test Coverage

Requirements

Package TypeMinimumTarget
Core packages80%90%
Utility packages75%85%
Integration packages70%80%

Measuring Coverage

# Package coverage
go test -cover ./package

# Detailed report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html

# Coverage by function
go tool cover -func=coverage.out

Best Practices

  1. Parallel Execution: Use t.Parallel() for all tests (except testing.AllocsPerRun)

  2. Assertions: Always use testify/assert or testify/require

  3. Error Messages: Include descriptive messages

  4. Test Isolation: Each test should be independent

  5. Cleanup: Use t.Cleanup() instead of defer:

func TestWithResource(t *testing.T) {
    t.Parallel()

    resource := createResource()
    t.Cleanup(func() {
        resource.Close()
    })

    // Use resource...
}
  1. Descriptive Names: Use clear test and subtest names

  2. Documentation: Document complex test scenarios

  3. Race Detection: Always run with -race in CI

  4. Deterministic Tests: Avoid depending on:

    • Current time (use clock injection)
    • Random values (use fixed seeds)
    • Network availability (use mocks)
    • Filesystem state (use temp directories)

Running Tests

# Run unit tests (excludes integration)
go test ./...

# Run unit tests with verbose output
go test -v ./...

# Run unit tests with race detection (REQUIRED in CI)
go test -race ./...

# Run integration tests with race detection
go test -tags=integration -race ./...

# Run unit tests with coverage
go test -cover ./...

# Run benchmarks
go test -bench=. -benchmem ./...

# Run specific test by name
go test -run TestFunctionName ./...

# Run tests with timeout
go test -timeout 5m ./...

CI Commands

# Unit tests with race and coverage (CI)
go test -race -coverprofile=coverage.out -timeout 10m ./...

# Integration tests with race and coverage (CI)
go test -tags=integration -race -coverprofile=coverage-integration.out -timeout 10m ./...

Summary

Good tests:

  • Use clear, descriptive names
  • Use table-driven patterns for multiple cases
  • Always use assertion libraries
  • Run in parallel when possible
  • Include examples for public APIs
  • Test both success and error cases
  • Use proper build tags for integration tests
  • Have good coverage (80%+)

Remember: Tests are documentation too. Write them clearly!