Application Framework
A complete web framework built on the Rivaas router. Includes integrated observability, lifecycle management, and sensible defaults for production-ready applications.
The Rivaas App package provides a high-level framework with pre-configured observability, graceful shutdown, and common middleware for rapid application development.
Overview
The App package is a complete web framework built on top of the Rivaas router. It provides a simple API for building web applications. It includes integrated observability with metrics, tracing, and logging. It has lifecycle management, graceful shutdown, and common middleware patterns.
Key Features
- Complete Framework - Pre-configured with sensible defaults for rapid development.
- Integrated Observability - Built-in metrics with Prometheus/OTLP, tracing with OpenTelemetry, and structured logging with slog.
- Request Binding & Validation - Automatic request parsing with validation strategies.
- OpenAPI Generation - Automatic OpenAPI spec generation with Swagger UI.
- Lifecycle Hooks - OnStart, OnReady, OnShutdown, OnStop for initialization and cleanup.
- Health Endpoints - Kubernetes-compatible liveness and readiness probes.
- Graceful Shutdown - Proper server shutdown with configurable timeouts.
- Environment-Aware - Development and production modes with appropriate defaults.
When to Use
Use App Package When
- Building a complete web application - Need a full framework with all features included.
- Want integrated observability - Metrics and tracing configured out of the box.
- Need quick development - Sensible defaults help you start immediately.
- Building a REST API - Pre-configured with common middleware and patterns.
- Prefer convention over configuration - Defaults that work well together.
Use Router Package Directly When
- Building a library or framework - Need full control over the routing layer.
- Have custom observability setup - Already using specific metrics or tracing solutions.
- Maximum performance is critical - Want zero overhead from default middleware.
- Need complete flexibility - Don’t want any opinions or defaults imposed.
- Integrating into existing systems - Need to fit into established patterns.
Performance Note: The app package adds about 1-2% latency compared to using router directly. Latency goes from 119ns to about 121-122ns. However, it provides significant development speed and maintainability benefits. This comes through integrated observability and sensible defaults.
Quick Start
Simple Application
Create a minimal application with defaults:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
// Create app with defaults
a, err := app.New()
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Register routes
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello from Rivaas App!",
})
})
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Start server with graceful shutdown
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatalf("Server error: %v", err)
}
}
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("/healthz", "/readyz", "/metrics"),
),
// Health endpoints: GET /healthz (liveness), GET /readyz (readiness)
app.WithHealthEndpoints(
app.WithHealthTimeout(800 * time.Millisecond),
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
),
// Server configuration
app.WithServer(
app.WithReadTimeout(15 * time.Second),
app.WithWriteTimeout(15 * time.Second),
),
)
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Register routes
a.GET("/users/:id", func(c *app.Context) {
userID := c.Param("id")
// Request-scoped logger with automatic context
c.Logger().Info("processing request", "user_id", userID)
c.JSON(http.StatusOK, map[string]any{
"user_id": userID,
"name": "John Doe",
"trace_id": c.TraceID(),
})
})
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Start server
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatalf("Server error: %v", err)
}
}
Learning Path
Follow this structured path to master the Rivaas App framework:
1. Getting Started
Start with the basics:
2. Request Handling
Handle requests effectively:
- Context - Use the app context for binding, validation, and error handling
- Routing - Organize routes with groups, versioning, and static files
- Middleware - Add cross-cutting concerns with built-in middleware
3. Observability
Monitor your application:
4. Production Readiness
Prepare for production:
- Lifecycle - Use lifecycle hooks for initialization and cleanup
- Server - Configure HTTP, HTTPS, and mTLS servers with graceful shutdown
- OpenAPI - Generate OpenAPI specs and Swagger UI automatically
5. Testing & Migration
Test and migrate:
- Testing - Test your routes and handlers without starting a server
- Migration - Migrate from the router package to the app package
- Examples - Complete working examples and patterns
Common Use Cases
The Rivaas App excels in these scenarios:
- REST APIs - Full-featured JSON APIs with observability and validation
- Microservices - Cloud-native services with health checks and graceful shutdown
- Web Applications - Complete web apps with middleware and lifecycle management
- Production Services - Production-ready defaults with integrated monitoring
Next Steps
Need Help?
1 - Installation
Install the Rivaas App package and set up your development environment.
Requirements
- Go 1.25 or later - The app package requires Go 1.25 or higher. It uses the latest language features and standard library.
- Module support - Your project must use Go modules. It needs a
go.mod file.
Installation
Install the app package using go get:
This downloads the app package and all its dependencies. These include:
rivaas.dev/router - High-performance HTTP router.rivaas.dev/binding - Request binding and parsing.rivaas.dev/validation - Request validation.rivaas.dev/errors - Error formatting.rivaas.dev/logging - Structured logging (optional).rivaas.dev/metrics - Metrics collection (optional).rivaas.dev/tracing - OpenTelemetry tracing (optional).rivaas.dev/openapi - OpenAPI generation (optional).
Verify Installation
Create a simple main.go to verify the installation:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a, err := app.New()
if err != nil {
log.Fatal(err)
}
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Installation successful!",
})
})
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
log.Println("Server starting on :8080")
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatal(err)
}
}
Run the application:
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.MustBindAndValidate(&req) {
return // Error response already sent
}
// Create user in database...
c.JSON(http.StatusCreated, map[string]any{
"id": "123",
"name": req.Name,
"email": req.Email,
})
}
// main.go
package main
import (
"myapp/handlers"
"rivaas.dev/app"
)
func main() {
a := app.MustNew()
a.GET("/users/:id", handlers.GetUser)
a.POST("/users", handlers.CreateUser)
// ...
}
Response Rendering
JSON Responses
Send JSON responses with c.JSON():
a.GET("/users", func(c *app.Context) {
users := []map[string]string{
{"id": "1", "name": "Alice"},
{"id": "2", "name": "Bob"},
}
c.JSON(http.StatusOK, users)
})
Status Codes
Set status without body using c.Status():
a.DELETE("/users/:id", func(c *app.Context) {
id := c.Param("id")
// Delete user from database...
c.Status(http.StatusNoContent)
})
String Responses
Send plain text responses:
a.GET("/health", func(c *app.Context) {
c.String(http.StatusOK, "OK")
})
HTML Responses
Send HTML responses:
a.GET("/", func(c *app.Context) {
html := `
<!DOCTYPE html>
<html>
<head><title>Welcome</title></head>
<body><h1>Welcome to My App</h1></body>
</html>
`
c.HTML(http.StatusOK, html)
})
Running the Server
HTTP Server
Start the HTTP server with graceful shutdown:
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a := app.MustNew()
// Register routes...
a.GET("/", homeHandler)
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
// Start server
log.Println("Server starting on :8080")
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatalf("Server error: %v", err)
}
}
Port Configuration
Specify different ports:
// Development
a.Start(ctx, ":8080")
// Production
a.Start(ctx, ":80")
// Bind to specific interface
a.Start(ctx, "127.0.0.1:8080")
// Use environment variable
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
a.Start(ctx, ":"+port)
Complete Example
Here’s a complete working example:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
// Create app
a, err := app.New(
app.WithServiceName("hello-api"),
app.WithServiceVersion("v1.0.0"),
)
if err != nil {
log.Fatalf("Failed to create app: %v", err)
}
// Home route
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Welcome to Hello API",
"version": "v1.0.0",
})
})
// Greet route with parameter
a.GET("/greet/:name", func(c *app.Context) {
name := c.Param("name")
c.JSON(http.StatusOK, map[string]string{
"greeting": "Hello, " + name + "!",
})
})
// Echo route with request body
a.POST("/echo", func(c *app.Context) {
var req map[string]any
if !c.MustBindAndValidate(&req) {
return
}
c.JSON(http.StatusOK, req)
})
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
// Start server
log.Println("Server starting on :8080")
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatal(err)
}
}
Test the endpoints:
# Home route
curl http://localhost:8080/
# Greet route
curl http://localhost:8080/greet/Alice
# Echo route
curl -X POST http://localhost:8080/echo \
-H "Content-Type: application/json" \
-d '{"message": "Hello, World!"}'
Next Steps
- Configuration - Configure service name, environment, and server settings
- Context - Learn about request binding and validation
- Routing - Organize routes with groups and middleware
- Examples - Explore complete working examples
3 - Configuration
Configure your application with service metadata, environment modes, and server settings.
Service Configuration
Service Name
Set the service name used in observability metadata. This includes metrics, traces, and logs:
a, err := app.New(
app.WithServiceName("orders-api"),
)
The service name must be non-empty or validation will fail. Default: "rivaas-app".
Service Version
Set the service version for observability and API documentation:
a, err := app.New(
app.WithServiceVersion("v1.2.3"),
)
The service version must be non-empty or validation will fail. Default: "1.0.0".
Configure both service name and version:
a, err := app.New(
app.WithServiceName("payments-api"),
app.WithServiceVersion("v2.0.0"),
)
These values are automatically injected into:
- Metrics - Service name and version labels on all metrics.
- Tracing - Service name and version attributes on all spans.
- Logging - Service name and version fields in all log entries.
- OpenAPI - API title and version in the specification.
Environment Modes
Development Mode
Development mode enables verbose logging and developer-friendly features:
a, err := app.New(
app.WithEnvironment("development"),
)
Development mode features:
- Verbose access logging for all requests.
- Route table displayed in startup banner.
- More detailed error messages.
- Terminal colors enabled.
Production Mode
Production mode optimizes for performance and security:
a, err := app.New(
app.WithEnvironment("production"),
)
Production mode features:
- Error-only access logging. Reduces log volume.
- Minimal startup banner.
- Sanitized error messages.
- Terminal colors stripped for log aggregation.
Environment from Environment Variables
Use environment variables for configuration:
env := os.Getenv("ENVIRONMENT")
if env == "" {
env = "development"
}
a, err := app.New(
app.WithEnvironment(env),
)
Valid values: "development", "production". Invalid values cause validation to fail.
Server Configuration
Timeouts
Configure server timeouts for safety and performance:
a, err := app.New(
app.WithServer(
app.WithReadTimeout(10 * time.Second),
app.WithWriteTimeout(15 * time.Second),
app.WithIdleTimeout(60 * time.Second),
app.WithReadHeaderTimeout(2 * time.Second),
),
)
Timeout descriptions:
- ReadTimeout - Maximum time to read entire request. Includes body.
- WriteTimeout - Maximum time to write response.
- IdleTimeout - Maximum time to wait for next request on keep-alive connection.
- ReadHeaderTimeout - Maximum time to read request headers.
Default values:
- ReadTimeout:
10s - WriteTimeout:
10s - IdleTimeout:
60s - ReadHeaderTimeout:
2s
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.
Unified Observability Configuration
Configure all three pillars in one place.
a, err := app.New(
app.WithServiceName("orders-api"),
app.WithServiceVersion("v1.2.3"),
app.WithObservability(
app.WithLogging(logging.WithJSONHandler()),
app.WithMetrics(), // Prometheus is default
app.WithTracing(tracing.WithOTLP("localhost:4317")),
),
)
Logging
Basic Logging
Enable structured logging with slog:
a, err := app.New(
app.WithObservability(
app.WithLogging(logging.WithJSONHandler()),
),
)
Log Handlers
Choose from different log handlers.
// JSON handler (production)
app.WithLogging(logging.WithJSONHandler())
// Console handler (development)
app.WithLogging(logging.WithConsoleHandler())
// Text handler
app.WithLogging(logging.WithTextHandler())
Log Levels
Configure log level:
app.WithLogging(
logging.WithJSONHandler(),
logging.WithLevel(slog.LevelDebug),
)
Request-Scoped Logging
Use the request-scoped logger in handlers.
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
// Logger automatically includes:
// - HTTP metadata (method, route, target, client IP)
// - Request ID (if present)
// - Trace/span IDs (if tracing enabled)
c.Logger().Info("processing order",
slog.String("order.id", orderID),
)
// Subsequent logs maintain context
c.Logger().Debug("fetching from database")
c.JSON(http.StatusOK, map[string]string{
"order_id": orderID,
})
})
Log output includes automatic context:
{
"time": "2024-01-18T10:30:00Z",
"level": "INFO",
"msg": "processing order",
"http.method": "GET",
"http.route": "/orders/:id",
"http.target": "/orders/123",
"network.client.ip": "203.0.113.1",
"trace_id": "abc...",
"span_id": "def...",
"order.id": "123"
}
Metrics
Prometheus Metrics (Default)
Enable Prometheus metrics on a separate server:
a, err := app.New(
app.WithObservability(
app.WithMetrics(), // Default: Prometheus on :9090/metrics
),
)
Custom Prometheus Configuration
Configure Prometheus address and path:
a, err := app.New(
app.WithObservability(
app.WithMetrics(metrics.WithPrometheus(":9091", "/custom-metrics")),
),
)
Mount Metrics on Main Router
Mount metrics endpoint on the main HTTP server:
a, err := app.New(
app.WithObservability(
app.WithMetricsOnMainRouter("/metrics"),
),
)
// Metrics available at http://localhost:8080/metrics
OTLP Metrics
Send metrics via OTLP to collectors like Prometheus, Grafana, or Datadog:
a, err := app.New(
app.WithObservability(
app.WithMetrics(metrics.WithOTLP("localhost:4317")),
),
)
Custom Metrics in Handlers
Record custom metrics in your handlers:
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
// Increment counter
c.IncrementCounter("order.lookups",
attribute.String("order.id", orderID),
)
// Record histogram
c.RecordHistogram("order.processing_time", 0.250,
attribute.String("order.id", orderID),
)
c.JSON(http.StatusOK, order)
})
Tracing
OpenTelemetry Tracing
Enable OpenTelemetry tracing with OTLP exporter:
a, err := app.New(
app.WithObservability(
app.WithTracing(tracing.WithOTLP("localhost:4317")),
),
)
Stdout Tracing (Development)
Use stdout tracing for development:
a, err := app.New(
app.WithObservability(
app.WithTracing(tracing.WithStdout()),
),
)
Sample Rate
Configure trace sampling:
a, err := app.New(
app.WithObservability(
app.WithTracing(
tracing.WithOTLP("localhost:4317"),
tracing.WithSampleRate(0.1), // Sample 10% of requests
),
),
)
Span Attributes in Handlers
Add span attributes and events in your handlers:
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
// Add span attribute
c.SetSpanAttribute("order.id", orderID)
// Add span event
c.AddSpanEvent("order_lookup_started")
// Fetch order...
c.AddSpanEvent("order_lookup_completed")
c.JSON(http.StatusOK, order)
})
Accessing Trace IDs
Get the current trace ID for correlation:
a.GET("/orders/:id", func(c *app.Context) {
traceID := c.TraceID()
c.JSON(http.StatusOK, map[string]string{
"order_id": orderID,
"trace_id": traceID,
})
})
Service 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("/healthz", "/readyz", "/metrics"),
),
)
Exclude Prefixes
Exclude path prefixes:
a, err := app.New(
app.WithObservability(
app.WithExcludePrefixes("/internal/", "/admin/debug/"),
),
)
Exclude Patterns
Exclude paths matching regex patterns:
a, err := app.New(
app.WithObservability(
app.WithExcludePatterns(`^/api/v\d+/health$`, `^/debug/.*`),
),
)
Default Exclusions
By default, the following paths are excluded:
/health, /healthz, /live, /livez/ready, /readyz/metrics/debug/*
To disable default exclusions:
a, err := app.New(
app.WithObservability(
app.WithoutDefaultExclusions(),
app.WithExcludePaths("/custom-health"), // Add your own
),
)
Access Logging
Enable/Disable Access Logging
Control access logging:
// Enable access logging (default)
a, err := app.New(
app.WithObservability(
app.WithAccessLogging(true),
),
)
// Disable access logging
a, err := app.New(
app.WithObservability(
app.WithAccessLogging(false),
),
)
Log Only Errors
Log only errors and slow requests (automatically enabled in production):
a, err := app.New(
app.WithObservability(
app.WithLogOnlyErrors(),
),
)
Slow Request Threshold
Mark requests as slow and log them:
a, err := app.New(
app.WithObservability(
app.WithLogOnlyErrors(),
app.WithSlowThreshold(500 * time.Millisecond),
),
)
Complete Example
Production-ready observability configuration:
package main
import (
"log"
"time"
"rivaas.dev/app"
"rivaas.dev/logging"
"rivaas.dev/metrics"
"rivaas.dev/tracing"
)
func main() {
a, err := app.New(
// Service metadata (automatically injected into all components)
app.WithServiceName("orders-api"),
app.WithServiceVersion("v2.1.0"),
app.WithEnvironment("production"),
// Unified observability configuration
app.WithObservability(
// Logging: JSON handler for production
app.WithLogging(
logging.WithJSONHandler(),
logging.WithLevel(slog.LevelInfo),
),
// Metrics: Prometheus on separate server
app.WithMetrics(
metrics.WithPrometheus(":9090", "/metrics"),
),
// Tracing: OTLP to Jaeger/Tempo
app.WithTracing(
tracing.WithOTLP("jaeger:4317"),
tracing.WithSampleRate(0.1), // 10% sampling
),
// Path filtering
app.WithExcludePaths("/healthz", "/readyz"),
app.WithExcludePrefixes("/internal/"),
// Access logging: errors and slow requests only
app.WithLogOnlyErrors(),
app.WithSlowThreshold(1 * time.Second),
),
)
if err != nil {
log.Fatal(err)
}
// Register routes...
a.GET("/orders/:id", handleGetOrder)
// Start server...
}
Next Steps
5 - Context
Use the app context for request binding, validation, error handling, and logging.
Overview
The app.Context wraps router.Context and provides app-level features:
- Request Binding - Parse JSON, form, query, path, header, and cookie data automatically
- Validation - Comprehensive validation with multiple strategies
- Error Handling - Structured error responses with content negotiation
- Logging - Request-scoped logger with automatic context
Request Binding
Automatic Binding
Bind() automatically detects struct tags and binds from all relevant sources:
type GetUserRequest struct {
ID int `path:"id"` // Path parameter
Expand string `query:"expand"` // Query parameter
APIKey string `header:"X-API-Key"` // HTTP header
Session string `cookie:"session"` // Cookie
}
a.GET("/users/:id", func(c *app.Context) {
var req GetUserRequest
if err := c.Bind(&req); err != nil {
c.Error(err)
return
}
// req is populated from path, query, headers, and cookies
})
JSON Binding
For JSON request bodies:
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if err := c.Bind(&req); err != nil {
c.Error(err)
return
}
// req is populated from JSON body
})
Strict JSON Binding
Reject unknown fields to catch typos and API drift:
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if err := c.BindJSONStrict(&req); err != nil {
c.Error(err) // Returns error if unknown fields present
return
}
})
Multi-Source Binding
Bind from multiple sources simultaneously:
type UpdateUserRequest struct {
ID int `path:"id"` // From path
Name string `json:"name"` // From JSON body
Token string `header:"X-Token"` // From header
}
a.PUT("/users/:id", func(c *app.Context) {
var req UpdateUserRequest
if err := c.Bind(&req); err != nil {
c.Error(err)
return
}
// req.ID from path, req.Name from JSON, req.Token from header
})
Validation
Bind and Validate
Combine binding and validation in one call:
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"required,gte=18,lte=120"`
}
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if err := c.BindAndValidate(&req); err != nil {
c.Error(err)
return
}
// req is validated
})
Strict Bind and Validate
Reject unknown fields AND validate:
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if err := c.BindAndValidateStrict(&req); err != nil {
c.Error(err) // Returns error if unknown fields OR validation fails
return
}
})
Must Bind and Validate
Automatically send error responses on binding/validation failure:
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if !c.MustBindAndValidate(&req) {
return // Error response already sent
}
// Continue with validated request
})
Generic Bind and Validate
Use generics for type-safe binding:
a.POST("/users", func(c *app.Context) {
req, err := app.BindAndValidateInto[CreateUserRequest](c)
if err != nil {
c.Error(err)
return
}
// req is of type CreateUserRequest
})
// Or with automatic error handling
a.POST("/users", func(c *app.Context) {
req, ok := app.MustBindAndValidateInto[CreateUserRequest](c)
if !ok {
return // Error response already sent
}
// Continue with req
})
Partial Validation (PATCH)
Validate only fields present in the request:
type PatchUserRequest struct {
Name *string `json:"name" validate:"omitempty,min=3,max=50"`
Email *string `json:"email" validate:"omitempty,email"`
}
a.PATCH("/users/:id", func(c *app.Context) {
var req PatchUserRequest
if err := c.BindAndValidate(&req, validation.WithPartial(true)); err != nil {
c.Error(err)
return
}
// Only present fields are validated
})
Validation Strategies
Choose different validation strategies:
// Interface validation (default)
c.BindAndValidate(&req)
// Tag validation (go-playground/validator)
c.BindAndValidate(&req, validation.WithStrategy(validation.StrategyTags))
// JSON Schema validation
c.BindAndValidate(&req, validation.WithStrategy(validation.StrategyJSONSchema))
Error Handling
Basic Error Handling
Send error responses with automatic formatting:
a.GET("/users/:id", func(c *app.Context) {
id := c.Param("id")
user, err := db.GetUser(id)
if err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, user)
})
Explicit Status Codes
Override error status codes:
a.GET("/users/:id", func(c *app.Context) {
user, err := db.GetUser(id)
if err != nil {
c.ErrorStatus(err, http.StatusNotFound)
return
}
c.JSON(http.StatusOK, user)
})
Convenience Error Methods
Use convenience methods for common status codes:
// 404 Not Found
if user == nil {
c.NotFound("user not found")
return
}
// 400 Bad Request
if err := validateInput(input); err != nil {
c.BadRequest("invalid input")
return
}
// 401 Unauthorized
if !isAuthenticated {
c.Unauthorized("authentication required")
return
}
// 403 Forbidden
if !hasPermission {
c.Forbidden("insufficient permissions")
return
}
// 500 Internal Server Error
if err := processRequest(); err != nil {
c.InternalError(err)
return
}
Configure error formatting at app level:
// Single formatter
a, err := app.New(
app.WithErrorFormatter(&errors.RFC9457{
BaseURL: "https://api.example.com/problems",
}),
)
// Multiple formatters with content negotiation
a, err := app.New(
app.WithErrorFormatters(map[string]errors.Formatter{
"application/problem+json": &errors.RFC9457{},
"application/json": &errors.Simple{},
}),
app.WithDefaultErrorFormat("application/problem+json"),
)
Request-Scoped Logging
Accessing the Logger
Get the request-scoped logger with automatic context:
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
// Logger automatically includes:
// - HTTP metadata (method, route, target, client IP)
// - Request ID (if present)
// - Trace/span IDs (if tracing enabled)
c.Logger().Info("processing order",
slog.String("order.id", orderID),
)
c.JSON(http.StatusOK, order)
})
Structured Logging
Use structured logging with key-value pairs:
a.POST("/orders", func(c *app.Context) {
var req CreateOrderRequest
if !c.MustBindAndValidate(&req) {
return
}
c.Logger().Info("creating order",
slog.String("customer.id", req.CustomerID),
slog.Int("item.count", len(req.Items)),
slog.Float64("order.total", req.Total),
)
// Process order...
c.Logger().Info("order created successfully",
slog.String("order.id", orderID),
)
})
Log Levels
Use different log levels:
c.Logger().Debug("fetching from cache")
c.Logger().Info("request processed successfully")
c.Logger().Warn("cache miss, fetching from database")
c.Logger().Error("failed to save to database", "error", err)
Automatic Context
The logger automatically includes request context:
{
"time": "2024-01-18T10:30:00Z",
"level": "INFO",
"msg": "processing order",
"http.method": "GET",
"http.route": "/orders/:id",
"http.target": "/orders/123",
"network.client.ip": "203.0.113.1",
"trace_id": "abc...",
"span_id": "def...",
"order.id": "123"
}
Router Context Features
The app context embeds router.Context, so all router features are available:
HTTP Methods
method := c.Request.Method
path := c.Request.URL.Path
headers := c.Request.Header
Response Handling
c.Status(http.StatusOK)
c.Header("Content-Type", "application/json")
c.JSON(http.StatusOK, data)
c.String(http.StatusOK, "text")
c.HTML(http.StatusOK, html)
Content Negotiation
accepts := c.Accepts("application/json", "text/html")
Complete Example
package main
import (
"log"
"log/slog"
"net/http"
"rivaas.dev/app"
"rivaas.dev/validation"
)
type CreateOrderRequest struct {
CustomerID string `json:"customer_id" validate:"required,uuid"`
Items []string `json:"items" validate:"required,min=1,dive,required"`
Total float64 `json:"total" validate:"required,gt=0"`
}
func main() {
a := app.MustNew(
app.WithServiceName("orders-api"),
)
a.POST("/orders", func(c *app.Context) {
// Bind and validate
var req CreateOrderRequest
if !c.MustBindAndValidate(&req) {
return // Error response already sent
}
// Log with context
c.Logger().Info("creating order",
slog.String("customer.id", req.CustomerID),
slog.Int("item.count", len(req.Items)),
slog.Float64("order.total", req.Total),
)
// Business logic...
orderID := "order-123"
// Log success
c.Logger().Info("order created",
slog.String("order.id", orderID),
)
// Return response
c.JSON(http.StatusCreated, map[string]string{
"order_id": orderID,
})
})
// Start server...
}
Next Steps
6 - Middleware
Add cross-cutting concerns with built-in and custom middleware.
Overview
Middleware functions execute before and after route handlers. They add cross-cutting concerns like logging, authentication, and rate limiting.
The app package provides access to high-quality middleware from the router/middleware subpackages.
Using Middleware
Global Middleware
Apply middleware to all routes:
a := app.MustNew()
a.Use(requestid.New())
a.Use(cors.New(cors.WithAllowAllOrigins(true)))
// All routes registered after Use() will have this middleware
a.GET("/users", handler)
a.POST("/orders", handler)
Middleware During Initialization
Add middleware when creating the app:
a, err := app.New(
app.WithServiceName("my-api"),
app.WithMiddleware(
requestid.New(),
cors.New(cors.WithAllowAllOrigins(true)),
),
)
Default Middleware
The app package automatically includes recovery middleware by default in both development and production modes.
To disable default middleware:
a, err := app.New(
app.WithoutDefaultMiddleware(),
app.WithMiddleware(myCustomRecovery), // Add your own
)
Built-in Middleware
Request ID
Generate unique request IDs for tracing:
import "rivaas.dev/router/middleware/requestid"
a.Use(requestid.New())
// Access in handler
a.GET("/", func(c *app.Context) {
reqID := c.Response.Header().Get("X-Request-ID")
c.JSON(http.StatusOK, map[string]string{
"request_id": reqID,
})
})
Options:
requestid.New(
requestid.WithRequestIDHeader("X-Correlation-ID"),
requestid.WithGenerator(customGenerator),
)
CORS
Handle Cross-Origin Resource Sharing:
import "rivaas.dev/router/middleware/cors"
// Allow all origins (development)
a.Use(cors.New(cors.WithAllowAllOrigins(true)))
// Specific origins (production)
a.Use(cors.New(
cors.WithAllowedOrigins([]string{"https://example.com"}),
cors.WithAllowCredentials(true),
cors.WithAllowedMethods([]string{"GET", "POST", "PUT", "DELETE"}),
cors.WithAllowedHeaders([]string{"Content-Type", "Authorization"}),
))
Recovery
Recover from panics gracefully (included by default):
import "rivaas.dev/router/middleware/recovery"
a.Use(recovery.New(
recovery.WithStackTrace(true),
))
Access Logging
Log HTTP requests (when not using app’s built-in observability):
import "rivaas.dev/router/middleware/accesslog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
a.Use(accesslog.New(
accesslog.WithLogger(logger),
accesslog.WithSkipPaths([]string{"/health", "/metrics"}),
))
Note: The app package automatically configures access logging through its unified observability when WithLogging() is used.
Timeout
Add request timeout handling:
import "rivaas.dev/router/middleware/timeout"
// Default timeout (30s)
a.Use(timeout.New())
// Custom timeout
a.Use(timeout.New(
timeout.WithDuration(5 * time.Second),
timeout.WithSkipPaths("/stream"),
timeout.WithSkipPrefix("/admin"),
))
Rate Limiting
Rate limit requests (single-instance only):
import "rivaas.dev/router/middleware/ratelimit"
// 100 requests per minute
a.Use(ratelimit.New(100, time.Minute))
Note: This is in-memory rate limiting suitable for single-instance deployments only. For production with multiple instances, use a distributed rate limiting solution.
Compression
Compress responses with gzip or brotli:
import "rivaas.dev/router/middleware/compression"
a.Use(compression.New(
compression.WithLevel(compression.BestSpeed),
compression.WithMinSize(1024), // Only compress responses > 1KB
))
Body Limit
Limit request body size:
import "rivaas.dev/router/middleware/bodylimit"
a.Use(bodylimit.New(
bodylimit.WithMaxBytes(5 << 20), // 5MB max
))
Add security headers (HSTS, CSP, etc.):
import "rivaas.dev/router/middleware/securityheaders"
a.Use(securityheaders.New(
securityheaders.WithHSTS(true),
securityheaders.WithContentSecurityPolicy("default-src 'self'"),
securityheaders.WithXFrameOptions("DENY"),
))
Basic Auth
HTTP Basic Authentication:
import "rivaas.dev/router/middleware/basicauth"
a.Use(basicauth.New(
basicauth.WithUsers(map[string]string{
"admin": "password123",
}),
basicauth.WithRealm("Admin Area"),
))
Custom Middleware
Writing Custom Middleware
Create custom middleware as functions:
func AuthMiddleware() app.HandlerFunc {
return func(c *app.Context) {
token := c.Request.Header.Get("Authorization")
if token == "" {
c.Unauthorized("missing authorization token")
return
}
// Validate token...
if !isValid(token) {
c.Unauthorized("invalid token")
return
}
// Continue to next middleware/handler
c.Next()
}
}
// Use it
a.Use(AuthMiddleware())
Middleware with Configuration
Create configurable middleware:
type AuthConfig struct {
TokenHeader string
SkipPaths []string
}
func AuthWithConfig(config AuthConfig) app.HandlerFunc {
return func(c *app.Context) {
// Skip authentication for certain paths
for _, path := range config.SkipPaths {
if c.Request.URL.Path == path {
c.Next()
return
}
}
token := c.Request.Header.Get(config.TokenHeader)
if token == "" || !isValid(token) {
c.Unauthorized("authentication failed")
return
}
c.Next()
}
}
// Use it
a.Use(AuthWithConfig(AuthConfig{
TokenHeader: "X-API-Key",
SkipPaths: []string{"/health", "/public"},
}))
Middleware with State
Share state across requests:
type RateLimiter struct {
requests map[string]int
mu sync.Mutex
}
func NewRateLimiter() *RateLimiter {
return &RateLimiter{
requests: make(map[string]int),
}
}
func (rl *RateLimiter) Middleware() app.HandlerFunc {
return func(c *app.Context) {
clientIP := c.ClientIP()
rl.mu.Lock()
count := rl.requests[clientIP]
rl.requests[clientIP]++
rl.mu.Unlock()
if count > 100 {
c.Status(http.StatusTooManyRequests)
return
}
c.Next()
}
}
// Use it
limiter := NewRateLimiter()
a.Use(limiter.Middleware())
Route-Specific Middleware
Per-Route Middleware
Apply middleware to specific routes:
// Using WithBefore option
a.GET("/admin", adminHandler,
app.WithBefore(AuthMiddleware()),
)
// Multiple middleware
a.GET("/admin/users", handler,
app.WithBefore(
AuthMiddleware(),
AdminOnlyMiddleware(),
),
)
After Middleware
Execute middleware after the handler:
a.GET("/orders/:id", handler,
app.WithAfter(AuditLogMiddleware()),
)
Combined Middleware
Combine before and after middleware:
a.POST("/orders", handler,
app.WithBefore(AuthMiddleware(), RateLimitMiddleware()),
app.WithAfter(AuditLogMiddleware()),
)
Group Middleware
Apply middleware to route groups:
// Admin routes with auth middleware
admin := a.Group("/admin", AuthMiddleware(), AdminOnlyMiddleware())
admin.GET("/users", getUsersHandler)
admin.POST("/users", createUserHandler)
// API routes with rate limiting
api := a.Group("/api", RateLimitMiddleware())
api.GET("/status", statusHandler)
api.GET("/version", versionHandler)
Middleware Execution Order
Middleware executes in the order it’s registered:
a.Use(Middleware1()) // Executes first
a.Use(Middleware2()) // Executes second
a.Use(Middleware3()) // Executes third
a.GET("/", handler) // Handler executes last
// Execution order:
// 1. Middleware1
// 2. Middleware2
// 3. Middleware3
// 4. handler
// 5. Middleware3 (after c.Next())
// 6. Middleware2 (after c.Next())
// 7. Middleware1 (after c.Next())
Complete Example
package main
import (
"log"
"net/http"
"time"
"rivaas.dev/app"
"rivaas.dev/router/middleware/requestid"
"rivaas.dev/router/middleware/cors"
"rivaas.dev/router/middleware/timeout"
)
func main() {
a, err := app.New(
app.WithServiceName("api"),
app.WithMiddleware(
requestid.New(),
cors.New(cors.WithAllowAllOrigins(true)),
timeout.New(timeout.WithDuration(30 * time.Second)),
),
)
if err != nil {
log.Fatal(err)
}
// Custom middleware
a.Use(LoggingMiddleware())
a.Use(AuthMiddleware())
// Public routes (no auth)
a.GET("/health", healthHandler)
// Protected routes (with auth)
a.GET("/users", usersHandler)
// Admin routes (with auth + admin check)
admin := a.Group("/admin", AdminOnlyMiddleware())
admin.GET("/dashboard", dashboardHandler)
// Start server...
}
func LoggingMiddleware() app.HandlerFunc {
return func(c *app.Context) {
start := time.Now()
c.Next()
duration := time.Since(start)
c.Logger().Info("request completed",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"duration", duration,
)
}
}
func AuthMiddleware() app.HandlerFunc {
return func(c *app.Context) {
// Skip auth for health check
if c.Request.URL.Path == "/health" {
c.Next()
return
}
token := c.Request.Header.Get("Authorization")
if token == "" {
c.Unauthorized("missing authorization token")
return
}
c.Next()
}
}
func AdminOnlyMiddleware() app.HandlerFunc {
return func(c *app.Context) {
// Check if user is admin...
if !isAdmin() {
c.Forbidden("admin access required")
return
}
c.Next()
}
}
Next Steps
- Routing - Organize routes with groups and versioning
- Context - Access request and response in middleware
- Examples - See complete working examples
7 - Routing
Organize routes with groups, versioning, and static files.
Route Registration
HTTP Method Shortcuts
Register routes using HTTP method shortcuts:
a.GET("/users", handler)
a.POST("/users", handler)
a.PUT("/users/:id", handler)
a.PATCH("/users/:id", handler)
a.DELETE("/users/:id", handler)
a.HEAD("/users", handler)
a.OPTIONS("/users", handler)
Match All Methods
Register a route that matches all HTTP methods:
a.Any("/webhook", webhookHandler)
// Handles GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
Route Groups
Basic Groups
Organize routes with shared prefixes:
api := a.Group("/api")
api.GET("/users", getUsersHandler)
api.POST("/users", createUserHandler)
// Routes: GET /api/users, POST /api/users
Nested Groups
Create hierarchical route structures:
api := a.Group("/api")
v1 := api.Group("/v1")
v1.GET("/users", getUsersHandler)
// Route: GET /api/v1/users
Groups with Middleware
Apply middleware to all routes in a group:
admin := a.Group("/admin", AuthMiddleware(), AdminOnlyMiddleware())
admin.GET("/users", getUsersHandler)
admin.POST("/users", createUserHandler)
// Both routes have auth and admin middleware
API Versioning
Version Groups
Create version-specific routes:
v1 := a.Version("v1")
v1.GET("/users", v1GetUsersHandler)
v2 := a.Version("v2")
v2.GET("/users", v2GetUsersHandler)
Version Detection
Configure how versions are detected. This requires router configuration:
a, err := app.New(
app.WithRouter(
router.WithVersioning(
router.WithVersionHeader("API-Version"),
router.WithVersionQuery("version"),
),
),
)
Static Files
Serve Directory
Serve static files from a directory:
a.Static("/static", "./public")
// Files in ./public served at /static/*
Serve Single File
Serve a single file at a specific path:
a.File("/favicon.ico", "./static/favicon.ico")
a.File("/robots.txt", "./static/robots.txt")
Serve from Filesystem
Serve files from an http.FileSystem:
//go:embed static
var staticFiles embed.FS
a.StaticFS("/assets", http.FS(staticFiles))
Route Naming
Named Routes
Name routes for URL generation:
a.GET("/users/:id", getUserHandler).Name("users.get")
a.POST("/users", createUserHandler).Name("users.create")
Generate URLs
Generate URLs from route names:
// After router is frozen (after a.Start())
url, err := a.URLFor("users.get", map[string]string{"id": "123"}, nil)
// Returns: "/users/123"
// With query parameters
url, err := a.URLFor("users.get",
map[string]string{"id": "123"},
map[string][]string{"expand": {"profile"}},
)
// Returns: "/users/123?expand=profile"
Must Generate URLs
Generate URLs and panic on error:
url := a.MustURLFor("users.get", map[string]string{"id": "123"}, nil)
Route Constraints
Numeric Constraints
Constrain parameters to numeric values:
a.GET("/users/:id", handler).WhereInt("id")
// Only matches /users/123, not /users/abc
UUID Constraints
Constrain parameters to UUIDs:
a.GET("/orders/:id", handler).WhereUUID("id")
// Only matches valid UUIDs
Custom Constraints
Use regex patterns for custom constraints:
a.GET("/posts/:slug", handler).Where("slug", `[a-z\-]+`)
// Only matches lowercase letters and hyphens
Custom 404 Handler
Set NoRoute Handler
Handle routes that don’t match:
a.NoRoute(func(c *app.Context) {
c.JSON(http.StatusNotFound, map[string]string{
"error": "route not found",
"path": c.Request.URL.Path,
})
})
Complete Example
package main
import (
"log"
"net/http"
"rivaas.dev/app"
)
func main() {
a := app.MustNew(
app.WithServiceName("api"),
)
// Root routes
a.GET("/", homeHandler)
a.GET("/health", healthHandler)
// API v1
v1 := a.Group("/api/v1")
v1.GET("/status", statusHandler)
// Users
users := v1.Group("/users")
users.GET("", getUsersHandler).Name("users.list")
users.POST("", createUserHandler).Name("users.create")
users.GET("/:id", getUserHandler).Name("users.get").WhereInt("id")
users.PUT("/:id", updateUserHandler).Name("users.update").WhereInt("id")
users.DELETE("/:id", deleteUserHandler).Name("users.delete").WhereInt("id")
// Admin routes with authentication
admin := a.Group("/admin", AuthMiddleware())
admin.GET("/dashboard", dashboardHandler)
admin.GET("/users", adminGetUsersHandler)
// Static files
a.Static("/assets", "./public")
a.File("/favicon.ico", "./public/favicon.ico")
// Custom 404
a.NoRoute(func(c *app.Context) {
c.NotFound("route not found")
})
// Start server...
}
Next Steps
- Middleware - Add middleware to routes and groups
- Context - Access route parameters and query strings
- Examples - See complete working examples
8 - Lifecycle
Use lifecycle hooks for initialization, cleanup, and event handling.
Overview
The app package provides lifecycle hooks for managing application state:
- OnStart - Called before server starts. Runs sequentially. Stops on first error.
- OnReady - Called when server is ready to accept connections. Runs async. Non-blocking.
- OnShutdown - Called during graceful shutdown. LIFO order.
- OnStop - Called after shutdown completes. Best-effort.
- OnRoute - Called when a route is registered. Synchronous.
OnStart Hook
Basic Usage
Initialize resources before the server starts:
a := app.MustNew()
a.OnStart(func(ctx context.Context) error {
log.Println("Connecting to database...")
return db.Connect(ctx)
})
a.OnStart(func(ctx context.Context) error {
log.Println("Running migrations...")
return db.Migrate(ctx)
})
// Start server - hooks execute before listening
a.Start(ctx, ":8080")
Error Handling
OnStart hooks run sequentially and stop on first error:
a.OnStart(func(ctx context.Context) error {
if err := db.Connect(ctx); err != nil {
return fmt.Errorf("database connection failed: %w", err)
}
return nil
})
// If this hook fails, server won't start
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatalf("Startup failed: %v", err)
}
Common Use Cases
// Database connection
a.OnStart(func(ctx context.Context) error {
return db.PingContext(ctx)
})
// Load configuration
a.OnStart(func(ctx context.Context) error {
return config.Load("config.yaml")
})
// Initialize caches
a.OnStart(func(ctx context.Context) error {
return cache.Warmup(ctx)
})
// Check external dependencies
a.OnStart(func(ctx context.Context) error {
return checkExternalServices(ctx)
})
OnReady Hook
Basic Usage
Execute tasks after the server starts listening:
a.OnReady(func() {
log.Println("Server is ready!")
log.Printf("Listening on :8080")
})
a.OnReady(func() {
// Register with service discovery
consul.Register("my-service", ":8080")
})
Async Execution
OnReady hooks run asynchronously and don’t block startup:
a.OnReady(func() {
// Long-running warmup task
time.Sleep(5 * time.Second)
cache.Preload()
})
// Server accepts connections immediately, warmup runs in background
Error Handling
Panics in OnReady hooks are caught and logged:
a.OnReady(func() {
// If this panics, it's logged but doesn't crash the server
doSomethingRisky()
})
OnShutdown Hook
Basic Usage
Clean up resources during graceful shutdown:
a.OnShutdown(func(ctx context.Context) {
log.Println("Shutting down gracefully...")
db.Close()
})
a.OnShutdown(func(ctx context.Context) {
log.Println("Flushing metrics...")
metrics.Flush(ctx)
})
LIFO Execution Order
OnShutdown hooks execute in reverse order (Last In, First Out):
a.OnShutdown(func(ctx context.Context) {
log.Println("1. First registered")
})
a.OnShutdown(func(ctx context.Context) {
log.Println("2. Second registered")
})
// During shutdown, prints:
// "2. Second registered"
// "1. First registered"
This ensures cleanup happens in reverse dependency order.
Timeout Handling
OnShutdown hooks must complete within the shutdown timeout:
a, err := app.New(
app.WithServer(
app.WithShutdownTimeout(30 * time.Second),
),
)
a.OnShutdown(func(ctx context.Context) {
// This context has a 30s deadline
select {
case <-flushComplete:
log.Println("Flush completed")
case <-ctx.Done():
log.Println("Flush timed out")
}
})
Common Use Cases
// Close database connections
a.OnShutdown(func(ctx context.Context) {
db.Close()
})
// Flush metrics and traces
a.OnShutdown(func(ctx context.Context) {
metrics.Shutdown(ctx)
tracing.Shutdown(ctx)
})
// Deregister from service discovery
a.OnShutdown(func(ctx context.Context) {
consul.Deregister("my-service")
})
// Close external connections
a.OnShutdown(func(ctx context.Context) {
redis.Close()
messageQueue.Close()
})
OnStop Hook
Basic Usage
Final cleanup after shutdown completes:
a.OnStop(func() {
log.Println("Cleanup complete")
cleanupTempFiles()
})
Best-Effort Execution
OnStop hooks run in best-effort mode - panics are caught and logged:
a.OnStop(func() {
// Even if this panics, other hooks still run
cleanupTempFiles()
})
No Timeout
OnStop hooks don’t have a timeout constraint:
a.OnStop(func() {
// This can take as long as needed
archiveLogs()
})
OnRoute Hook
Basic Usage
Execute code when routes are registered:
a.OnRoute(func(rt *route.Route) {
log.Printf("Registered: %s %s", rt.Method(), rt.Path())
})
// Register routes - hook fires for each one
a.GET("/users", handler)
a.POST("/users", handler)
Route Validation
Validate routes during registration:
a.OnRoute(func(rt *route.Route) {
// Ensure all routes have names
if rt.Name() == "" {
log.Printf("Warning: Route %s %s has no name", rt.Method(), rt.Path())
}
})
Documentation Generation
Use for automatic documentation:
var routes []string
a.OnRoute(func(rt *route.Route) {
routes = append(routes, fmt.Sprintf("%s %s", rt.Method(), rt.Path()))
})
// After all routes registered
a.OnReady(func() {
log.Printf("Registered %d routes:", len(routes))
for _, r := range routes {
log.Println(" ", r)
}
})
Complete Example
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
var db *Database
func main() {
a := app.MustNew(
app.WithServiceName("api"),
app.WithServer(
app.WithShutdownTimeout(30 * time.Second),
),
)
// OnStart: Initialize resources
a.OnStart(func(ctx context.Context) error {
log.Println("Connecting to database...")
var err error
db, err = ConnectDB(ctx)
if err != nil {
return fmt.Errorf("database connection failed: %w", err)
}
return nil
})
a.OnStart(func(ctx context.Context) error {
log.Println("Running migrations...")
return db.Migrate(ctx)
})
// OnRoute: Log route registration
a.OnRoute(func(rt *route.Route) {
log.Printf("Route registered: %s %s", rt.Method(), rt.Path())
})
// OnReady: Post-startup tasks
a.OnReady(func() {
log.Println("Server is ready!")
log.Println("Registering with service discovery...")
consul.Register("api", ":8080")
})
// OnShutdown: Graceful cleanup
a.OnShutdown(func(ctx context.Context) {
log.Println("Deregistering from service discovery...")
consul.Deregister("api")
})
a.OnShutdown(func(ctx context.Context) {
log.Println("Closing database connection...")
if err := db.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
})
// OnStop: Final cleanup
a.OnStop(func() {
log.Println("Cleanup complete")
})
// Register routes
a.GET("/", homeHandler)
a.GET("/health", healthHandler)
// Setup graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
// Start server
log.Println("Starting server...")
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatalf("Server error: %v", err)
}
}
Hook Execution Flow
1. app.Start(ctx, ":8080") called
2. OnStart hooks execute (sequential, stop on error)
3. Server starts listening
4. OnReady hooks execute (async, non-blocking)
5. Server handles requests...
6. Context canceled (SIGTERM/SIGINT)
7. OnShutdown hooks execute (LIFO order, with timeout)
8. Server shutdown complete
9. OnStop hooks execute (best-effort, no timeout)
10. Process exits
Next Steps
9 - Health Endpoints
Configure Kubernetes-compatible liveness and readiness probes.
Overview
The app package provides standard health check endpoints. They work with Kubernetes and other orchestration platforms:
- Liveness Probe (
/healthz) - Shows if the process is alive. Restart if failing. - Readiness Probe (
/readyz) - Shows if the service can handle traffic.
Basic Configuration
Enable Health Endpoints
Enable health endpoints with defaults.
a, err := app.New(
app.WithHealthEndpoints(),
)
// Endpoints:
// GET /healthz - Liveness probe
// GET /readyz - Readiness probe
Custom Paths
Configure custom health check paths:
a, err := app.New(
app.WithHealthEndpoints(
app.WithHealthzPath("/health/live"),
app.WithReadyzPath("/health/ready"),
),
)
Path Prefix
Mount health endpoints under a prefix.
a, err := app.New(
app.WithHealthEndpoints(
app.WithHealthPrefix("/_system"),
),
)
// Endpoints:
// GET /_system/healthz
// GET /_system/readyz
Liveness Checks
Basic Liveness Check
Liveness checks should be dependency-free and fast:
a, err := app.New(
app.WithHealthEndpoints(
app.WithLivenessCheck("process", func(ctx context.Context) error {
// Process is alive if we can execute this
return nil
}),
),
)
Multiple Liveness Checks
Add multiple liveness checks.
a, err := app.New(
app.WithHealthEndpoints(
app.WithLivenessCheck("process", func(ctx context.Context) error {
return nil
}),
app.WithLivenessCheck("goroutines", func(ctx context.Context) error {
if runtime.NumGoroutine() > 10000 {
return fmt.Errorf("too many goroutines: %d", runtime.NumGoroutine())
}
return nil
}),
),
)
Liveness Behavior
- Returns
200 "ok" if all checks pass - Returns
503 if any check fails - If no checks configured, always returns
200
Readiness Checks
Basic Readiness Check
Readiness checks verify external dependencies:
a, err := app.New(
app.WithHealthEndpoints(
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
),
)
Multiple Readiness Checks
Check multiple dependencies:
a, err := app.New(
app.WithHealthEndpoints(
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
app.WithReadinessCheck("cache", func(ctx context.Context) error {
return redis.Ping(ctx).Err()
}),
app.WithReadinessCheck("api", func(ctx context.Context) error {
return checkUpstreamAPI(ctx)
}),
),
)
Readiness Behavior
- Returns
204 if all checks pass - Returns
503 if any check fails - If no checks configured, always returns
204
Health Check Timeout
Configure timeout for individual checks:
a, err := app.New(
app.WithHealthEndpoints(
app.WithHealthTimeout(800 * time.Millisecond),
app.WithReadinessCheck("database", func(ctx context.Context) error {
// This check has 800ms to complete
return db.PingContext(ctx)
}),
),
)
Default timeout: 1s
Runtime Readiness Gates
Readiness Manager
Dynamically manage readiness state at runtime:
type DatabaseGate struct {
db *sql.DB
}
func (g *DatabaseGate) Ready() bool {
return g.db.Ping() == nil
}
func (g *DatabaseGate) Name() string {
return "database"
}
// Register gate at runtime
a.Readiness().Register("db", &DatabaseGate{db: db})
// Unregister during shutdown
a.OnShutdown(func(ctx context.Context) {
a.Readiness().Unregister("db")
})
Use Cases
Runtime gates are useful for:
- Connection pools that manage their own health
- Circuit breakers that track upstream failures
- Dynamic dependencies that come and go at runtime
Liveness vs Readiness
When to Use Liveness
Liveness checks answer: “Should the process be restarted?”
Use for:
- Detecting deadlocks
- Detecting infinite loops
- Detecting corrupted state that requires restart
Don’t use for:
- External dependency failures (use readiness instead)
- Temporary errors that will resolve themselves
- Network connectivity issues
When to Use Readiness
Readiness checks answer: “Can this instance handle traffic?”
Use for:
- Database connectivity
- Cache availability
- Upstream service health
- Initialization completion
Don’t use for:
- Process-level health (use liveness instead)
- Permanent failures that require restart
Kubernetes Configuration
Deployment YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: my-api:latest
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 1
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 1
failureThreshold: 3
Complete Example
package main
import (
"context"
"database/sql"
"log"
"time"
"rivaas.dev/app"
)
var db *sql.DB
func main() {
a, err := app.New(
app.WithServiceName("api"),
// Health endpoints configuration
app.WithHealthEndpoints(
// Custom paths
app.WithHealthPrefix("/_system"),
// Timeout for checks
app.WithHealthTimeout(800 * time.Millisecond),
// Liveness: process-level health
app.WithLivenessCheck("process", func(ctx context.Context) error {
// Always healthy if we can execute this
return nil
}),
// Readiness: dependency health
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
app.WithReadinessCheck("cache", func(ctx context.Context) error {
return checkCache(ctx)
}),
),
)
if err != nil {
log.Fatal(err)
}
// Initialize database
a.OnStart(func(ctx context.Context) error {
var err error
db, err = sql.Open("postgres", "...")
return err
})
// Unregister readiness during shutdown
a.OnShutdown(func(ctx context.Context) {
// Mark as not ready before closing connections
log.Println("Marking service as not ready")
time.Sleep(100 * time.Millisecond) // Allow load balancer to notice
})
// Register routes...
// Start server...
// Endpoints available at:
// GET /_system/healthz - Liveness
// GET /_system/readyz - Readiness
}
func checkCache(ctx context.Context) error {
// Check cache connectivity
return nil
}
Testing Health Endpoints
Test Liveness
curl http://localhost:8080/healthz
# Expected: 200 OK
# Body: "ok"
Test Readiness
curl http://localhost:8080/readyz
# Expected: 204 No Content (healthy)
# Or: 503 Service Unavailable (unhealthy)
Test with Custom Prefix
curl http://localhost:8080/_system/healthz
curl http://localhost:8080/_system/readyz
Next Steps
10 - Debug Endpoints
Enable pprof profiling endpoints for performance analysis and debugging.
Overview
The app package provides optional debug endpoints for profiling and diagnostics. It uses Go’s net/http/pprof package.
Security Warning: Debug endpoints expose sensitive runtime information. NEVER enable them in production without proper security measures.
Basic Configuration
Enable pprof Unconditionally
Enable pprof endpoints. Use for development only.
a, err := app.New(
app.WithDebugEndpoints(
app.WithPprof(),
),
)
Enable pprof Conditionally
Enable based on environment variable. This is recommended:
a, err := app.New(
app.WithDebugEndpoints(
app.WithPprofIf(os.Getenv("PPROF_ENABLED") == "true"),
),
)
Custom Prefix
Mount debug endpoints under a custom prefix.
a, err := app.New(
app.WithDebugEndpoints(
app.WithDebugPrefix("/_internal/debug"),
app.WithPprof(),
),
)
Available Endpoints
When pprof is enabled, the following endpoints are registered:
| 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("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
11 - Server
Start HTTP, HTTPS, and mTLS servers with graceful shutdown.
HTTP Server
Basic HTTP Server
Start an HTTP server:
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatal(err)
}
Custom Address
Bind to specific interface:
a.Start(ctx, "127.0.0.1:8080") // Localhost only
a.Start(ctx, "0.0.0.0:8080") // All interfaces
HTTPS Server
Start HTTPS Server
Start with TLS certificates:
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := a.StartTLS(ctx, ":8443", "server.crt", "server.key"); err != nil {
log.Fatal(err)
}
Generate Self-Signed Certificate
For development:
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
mTLS Server
Start mTLS Server
Mutual TLS with client certificate verification:
// Load server certificate
serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal(err)
}
// Load CA certificate for client validation
caCert, err := os.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
// Start mTLS server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
err = a.StartMTLS(ctx, ":8443", serverCert,
app.WithClientCAs(caCertPool),
app.WithMinVersion(tls.VersionTLS13),
)
Client Authorization
Authorize clients based on certificate:
err = a.StartMTLS(ctx, ":8443", serverCert,
app.WithClientCAs(caCertPool),
app.WithAuthorize(func(cert *x509.Certificate) (string, bool) {
// Extract principal from certificate
principal := cert.Subject.CommonName
// Check if authorized
if principal == "" {
return "", false
}
return principal, true
}),
)
Graceful Shutdown
Signal-Based Shutdown
Use signal.NotifyContext for graceful shutdown:
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatal(err)
}
Shutdown Process
When context is canceled:
- 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, ":8080"); err != nil {
log.Fatal(err)
}
}
HTTPS with mTLS
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"log"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a := app.MustNew(app.WithServiceName("secure-api"))
// Load certificates
serverCert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatal(err)
}
caCert, err := os.ReadFile("ca.crt")
if err != nil {
log.Fatal(err)
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
// Register routes
a.GET("/", homeHandler)
// Start mTLS server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
log.Println("mTLS server starting on :8443")
err = a.StartMTLS(ctx, ":8443", serverCert,
app.WithClientCAs(caCertPool),
app.WithMinVersion(tls.VersionTLS13),
)
if err != nil {
log.Fatal(err)
}
}
Next Steps
12 - OpenAPI
Automatically generate OpenAPI specifications and Swagger UI.
Overview
The app package integrates with the rivaas.dev/openapi package. It automatically generates OpenAPI specifications with Swagger UI.
Basic Configuration
Enable OpenAPI
Enable OpenAPI with default configuration:
a, err := app.New(
app.WithServiceName("my-api"),
app.WithServiceVersion("v1.0.0"),
app.WithOpenAPI(
openapi.WithSwaggerUI(true, "/docs"),
),
)
Service name and version are automatically injected into the OpenAPI spec.
Configure 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
13 - Testing
Test routes and handlers without starting a server.
Overview
The app package provides built-in testing utilities. Test routes and handlers without starting an HTTP server.
Test Method
Basic Testing
Test routes using app.Test():
func TestHome(t *testing.T) {
a := app.MustNew()
a.GET("/", homeHandler)
req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
With Timeout
Configure test timeout:
req := httptest.NewRequest("GET", "/slow", nil)
resp, err := a.Test(req, app.WithTimeout(5*time.Second))
With Context
Pass custom context:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req, app.WithContext(ctx))
TestJSON Method
Basic JSON Testing
Test JSON endpoints easily:
func TestCreateUser(t *testing.T) {
a := app.MustNew()
a.POST("/users", createUserHandler)
body := map[string]string{
"name": "Alice",
"email": "alice@example.com",
}
resp, err := a.TestJSON("POST", "/users", body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 201 {
t.Errorf("expected 201, got %d", resp.StatusCode)
}
}
ExpectJSON Helper
Assert JSON Responses
Use ExpectJSON for easy JSON assertions:
func TestGetUser(t *testing.T) {
a := app.MustNew()
a.GET("/users/:id", getUserHandler)
req := httptest.NewRequest("GET", "/users/123", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
var user User
app.ExpectJSON(t, resp, 200, &user)
if user.ID != "123" {
t.Errorf("expected ID 123, got %s", user.ID)
}
}
Complete Test Examples
Testing Routes
package main
import (
"net/http"
"net/http/httptest"
"testing"
"rivaas.dev/app"
)
func TestHomeRoute(t *testing.T) {
a := app.MustNew()
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello, World!",
})
})
req := httptest.NewRequest("GET", "/", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var result map[string]string
app.ExpectJSON(t, resp, 200, &result)
if result["message"] != "Hello, World!" {
t.Errorf("unexpected message: %s", result["message"])
}
}
Testing with Dependencies
func TestWithDatabase(t *testing.T) {
// Setup test database
db := setupTestDB(t)
defer db.Close()
a := app.MustNew()
a.GET("/users/:id", func(c *app.Context) {
id := c.Param("id")
user, err := db.GetUser(id)
if err != nil {
c.NotFound("user not found")
return
}
c.JSON(http.StatusOK, user)
})
req := httptest.NewRequest("GET", "/users/123", nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
var user User
app.ExpectJSON(t, resp, 200, &user)
}
Table-Driven Tests
func TestUserRoutes(t *testing.T) {
a := app.MustNew()
a.GET("/users/:id", getUserHandler)
tests := []struct {
name string
id string
wantStatus int
}{
{"valid ID", "123", 200},
{"invalid ID", "abc", 400},
{"not found", "999", 404},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/users/"+tt.id, nil)
resp, err := a.Test(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != tt.wantStatus {
t.Errorf("expected %d, got %d", tt.wantStatus, resp.StatusCode)
}
})
}
}
Testing Middleware
func TestAuthMiddleware(t *testing.T) {
a := app.MustNew()
a.Use(AuthMiddleware())
a.GET("/protected", protectedHandler)
// Test without token
req := httptest.NewRequest("GET", "/protected", nil)
resp, _ := a.Test(req)
if resp.StatusCode != 401 {
t.Errorf("expected 401, got %d", resp.StatusCode)
}
// Test with token
req = httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer valid-token")
resp, _ = a.Test(req)
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
}
Next Steps
14 - Migration
Migrate from the router package to the app package.
When to Migrate
Consider migrating from router to app when you need:
- Integrated observability - Built-in metrics, tracing, and logging.
- Lifecycle management - OnStart, OnReady, OnShutdown, OnStop hooks.
- Graceful shutdown - Automatic shutdown handling with context.
- Health endpoints - Kubernetes-compatible liveness and readiness probes.
- Sensible defaults - Pre-configured with production-ready settings.
Key Differences
Constructor Returns Error
Router:
r := router.New() // No error returned
App:
a, err := app.New() // Returns (*App, error)
if err != nil {
log.Fatal(err)
}
// Or use MustNew() for panic on error
a := app.MustNew()
Context Type
Router:
r.GET("/", func(c *router.Context) {
c.JSON(http.StatusOK, data)
})
App:
a.GET("/", func(c *app.Context) { // Different context type
c.JSON(http.StatusOK, data)
})
app.Context embeds router.Context. It adds binding, validation, and error handling methods.
Server Startup
Router:
r := router.New()
http.ListenAndServe(":8080", r)
App:
a := app.MustNew()
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
a.Start(ctx, ":8080") // Includes graceful shutdown
Migration Steps
1. Update Imports
// Before
import "rivaas.dev/router"
// After
import "rivaas.dev/app"
2. Change Constructor
// Before
r := router.New(
router.WithMetrics(),
router.WithTracing(),
)
// After
a, err := app.New(
app.WithServiceName("my-service"),
app.WithObservability(
app.WithMetrics(),
app.WithTracing(),
),
)
if err != nil {
log.Fatal(err)
}
3. Update Handler Signatures
// Before
func handler(c *router.Context) {
c.JSON(http.StatusOK, data)
}
// After
func handler(c *app.Context) { // Change context type
c.JSON(http.StatusOK, data)
}
4. Update Server Startup
// Before
http.ListenAndServe(":8080", r)
// After
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
a.Start(ctx, ":8080")
Complete Migration Example
Before (Router)
package main
import (
"net/http"
"rivaas.dev/router"
)
func main() {
r := router.New(
router.WithMetrics(),
router.WithTracing(),
)
r.GET("/", func(c *router.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello!",
})
})
http.ListenAndServe(":8080", r)
}
After (App)
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a, err := app.New(
app.WithServiceName("my-service"),
app.WithServiceVersion("v1.0.0"),
app.WithObservability(
app.WithMetrics(),
app.WithTracing(),
),
)
if err != nil {
log.Fatal(err)
}
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello!",
})
})
ctx, cancel := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer cancel()
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatal(err)
}
}
Accessing Router
If you need router-specific features, access the underlying router:
a := app.MustNew()
// Access router for advanced features
router := a.Router()
router.Freeze() // Manually freeze router
Gradual Migration
You can migrate gradually:
- 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
15 - Examples
Complete working examples of Rivaas applications.
Quick Start Example
Minimal application to get started.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"rivaas.dev/app"
)
func main() {
a, err := app.New()
if err != nil {
log.Fatal(err)
}
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"message": "Hello from Rivaas!",
})
})
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatal(err)
}
}
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("/healthz", "/readyz", "/metrics"),
app.WithLogOnlyErrors(),
app.WithSlowThreshold(1 * time.Second),
),
// Health endpoints
app.WithHealthEndpoints(
app.WithHealthTimeout(800 * time.Millisecond),
app.WithReadinessCheck("database", func(ctx context.Context) error {
return db.PingContext(ctx)
}),
),
// Server configuration
app.WithServer(
app.WithReadTimeout(10 * time.Second),
app.WithWriteTimeout(15 * time.Second),
app.WithShutdownTimeout(30 * time.Second),
),
)
if err != nil {
log.Fatal(err)
}
// Lifecycle hooks
a.OnStart(func(ctx context.Context) error {
log.Println("Connecting to database...")
var err error
db, err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
return err
})
a.OnShutdown(func(ctx context.Context) {
log.Println("Closing database connection...")
db.Close()
})
// Register routes
a.GET("/", func(c *app.Context) {
c.JSON(http.StatusOK, map[string]string{
"service": "orders-api",
"version": "v2.0.0",
})
})
a.GET("/orders/:id", func(c *app.Context) {
orderID := c.Param("id")
c.Logger().Info("fetching order", "order_id", orderID)
c.JSON(http.StatusOK, map[string]string{
"order_id": orderID,
"status": "completed",
})
})
// Start server
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
log.Println("Server starting on :8080")
if err := a.Start(ctx, ":8080"); err != nil {
log.Fatal(err)
}
}
REST API Example
Complete REST API with CRUD operations:
package main
import (
"log"
"net/http"
"rivaas.dev/app"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=3"`
Email string `json:"email" validate:"required,email"`
}
func main() {
a := app.MustNew(app.WithServiceName("users-api"))
// List users
a.GET("/users", func(c *app.Context) {
users := []User{
{ID: "1", Name: "Alice", Email: "alice@example.com"},
{ID: "2", Name: "Bob", Email: "bob@example.com"},
}
c.JSON(http.StatusOK, users)
})
// Create user
a.POST("/users", func(c *app.Context) {
var req CreateUserRequest
if !c.MustBindAndValidate(&req) {
return
}
user := User{
ID: "123",
Name: req.Name,
Email: req.Email,
}
c.JSON(http.StatusCreated, user)
})
// Get user
a.GET("/users/:id", func(c *app.Context) {
id := c.Param("id")
user := User{ID: id, Name: "Alice", Email: "alice@example.com"}
c.JSON(http.StatusOK, user)
})
// Update user
a.PUT("/users/:id", func(c *app.Context) {
id := c.Param("id")
var req CreateUserRequest
if !c.MustBindAndValidate(&req) {
return
}
user := User{ID: id, Name: req.Name, Email: req.Email}
c.JSON(http.StatusOK, user)
})
// Delete user
a.DELETE("/users/:id", func(c *app.Context) {
c.Status(http.StatusNoContent)
})
// Start server...
}
More Examples
See the examples/ directory in the repository for additional examples:
- 01-quick-start/ - Minimal setup (~20 lines)
- 02-blog/ - Complete blog API with database, validation, and testing
Next Steps