Application Framework
A complete web framework built on the Rivaas router. Includes integrated observability, lifecycle management, and sensible defaults for production-ready applications.
The Rivaas App package provides a high-level framework with pre-configured observability, graceful shutdown, and common middleware for rapid application development.
Overview
The App package is a complete web framework built on top of the Rivaas router. It provides a simple API for building web applications. It includes integrated observability with metrics, tracing, and logging. It has lifecycle management, graceful shutdown, and common middleware patterns.
Key Features
- Complete Framework - Pre-configured with sensible defaults for rapid development.
- Integrated Observability - Built-in metrics with Prometheus/OTLP, tracing with OpenTelemetry, and structured logging with slog.
- Request Binding & Validation - Automatic request parsing with validation strategies.
- OpenAPI Generation - Automatic OpenAPI spec generation with Swagger UI.
- Lifecycle Hooks - OnStart, OnReady, OnShutdown, OnStop for initialization and cleanup.
- Health Endpoints - Kubernetes-compatible liveness and readiness probes.
- Graceful Shutdown - Proper server shutdown with configurable timeouts.
- Environment-Aware - Development and production modes with appropriate defaults.
When to Use
Use App Package When
- Building a complete web application - Need a full framework with all features included.
- Want integrated observability - Metrics and tracing configured out of the box.
- Need quick development - Sensible defaults help you start immediately.
- Building a REST API - Pre-configured with common middleware and patterns.
- Prefer convention over configuration - Defaults that work well together.
Use Router Package Directly When
- Building a library or framework - Need full control over the routing layer.
- Have custom observability setup - Already using specific metrics or tracing solutions.
- Maximum performance is critical - Want zero overhead from default middleware.
- Need complete flexibility - Don’t want any opinions or defaults imposed.
- Integrating into existing systems - Need to fit into established patterns.
Performance Note: The app package adds about 1-2% latency compared to using the router directly. See Router Performance for baseline numbers. However, it provides significant development speed and maintainability benefits. This comes through integrated observability and sensible defaults.
Quick Start
Simple Application
Create a minimal application with defaults:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
// Create app with defaults
a, err := app.New()
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Register routes
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello from Rivaas App!",
})
})
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Start server with graceful shutdown
if err := a.Start(ctx); err != nil {
log.Fatalf("Server error: %v", err)
}
}
Full-Featured Application
Create a production-ready application with full observability:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"rivaas.dev/app"
"rivaas.dev/logging"
"rivaas.dev/metrics"
"rivaas.dev/tracing"
)
func main() {
// Create app with full observability
a, err := app.New(
app.WithServiceName("my-api"),
app.WithServiceVersion("v1.0.0"),
app.WithEnvironment("production"),
// Observability: logging, metrics, tracing
app.WithObservability(
app.WithLogging(logging.WithJSONHandler()),
app.WithMetrics(), // Prometheus is default
app.WithTracing(tracing.WithOTLP("localhost:4317")),
app.WithExcludePaths("/livez", "/readyz", "/metrics"),
),
// Health endpoints: GET /livez (liveness), GET /readyz (readiness)
app.WithHealthEndpoints(
app.WithHealthTimeout(800 * time.Millisecond),
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
),
// Server configuration
app.WithServer(
app.WithReadTimeout(15 * time.Second),
app.WithWriteTimeout(15 * time.Second),
),
)
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Register routes
a.GET("/users/:id", func(c *app.Context) {
userID := c.Param("id")
// Request-scoped logger with automatic context
c.Logger().Info("processing request", "user_id", userID)
c.JSON(http.StatusOK, map[string]any{
"user_id": userID,
"name": "John Doe",
"trace_id": c.TraceID(),
})
})
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Start server
if err := a.Start(ctx); err != nil {
log.Fatalf("Server error: %v", err)
}
}
Learning Path
Follow this structured path to master the Rivaas App framework:
1. Getting Started
Start with the basics:
2. Request Handling
Handle requests effectively:
- Context - Use the app context for binding, validation, and error handling
- Routing - Organize routes with groups, versioning, and static files
- Middleware - Add cross-cutting concerns with built-in middleware
3. Observability
Monitor your application:
4. Production Readiness
Prepare for production:
- Lifecycle - Use lifecycle hooks for initialization and cleanup
- Server - Configure HTTP, HTTPS, and mTLS servers with graceful shutdown
- OpenAPI - Generate OpenAPI specs and Swagger UI automatically
5. Testing & Migration
Test and migrate:
- Testing - Test your routes and handlers without starting a server
- Migration - Migrate from the router package to the app package
- Examples - Complete working examples and patterns
Common Use Cases
The Rivaas App excels in these scenarios:
- REST APIs - Full-featured JSON APIs with observability and validation
- Microservices - Cloud-native services with health checks and graceful shutdown
- Web Applications - Complete web apps with middleware and lifecycle management
- Production Services - Production-ready defaults with integrated monitoring
Next Steps
Need Help?
1 - Installation
Install the Rivaas App package and set up your development environment.
Requirements
- Go 1.25 or later - The app package requires Go 1.25 or higher. It uses the latest language features and standard library.
- Module support - Your project must use Go modules. It needs a
go.mod file.
Installation
Install the app package using go get:
This downloads the app package and all its dependencies. These include:
rivaas.dev/router - High-performance HTTP router.rivaas.dev/binding - Request binding and parsing.rivaas.dev/validation - Request validation.rivaas.dev/errors - Error formatting.rivaas.dev/logging - Structured logging (optional).rivaas.dev/metrics - Metrics collection (optional).rivaas.dev/tracing - OpenTelemetry tracing (optional).rivaas.dev/openapi - OpenAPI generation (optional).
Verify Installation
Create a simple main.go to verify the installation:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a, err := app.New()
if err != nil {
log.Fatal(err)
}
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Installation successful!",
})
})
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
log.Println("Server starting on :8080")
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
Run the application:
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
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
The app package includes built-in testing utilities. No additional tools required:
package main
import (
"net/http/httptest"
"testing"
)
func TestHome(t *testing.T) {
a, _ := app.New()
a.GET("/", homeHandler)
req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
Optional Dependencies
Observability
If you plan to use observability features, you may want to configure exporters:
# For Prometheus metrics (default, no additional setup needed)
# For OTLP metrics/tracing (to send to Jaeger, Tempo, etc.)
# No additional packages needed - built into the tracing package
OpenAPI
If you plan to use OpenAPI spec generation:
# No additional packages needed - included in app
Next Steps
Troubleshooting
Import Errors
If you see import errors:
cannot find package "rivaas.dev/app"
Make sure you’ve run go get rivaas.dev/app and your Go version is 1.25+:
go version # Should show go1.25 or later
go mod tidy # Clean up dependencies
Module Issues
If you see module-related errors, ensure your project is using Go modules:
# Initialize a new module (if not already done)
go mod init myapp
# Download dependencies
go mod download
Version Conflicts
If you encounter version conflicts with other Rivaas packages:
# Update all Rivaas packages to latest versions
go get -u rivaas.dev/app
go get -u rivaas.dev/router
go get -u rivaas.dev/binding
go mod tidy
2 - Basic Usage
Learn the fundamentals of creating and running Rivaas applications.
Creating an App
Using New()
The recommended way to create an app is with app.New(). It returns an error if configuration is invalid.
package main
import (
"log"
"rivaas.dev/app"
)
func main() {
a, err := app.New()
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Use the app...
}
Using MustNew()
For initialization code where errors should panic (like main() functions), use app.MustNew():
package main
import (
"rivaas.dev/app"
)
func main() {
a := app.MustNew(
app.WithServiceName("my-api"),
app.WithServiceVersion("v1.0.0"),
)
// Use the app...
}
MustNew() panics if configuration is invalid. It follows the Go idiom of Must* constructors like regexp.MustCompile().
Registering Routes
Basic Routes
Register routes using HTTP method shortcuts.
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello, World!",
})
})
a.POST("/users", func(c *app.Context) {
c.JSON(http.StatusCreated, map[string]string{
"message": "User created",
})
})
a.PUT("/users/:id", func(c *app.Context) {
id := c.Param("id")
c.JSON(http.StatusOK, map[string]string{
"id": id,
"message": "User updated",
})
})
a.DELETE("/users/:id", func(c *app.Context) {
c.Status(http.StatusNoContent)
})
Path Parameters
Extract path parameters using c.Param():
a.GET("/users/:id", func(c *app.Context) {
userID := c.Param("id")
c.JSON(http.StatusOK, map[string]string{
"user_id": userID,
})
})
a.GET("/posts/:postID/comments/:commentID", func(c *app.Context) {
postID := c.Param("postID")
commentID := c.Param("commentID")
c.JSON(http.StatusOK, map[string]string{
"post_id": postID,
"comment_id": commentID,
})
})
Query Parameters
Access query parameters using c.Query():
a.GET("/search", func(c *app.Context) {
query := c.Query("q")
page := c.QueryDefault("page", "1")
c.JSON(http.StatusOK, map[string]string{
"query": query,
"page": page,
})
})
Wildcard Routes
Use wildcards to match remaining path segments:
a.GET("/files/*filepath", func(c *app.Context) {
filepath := c.Param("filepath")
c.JSON(http.StatusOK, map[string]string{
"filepath": filepath,
})
})
Request Handlers
Handler Function Signature
Handlers receive an *app.Context which provides access to the request, response, and app features:
func handler(c *app.Context) {
// Access request
method := c.Request.Method
path := c.Request.URL.Path
// Access parameters
id := c.Param("id")
query := c.Query("q")
// Send response
c.JSON(http.StatusOK, map[string]string{
"method": method,
"path": path,
"id": id,
"query": query,
})
}
a.GET("/example/:id", handler)
Organizing Handlers
For larger applications, organize handlers in separate files:
// handlers/users.go
package handlers
import (
"net/http"
"rivaas.dev/app"
)
func GetUser(c *app.Context) {
id := c.Param("id")
// Fetch user from database...
c.JSON(http.StatusOK, map[string]any{
"id": id,
"name": "John Doe",
})
}
func CreateUser(c *app.Context) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if !c.MustBind(&req) {
return // Error response already sent
}
// Create user in database...
c.JSON(http.StatusCreated, map[string]any{
"id": "123",
"name": req.Name,
"email": req.Email,
})
}
// main.go
package main
import (
"myapp/handlers"
"rivaas.dev/app"
)
func main() {
a := app.MustNew()
a.GET("/users/:id", handlers.GetUser)
a.POST("/users", handlers.CreateUser)
// ...
}
Response Rendering
JSON Responses
Send JSON responses with c.JSON():
a.GET("/users", func(c *app.Context) {
users := []map[string]string{
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"},
}
c.JSON(http.StatusOK, users)
})
Status Codes
Set status without body using c.Status():
a.DELETE("/users/:id", func(c *app.Context) {
id := c.Param("id")
// Delete user from database...
c.Status(http.StatusNoContent)
})
String Responses
Send plain text responses:
a.GET("/health", func(c *app.Context) {
c.String(http.StatusOK, "OK")
})
HTML Responses
Send HTML responses:
a.GET("/", func(c *app.Context) {
html := `
<!DOCTYPE html>
<html>
<head><title>Welcome</title></head>
<body><h1>Welcome to My App</h1></body>
</html>
`
c.HTML(http.StatusOK, html)
})
Running the Server
HTTP Server
Start the HTTP server with graceful shutdown:
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a := app.MustNew()
// Register routes...
a.GET("/", homeHandler)
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
// Start server
log.Println("Server starting on :8080")
if err := a.Start(ctx); err != nil {
log.Fatalf("Server error: %v", err)
}
}
Port Configuration
Configure the listen address via options when creating the app (default is :8080):
// Development (default port 8080)
a, err := app.New(
app.WithServiceName("my-api"),
app.WithPort(8080),
)
// ...
a.Start(ctx)
// Production
a, err := app.New(
app.WithServiceName("my-api"),
app.WithPort(80),
)
// ...
a.Start(ctx)
// Bind to specific interface
a, err := app.New(
app.WithServiceName("my-api"),
app.WithHost("127.0.0.1"),
app.WithPort(8080),
)
// ...
a.Start(ctx)
// Use environment variable
port := 8080
if p := os.Getenv("PORT"); p != "" {
port, _ = strconv.Atoi(p)
}
a, err := app.New(
app.WithServiceName("my-api"),
app.WithPort(port),
)
// ...
a.Start(ctx)
Complete Example
Here’s a complete working example:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
// Create app
a, err := app.New(
app.WithServiceName("hello-api"),
app.WithServiceVersion("v1.0.0"),
)
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Home route
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Welcome to Hello API",
"version": "v1.0.0",
})
})
// Greet route with parameter
a.GET("/greet/:name", func(c *app.Context) {
name := c.Param("name")
c.JSON(http.StatusOK, map[string]string{
"greeting": "Hello, " + name + "!",
})
})
// Echo route with request body
a.POST("/echo", func(c *app.Context) {
var req map[string]any
if !c.MustBind(&req) {
return
}
c.JSON(http.StatusOK, req)
})
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
// Start server
log.Println("Server starting on :8080")
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
Test the endpoints:
# Home route
curl http://localhost:8080/
# Greet route
curl http://localhost:8080/greet/Alice
# Echo route
curl -X POST http://localhost:8080/echo \
-H "Content-Type: application/json" \
-d '{"message": "Hello, World!"}'
Next Steps
- Configuration - Configure service name, environment, and server settings
- Context - Learn about request binding and validation
- Routing - Organize routes with groups and middleware
- Examples - Explore complete working examples
3 - Configuration
Configure your application with service metadata, environment modes, and server settings.
Service Configuration
Service Name
Set the service name used in observability metadata. This includes metrics, traces, and logs:
a, err := app.New(
app.WithServiceName("orders-api"),
)
The service name must be non-empty or validation will fail. Default: "rivaas-app".
Service Version
Set the service version for observability and API documentation:
a, err := app.New(
app.WithServiceVersion("v1.2.3"),
)
The service version must be non-empty or validation will fail. Default: "1.0.0".
Configure both service name and version:
a, err := app.New(
app.WithServiceName("payments-api"),
app.WithServiceVersion("v2.0.0"),
)
These values are automatically injected into:
- Metrics - Service name and version labels on all metrics.
- Tracing - Service name and version attributes on all spans.
- Logging - Service name and version fields in all log entries.
- OpenAPI - API title and version in the specification.
Environment Modes
Development Mode
Development mode enables verbose logging and developer-friendly features:
a, err := app.New(
app.WithEnvironment("development"),
)
Development mode features:
- Verbose access logging for all requests.
- Route table displayed in startup banner.
- More detailed error messages.
- Terminal colors enabled.
Production Mode
Production mode optimizes for performance and security:
a, err := app.New(
app.WithEnvironment("production"),
)
Production mode features:
- Access log scope defaults to errors-only when not set via WithAccessLogScope. Reduces log volume.
- Minimal startup banner.
- Sanitized error messages.
- Terminal colors stripped for log aggregation.
Environment from Environment Variables
Use environment variables for configuration:
env := os.Getenv("ENVIRONMENT")
if env == "" {
env = "development"
}
a, err := app.New(
app.WithEnvironment(env),
)
Valid values: "development", "production". Invalid values cause validation to fail.
Server Configuration
Configure server timeouts and (optionally) transport. Default port is 8080 for HTTP and 8443 for TLS/mTLS; override with WithPort. For HTTPS or mTLS, use WithTLS or WithMTLS at construction; see Server for examples.
Timeouts
Configure server timeouts for safety and performance:
a, err := app.New(
app.WithServer(
app.WithReadTimeout(10 * time.Second),
app.WithWriteTimeout(15 * time.Second),
app.WithIdleTimeout(60 * time.Second),
app.WithReadHeaderTimeout(2 * time.Second),
),
)
Timeout descriptions:
- ReadTimeout - Maximum time to read entire request. Includes body.
- WriteTimeout - Maximum time to write response.
- IdleTimeout - Maximum time to wait for next request on keep-alive connection.
- ReadHeaderTimeout - Maximum time to read request headers.
Default values:
- ReadTimeout:
10s - WriteTimeout:
10s - IdleTimeout:
60s - ReadHeaderTimeout:
2s
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:
- In-flight requests to complete.
- OnShutdown hooks to execute.
- Observability components to flush.
- Connections to close gracefully.
Validation Rules
Server configuration is automatically validated:
Timeout validation:
- All timeouts must be positive.
- ReadTimeout should not exceed WriteTimeout. This is a common misconfiguration.
- ShutdownTimeout must be at least 1 second.
Size validation:
- MaxHeaderBytes must be at least 1KB (1024 bytes)
Invalid configuration example:
a, err := app.New(
app.WithServer(
app.WithReadTimeout(15 * time.Second),
app.WithWriteTimeout(10 * time.Second), // ❌ Invalid: read > write
app.WithShutdownTimeout(100 * time.Millisecond), // ❌ Invalid: too short
app.WithMaxHeaderBytes(512), // ❌ Invalid: too small
),
)
// err contains all validation errors
Valid configuration example:
a, err := app.New(
app.WithServer(
app.WithReadTimeout(10 * time.Second),
app.WithWriteTimeout(15 * time.Second), // ✅ Valid: write >= read
app.WithShutdownTimeout(5 * time.Second), // ✅ Valid: >= 1s
app.WithMaxHeaderBytes(2048), // ✅ Valid: >= 1KB
),
)
Partial Configuration
You can set only the options you need - unset fields use defaults:
// Only override read and write timeouts
a, err := app.New(
app.WithServer(
app.WithReadTimeout(15 * time.Second),
app.WithWriteTimeout(15 * time.Second),
// Other fields use defaults: IdleTimeout=60s, etc.
),
)
Configuration from Environment
Load configuration from environment variables:
package main
import (
"log"
"os"
"strconv"
"time"
"rivaas.dev/app"
)
func main() {
// Parse timeouts from environment
readTimeout := parseDuration("READ_TIMEOUT", 10*time.Second)
writeTimeout := parseDuration("WRITE_TIMEOUT", 10*time.Second)
a, err := app.New(
app.WithServiceName(getEnv("SERVICE_NAME", "my-api")),
app.WithServiceVersion(getEnv("SERVICE_VERSION", "v1.0.0")),
app.WithEnvironment(getEnv("ENVIRONMENT", "development")),
app.WithServer(
app.WithReadTimeout(readTimeout),
app.WithWriteTimeout(writeTimeout),
),
)
if err != nil {
log.Fatal(err)
}
// ...
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func parseDuration(key string, defaultValue time.Duration) time.Duration {
if value := os.Getenv(key); value != "" {
if d, err := time.ParseDuration(value); err == nil {
return d
}
}
return defaultValue
}
Configuration Validation
All configuration is validated when calling app.New():
a, err := app.New(
app.WithServiceName(""), // ❌ Empty service name
app.WithEnvironment("staging"), // ❌ Invalid environment
)
if err != nil {
// Handle validation errors
log.Fatalf("Configuration error: %v", err)
}
Validation errors are structured and include all issues:
validation errors (2):
1. configuration error in serviceName: must not be empty
2. configuration error in environment: must be "development" or "production", got "staging"
Complete Configuration Example
package main
import (
"log"
"os"
"time"
"rivaas.dev/app"
)
func main() {
a, err := app.New(
// Service metadata
app.WithServiceName("orders-api"),
app.WithServiceVersion("v2.1.0"),
app.WithEnvironment("production"),
// Server configuration
app.WithServer(
app.WithReadTimeout(10 * time.Second),
app.WithWriteTimeout(15 * time.Second),
app.WithIdleTimeout(120 * time.Second),
app.WithReadHeaderTimeout(3 * time.Second),
app.WithMaxHeaderBytes(2 << 20), // 2MB
app.WithShutdownTimeout(30 * time.Second),
),
)
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Register routes...
// Start server...
}
Next Steps
- Observability - Configure metrics, tracing, and logging
- Server - Learn about HTTP, HTTPS, and mTLS servers
- Lifecycle - Use lifecycle hooks for initialization and cleanup
4 - Observability
Integrate metrics, tracing, and logging for complete application observability.
Overview
The app package provides unified configuration for the three pillars of observability:
- Metrics - Prometheus or OTLP metrics with automatic HTTP instrumentation.
- Tracing - OpenTelemetry distributed tracing with context propagation.
- Logging - Structured logging with slog that includes request-scoped fields.
All three pillars use the same functional options pattern. They automatically receive service metadata (name and version) from app-level configuration.
Environment Variable Configuration
You can configure observability using environment variables. This is useful for container deployments and following 12-factor app principles.
See Environment Variables for the complete guide.
Quick example:
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=localhost:4317
export RIVAAS_LOG_LEVEL=info
export RIVAAS_LOG_FORMAT=json
app, err := app.New(
app.WithServiceName("my-api"),
app.WithEnv(), // Reads environment variables
)
Environment variables override code configuration, making it easy to deploy the same code to different environments.
Unified Observability Configuration
Configure all three pillars in one place.
a, err := app.New(
app.WithServiceName("orders-api"),
app.WithServiceVersion("v1.2.3"),
app.WithObservability(
app.WithLogging(logging.WithJSONHandler()),
app.WithMetrics(), // Prometheus is default
app.WithTracing(tracing.WithOTLP("localhost:4317")),
),
)
Logging
Basic Logging
Enable structured logging with slog:
a, err := app.New(
app.WithObservability(
app.WithLogging(logging.WithJSONHandler()),
),
)
Log Handlers
Choose from different log handlers.
// JSON handler (production)
app.WithLogging(logging.WithJSONHandler())
// Console handler (development)
app.WithLogging(logging.WithConsoleHandler())
// Text handler
app.WithLogging(logging.WithTextHandler())
Log Levels
Configure log level:
app.WithLogging(
logging.WithJSONHandler(),
logging.WithLevel(slog.LevelDebug),
)
Request-Scoped Logging
Pass the request context when you log so trace IDs are attached automatically:
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
slog.InfoContext(c.RequestContext(), "processing order",
slog.String("order.id", orderID),
)
slog.DebugContext(c.RequestContext(), "fetching from database")
c.JSON(http.StatusOK, map[string]string{
"order_id": orderID,
})
})
Handler log lines stay lean: they include trace_id and span_id (when tracing is enabled) plus whatever attributes you add. HTTP details (method, route, client IP, etc.) are in the access log, not in every handler log.
Example handler log line:
{
"time": "2024-01-18T10:30:00Z",
"level": "INFO",
"msg": "processing order",
"trace_id": "abc...",
"span_id": "def...",
"order.id": "123"
}
Metrics
Prometheus Metrics (Default)
Enable Prometheus metrics on a separate server:
a, err := app.New(
app.WithObservability(
app.WithMetrics(), // Default: Prometheus on :9090/metrics
),
)
Custom Prometheus Configuration
Configure Prometheus address and path:
a, err := app.New(
app.WithObservability(
app.WithMetrics(metrics.WithPrometheus(":9091", "/custom-metrics")),
),
)
Mount Metrics on Main Router
Mount metrics endpoint on the main HTTP server:
a, err := app.New(
app.WithObservability(
app.WithMetricsOnMainRouter("/metrics"),
),
)
// Metrics available at http://localhost:8080/metrics
OTLP Metrics
Send metrics via OTLP to collectors like Prometheus, Grafana, or Datadog:
a, err := app.New(
app.WithObservability(
app.WithMetrics(metrics.WithOTLP("localhost:4317")),
),
)
Custom Metrics in Handlers
These methods are part of app.Context. They use the metrics and tracing you set up with app.WithObservability(). If observability is not configured, the methods simply do nothing (no-ops).
Record custom metrics in your handlers:
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
// Increment counter
c.IncrementCounter("order.lookups",
attribute.String("order.id", orderID),
)
// Record histogram
c.RecordHistogram("order.processing_time", 0.250,
attribute.String("order.id", orderID),
)
c.JSON(http.StatusOK, order)
})
Tracing
OpenTelemetry Tracing
Enable OpenTelemetry tracing with OTLP exporter:
a, err := app.New(
app.WithObservability(
app.WithTracing(tracing.WithOTLP("localhost:4317")),
),
)
Stdout Tracing (Development)
Use stdout tracing for development:
a, err := app.New(
app.WithObservability(
app.WithTracing(tracing.WithStdout()),
),
)
Sample Rate
Configure trace sampling:
a, err := app.New(
app.WithObservability(
app.WithTracing(
tracing.WithOTLP("localhost:4317"),
tracing.WithSampleRate(0.1), // Sample 10% of requests
),
),
)
Span Attributes in Handlers
Add span attributes and events in your handlers:
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
// Add span attribute
c.SetSpanAttribute("order.id", orderID)
// Add span event
c.AddSpanEvent("order_lookup_started")
// Fetch order...
c.AddSpanEvent("order_lookup_completed")
c.JSON(http.StatusOK, order)
})
Accessing Trace IDs
Get the current trace ID for correlation:
a.GET("/orders/:id", func(c *app.Context) {
traceID := c.TraceID()
c.JSON(http.StatusOK, map[string]string{
"order_id": orderID,
"trace_id": traceID,
})
})
Service 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.
If needed, you can override service metadata for specific components:
a, err := app.New(
app.WithServiceName("orders-api"),
app.WithServiceVersion("v1.2.3"),
app.WithObservability(
app.WithLogging(
logging.WithServiceName("custom-logger"), // Overrides injected value
),
),
)
Path Filtering
Exclude specific paths from observability (metrics, tracing, logging):
Exclude Paths
Exclude exact paths:
a, err := app.New(
app.WithObservability(
app.WithLogging(),
app.WithMetrics(),
app.WithTracing(),
app.WithExcludePaths("/livez", "/readyz", "/metrics"),
),
)
Exclude Prefixes
Exclude path prefixes:
a, err := app.New(
app.WithObservability(
app.WithExcludePrefixes("/internal/", "/admin/debug/"),
),
)
Exclude Patterns
Exclude paths matching regex patterns:
a, err := app.New(
app.WithObservability(
app.WithExcludePatterns(`^/api/v\d+/health$`, `^/debug/.*`),
),
)
Default Exclusions
By default, the following paths are excluded:
/health, /livez, /ready, /readyz/ready, /readyz/metrics/debug/*
To disable default exclusions:
a, err := app.New(
app.WithObservability(
app.WithoutDefaultExclusions(),
app.WithExcludePaths("/custom-health"), // Add your own
),
)
Access Logging
Enable/Disable Access Logging
Control access logging:
// Enable access logging (default)
a, err := app.New(
app.WithObservability(
app.WithAccessLogging(true),
),
)
// Disable access logging
a, err := app.New(
app.WithObservability(
app.WithAccessLogging(false),
),
)
Access Log Scope
Control which requests are logged. When you do not set a scope, production defaults to errors-only and development to full access logs.
Errors and slow requests only (explicit; also the production default when unset):
a, err := app.New(
app.WithObservability(
app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
),
)
Full access logs (including production)
To log every request in production, set scope explicitly. Consider log volume and cost before enabling.
a, err := app.New(
app.WithEnvironment("production"),
app.WithObservability(
app.WithAccessLogScope(app.AccessLogScopeAll),
),
)
Slow Request Threshold
Mark requests as slow and log them. Slow requests are always logged regardless of access log scope.
a, err := app.New(
app.WithObservability(
app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
app.WithSlowThreshold(500 * time.Millisecond),
),
)
Complete Example
Production-ready observability configuration:
package main
import (
"log"
"time"
"rivaas.dev/app"
"rivaas.dev/logging"
"rivaas.dev/metrics"
"rivaas.dev/tracing"
)
func main() {
a, err := app.New(
// Service metadata (automatically injected into all components)
app.WithServiceName("orders-api"),
app.WithServiceVersion("v2.1.0"),
app.WithEnvironment("production"),
// Unified observability configuration
app.WithObservability(
// Logging: JSON handler for production
app.WithLogging(
logging.WithJSONHandler(),
logging.WithLevel(slog.LevelInfo),
),
// Metrics: Prometheus on separate server
app.WithMetrics(
metrics.WithPrometheus(":9090", "/metrics"),
),
// Tracing: OTLP to Jaeger/Tempo
app.WithTracing(
tracing.WithOTLP("jaeger:4317"),
tracing.WithSampleRate(0.1), // 10% sampling
),
// Path filtering
app.WithExcludePaths("/livez", "/readyz"),
app.WithExcludePrefixes("/internal/"),
// Access logging: errors and slow requests only (explicit; also production default when unset)
app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
app.WithSlowThreshold(1 * time.Second),
),
)
if err != nil {
log.Fatal(err)
}
// Register routes...
a.GET("/orders/:id", handleGetOrder)
// Start server...
}
Next Steps
5 - Environment Variables
Configure your app using environment variables for easier deployment.
Overview
Want to configure your app without changing code? Use environment variables. This is helpful when you deploy to containers or cloud platforms.
The app package supports environment variables through the WithEnv() option. Just add it to your app setup, and you can control settings like port, logging, metrics, and tracing using environment variables.
This follows the 12-factor app approach, which means your code stays the same across different environments. You just change the environment variables.
Quick Start
Here’s a simple example. First, set some environment variables:
export RIVAAS_PORT=3000
export RIVAAS_LOG_LEVEL=debug
export RIVAAS_METRICS_EXPORTER=prometheus
Then create your app with WithEnv():
app, err := app.New(
app.WithServiceName("my-api"),
app.WithEnv(), // This reads environment variables
)
if err != nil {
log.Fatal(err)
}
// Your app now runs on port 3000 with debug logging and Prometheus metrics
That’s it! No need to set these in code anymore.
Environment Variables Reference
All environment variables start with the RIVAAS_ prefix. You can also use a custom prefix with WithEnvPrefix().
Server Configuration
| Variable | Description | Default | Example |
|---|
RIVAAS_PORT | Port number to listen on | 8080 | 3000 |
RIVAAS_HOST | Host address to bind to | 0.0.0.0 | 127.0.0.1 |
Logging Configuration
| Variable | Description | Default | Example |
|---|
RIVAAS_LOG_LEVEL | Log level to use | info | debug, info, warn, error |
RIVAAS_LOG_FORMAT | Log output format | json | json, text, console |
Metrics Configuration
| Variable | Description | Default | Example |
|---|
RIVAAS_METRICS_EXPORTER | Type of metrics exporter | - | prometheus, otlp, stdout |
RIVAAS_METRICS_ADDR | Prometheus server address | :9090 | :9000, 0.0.0.0:9090 |
RIVAAS_METRICS_PATH | Prometheus metrics path | /metrics | /custom-metrics |
RIVAAS_METRICS_ENDPOINT | OTLP endpoint for metrics | - | http://localhost:4318 |
Tracing Configuration
| Variable | Description | Default | Example |
|---|
RIVAAS_TRACING_EXPORTER | Type of tracing exporter | - | otlp, otlp-http, stdout |
RIVAAS_TRACING_ENDPOINT | OTLP endpoint for traces | - | localhost:4317 |
Debug Configuration
| Variable | Description | Default | Example |
|---|
RIVAAS_PPROF_ENABLED | Enable pprof endpoints | false | true, false |
Metrics Configuration
You can set up metrics using just environment variables. No need to write code for it.
Prometheus (Default)
The simplest way to get metrics:
export RIVAAS_METRICS_EXPORTER=prometheus
This starts a Prometheus server on :9090/metrics. Your app will expose metrics there.
Custom Prometheus Settings
Want to use a different port or path?
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_METRICS_ADDR=:9000
export RIVAAS_METRICS_PATH=/custom-metrics
Now your metrics are at http://localhost:9000/custom-metrics.
OTLP Metrics
Need to send metrics to an OTLP collector (like Grafana, Datadog, or Prometheus)?
export RIVAAS_METRICS_EXPORTER=otlp
export RIVAAS_METRICS_ENDPOINT=http://localhost:4318
Make sure to set the endpoint. The app will fail to start if you forget it.
Stdout Metrics (Development)
For local development, you can print metrics to stdout:
export RIVAAS_METRICS_EXPORTER=stdout
This shows all metrics in your terminal. Good for debugging.
Tracing Configuration
Set up distributed tracing using environment variables.
OTLP Tracing (gRPC)
This is the most common way to send traces:
export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=localhost:4317
This works with Jaeger, Tempo, and other tracing backends that support OTLP over gRPC.
OTLP Tracing (HTTP)
Prefer HTTP instead of gRPC?
export RIVAAS_TRACING_EXPORTER=otlp-http
export RIVAAS_TRACING_ENDPOINT=http://localhost:4318
This is useful when your tracing backend only supports HTTP.
Stdout Tracing (Development)
For local development, print traces to your terminal:
export RIVAAS_TRACING_EXPORTER=stdout
You’ll see all traces in your console. Great for testing.
Logging Configuration
Control how your app logs messages.
Log Level
Set the minimum log level:
export RIVAAS_LOG_LEVEL=debug # Show everything
export RIVAAS_LOG_LEVEL=info # Normal logging (default)
export RIVAAS_LOG_LEVEL=warn # Only warnings and errors
export RIVAAS_LOG_LEVEL=error # Only errors
Choose how logs look:
export RIVAAS_LOG_FORMAT=json # JSON format (good for production)
export RIVAAS_LOG_FORMAT=text # Simple text format
export RIVAAS_LOG_FORMAT=console # Colored output (good for development)
Common Patterns
Here are some typical setups for different environments.
Development Setup
For local development, you want to see everything:
export RIVAAS_PORT=3000
export RIVAAS_LOG_LEVEL=debug
export RIVAAS_LOG_FORMAT=console
export RIVAAS_METRICS_EXPORTER=stdout
export RIVAAS_TRACING_EXPORTER=stdout
This gives you:
- Port 3000 (so you can run multiple apps)
- Debug logging with colors
- Metrics and traces in your terminal
Production Setup
For production, you want structured logs and proper observability:
export RIVAAS_PORT=8080
export RIVAAS_LOG_LEVEL=info
export RIVAAS_LOG_FORMAT=json
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=jaeger:4317
This gives you:
- Standard port 8080
- JSON logs (easy to parse)
- Prometheus metrics on
:9090 - Traces sent to Jaeger
Docker Setup
For Docker containers, you often need to bind to all addresses:
export RIVAAS_HOST=0.0.0.0
export RIVAAS_PORT=8080
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_METRICS_ADDR=0.0.0.0:9090
This makes sure your app is reachable from outside the container.
Custom Prefix
Don’t like the RIVAAS_ prefix? You can change it:
app, err := app.New(
app.WithServiceName("my-api"),
app.WithEnvPrefix("MYAPP_"), // Use MYAPP_ instead of RIVAAS_
)
Now you can use variables like:
export MYAPP_PORT=3000
export MYAPP_LOG_LEVEL=debug
Environment Variables Override Code
Environment variables always win. If you set something in code and in an environment variable, the environment variable is used.
app, err := app.New(
app.WithPort(8080), // Set port in code
app.WithEnv(), // But environment variable overrides it
)
If you set RIVAAS_PORT=3000, your app uses port 3000, not 8080.
This is by design. It follows the 12-factor app principle where configuration comes from the environment.
Error Messages
The app checks your environment variables at startup. If something is wrong, it tells you clearly.
Missing Required Endpoint
If you set an OTLP exporter but forget the endpoint:
export RIVAAS_METRICS_EXPORTER=otlp
# Forgot to set RIVAAS_METRICS_ENDPOINT
You get this error:
RIVAAS_METRICS_EXPORTER=otlp requires RIVAAS_METRICS_ENDPOINT to be set
The app won’t start. This is good! It prevents wrong configurations in production.
Invalid Exporter Type
If you use a wrong exporter name:
export RIVAAS_METRICS_EXPORTER=datadog # Not supported
You get:
RIVAAS_METRICS_EXPORTER must be one of: prometheus, otlp, stdout (got: datadog)
Invalid Port
If you set a bad port number:
export RIVAAS_PORT=99999 # Too high
You get:
invalid port: must be between 1 and 65535
These clear error messages help you fix problems quickly.
Complete Example
Here’s a full example showing everything together:
package main
import (
"context"
"log"
"os"
"os/signal"
"rivaas.dev/app"
)
func main() {
// Create app with environment variable support
a, err := app.New(
app.WithServiceName("orders-api"),
app.WithServiceVersion("v1.0.0"),
app.WithEnv(), // Read RIVAAS_* environment variables
)
if err != nil {
log.Fatal(err)
}
// Register routes
a.GET("/orders/:id", func(c *app.Context) {
c.JSON(200, map[string]string{
"order_id": c.Param("id"),
})
})
// Start server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
Now you can configure this app without changing the code:
# Development
export RIVAAS_PORT=3000
export RIVAAS_LOG_LEVEL=debug
export RIVAAS_LOG_FORMAT=console
# Production
export RIVAAS_PORT=8080
export RIVAAS_LOG_LEVEL=info
export RIVAAS_LOG_FORMAT=json
export RIVAAS_METRICS_EXPORTER=prometheus
export RIVAAS_TRACING_EXPORTER=otlp
export RIVAAS_TRACING_ENDPOINT=jaeger:4317
Next Steps
6 - Context
Use the app context for request binding, validation, error handling, and logging.
Overview
The app.Context wraps router.Context and provides app-level features:
- Request Binding - Parse JSON, form, query, path, header, and cookie data automatically
- Validation - Comprehensive validation with multiple strategies
- Error Handling - Structured error responses with content negotiation
- Logging - Request-scoped logger with automatic context
Request Binding
Binding and Validation
Bind() reads your request data and checks if it’s valid. It handles JSON, forms, query parameters, and more.
Use Bind() for most cases. It automatically validates your data:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=18"`
}
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if err := c.Bind(&req); err != nil {
c.Fail(err) // Handles binding and validation errors
return
}
// req is valid and ready to use
})
The Bind() method does two things: it reads the request data and validates it. If either step fails, you get an error.
Binding from Multiple Sources
You can bind data from different places at once. Use struct tags to tell Rivaas where to look:
type GetUserRequest struct {
ID int `path:"id"` // From URL path
Expand string `query:"expand"` // From query string
APIKey string `header:"X-API-Key"` // From HTTP header
Session string `cookie:"session"` // From cookie
}
a.GET("/users/:id", func(c *app.Context) {
var req GetUserRequest
if err := c.Bind(&req); err != nil {
c.Fail(err)
return
}
// All fields are populated from their sources
})
Binding Without Validation
Sometimes you need to process data before validating it. Use BindOnly() for this:
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if err := c.BindOnly(&req); err != nil {
c.Fail(err)
return
}
// Clean up the data
req.Email = strings.ToLower(req.Email)
// Now validate
if err := c.Validate(&req); err != nil {
c.Fail(err)
return
}
})
Multi-Source Binding
Bind from multiple sources in one call. This is useful when your request needs data from different places:
type UpdateUserRequest struct {
ID int `path:"id"` // From URL path
Name string `json:"name"` // From JSON body
Token string `header:"X-Token"` // From header
}
a.PUT("/users/:id", func(c *app.Context) {
var req UpdateUserRequest
if err := c.Bind(&req); err != nil {
c.Fail(err)
return
}
// All fields populated: ID from path, Name from JSON, Token from header
})
For file uploads, use the *binding.File type. The context automatically detects and handles multipart form data:
type UploadRequest struct {
File *binding.File `form:"file"`
Title string `form:"title"`
Description string `form:"description"`
// JSON in form fields is automatically parsed
Settings struct {
Quality int `json:"quality"`
Format string `json:"format"`
} `form:"settings"`
}
a.POST("/upload", func(c *app.Context) {
var req UploadRequest
if err := c.Bind(&req); err != nil {
c.Fail(err)
return
}
// Validate file type
allowedTypes := []string{".jpg", ".png", ".gif"}
if !slices.Contains(allowedTypes, req.File.Ext()) {
c.BadRequest(fmt.Errorf("invalid file type"))
return
}
// Save the file
filename := fmt.Sprintf("/uploads/%d_%s", time.Now().Unix(), req.File.Name)
if err := req.File.Save(filename); err != nil {
c.InternalError(err)
return
}
c.JSON(http.StatusCreated, map[string]interface{}{
"filename": filepath.Base(filename),
"size": req.File.Size,
"url": "/uploads/" + filepath.Base(filename),
})
})
Multiple file uploads:
type GalleryUpload struct {
Photos []*binding.File `form:"photos"`
Title string `form:"title"`
}
a.POST("/gallery", func(c *app.Context) {
var req GalleryUpload
if err := c.Bind(&req); err != nil {
c.Fail(err)
return
}
// Process each photo
for i, photo := range req.Photos {
filename := fmt.Sprintf("/uploads/%s_%d%s", req.Title, i, photo.Ext())
if err := photo.Save(filename); err != nil {
c.InternalError(err)
return
}
}
c.JSON(http.StatusCreated, map[string]int{
"uploaded": len(req.Photos),
})
})
File security best practices:
- Always validate file types using
file.Ext() or check magic bytes - Limit file sizes (check
file.Size) - Generate safe filenames (don’t use user-provided names directly)
- Store files outside your web root
- Scan for malware in production environments
See Multipart Forms for detailed examples and security patterns.
Validation
The Must Pattern
The easiest way to handle requests is with MustBind(). It reads the data, validates it, and sends an error response if something is wrong:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,gte=18,lte=120"`
}
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if !c.MustBind(&req) {
return // Error already sent to client
}
// req is valid, continue with your logic
})
This is the recommended approach. It keeps your code clean and handles errors automatically.
Type-Safe Binding with Generics
If you prefer working with return values instead of pointers, use the generic functions:
a.POST("/users", func(c *app.Context) {
req, ok := app.MustBind[CreateUserRequest](c)
if !ok {
return // Error already sent
}
// req is type CreateUserRequest, not a pointer
})
This approach is more concise. You don’t need to declare the variable first.
Manual Error Handling
When you need more control over error handling, use Bind() directly:
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if err := c.Bind(&req); err != nil {
slog.ErrorContext(c.RequestContext(), "binding failed", "error", err)
c.Fail(err)
return
}
// Continue processing
})
Or with generics:
a.POST("/users", func(c *app.Context) {
req, err := app.Bind[CreateUserRequest](c)
if err != nil {
c.Fail(err)
return
}
// Continue processing
})
Partial Validation for PATCH Requests
PATCH requests only update some fields. Use WithPartial() to validate only the fields that are present:
type UpdateUserRequest struct {
Name *string `json:"name" validate:"omitempty,min=3,max=50"`
Email *string `json:"email" validate:"omitempty,email"`
}
a.PATCH("/users/:id", func(c *app.Context) {
req, ok := app.MustBind[UpdateUserRequest](c, app.WithPartial())
if !ok {
return
}
// Only fields in the request are validated
})
You can also use the shortcut function BindPatch():
a.PATCH("/users/:id", func(c *app.Context) {
req, ok := app.MustBindPatch[UpdateUserRequest](c)
if !ok {
return
}
// Same as above, but shorter
})
Strict Mode (Reject Unknown Fields)
Catch typos and API mismatches by rejecting unknown fields:
a.POST("/users", func(c *app.Context) {
req, ok := app.MustBind[CreateUserRequest](c, app.WithStrict())
if !ok {
return // Error sent if client sends unknown fields
}
})
Or use the shortcut:
a.POST("/users", func(c *app.Context) {
req, ok := app.MustBindStrict[CreateUserRequest](c)
if !ok {
return
}
})
This is helpful during development to catch mistakes early.
Binding Options
You can customize how binding and validation work:
| Option | What it does |
|---|
app.WithStrict() | Reject unknown JSON fields |
app.WithPartial() | Only validate fields that are present |
app.WithoutValidation() | Skip validation (bind only) |
app.WithBindingOptions(...) | Advanced binding settings |
app.WithValidationOptions(...) | Advanced validation settings |
Example with multiple options:
req, err := app.Bind[Request](c,
app.WithStrict(),
app.WithValidationOptions(validation.WithMaxErrors(10)),
)
Validation Strategies
Choose how validation works:
// Tag validation (default, uses struct tags)
c.Bind(&req)
// Explicit strategy selection
c.Bind(&req, app.WithValidationOptions(
validation.WithStrategy(validation.StrategyTags),
))
// JSON Schema validation
c.Bind(&req, app.WithValidationOptions(
validation.WithStrategy(validation.StrategyJSONSchema),
))
Most apps use tag validation. It’s simple and works well.
Error Handling
Basic Error Handling
When something goes wrong in your handler, use Fail() to send an error response. This method formats the error, writes the HTTP response, and automatically stops the handler chain so no other handlers run after it:
a.GET("/users/:id", func(c *app.Context) {
id := c.Param("id")
user, err := db.GetUser(id)
if err != nil {
c.Fail(err)
return
}
c.JSON(http.StatusOK, user)
})
Explicit Status Codes
When you need a specific HTTP status code for an error, use FailStatus():
a.GET("/users/:id", func(c *app.Context) {
user, err := db.GetUser(id)
if err != nil {
c.FailStatus(http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, user)
})
Convenience Error Methods
Use convenience methods for common HTTP error status codes. These methods automatically format and send the error response, then stop the handler chain:
// 404 Not Found
if user == nil {
c.NotFound(fmt.Errorf("user not found"))
return
}
// 400 Bad Request
if err := validateInput(input); err != nil {
c.BadRequest(fmt.Errorf("invalid input"))
return
}
// 401 Unauthorized
if !isAuthenticated {
c.Unauthorized(fmt.Errorf("authentication required"))
return
}
// 403 Forbidden
if !hasPermission {
c.Forbidden(fmt.Errorf("insufficient permissions"))
return
}
// 409 Conflict
if userExists {
c.Conflict(fmt.Errorf("user already exists"))
return
}
// 422 Unprocessable Entity
if validationErr != nil {
c.UnprocessableEntity(validationErr)
return
}
// 429 Too Many Requests
if rateLimitExceeded {
c.TooManyRequests(fmt.Errorf("rate limit exceeded"))
return
}
// 500 Internal Server Error
if err := processRequest(); err != nil {
c.InternalError(err)
return
}
// 503 Service Unavailable
if maintenanceMode {
c.ServiceUnavailable(fmt.Errorf("maintenance mode"))
return
}
You can also pass nil to use a generic default message:
c.NotFound(nil) // Uses "Not Found" as the message
c.BadRequest(nil) // Uses "Bad Request" as the message
Configure error formatting at app level:
// Single formatter
a, err := app.New(
app.WithErrorFormatter(&errors.RFC9457{
BaseURL: "https://api.example.com/problems",
}),
)
// Multiple formatters with content negotiation
a, err := app.New(
app.WithErrorFormatters(map[string]errors.Formatter{
"application/problem+json": &errors.RFC9457{},
"application/json": &errors.Simple{},
}),
app.WithDefaultErrorFormat("application/problem+json"),
)
Request-Scoped Logging
Pass the request context when you log so trace IDs are attached automatically. Use the standard library’s context-aware logging:
import "log/slog"
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
slog.InfoContext(c.RequestContext(), "processing order",
slog.String("order.id", orderID),
)
c.JSON(http.StatusOK, order)
})
trace_id and span_id are injected automatically when tracing is enabled. HTTP details (method, route, client IP, etc.) live in the access log; handler logs stay lean with just your message and attributes plus trace correlation.
Structured Logging
Use key-value pairs with any slog.*Context call:
a.POST("/orders", func(c *app.Context) {
req, ok := app.MustBind[CreateOrderRequest](c)
if !ok {
return
}
slog.InfoContext(c.RequestContext(), "creating order",
slog.String("customer.id", req.CustomerID),
slog.Int("item.count", len(req.Items)),
slog.Float64("order.total", req.Total),
)
// Process order...
slog.InfoContext(c.RequestContext(), "order created successfully",
slog.String("order.id", orderID),
)
})
Log Levels
Use the context-aware variants for each level:
slog.DebugContext(c.RequestContext(), "fetching from cache")
slog.InfoContext(c.RequestContext(), "request processed successfully")
slog.WarnContext(c.RequestContext(), "cache miss, fetching from database")
slog.ErrorContext(c.RequestContext(), "failed to save to database", "error", err)
What Appears in Handler Logs
Handler log lines include service metadata, trace correlation, and whatever attributes you add. They do not duplicate HTTP fields; those are in the access log:
{
"time": "2024-01-18T10:30:00Z",
"level": "INFO",
"msg": "processing order",
"service": "orders-api",
"trace_id": "abc...",
"span_id": "def...",
"order.id": "123"
}
Observability
When you enable observability with app.WithObservability(), your handlers can record custom metrics and add tracing data from app.Context:
Tracing: TraceID(), SpanID(), SetSpanAttribute(), AddSpanEvent(), TraceContext(), Span()
Metrics: RecordHistogram(), IncrementCounter(), SetGauge()
Example:
a.GET("/orders/:id", func(c *app.Context) {
c.SetSpanAttribute("order.id", c.Param("id"))
c.AddSpanEvent("order_lookup_started")
c.IncrementCounter("order.lookups", attribute.String("order.id", c.Param("id")))
// ...
})
If observability is not configured, these methods do nothing (no-ops). For full setup and options, see the observability guide.
Router Context Features
The app context embeds router.Context, so all router features are available:
HTTP Methods
method := c.Request.Method
path := c.Request.URL.Path
headers := c.Request.Header
Response Handling
c.Status(http.StatusOK)
c.Header("Content-Type", "application/json")
c.JSON(http.StatusOK, data)
c.String(http.StatusOK, "text")
c.HTML(http.StatusOK, html)
Content Negotiation
accepts := c.Accepts("application/json", "text/html")
Complete Example
Here’s a complete example showing binding, validation, and logging:
package main
import (
"log"
"log/slog"
"net/http"
"rivaas.dev/app"
)
type CreateOrderRequest struct {
CustomerID string `json:"customer_id" validate:"required,uuid"`
Items []string `json:"items" validate:"required,min=1,dive,required"`
Total float64 `json:"total" validate:"required,gt=0"`
}
func main() {
a := app.MustNew(
app.WithServiceName("orders-api"),
)
a.POST("/orders", func(c *app.Context) {
// Bind and validate in one step
req, ok := app.MustBind[CreateOrderRequest](c)
if !ok {
return // Error already sent
}
slog.InfoContext(c.RequestContext(), "creating order",
slog.String("customer.id", req.CustomerID),
slog.Int("item.count", len(req.Items)),
slog.Float64("order.total", req.Total),
)
// Your business logic here...
orderID := "order-123"
slog.InfoContext(c.RequestContext(), "order created",
slog.String("order.id", orderID),
)
// Send response
c.JSON(http.StatusCreated, map[string]string{
"order_id": orderID,
})
})
// Start server...
}
Next Steps
7 - Middleware
Add cross-cutting concerns with built-in and custom middleware.
Overview
Middleware functions execute before and after route handlers. They add cross-cutting concerns like logging, authentication, and rate limiting.
The app package provides access to high-quality middleware from the router/middleware subpackages.
Using Middleware
Global Middleware
Apply middleware to all routes:
a := app.MustNew()
a.Use(requestid.New())
a.Use(cors.New(cors.WithAllowAllOrigins(true)))
// All routes registered after Use() will have this middleware
a.GET("/users", handler)
a.POST("/orders", handler)
Middleware During Initialization
Add middleware when creating the app:
a, err := app.New(
app.WithServiceName("my-api"),
app.WithMiddleware(
requestid.New(),
cors.New(cors.WithAllowAllOrigins(true)),
),
)
Default Middleware
The app package automatically includes recovery middleware by default in both development and production modes.
To disable default middleware:
a, err := app.New(
app.WithoutDefaultMiddleware(),
app.WithMiddleware(myCustomRecovery), // Add your own
)
Built-in Middleware
Request ID
Generate unique request IDs for tracing:
import "rivaas.dev/middleware/requestid"
a.Use(requestid.New())
// Access in handler
a.GET("/", func(c *app.Context) {
reqID := c.Response.Header().Get("X-Request-ID")
c.JSON(http.StatusOK, map[string]string{
"request_id": reqID,
})
})
Options:
requestid.New(
requestid.WithRequestIDHeader("X-Correlation-ID"),
requestid.WithGenerator(customGenerator),
)
CORS
Handle Cross-Origin Resource Sharing:
import "rivaas.dev/middleware/cors"
// Allow all origins (development)
a.Use(cors.New(cors.WithAllowAllOrigins(true)))
// Specific origins (production)
a.Use(cors.New(
cors.WithAllowedOrigins([]string{"https://example.com"}),
cors.WithAllowCredentials(true),
cors.WithAllowedMethods([]string{"GET", "POST", "PUT", "DELETE"}),
cors.WithAllowedHeaders([]string{"Content-Type", "Authorization"}),
))
Recovery
Recover from panics gracefully (included by default):
import "rivaas.dev/middleware/recovery"
a.Use(recovery.New(
recovery.WithStackTrace(true),
))
Access Logging
Log HTTP requests (when not using app’s built-in observability):
import "rivaas.dev/middleware/accesslog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
a.Use(accesslog.New(
accesslog.WithLogger(logger),
accesslog.WithSkipPaths([]string{"/health", "/metrics"}),
))
Note: The app package automatically configures access logging through its unified observability when WithLogging() is used.
Timeout
Add request timeout handling:
import "rivaas.dev/middleware/timeout"
// Default timeout (30s)
a.Use(timeout.New())
// Custom timeout
a.Use(timeout.New(
timeout.WithDuration(5 * time.Second),
timeout.WithSkipPaths("/stream"),
timeout.WithSkipPrefix("/admin"),
))
Rate Limiting
Rate limit requests (single-instance only):
import "rivaas.dev/middleware/ratelimit"
// 100 requests per minute
a.Use(ratelimit.New(100, time.Minute))
Note: This is in-memory rate limiting suitable for single-instance deployments only. For production with multiple instances, use a distributed rate limiting solution.
Compression
Compress responses with gzip or brotli:
import "rivaas.dev/middleware/compression"
a.Use(compression.New(
compression.WithLevel(compression.BestSpeed),
compression.WithMinSize(1024), // Only compress responses > 1KB
))
Body Limit
Limit request body size:
import "rivaas.dev/middleware/bodylimit"
a.Use(bodylimit.New(
bodylimit.WithMaxBytes(5 << 20), // 5MB max
))
Add security headers (HSTS, CSP, etc.):
import "rivaas.dev/middleware/securityheaders"
a.Use(securityheaders.New(
securityheaders.WithHSTS(true),
securityheaders.WithContentSecurityPolicy("default-src 'self'"),
securityheaders.WithXFrameOptions("DENY"),
))
Basic Auth
HTTP Basic Authentication:
import "rivaas.dev/middleware/basicauth"
a.Use(basicauth.New(
basicauth.WithUsers(map[string]string{
"admin": "password123",
}),
basicauth.WithRealm("Admin Area"),
))
Custom Middleware
Writing Custom Middleware
Create custom middleware as functions:
func AuthMiddleware() app.HandlerFunc {
return func(c *app.Context) {
token := c.Request.Header.Get("Authorization")
if token == "" {
c.Unauthorized(fmt.Errorf("missing authorization token"))
return
}
// Validate token...
if !isValid(token) {
c.Unauthorized(fmt.Errorf("invalid token"))
return
}
// Continue to next middleware/handler
c.Next()
}
}
// Use it
a.Use(AuthMiddleware())
Middleware with Configuration
Create configurable middleware:
type AuthConfig struct {
TokenHeader string
SkipPaths []string
}
func AuthWithConfig(config AuthConfig) app.HandlerFunc {
return func(c *app.Context) {
// Skip authentication for certain paths
for _, path := range config.SkipPaths {
if c.Request.URL.Path == path {
c.Next()
return
}
}
token := c.Request.Header.Get(config.TokenHeader)
if token == "" || !isValid(token) {
c.Unauthorized(fmt.Errorf("authentication failed"))
return
}
c.Next()
}
}
// Use it
a.Use(AuthWithConfig(AuthConfig{
TokenHeader: "X-API-Key",
SkipPaths: []string{"/health", "/public"},
}))
Middleware with State
Share state across requests:
type RateLimiter struct {
requests map[string]int
mu sync.Mutex
}
func NewRateLimiter() *RateLimiter {
return &RateLimiter{
requests: make(map[string]int),
}
}
func (rl *RateLimiter) Middleware() app.HandlerFunc {
return func(c *app.Context) {
clientIP := c.ClientIP()
rl.mu.Lock()
count := rl.requests[clientIP]
rl.requests[clientIP]++
rl.mu.Unlock()
if count > 100 {
c.Status(http.StatusTooManyRequests)
return
}
c.Next()
}
}
// Use it
limiter := NewRateLimiter()
a.Use(limiter.Middleware())
Route-Specific Middleware
Per-Route Middleware
Apply middleware to specific routes:
// Using WithBefore option
a.GET("/admin", adminHandler,
app.WithBefore(AuthMiddleware()),
)
// Multiple middleware
a.GET("/admin/users", handler,
app.WithBefore(
AuthMiddleware(),
AdminOnlyMiddleware(),
),
)
After Middleware
Execute middleware after the handler:
a.GET("/orders/:id", handler,
app.WithAfter(AuditLogMiddleware()),
)
Combined Middleware
Combine before and after middleware:
a.POST("/orders", handler,
app.WithBefore(AuthMiddleware(), RateLimitMiddleware()),
app.WithAfter(AuditLogMiddleware()),
)
Group Middleware
Apply middleware to route groups:
// Admin routes with auth middleware
admin := a.Group("/admin", AuthMiddleware(), AdminOnlyMiddleware())
admin.GET("/users", getUsersHandler)
admin.POST("/users", createUserHandler)
// API routes with rate limiting
api := a.Group("/api", RateLimitMiddleware())
api.GET("/status", statusHandler)
api.GET("/version", versionHandler)
Middleware Execution Order
Middleware executes in the order it’s registered:
a.Use(Middleware1()) // Executes first
a.Use(Middleware2()) // Executes second
a.Use(Middleware3()) // Executes third
a.GET("/", handler) // Handler executes last
// Execution order:
// 1. Middleware1
// 2. Middleware2
// 3. Middleware3
// 4. handler
// 5. Middleware3 (after c.Next())
// 6. Middleware2 (after c.Next())
// 7. Middleware1 (after c.Next())
Complete Example
package main
import (
"log/slog"
"net/http"
"time"
"rivaas.dev/app"
"rivaas.dev/middleware/requestid"
"rivaas.dev/middleware/cors"
"rivaas.dev/middleware/timeout"
)
func main() {
a := app.MustNew(
app.WithServiceName("api"),
app.WithMiddleware(
requestid.New(),
cors.New(cors.WithAllowAllOrigins(true)),
timeout.New(timeout.WithDuration(30 * time.Second)),
),
)
// Custom middleware
a.Use(LoggingMiddleware())
a.Use(AuthMiddleware())
// Public routes (no auth)
a.GET("/health", healthHandler)
// Protected routes (with auth)
a.GET("/users", usersHandler)
// Admin routes (with auth + admin check)
admin := a.Group("/admin", AdminOnlyMiddleware())
admin.GET("/dashboard", dashboardHandler)
// Start server...
}
func LoggingMiddleware() app.HandlerFunc {
return func(c *app.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
slog.InfoContext(c.RequestContext(), "request completed",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"duration", duration,
)
}
}
func AuthMiddleware() app.HandlerFunc {
return func(c *app.Context) {
// Skip auth for health check
if c.Request.URL.Path == "/health" {
c.Next()
return
}
token := c.Request.Header.Get("Authorization")
if token == "" {
c.Unauthorized(fmt.Errorf("missing authorization token"))
return
}
c.Next()
}
}
func AdminOnlyMiddleware() app.HandlerFunc {
return func(c *app.Context) {
// Check if user is admin...
if !isAdmin() {
c.Forbidden(fmt.Errorf("admin access required"))
return
}
c.Next()
}
}
Next Steps
- Routing - Organize routes with groups and versioning
- Context - Access request and response in middleware
- Examples - See complete working examples
8 - Routing
Organize routes with groups, versioning, and static files.
Route Registration
HTTP Method Shortcuts
Register routes using HTTP method shortcuts:
a.GET("/users", handler)
a.POST("/users", handler)
a.PUT("/users/:id", handler)
a.PATCH("/users/:id", handler)
a.DELETE("/users/:id", handler)
a.HEAD("/users", handler)
a.OPTIONS("/users", handler)
Only GET, POST, PUT, DELETE, PATCH, HEAD, and OPTIONS are supported. Using any other method (including via internal or reflection-based registration) causes a panic with a clear error so mistakes are caught early (see Fail Fast with Clear Errors).
Match All Methods
Register a route that matches all HTTP methods:
a.Any("/webhook", webhookHandler)
// Handles GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
Route Groups
Basic Groups
Organize routes with shared prefixes:
api := a.Group("/api")
api.GET("/users", getUsersHandler)
api.POST("/users", createUserHandler)
// Routes: GET /api/users, POST /api/users
Nested Groups
Create hierarchical route structures:
api := a.Group("/api")
v1 := api.Group("/v1")
v1.GET("/users", getUsersHandler)
// Route: GET /api/v1/users
Groups with Middleware
Apply middleware to all routes in a group:
admin := a.Group("/admin", AuthMiddleware(), AdminOnlyMiddleware())
admin.GET("/users", getUsersHandler)
admin.POST("/users", createUserHandler)
// Both routes have auth and admin middleware
API Versioning
Version Groups
Create version-specific routes:
v1 := a.Version("v1")
v1.GET("/users", v1GetUsersHandler)
v2 := a.Version("v2")
v2.GET("/users", v2GetUsersHandler)
Version Detection
Configure how versions are detected. This requires router configuration:
a, err := app.New(
app.WithRouter(
router.WithVersioning(
router.WithVersionHeader("API-Version"),
router.WithVersionQuery("version"),
),
),
)
Static Files
Serve Directory
Serve static files from a directory:
a.Static("/static", "./public")
// Files in ./public served at /static/*
Serve Single File
Serve a single file at a specific path:
a.File("/favicon.ico", "./static/favicon.ico")
a.File("/robots.txt", "./static/robots.txt")
Serve from Filesystem
Serve files from an http.FileSystem:
//go:embed static
var staticFiles embed.FS
a.StaticFS("/assets", http.FS(staticFiles))
Route Naming
Named Routes
Name routes for URL generation:
a.GET("/users/:id", getUserHandler).Name("users.get")
a.POST("/users", createUserHandler).Name("users.create")
Generate URLs
Generate URLs from route names:
// After router is frozen (after a.Start())
url, err := a.URLFor("users.get", map[string]string{"id": "123"}, nil)
// Returns: "/users/123"
// With query parameters
url, err := a.URLFor("users.get",
map[string]string{"id": "123"},
map[string][]string{"expand": {"profile"}},
)
// Returns: "/users/123?expand=profile"
Must Generate URLs
Generate URLs and panic on error:
url := a.MustURLFor("users.get", map[string]string{"id": "123"}, nil)
Route Constraints
Numeric Constraints
Constrain parameters to numeric values:
a.GET("/users/:id", handler).WhereInt("id")
// Only matches /users/123, not /users/abc
UUID Constraints
Constrain parameters to UUIDs:
a.GET("/orders/:id", handler).WhereUUID("id")
// Only matches valid UUIDs
Custom Constraints
Use regex patterns for custom constraints:
a.GET("/posts/:slug", handler).Where("slug", `[a-z\-]+`)
// Only matches lowercase letters and hyphens
Custom 404 Handler
Set NoRoute Handler
Handle routes that don’t match:
a.NoRoute(func(c *app.Context) {
c.JSON(http.StatusNotFound, map[string]string{
"error": "route not found",
"path": c.Request.URL.Path,
})
})
Complete Example
package main
import (
"log"
"net/http"
"rivaas.dev/app"
)
func main() {
a := app.MustNew(
app.WithServiceName("api"),
)
// Root routes
a.GET("/", homeHandler)
a.GET("/health", healthHandler)
// API v1
v1 := a.Group("/api/v1")
v1.GET("/status", statusHandler)
// Users
users := v1.Group("/users")
users.GET("", getUsersHandler).Name("users.list")
users.POST("", createUserHandler).Name("users.create")
users.GET("/:id", getUserHandler).Name("users.get").WhereInt("id")
users.PUT("/:id", updateUserHandler).Name("users.update").WhereInt("id")
users.DELETE("/:id", deleteUserHandler).Name("users.delete").WhereInt("id")
// Admin routes with authentication
admin := a.Group("/admin", AuthMiddleware())
admin.GET("/dashboard", dashboardHandler)
admin.GET("/users", adminGetUsersHandler)
// Static files
a.Static("/assets", "./public")
a.File("/favicon.ico", "./public/favicon.ico")
// Custom 404
a.NoRoute(func(c *app.Context) {
c.NotFound(fmt.Errorf("route not found"))
})
// Start server...
}
Next Steps
- Middleware - Add middleware to routes and groups
- Context - Access route parameters and query strings
- Examples - See complete working examples
9 - Lifecycle
Use lifecycle hooks for initialization, cleanup, reload, and event handling.
Overview
The app package provides lifecycle hooks for managing application state. Registering a hook after the router is frozen (e.g. after Start()) returns an error; register all hooks before calling Start().
- OnStart - Called before server starts. Runs sequentially. Stops on first error.
- OnReady - Called when server is ready to accept connections. Runs async. Non-blocking.
- OnReload - Called when SIGHUP is received or
Reload() is called. Runs sequentially. Errors logged. - OnShutdown - Called during graceful shutdown. LIFO order.
- OnStop - Called after shutdown completes. Best-effort.
- OnRoute - Called when a route is registered. Synchronous.
OnStart Hook
Basic Usage
Initialize resources before the server starts:
a := app.MustNew()
if err := a.OnStart(func(ctx context.Context) error {
log.Println("Connecting to database...")
return db.Connect(ctx)
}); err != nil {
log.Fatal(err)
}
if err := a.OnStart(func(ctx context.Context) error {
log.Println("Running migrations...")
return db.Migrate(ctx)
}); err != nil {
log.Fatal(err)
}
// Start server - hooks execute before listening
a.Start(ctx)
Error Handling
OnStart hooks run sequentially and stop on first error:
a.OnStart(func(ctx context.Context) error {
if err := db.Connect(ctx); err != nil {
return fmt.Errorf("database connection failed: %w", err)
}
return nil
})
// If this hook fails, server won't start
if err := a.Start(ctx); err != nil {
log.Fatalf("Startup failed: %v", err)
}
Common Use Cases
// Database connection
a.OnStart(func(ctx context.Context) error {
return db.PingContext(ctx)
})
// Load configuration
a.OnStart(func(ctx context.Context) error {
return config.Load("config.yaml")
})
// Initialize caches
a.OnStart(func(ctx context.Context) error {
return cache.Warmup(ctx)
})
// Check external dependencies
a.OnStart(func(ctx context.Context) error {
return checkExternalServices(ctx)
})
OnReady Hook
Basic Usage
Execute tasks after the server starts listening:
a.OnReady(func() {
log.Println("Server is ready!")
log.Printf("Listening on :8080")
})
a.OnReady(func() {
// Register with service discovery
consul.Register("my-service", ":8080")
})
Async Execution
OnReady hooks run asynchronously and don’t block startup:
a.OnReady(func() {
// Long-running warmup task
time.Sleep(5 * time.Second)
cache.Preload()
})
// Server accepts connections immediately, warmup runs in background
Error Handling
Panics in OnReady hooks are caught and logged:
a.OnReady(func() {
// If this panics, it's logged but doesn't crash the server
doSomethingRisky()
})
OnReload Hook
What is it?
The OnReload hook lets you reload your app’s configuration without stopping the server. When you register this hook, your app automatically listens for SIGHUP signals on Unix systems (Linux, macOS). No extra setup needed!
Basic Usage
Here’s how to reload configuration when you get a SIGHUP signal:
a := app.MustNew(
app.WithServiceName("my-api"),
)
// Register a reload hook - SIGHUP is now automatically enabled!
a.OnReload(func(ctx context.Context) error {
log.Println("Reloading configuration...")
// Load new config
newConfig, err := loadConfig("config.yaml")
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Apply new config
applyConfig(newConfig)
return nil
})
// Start server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
a.Start(ctx)
Now you can reload without restarting:
# Send SIGHUP to reload
kill -HUP <pid>
# Or use killall
killall -HUP my-api
How it works
When you register an OnReload hook:
- On Unix/Linux/macOS: Your app automatically listens for SIGHUP signals
- On Windows: SIGHUP doesn’t exist, but you can still call
Reload() programmatically - All platforms: You can trigger reload from your code using
app.Reload(ctx)
When no OnReload hooks are registered, SIGHUP is ignored on Unix so the process is not terminated (e.g. by kill -HUP or terminal disconnect).
Error Handling
If reload fails, your app keeps running with the old configuration:
a.OnReload(func(ctx context.Context) error {
cfg, err := loadConfig("config.yaml")
if err != nil {
// Error is logged, but server keeps running
return err
}
// Validate before applying
if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
applyConfig(cfg)
return nil
})
The hooks run one at a time (sequentially) and stop on the first error. This means if you have multiple reload hooks and one fails, the rest won’t run.
Programmatic Reload
You can also trigger reload from your code - useful for admin endpoints:
// Create an admin endpoint to trigger reload
a.POST("/admin/reload", func(c *app.Context) {
if err := a.Reload(c.Request.Context()); err != nil {
c.InternalError(err)
return
}
c.JSON(200, map[string]string{"status": "config reloaded"})
})
Multiple Reload Hooks
You can register multiple hooks for different parts of your config:
// Reload database pool settings
a.OnReload(func(ctx context.Context) error {
log.Println("Reloading database config...")
return db.ReconfigurePool(ctx)
})
// Reload cache settings
a.OnReload(func(ctx context.Context) error {
log.Println("Reloading cache config...")
return cache.Reload(ctx)
})
// Reload log level
a.OnReload(func(ctx context.Context) error {
log.Println("Reloading log level...")
return logger.SetLevel(newLevel)
})
Common Use Cases
// Reload TLS certificates
a.OnReload(func(ctx context.Context) error {
return tlsManager.ReloadCertificates()
})
// Reload feature flags
a.OnReload(func(ctx context.Context) error {
return features.Reload(ctx)
})
// Reload rate limits
a.OnReload(func(ctx context.Context) error {
return rateLimiter.UpdateLimits(ctx)
})
// Flush caches
a.OnReload(func(ctx context.Context) error {
cache.Clear()
return nil
})
What can’t be reloaded?
Routes and middleware can’t be changed after the server starts - they’re frozen for safety. Only reload things like:
- Configuration files
- Database connection settings
- TLS certificates
- Cache contents
- Log levels
- Feature flags
- Unix/Linux/macOS: SIGHUP works automatically
- Windows: SIGHUP isn’t available, use
app.Reload(ctx) instead
Thread Safety
Don’t worry about multiple reload signals at the same time - the framework handles this automatically. If multiple SIGHUPs come in, they’ll run one at a time.
OnShutdown Hook
Basic Usage
Clean up resources during graceful shutdown:
a.OnShutdown(func(ctx context.Context) {
log.Println("Shutting down gracefully...")
db.Close()
})
a.OnShutdown(func(ctx context.Context) {
log.Println("Flushing metrics...")
metrics.Flush(ctx)
})
LIFO Execution Order
OnShutdown hooks execute in reverse order (Last In, First Out):
a.OnShutdown(func(ctx context.Context) {
log.Println("1. First registered")
})
a.OnShutdown(func(ctx context.Context) {
log.Println("2. Second registered")
})
// During shutdown, prints:
// "2. Second registered"
// "1. First registered"
This ensures cleanup happens in reverse dependency order.
Timeout Handling
OnShutdown hooks must complete within the shutdown timeout:
a, err := app.New(
app.WithServer(
app.WithShutdownTimeout(30 * time.Second),
),
)
a.OnShutdown(func(ctx context.Context) {
// This context has a 30s deadline
select {
case <-flushComplete:
log.Println("Flush completed")
case <-ctx.Done():
log.Println("Flush timed out")
}
})
Common Use Cases
// Close database connections
a.OnShutdown(func(ctx context.Context) {
db.Close()
})
// Flush metrics and traces
a.OnShutdown(func(ctx context.Context) {
metrics.Shutdown(ctx)
tracing.Shutdown(ctx)
})
// Deregister from service discovery
a.OnShutdown(func(ctx context.Context) {
consul.Deregister("my-service")
})
// Close external connections
a.OnShutdown(func(ctx context.Context) {
redis.Close()
messageQueue.Close()
})
OnStop Hook
Basic Usage
Final cleanup after shutdown completes:
a.OnStop(func() {
log.Println("Cleanup complete")
cleanupTempFiles()
})
Best-Effort Execution
OnStop hooks run in best-effort mode - panics are caught and logged:
a.OnStop(func() {
// Even if this panics, other hooks still run
cleanupTempFiles()
})
No Timeout
OnStop hooks don’t have a timeout constraint:
a.OnStop(func() {
// This can take as long as needed
archiveLogs()
})
OnRoute Hook
Basic Usage
Execute code when routes are registered:
a.OnRoute(func(rt *route.Route) {
log.Printf("Registered: %s %s", rt.Method(), rt.Path())
})
// Register routes - hook fires for each one
a.GET("/users", handler)
a.POST("/users", handler)
Route Validation
Validate routes during registration:
a.OnRoute(func(rt *route.Route) {
// Ensure all routes have names
if rt.Name() == "" {
log.Printf("Warning: Route %s %s has no name", rt.Method(), rt.Path())
}
})
Documentation Generation
Use for automatic documentation:
var routes []string
a.OnRoute(func(rt *route.Route) {
routes = append(routes, fmt.Sprintf("%s %s", rt.Method(), rt.Path()))
})
// After all routes registered
a.OnReady(func() {
log.Printf("Registered %d routes:", len(routes))
for _, r := range routes {
log.Println(" ", r)
}
})
Complete Example
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
var db *Database
func main() {
a := app.MustNew(
app.WithServiceName("api"),
app.WithServer(
app.WithShutdownTimeout(30 * time.Second),
),
)
// OnStart: Initialize resources
if err := a.OnStart(func(ctx context.Context) error {
log.Println("Connecting to database...")
var err error
db, err = ConnectDB(ctx)
if err != nil {
return fmt.Errorf("database connection failed: %w", err)
}
return nil
}); err != nil {
log.Fatalf("failed to register OnStart: %v", err)
}
if err := a.OnStart(func(ctx context.Context) error {
log.Println("Running migrations...")
return db.Migrate(ctx)
}); err != nil {
log.Fatalf("failed to register OnStart: %v", err)
}
// OnRoute: Log route registration
if err := a.OnRoute(func(rt *route.Route) {
log.Printf("Route registered: %s %s", rt.Method(), rt.Path())
}); err != nil {
log.Fatalf("failed to register OnRoute: %v", err)
}
// OnReady: Post-startup tasks
if err := a.OnReady(func() {
log.Println("Server is ready!")
log.Println("Registering with service discovery...")
consul.Register("api", ":8080")
}); err != nil {
log.Fatalf("failed to register OnReady: %v", err)
}
// OnShutdown: Graceful cleanup
if err := a.OnShutdown(func(ctx context.Context) {
log.Println("Deregistering from service discovery...")
consul.Deregister("api")
}); err != nil {
log.Fatalf("failed to register OnShutdown: %v", err)
}
if err := a.OnShutdown(func(ctx context.Context) {
log.Println("Closing database connection...")
if err := db.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
}); err != nil {
log.Fatalf("failed to register OnShutdown: %v", err)
}
// OnStop: Final cleanup
if err := a.OnStop(func() {
log.Println("Cleanup complete")
}); err != nil {
log.Fatalf("failed to register OnStop: %v", err)
}
// Register routes
a.GET("/", homeHandler)
a.GET("/health", healthHandler)
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Start server
log.Println("Starting server...")
if err := a.Start(ctx); err != nil {
log.Fatalf("Server error: %v", err)
}
}
Hook Execution Flow
1. app.Start(ctx) called
2. OnStart hooks execute (sequential, stop on error)
3. Server starts listening
4. OnReady hooks execute (async, non-blocking)
5. Server handles requests...
→ OnReload hooks execute when SIGHUP received (sequential, logged on error)
6. Context canceled (SIGTERM/SIGINT)
7. OnShutdown hooks execute (LIFO order, with timeout)
8. Server shutdown complete
9. OnStop hooks execute (best-effort, no timeout)
10. Process exits
Next Steps
10 - Health Endpoints
Configure Kubernetes-compatible liveness and readiness probes.
Overview
The app package provides standard health check endpoints. They work with Kubernetes and other orchestration platforms:
- Liveness Probe (
/livez) — Tells you if the process is alive. Restart the container if it fails. - Readiness Probe (
/readyz) — Tells you if the service can accept traffic.
Basic Configuration
Enable Health Endpoints
Enable health endpoints with defaults.
a, err := app.New(
app.WithHealthEndpoints(),
)
// Endpoints:
// GET /livez - Liveness probe
// GET /readyz - Readiness probe
Custom Paths
Configure custom health check paths:
a, err := app.New(
app.WithHealthEndpoints(
app.WithLivezPath("/health/live"),
app.WithReadyzPath("/health/ready"),
),
)
Path Prefix
Mount health endpoints under a prefix.
a, err := app.New(
app.WithHealthEndpoints(
app.WithHealthPrefix("/_system"),
),
)
// Endpoints:
// GET /_system/livez
// GET /_system/readyz
Liveness Checks
Basic Liveness Check
Liveness checks should be dependency-free and fast:
a, err := app.New(
app.WithHealthEndpoints(
app.WithLivenessCheck("process", func(ctx context.Context) error {
// Process is alive if we can execute this
return nil
}),
),
)
Multiple Liveness Checks
Add multiple liveness checks.
a, err := app.New(
app.WithHealthEndpoints(
app.WithLivenessCheck("process", func(ctx context.Context) error {
return nil
}),
app.WithLivenessCheck("goroutines", func(ctx context.Context) error {
if runtime.NumGoroutine() > 10000 {
return fmt.Errorf("too many goroutines: %d", runtime.NumGoroutine())
}
return nil
}),
),
)
Liveness Behavior
- Returns
200 "ok" if all checks pass - Returns
503 if any check fails - If no checks configured, always returns
200
Readiness Checks
Basic Readiness Check
Readiness checks verify external dependencies:
a, err := app.New(
app.WithHealthEndpoints(
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
),
)
Multiple Readiness Checks
Check multiple dependencies:
a, err := app.New(
app.WithHealthEndpoints(
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
app.WithReadinessCheck("cache", func(ctx context.Context) error {
return redis.Ping(ctx).Err()
}),
app.WithReadinessCheck("api", func(ctx context.Context) error {
return checkUpstreamAPI(ctx)
}),
),
)
Readiness Behavior
- Returns
204 if all checks pass - Returns
503 if any check fails - If no checks configured, always returns
204
Health Check Timeout
Configure timeout for individual checks:
a, err := app.New(
app.WithHealthEndpoints(
app.WithHealthTimeout(800 * time.Millisecond),
app.WithReadinessCheck("database", func(ctx context.Context) error {
// This check has 800ms to complete
return db.PingContext(ctx)
}),
),
)
Default timeout: 1s
Runtime Readiness Gates
Readiness Manager
Dynamically manage readiness state at runtime:
type DatabaseGate struct {
db *sql.DB
}
func (g *DatabaseGate) Ready() bool {
return g.db.Ping() == nil
}
func (g *DatabaseGate) Name() string {
return "database"
}
// Register gate at runtime
a.Readiness().Register("db", &DatabaseGate{db: db})
// Unregister during shutdown
a.OnShutdown(func(ctx context.Context) {
a.Readiness().Unregister("db")
})
Use Cases
Runtime gates are useful for:
- Connection pools that manage their own health
- Circuit breakers that track upstream failures
- Dynamic dependencies that come and go at runtime
Liveness vs Readiness
When to Use Liveness
Liveness checks answer: “Should the process be restarted?”
Use for:
- Detecting deadlocks
- Detecting infinite loops
- Detecting corrupted state that requires restart
Don’t use for:
- External dependency failures (use readiness instead)
- Temporary errors that will resolve themselves
- Network connectivity issues
When to Use Readiness
Readiness checks answer: “Can this instance handle traffic?”
Use for:
- Database connectivity
- Cache availability
- Upstream service health
- Initialization completion
Don’t use for:
- Process-level health (use liveness instead)
- Permanent failures that require restart
Kubernetes Configuration
Deployment YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: my-api:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /livez
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 1
failureThreshold: 3
Complete Example
package main
import (
"context"
"database/sql"
"log"
"time"
"rivaas.dev/app"
)
var db *sql.DB
func main() {
a, err := app.New(
app.WithServiceName("api"),
// Health endpoints configuration
app.WithHealthEndpoints(
// Custom paths
app.WithHealthPrefix("/_system"),
// Timeout for checks
app.WithHealthTimeout(800 * time.Millisecond),
// Liveness: process-level health
app.WithLivenessCheck("process", func(ctx context.Context) error {
// Always healthy if we can execute this
return nil
}),
// Readiness: dependency health
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
app.WithReadinessCheck("cache", func(ctx context.Context) error {
return checkCache(ctx)
}),
),
)
if err != nil {
log.Fatal(err)
}
// Initialize database
a.OnStart(func(ctx context.Context) error {
var err error
db, err = sql.Open("postgres", "...")
return err
})
// Unregister readiness during shutdown
a.OnShutdown(func(ctx context.Context) {
// Mark as not ready before closing connections
log.Println("Marking service as not ready")
time.Sleep(100 * time.Millisecond) // Allow load balancer to notice
})
// Register routes...
// Start server...
// Endpoints available at:
// GET /_system/livez - Liveness
// GET /_system/readyz - Readiness
}
func checkCache(ctx context.Context) error {
// Check cache connectivity
return nil
}
Testing Health Endpoints
Test Liveness
curl http://localhost:8080/livez
# Expected: 200 OK
# Body: "ok"
Test Readiness
curl http://localhost:8080/readyz
# Expected: 204 No Content (healthy)
# Or: 503 Service Unavailable (unhealthy)
Test with Custom Prefix
curl http://localhost:8080/_system/livez
curl http://localhost:8080/_system/readyz
Next Steps
11 - Debug Endpoints
Enable pprof profiling endpoints for performance analysis and debugging.
Overview
The app package provides optional debug endpoints for profiling and diagnostics. It uses Go’s net/http/pprof package.
Security Warning: Debug endpoints expose sensitive runtime information. NEVER enable them in production without proper security measures.
Basic Configuration
Enable pprof Unconditionally
Enable pprof endpoints. Use for development only.
a, err := app.New(
app.WithDebugEndpoints(
app.WithPprof(),
),
)
Enable pprof Conditionally
Enable based on environment variable. This is recommended:
a, err := app.New(
app.WithDebugEndpoints(
app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
),
)
Custom Prefix
Mount debug endpoints under a custom prefix.
a, err := app.New(
app.WithDebugEndpoints(
app.WithDebugPrefix("/_internal/debug"),
app.WithPprof(),
),
)
Available Endpoints
When pprof is enabled, the following endpoints are registered:
| Endpoint | Description |
|---|
GET /debug/pprof/ | Main pprof index |
GET /debug/pprof/cmdline | Command line invocation |
GET /debug/pprof/profile | CPU profile (30s by default) |
GET /debug/pprof/symbol | Symbol lookup |
POST /debug/pprof/symbol | Symbol lookup (POST) |
GET /debug/pprof/trace | Execution trace |
GET /debug/pprof/allocs | Memory allocations profile |
GET /debug/pprof/block | Block profile |
GET /debug/pprof/goroutine | Goroutine profile |
GET /debug/pprof/heap | Heap profile |
GET /debug/pprof/mutex | Mutex profile |
GET /debug/pprof/threadcreate | Thread creation profile |
Security Considerations
Development
Safe to enable unconditionally in development.
a, err := app.New(
app.WithEnvironment("development"),
app.WithDebugEndpoints(
app.WithPprof(),
),
)
Staging
Enable behind VPN or IP allowlist:
a, err := app.New(
app.WithEnvironment("staging"),
app.WithDebugEndpoints(
app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
),
)
// Use authentication middleware
a.Use(IPAllowlistMiddleware([]string{"10.0.0.0/8"}))
Production
Enable only with proper authentication:
a, err := app.New(
app.WithEnvironment("production"),
app.WithDebugEndpoints(
app.WithDebugPrefix("/_internal/debug"),
app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
),
)
// Protect debug endpoints with authentication
debugAuth := a.Group("/_internal", AdminAuthMiddleware())
// pprof endpoints are automatically under this group
Using pprof
CPU Profile
Capture a 30-second CPU profile:
curl http://localhost:8080/debug/pprof/profile > cpu.prof
go tool pprof cpu.prof
Heap Profile
Capture current heap allocations:
curl http://localhost:8080/debug/pprof/heap > heap.prof
go tool pprof heap.prof
Goroutine Profile
View current goroutines:
curl http://localhost:8080/debug/pprof/goroutine > goroutine.prof
go tool pprof goroutine.prof
Interactive Analysis
Analyze profiles interactively:
# CPU profile
go tool pprof http://localhost:8080/debug/pprof/profile
# Heap profile
go tool pprof http://localhost:8080/debug/pprof/heap
# Goroutine profile
go tool pprof http://localhost:8080/debug/pprof/goroutine
Web UI
View profiles in a web browser:
go tool pprof -http=:8081 http://localhost:8080/debug/pprof/profile
Complete Example
package main
import (
"log"
"os"
"rivaas.dev/app"
)
func main() {
env := os.Getenv("ENVIRONMENT")
if env == "" {
env = "development"
}
a, err := app.New(
app.WithServiceName("api"),
app.WithEnvironment(env),
// Debug endpoints with conditional pprof
app.WithDebugEndpoints(
app.WithDebugPrefix("/_internal/debug"),
app.WithPprofIf(env == "development" || os.Getenv("PPROF_ENABLED") == "true"),
),
)
if err != nil {
log.Fatal(err)
}
// In production, protect debug endpoints
if env == "production" {
// Add authentication middleware to /_internal/* routes
a.Use(func(c *app.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/_internal/") {
// Verify admin token
if !isAdmin(c) {
c.Forbidden(fmt.Errorf("admin access required"))
return
}
}
c.Next()
})
}
// Register routes...
// Start server...
}
Best Practices
- Never enable in production without authentication
- Use environment variables for conditional enablement
- Mount under non-obvious path prefix
- Log when pprof is enabled
- Document security requirements in deployment docs
- Consider using separate admin port
Next Steps
12 - Server
Start HTTP, HTTPS, and mTLS servers with graceful shutdown.
HTTP Server
Basic HTTP Server
Start an HTTP server:
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
Custom Address
Configure the listen address via options when creating the app. Default is :8080 for HTTP and :8443 for TLS/mTLS:
// Localhost only
a, err := app.New(
app.WithServiceName("my-api"),
app.WithHost("127.0.0.1"),
app.WithPort(8080),
)
// ...
a.Start(ctx)
// All interfaces (default)
a, err := app.New(
app.WithServiceName("my-api"),
app.WithPort(8080),
)
// ...
a.Start(ctx)
HTTPS Server
Start HTTPS Server
Configure TLS at construction with WithTLS, then start the server (default port 8443; use WithPort(443) to override):
a := app.MustNew(
app.WithServiceName("my-api"),
app.WithTLS("server.crt", "server.key"),
)
// ... register routes ...
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
Generate Self-Signed Certificate
For development:
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
mTLS Server
Start mTLS Server
Configure mTLS at construction with WithMTLS, then start the server:
// Load server certificate
serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal(err)
}
// Load CA certificate for client validation
caCert, err := os.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
a := app.MustNew(
app.WithServiceName("my-api"),
app.WithMTLS(serverCert,
app.WithClientCAs(caCertPool),
app.WithMinVersion(tls.VersionTLS13),
), // default port 8443; use WithPort(443) to override
)
// ... register routes ...
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
Client Authorization
Authorize clients based on certificate by adding WithAuthorize to WithMTLS:
a := app.MustNew(
app.WithServiceName("my-api"),
app.WithPort(8443),
app.WithMTLS(serverCert,
app.WithClientCAs(caCertPool),
app.WithAuthorize(func(cert *x509.Certificate) (string, bool) {
principal := cert.Subject.CommonName
if principal == "" {
return "", false
}
return principal, true
}),
),
)
// ...
if err := a.Start(ctx); err != nil { ... }
Graceful Shutdown
Signal-Based Shutdown
Use signal.NotifyContext for graceful shutdown:
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
Shutdown Process
When context is canceled:
- Server stops accepting new connections
- OnShutdown hooks execute (LIFO order)
- Server waits for in-flight requests (up to shutdown timeout)
- Observability components shut down (metrics, tracing)
- OnStop hooks execute (best-effort)
- Process exits
Shutdown Timeout
Configure shutdown timeout:
a, err := app.New(
app.WithServer(
app.WithShutdownTimeout(30 * time.Second),
),
)
Default: 30 seconds
Complete Examples
HTTP with Graceful Shutdown
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a := app.MustNew(
app.WithServiceName("api"),
)
a.GET("/", homeHandler)
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
log.Println("Server starting on :8080")
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
HTTPS with mTLS
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"log"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal(err)
}
caCert, err := os.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
a := app.MustNew(
app.WithServiceName("secure-api"),
app.WithMTLS(serverCert,
app.WithClientCAs(caCertPool),
app.WithMinVersion(tls.VersionTLS13),
), // default port 8443
)
a.GET("/", homeHandler)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
log.Println("mTLS server starting on :8443 (default)")
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
Next Steps
13 - OpenAPI
Automatically generate OpenAPI specifications and Swagger UI.
Overview
The app package integrates with the rivaas.dev/openapi package. It automatically generates OpenAPI specifications with Swagger UI.
Basic Configuration
Enable OpenAPI
Enable OpenAPI with default configuration:
a, err := app.New(
app.WithServiceName("my-api"),
app.WithServiceVersion("v1.0.0"),
app.WithOpenAPI(
openapi.WithSwaggerUI(true, "/docs"),
),
)
Service name and version are automatically injected into the OpenAPI spec.
Configure 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
Customize Swagger UI appearance:
a, err := app.New(
app.WithOpenAPI(
openapi.WithSwaggerUI(true, "/docs"),
openapi.WithUIDocExpansion(openapi.DocExpansionList),
openapi.WithUISyntaxTheme(openapi.SyntaxThemeMonokai),
openapi.WithUIDeepLinking(true),
),
)
OpenAPI Endpoints
When OpenAPI is enabled, two endpoints are registered:
GET /openapi.json - OpenAPI specification (JSON)GET /docs - Swagger UI (if enabled)
Custom Spec Path
Configure custom spec path:
a, err := app.New(
app.WithOpenAPI(
openapi.WithSpecPath("/api/spec.json"),
openapi.WithSwaggerUI(true, "/api/docs"),
),
)
Complete Example
package main
import (
"log"
"net/http"
"rivaas.dev/app"
"rivaas.dev/openapi"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
}
func main() {
a, err := app.New(
app.WithServiceName("users-api"),
app.WithServiceVersion("v1.0.0"),
app.WithOpenAPI(
openapi.WithDescription("API for managing users"),
openapi.WithServer("http://localhost:8080", "Development"),
openapi.WithBearerAuth("bearerAuth", "JWT authentication"),
openapi.WithSwaggerUI(true, "/docs"),
openapi.WithTags(
openapi.Tag("users", "User management"),
),
),
)
if err != nil {
log.Fatal(err)
}
// List users
a.GET("/users", listUsersHandler,
app.WithDoc(
openapi.WithSummary("List users"),
openapi.WithDescription("Returns a list of all users"),
openapi.WithResponse(200, []User{}),
openapi.WithTags("users"),
),
)
// Create user
a.POST("/users", createUserHandler,
app.WithDoc(
openapi.WithSummary("Create user"),
openapi.WithRequest(CreateUserRequest{}),
openapi.WithResponse(201, User{}),
openapi.WithResponse(400, map[string]string{}),
openapi.WithTags("users"),
openapi.WithSecurity("bearerAuth"),
),
)
// Get user
a.GET("/users/:id", getUserHandler,
app.WithDoc(
openapi.WithSummary("Get user by ID"),
openapi.WithResponse(200, User{}),
openapi.WithResponse(404, map[string]string{}),
openapi.WithTags("users"),
),
)
// Start server
// OpenAPI spec: http://localhost:8080/openapi.json
// Swagger UI: http://localhost:8080/docs
}
Next Steps
14 - Testing
Test routes and handlers without starting a server.
Overview
The app package provides built-in testing utilities. Test routes and handlers without starting an HTTP server.
Test Method
Basic Testing
Test routes using app.Test():
func TestHome(t *testing.T) {
a := app.MustNew()
a.GET("/", homeHandler)
req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
With Timeout
Configure test timeout:
req := httptest.NewRequest("GET", "/slow", nil)
resp, err := a.Test(req, app.WithTimeout(5*time.Second))
With Context
Pass custom context:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req, app.WithContext(ctx))
TestJSON Method
Basic JSON Testing
Test JSON endpoints easily:
func TestCreateUser(t *testing.T) {
a := app.MustNew()
a.POST("/users", createUserHandler)
body := map[string]string{
"name": "Alice",
"email": "alice@example.com",
}
resp, err := a.TestJSON("POST", "/users", body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 201 {
t.Errorf("expected 201, got %d", resp.StatusCode)
}
}
ExpectJSON Helper
Assert JSON Responses
Use ExpectJSON for easy JSON assertions:
func TestGetUser(t *testing.T) {
a := app.MustNew()
a.GET("/users/:id", getUserHandler)
req := httptest.NewRequest("GET", "/users/123", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
var user User
app.ExpectJSON(t, resp, 200, &user)
if user.ID != "123" {
t.Errorf("expected ID 123, got %s", user.ID)
}
}
Complete Test Examples
Testing Routes
package main
import (
"net/http"
"net/http/httptest"
"testing"
"rivaas.dev/app"
)
func TestHomeRoute(t *testing.T) {
a := app.MustNew()
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello, World!",
})
})
req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var result map[string]string
app.ExpectJSON(t, resp, 200, &result)
if result["message"] != "Hello, World!" {
t.Errorf("unexpected message: %s", result["message"])
}
}
Testing with Dependencies
func TestWithDatabase(t *testing.T) {
// Setup test database
db := setupTestDB(t)
defer db.Close()
a := app.MustNew()
a.GET("/users/:id", func(c *app.Context) {
id := c.Param("id")
user, err := db.GetUser(id)
if err != nil {
c.NotFound(fmt.Errorf("user not found"))
return
}
c.JSON(http.StatusOK, user)
})
req := httptest.NewRequest("GET", "/users/123", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
var user User
app.ExpectJSON(t, resp, 200, &user)
}
Table-Driven Tests
func TestUserRoutes(t *testing.T) {
a := app.MustNew()
a.GET("/users/:id", getUserHandler)
tests := []struct {
name string
id string
wantStatus int
}{
{"valid ID", "123", 200},
{"invalid ID", "abc", 400},
{"not found", "999", 404},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/users/"+tt.id, nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected %d, got %d", tt.wantStatus, resp.StatusCode)
}
})
}
}
Testing Middleware
func TestAuthMiddleware(t *testing.T) {
a := app.MustNew()
a.Use(AuthMiddleware())
a.GET("/protected", protectedHandler)
// Test without token
req := httptest.NewRequest("GET", "/protected", nil)
resp, _ := a.Test(req)
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
// Test with token
req = httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer valid-token")
resp, _ = a.Test(req)
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
Next Steps
15 - Migration
Migrate from the router package to the app package.
When to Migrate
Consider migrating from router to app when you need:
- Integrated observability - Built-in metrics, tracing, and logging.
- Lifecycle management - OnStart, OnReady, OnShutdown, OnStop hooks.
- Graceful shutdown - Automatic shutdown handling with context.
- Health endpoints - Kubernetes-compatible liveness and readiness probes.
- Sensible defaults - Pre-configured with production-ready settings.
Key Differences
Constructor Returns Error
Router:
r := router.New() // No error returned
App:
a, err := app.New() // Returns (*App, error)
if err != nil {
log.Fatal(err)
}
// Or use MustNew() for panic on error
a := app.MustNew()
Context Type
Router:
r.GET("/", func(c *router.Context) {
c.JSON(http.StatusOK, data)
})
App:
a.GET("/", func(c *app.Context) { // Different context type
c.JSON(http.StatusOK, data)
})
app.Context embeds router.Context. It adds binding, validation, and error handling methods.
Server Startup
Router:
r := router.New()
http.ListenAndServe(":8080", r)
App:
a := app.MustNew()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
a.Start(ctx) // Includes graceful shutdown
Migration Steps
1. Update Imports
// Before
import "rivaas.dev/router"
// After
import "rivaas.dev/app"
2. Change Constructor
// Before
r := router.New(
router.WithMetrics(),
router.WithTracing(),
)
// After
a, err := app.New(
app.WithServiceName("my-service"),
app.WithObservability(
app.WithMetrics(),
app.WithTracing(),
),
)
if err != nil {
log.Fatal(err)
}
3. Update Handler Signatures
// Before
func handler(c *router.Context) {
c.JSON(http.StatusOK, data)
}
// After
func handler(c *app.Context) { // Change context type
c.JSON(http.StatusOK, data)
}
4. Update Server Startup
// Before
http.ListenAndServe(":8080", r)
// After
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
a.Start(ctx)
Complete Migration Example
Before (Router)
package main
import (
"net/http"
"rivaas.dev/router"
)
func main() {
r := router.New(
router.WithMetrics(),
router.WithTracing(),
)
r.GET("/", func(c *router.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello!",
})
})
http.ListenAndServe(":8080", r)
}
After (App)
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a, err := app.New(
app.WithServiceName("my-service"),
app.WithServiceVersion("v1.0.0"),
app.WithObservability(
app.WithMetrics(),
app.WithTracing(),
),
)
if err != nil {
log.Fatal(err)
}
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello!",
})
})
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
Accessing Router
If you need router-specific features, access the underlying router:
a := app.MustNew()
// Access router for advanced features
router := a.Router()
router.Freeze() // Manually freeze router
Gradual Migration
You can migrate gradually:
- Start with app constructor - Change
router.New() to app.New() - Update handlers incrementally - Change handler signatures one at a time
- Add app features - Add observability, health checks, lifecycle hooks
- Update server startup - Add graceful shutdown last
Next Steps
16 - Examples
Complete working examples of Rivaas applications.
Quick Start Example
Minimal application to get started.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a, err := app.New()
if err != nil {
log.Fatal(err)
}
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello from Rivaas!",
})
})
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
Full-Featured Production App
Complete application with all features.
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"rivaas.dev/app"
"rivaas.dev/logging"
"rivaas.dev/metrics"
"rivaas.dev/tracing"
)
var db *sql.DB
func main() {
a, err := app.New(
// Service metadata
app.WithServiceName("orders-api"),
app.WithServiceVersion("v2.0.0"),
app.WithEnvironment("production"),
// Observability: all three pillars
app.WithObservability(
app.WithLogging(logging.WithJSONHandler()),
app.WithMetrics(),
app.WithTracing(tracing.WithOTLP("localhost:4317")),
app.WithExcludePaths("/livez", "/readyz", "/metrics"),
app.WithAccessLogScope(app.AccessLogScopeErrorsOnly),
app.WithSlowThreshold(1 * time.Second),
),
// Health endpoints
app.WithHealthEndpoints(
app.WithHealthTimeout(800 * time.Millisecond),
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
),
// Server configuration
app.WithServer(
app.WithReadTimeout(10 * time.Second),
app.WithWriteTimeout(15 * time.Second),
app.WithShutdownTimeout(30 * time.Second),
),
)
if err != nil {
log.Fatal(err)
}
// Lifecycle hooks
a.OnStart(func(ctx context.Context) error {
log.Println("Connecting to database...")
var err error
db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
return err
})
a.OnShutdown(func(ctx context.Context) {
log.Println("Closing database connection...")
db.Close()
})
// Register routes
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"service": "orders-api",
"version": "v2.0.0",
})
})
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
c.Logger().Info("fetching order", "order_id", orderID)
c.JSON(http.StatusOK, map[string]string{
"order_id": orderID,
"status": "completed",
})
})
// Start server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
log.Println("Server starting on :8080")
if err := a.Start(ctx); err != nil {
log.Fatal(err)
}
}
REST API Example
Complete REST API with CRUD operations:
package main
import (
"log"
"net/http"
"rivaas.dev/app"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
}
func main() {
a := app.MustNew(app.WithServiceName("users-api"))
// List users
a.GET("/users", func(c *app.Context) {
users := []User{
{ID: "1", Name: "Alice", Email: "alice@example.com"},
{ID: "2", Name: "Bob", Email: "bob@example.com"},
}
c.JSON(http.StatusOK, users)
})
// Create user
a.POST("/users", func(c *app.Context) {
req, ok := app.MustBind[CreateUserRequest](c)
if !ok {
return
}
user := User{
ID: "123",
Name: req.Name,
Email: req.Email,
}
c.JSON(http.StatusCreated, user)
})
// Get user
a.GET("/users/:id", func(c *app.Context) {
id := c.Param("id")
user := User{ID: id, Name: "Alice", Email: "alice@example.com"}
c.JSON(http.StatusOK, user)
})
// Update user
a.PUT("/users/:id", func(c *app.Context) {
id := c.Param("id")
req, ok := app.MustBind[CreateUserRequest](c)
if !ok {
return
}
user := User{ID: id, Name: req.Name, Email: req.Email}
c.JSON(http.StatusOK, user)
})
// Delete user
a.DELETE("/users/:id", func(c *app.Context) {
c.Status(http.StatusNoContent)
})
// Start server...
}
More Examples
See the examples/ directory in the repository for additional examples:
- 01-quick-start/ - Minimal setup (~20 lines)
- 02-blog/ - Complete blog API with database, validation, and testing
Next Steps