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 router directly. Latency goes from 119ns to about 121-122ns. However, it provides significant development speed and maintainability benefits. This comes through integrated observability and sensible defaults.

Quick Start

Simple Application

Create a minimal application with defaults:

package main

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

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

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

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

    // Start server with graceful shutdown
    if err := a.Start(ctx, ":8080"); 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("/healthz", "/readyz", "/metrics"),
        ),
        // Health endpoints: GET /healthz (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, ":8080"); 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, ":8080"); 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.MustBindAndValidate(&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, ":8080"); err != nil {
        log.Fatalf("Server error: %v", err)
    }
}

Port Configuration

Specify different ports:

// Development
a.Start(ctx, ":8080")

// Production
a.Start(ctx, ":80")

// Bind to specific interface
a.Start(ctx, "127.0.0.1:8080")

// Use environment variable
port := os.Getenv("PORT")
if port == "" {
    port = "8080"
}
a.Start(ctx, ":"+port)

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.MustBindAndValidate(&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, ":8080"); 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:

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

Environment from Environment Variables

Use environment variables for configuration:

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

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

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

Server Configuration

Timeouts

Configure server timeouts for safety and performance:

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

Timeout descriptions:

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

Default values:

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

Header Size Limits

Configure maximum request header size:

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

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

Shutdown Timeout

Configure graceful shutdown timeout:

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

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

The shutdown timeout controls how long the server waits for:

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

Validation Rules

Server configuration is automatically validated:

Timeout validation:

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

Size validation:

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

Invalid configuration example:

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

Valid configuration example:

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

Partial Configuration

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

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

Configuration from Environment

Load configuration from environment variables:

package main

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

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

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

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

Configuration Validation

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

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

Validation errors are structured and include all issues:

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

Complete Configuration Example

package main

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

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

Next Steps

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

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.

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

Use the request-scoped logger in handlers.

a.GET("/orders/:id", func(c *app.Context) {
    orderID := c.Param("id")
    
    // Logger automatically includes:
    // - HTTP metadata (method, route, target, client IP)
    // - Request ID (if present)
    // - Trace/span IDs (if tracing enabled)
    c.Logger().Info("processing order",
        slog.String("order.id", orderID),
    )
    
    // Subsequent logs maintain context
    c.Logger().Debug("fetching from database")
    
    c.JSON(http.StatusOK, map[string]string{
        "order_id": orderID,
    })
})

Log output includes automatic context:

{
  "time": "2024-01-18T10:30:00Z",
  "level": "INFO",
  "msg": "processing order",
  "http.method": "GET",
  "http.route": "/orders/:id",
  "http.target": "/orders/123",
  "network.client.ip": "203.0.113.1",
  "trace_id": "abc...",
  "span_id": "def...",
  "order.id": "123"
}

Metrics

Prometheus Metrics (Default)

Enable Prometheus metrics on a separate server:

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

Custom Prometheus Configuration

Configure Prometheus address and path:

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

Mount Metrics on Main Router

Mount metrics endpoint on the main HTTP server:

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

OTLP Metrics

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

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

Custom Metrics in Handlers

Record custom metrics in your handlers:

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

Tracing

OpenTelemetry Tracing

Enable OpenTelemetry tracing with OTLP exporter:

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

Stdout Tracing (Development)

Use stdout tracing for development:

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

Sample Rate

Configure trace sampling:

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

Span Attributes in Handlers

Add span attributes and events in your handlers:

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

Accessing Trace IDs

Get the current trace ID for correlation:

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

Service Metadata Injection

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

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

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

Overriding Service Metadata

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

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

Path Filtering

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

Exclude Paths

Exclude exact paths:

a, err := app.New(
    app.WithObservability(
        app.WithLogging(),
        app.WithMetrics(),
        app.WithTracing(),
        app.WithExcludePaths("/healthz", "/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, /healthz, /live, /livez
  • /ready, /readyz
  • /metrics
  • /debug/*

To disable default exclusions:

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

Access Logging

Enable/Disable Access Logging

Control access logging:

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

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

Log Only Errors

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

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

Slow Request Threshold

Mark requests as slow and log them:

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

Complete Example

Production-ready observability configuration:

package main

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

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

Next Steps

5 - Context

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

Overview

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

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

Request Binding

Automatic Binding

Bind() automatically detects struct tags and binds from all relevant sources:

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

a.GET("/users/:id", func(c *app.Context) {
    var req GetUserRequest
    if err := c.Bind(&req); err != nil {
        c.Error(err)
        return
    }
    
    // req is populated from path, query, headers, and cookies
})

JSON Binding

For JSON request bodies:

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
    Age   int    `json:"age"`
}

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if err := c.Bind(&req); err != nil {
        c.Error(err)
        return
    }
    
    // req is populated from JSON body
})

Strict JSON Binding

Reject unknown fields to catch typos and API drift:

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if err := c.BindJSONStrict(&req); err != nil {
        c.Error(err) // Returns error if unknown fields present
        return
    }
})

Multi-Source Binding

Bind from multiple sources simultaneously:

type UpdateUserRequest struct {
    ID    int    `path:"id"`          // From 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.Error(err)
        return
    }
    
    // req.ID from path, req.Name from JSON, req.Token from header
})

Validation

Bind and Validate

Combine binding and validation in one call:

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 err := c.BindAndValidate(&req); err != nil {
        c.Error(err)
        return
    }
    
    // req is validated
})

Strict Bind and Validate

Reject unknown fields AND validate:

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if err := c.BindAndValidateStrict(&req); err != nil {
        c.Error(err) // Returns error if unknown fields OR validation fails
        return
    }
})

Must Bind and Validate

Automatically send error responses on binding/validation failure:

a.POST("/users", func(c *app.Context) {
    var req CreateUserRequest
    if !c.MustBindAndValidate(&req) {
        return // Error response already sent
    }
    
    // Continue with validated request
})

Generic Bind and Validate

Use generics for type-safe binding:

a.POST("/users", func(c *app.Context) {
    req, err := app.BindAndValidateInto[CreateUserRequest](c)
    if err != nil {
        c.Error(err)
        return
    }
    
    // req is of type CreateUserRequest
})

// Or with automatic error handling
a.POST("/users", func(c *app.Context) {
    req, ok := app.MustBindAndValidateInto[CreateUserRequest](c)
    if !ok {
        return // Error response already sent
    }
    
    // Continue with req
})

Partial Validation (PATCH)

Validate only fields present in the request:

type PatchUserRequest 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) {
    var req PatchUserRequest
    if err := c.BindAndValidate(&req, validation.WithPartial(true)); err != nil {
        c.Error(err)
        return
    }
    
    // Only present fields are validated
})

Validation Strategies

Choose different validation strategies:

// Interface validation (default)
c.BindAndValidate(&req)

// Tag validation (go-playground/validator)
c.BindAndValidate(&req, validation.WithStrategy(validation.StrategyTags))

// JSON Schema validation
c.BindAndValidate(&req, validation.WithStrategy(validation.StrategyJSONSchema))

Error Handling

Basic Error Handling

Send error responses with automatic formatting:

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

Explicit Status Codes

Override error status codes:

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

Convenience Error Methods

Use convenience methods for common status codes:

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

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

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

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

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

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

Accessing the Logger

Get the request-scoped logger with automatic context:

a.GET("/orders/:id", func(c *app.Context) {
    orderID := c.Param("id")
    
    // Logger automatically includes:
    // - HTTP metadata (method, route, target, client IP)
    // - Request ID (if present)
    // - Trace/span IDs (if tracing enabled)
    c.Logger().Info("processing order",
        slog.String("order.id", orderID),
    )
    
    c.JSON(http.StatusOK, order)
})

Structured Logging

Use structured logging with key-value pairs:

a.POST("/orders", func(c *app.Context) {
    var req CreateOrderRequest
    if !c.MustBindAndValidate(&req) {
        return
    }
    
    c.Logger().Info("creating order",
        slog.String("customer.id", req.CustomerID),
        slog.Int("item.count", len(req.Items)),
        slog.Float64("order.total", req.Total),
    )
    
    // Process order...
    
    c.Logger().Info("order created successfully",
        slog.String("order.id", orderID),
    )
})

Log Levels

Use different log levels:

c.Logger().Debug("fetching from cache")
c.Logger().Info("request processed successfully")
c.Logger().Warn("cache miss, fetching from database")
c.Logger().Error("failed to save to database", "error", err)

Automatic Context

The logger automatically includes request context:

{
  "time": "2024-01-18T10:30:00Z",
  "level": "INFO",
  "msg": "processing order",
  "http.method": "GET",
  "http.route": "/orders/:id",
  "http.target": "/orders/123",
  "network.client.ip": "203.0.113.1",
  "trace_id": "abc...",
  "span_id": "def...",
  "order.id": "123"
}

Router Context Features

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

HTTP Methods

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

Response Handling

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

Content Negotiation

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

Complete Example

package main

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

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
        var req CreateOrderRequest
        if !c.MustBindAndValidate(&req) {
            return // Error response already sent
        }
        
        // Log with context
        c.Logger().Info("creating order",
            slog.String("customer.id", req.CustomerID),
            slog.Int("item.count", len(req.Items)),
            slog.Float64("order.total", req.Total),
        )
        
        // Business logic...
        orderID := "order-123"
        
        // Log success
        c.Logger().Info("order created",
            slog.String("order.id", orderID),
        )
        
        // Return response
        c.JSON(http.StatusCreated, map[string]string{
            "order_id": orderID,
        })
    })
    
    // Start server...
}

Next Steps

6 - Middleware

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

Overview

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

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

Using Middleware

Global Middleware

Apply middleware to all routes:

a := app.MustNew()

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

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

Middleware During Initialization

Add middleware when creating the app:

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

Default Middleware

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

To disable default middleware:

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

Built-in Middleware

Request ID

Generate unique request IDs for tracing:

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

a.Use(requestid.New())

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

Options:

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

CORS

Handle Cross-Origin Resource Sharing:

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

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

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

Recovery

Recover from panics gracefully (included by default):

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

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

Access Logging

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

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

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

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

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

Timeout

Add request timeout handling:

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

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

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

Rate Limiting

Rate limit requests (single-instance only):

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

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

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

Compression

Compress responses with gzip or brotli:

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

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

Body Limit

Limit request body size:

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

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

Security Headers

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

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

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

Basic Auth

HTTP Basic Authentication:

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

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

Custom Middleware

Writing Custom Middleware

Create custom middleware as functions:

func AuthMiddleware() app.HandlerFunc {
    return func(c *app.Context) {
        token := c.Request.Header.Get("Authorization")
        
        if token == "" {
            c.Unauthorized("missing authorization token")
            return
        }
        
        // Validate token...
        if !isValid(token) {
            c.Unauthorized("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("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"
    "net/http"
    "time"
    
    "rivaas.dev/app"
    "rivaas.dev/router/middleware/requestid"
    "rivaas.dev/router/middleware/cors"
    "rivaas.dev/router/middleware/timeout"
)

func main() {
    a, err := app.New(
        app.WithServiceName("api"),
        app.WithMiddleware(
            requestid.New(),
            cors.New(cors.WithAllowAllOrigins(true)),
            timeout.New(timeout.WithDuration(30 * time.Second)),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // 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)
        c.Logger().Info("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("missing authorization token")
            return
        }
        
        c.Next()
    }
}

func AdminOnlyMiddleware() app.HandlerFunc {
    return func(c *app.Context) {
        // Check if user is admin...
        if !isAdmin() {
            c.Forbidden("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

7 - Routing

Organize routes with groups, versioning, and static files.

Route Registration

HTTP Method Shortcuts

Register routes using HTTP method shortcuts:

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

Match All Methods

Register a route that matches all HTTP methods:

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

Route Groups

Basic Groups

Organize routes with shared prefixes:

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

Nested Groups

Create hierarchical route structures:

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

Groups with Middleware

Apply middleware to all routes in a group:

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

API Versioning

Version Groups

Create version-specific routes:

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

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

Version Detection

Configure how versions are detected. This requires router configuration:

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

Static Files

Serve Directory

Serve static files from a directory:

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

Serve Single File

Serve a single file at a specific path:

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

Serve from Filesystem

Serve files from an http.FileSystem:

//go:embed static
var staticFiles embed.FS

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

Route Naming

Named Routes

Name routes for URL generation:

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

Generate URLs

Generate URLs from route names:

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

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

Must Generate URLs

Generate URLs and panic on error:

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

Route Constraints

Numeric Constraints

Constrain parameters to numeric values:

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

UUID Constraints

Constrain parameters to UUIDs:

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

Custom Constraints

Use regex patterns for custom constraints:

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

Custom 404 Handler

Set NoRoute Handler

Handle routes that don’t match:

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

Complete Example

package main

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

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

8 - Lifecycle

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

Overview

The app package provides lifecycle hooks for managing application state:

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

OnStart Hook

Basic Usage

Initialize resources before the server starts:

a := app.MustNew()

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

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

// Start server - hooks execute before listening
a.Start(ctx, ":8080")

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, ":8080"); 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()
})

OnShutdown Hook

Basic Usage

Clean up resources during graceful shutdown:

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

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

LIFO Execution Order

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

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

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

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

This ensures cleanup happens in reverse dependency order.

Timeout Handling

OnShutdown hooks must complete within the shutdown timeout:

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

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

Common Use Cases

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

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

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

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

OnStop Hook

Basic Usage

Final cleanup after shutdown completes:

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

Best-Effort Execution

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

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

No Timeout

OnStop hooks don’t have a timeout constraint:

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

OnRoute Hook

Basic Usage

Execute code when routes are registered:

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

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

Route Validation

Validate routes during registration:

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

Documentation Generation

Use for automatic documentation:

var routes []string

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

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

Complete Example

package main

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

var db *Database

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

Hook Execution Flow

1. app.Start(ctx, ":8080") called
2. OnStart hooks execute (sequential, stop on error)
3. Server starts listening
4. OnReady hooks execute (async, non-blocking)
5. Server handles requests...
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

9 - 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 (/healthz) - Shows if the process is alive. Restart if failing.
  • Readiness Probe (/readyz) - Shows if the service can handle traffic.

Basic Configuration

Enable Health Endpoints

Enable health endpoints with defaults.

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

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

Custom Paths

Configure custom health check paths:

a, err := app.New(
    app.WithHealthEndpoints(
        app.WithHealthzPath("/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/healthz
// 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: /healthz
            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/healthz - 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/healthz
# 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/healthz
curl http://localhost:8080/_system/readyz

Next Steps

10 - 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("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

11 - 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, ":8080"); err != nil {
    log.Fatal(err)
}

Custom Address

Bind to specific interface:

a.Start(ctx, "127.0.0.1:8080")  // Localhost only
a.Start(ctx, "0.0.0.0:8080")    // All interfaces

HTTPS Server

Start HTTPS Server

Start with TLS certificates:

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

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

Generate Self-Signed Certificate

For development:

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

mTLS Server

Start mTLS Server

Mutual TLS with client certificate verification:

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

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

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

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

Client Authorization

Authorize clients based on certificate:

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

Graceful Shutdown

Signal-Based Shutdown

Use signal.NotifyContext for graceful shutdown:

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

if err := a.Start(ctx, ":8080"); 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, ":8080"); err != nil {
        log.Fatal(err)
    }
}

HTTPS with mTLS

package main

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

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

Next Steps

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

13 - 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("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

14 - 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, ":8080")  // 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, ":8080")

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, ":8080"); 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

15 - 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, ":8080"); 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("/healthz", "/readyz", "/metrics"),
            app.WithLogOnlyErrors(),
            app.WithSlowThreshold(1 * time.Second),
        ),
        
        // Health endpoints
        app.WithHealthEndpoints(
            app.WithHealthTimeout(800 * time.Millisecond),
            app.WithReadinessCheck("database", func(ctx context.Context) error {
                return db.PingContext(ctx)
            }),
        ),
        
        // Server configuration
        app.WithServer(
            app.WithReadTimeout(10 * time.Second),
            app.WithWriteTimeout(15 * time.Second),
            app.WithShutdownTimeout(30 * time.Second),
        ),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // Lifecycle hooks
    a.OnStart(func(ctx context.Context) error {
        log.Println("Connecting to database...")
        var err error
        db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
        return err
    })
    
    a.OnShutdown(func(ctx context.Context) {
        log.Println("Closing database connection...")
        db.Close()
    })
    
    // Register routes
    a.GET("/", func(c *app.Context) {
        c.JSON(http.StatusOK, map[string]string{
            "service": "orders-api",
            "version": "v2.0.0",
        })
    })
    
    a.GET("/orders/:id", func(c *app.Context) {
        orderID := c.Param("id")
        
        c.Logger().Info("fetching order", "order_id", orderID)
        
        c.JSON(http.StatusOK, map[string]string{
            "order_id": orderID,
            "status":   "completed",
        })
    })
    
    // Start server
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer cancel()
    
    log.Println("Server starting on :8080")
    if err := a.Start(ctx, ":8080"); 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) {
        var req CreateUserRequest
        if !c.MustBindAndValidate(&req) {
            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")
        
        var req CreateUserRequest
        if !c.MustBindAndValidate(&req) {
            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