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

Return to the regular view of this page.

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?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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