rakuda

package module
v0.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 13, 2025 License: MIT Imports: 15 Imported by: 0

README

rakuda 🐪

rakuda is an HTTP router for Go, designed around a core philosophy of compile-time safety and predictable lifecycle management. It enforces a strict separation between route configuration and request handling through a dedicated Builder pattern, eliminating an entire class of runtime errors.

The name "rakuda" has two meanings:

  • 「楽だ」(rakuda) - "Effortless" or "comfortable" in Japanese
  • 「ラクダ」(rakuda) - "Camel" in Japanese 🐪

🚧 This library is currently under development.

Features

  • Predictable Lifecycle: Configuration of routes is completely separate from serving traffic. The router's state is immutable once built.
  • Declarative, Order-Independent API: Declare routes and middlewares in any order without affecting final behavior.
  • Standard Library First: Leverages net/http package, including path parameter support introduced in Go 1.22, for maximum compatibility and minimal dependencies.
  • Tree-Based Configuration: Internal tree structure naturally maps to hierarchical RESTful API routes.
  • JSON Response Helper: Built-in Responder for easy JSON responses with status code management.
  • Built-in Middlewares: Recovery middleware for panic handling and CORS middleware for cross-origin requests.
  • Context-Aware Logging: Logger and status code can be stored in request context for consistent error handling.
  • Debugging Tools: PrintRoutes utility for visualizing all registered routes.

Quick Start

The primary entry point is rakuda.Builder, which is used to configure routes and then build an immutable http.Handler.

package main

import (
	"net/http"

	"github.com/podhmo/rakuda"
	"github.com/podhmo/rakuda/rakudamiddleware"
)

func main() {
    // Create a new builder for route configuration
    b := rakuda.NewBuilder()
    responder := rakuda.NewResponder()
    
    // Add global middleware
    b.Use(rakudamiddleware.Recovery)
    
    // Define routes
    b.Get("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        responder.JSON(w, r, http.StatusOK, map[string]string{
            "message": "Welcome to rakuda!",
        })
    }))
    
    b.Get("/users/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := r.PathValue("id")
        responder.JSON(w, r, http.StatusOK, map[string]string{
            "id": userID,
            "name": "John Doe",
        })
    }))
    
    // Build the immutable handler
    handler, err := b.Build()
    if err != nil {
        panic(err)
    }
    
    // Start the server
    http.ListenAndServe(":8080", handler)
}

Core Concepts

Builder Pattern

rakuda uses a two-phase approach:

  1. Configuration Phase: Use rakuda.Builder to define routes and middlewares
  2. Execution Phase: Call Build() to create an immutable http.Handler

This separation is enforced at compile-time. You cannot pass a Builder to http.ListenAndServe - it will fail to compile.

Path Parameters

rakuda uses Go 1.22's native path parameter support. Parameters are retrieved directly from the request:

b.Get("/posts/{postID}/comments/{commentID}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    postID := r.PathValue("postID")
    commentID := r.PathValue("commentID")
    // Handle the request
}))
Route Groups and Middleware

Apply middlewares to specific route groups using nested builders:

b := rakuda.NewBuilder()

// Global middleware
b.Use(loggingMiddleware)

// API v1 group
b.Route("/api/v1", func(api *rakuda.Builder) {
    // Middleware scoped to /api/v1
    api.Use(authMiddleware)
    
    api.Get("/users", listUsersHandler)
    api.Post("/users", createUserHandler)
    
    // Nested group
    api.Route("/admin", func(admin *rakuda.Builder) {
        admin.Use(adminOnlyMiddleware)
        admin.Get("/stats", adminStatsHandler)
    })
})

handler, err := b.Build()
if err != nil {
    panic(err)
}
Order-Independent Configuration

One of rakuda's key features is its order-independent API. You can declare routes and middlewares in any order within the same scope without affecting the final behavior:

// These two configurations produce identical results:

// Configuration 1: middleware first
b.Route("/api", func(api *rakuda.Builder) {
    api.Use(authMiddleware)
    api.Get("/users", listUsersHandler)
})

// Configuration 2: route first
b.Route("/api", func(api *rakuda.Builder) {
    api.Get("/users", listUsersHandler)
    api.Use(authMiddleware)
})

This is possible because the actual middleware chain is assembled during the Build() phase, not at the time of declaration. The builder collects all configuration declaratively and processes it consistently, regardless of the order in which you register routes and middlewares.

JSON Responses with Responder

rakuda provides a Responder type for easy JSON response handling with built-in error logging:

responder := rakuda.NewResponder()

b.Get("/users/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    userID := r.PathValue("id")
    
    // Respond with JSON, providing the status code directly
    responder.JSON(w, r, http.StatusOK, map[string]string{
        "id": userID,
        "name": "John Doe",
    })
}))

The Responder automatically:

  • Sets the correct Content-Type header
  • Sets the HTTP status code
  • Encodes data to JSON
  • Logs encoding errors using the logger from context (or a default logger)
Simplified Handlers with Lift

For handlers that simply return data and an error, rakuda provides a Lift function. This generic function converts a handler of the form func(*http.Request) (T, error) into a standard http.Handler, automating JSON encoding and error handling.

  • On success: The returned data is automatically encoded as a JSON response with a 200 OK status.
  • On error:
    • If the error provides a StatusCode() int method (like rakuda.APIError), that status code is used.
    • Otherwise, a generic 500 Internal Server Error is returned to the client, while the original error is logged internally.
// Define a response struct
type User struct {
    ID   string `json:"id"`
    Name string `json:"name"`
}

// This function matches the signature required by Lift
func GetUser(r *http.Request) (User, error) {
    userID := r.PathValue("id")
    if userID == "" {
        // Return an error with a specific status code
        return User{}, rakuda.NewAPIError(http.StatusBadRequest, errors.New("user ID is required"))
    }

    // On success, just return the data
    return User{ID: userID, Name: "John Doe"}, nil
}

// Use Lift to create the http.Handler
b.Get("/users/{id}", rakuda.Lift(responder, GetUser))

This pattern simplifies handler logic by removing the boilerplate of response writing and error checking.

Built-in Middlewares
Recovery Middleware

The Recovery middleware catches panics, logs them with stack traces, and returns a 500 error:

b := rakuda.NewBuilder()

// Apply recovery globally
b.Use(rakudamiddleware.Recovery)

b.Get("/panic", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    panic("something went wrong")  // Will be caught and logged
}))
CORS Middleware

The CORS middleware handles Cross-Origin Resource Sharing with configurable options:

b := rakuda.NewBuilder()

// Use default permissive CORS settings
b.Use(rakudamiddleware.CORS(nil))

// Or configure CORS explicitly
b.Use(rakudamiddleware.CORS(&rakudamiddleware.CORSConfig{
    AllowedOrigins: []string{"https://example.com"},
    AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
    AllowedHeaders: []string{"Content-Type", "Authorization"},
    AllowCredentials: true,
    MaxAge: 3600,
}))
Custom 404 Handler

Set a custom handler for routes that don't match:

b := rakuda.NewBuilder()

b.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusNotFound)
    w.Write([]byte("Page not found"))
}))

If not set, a default JSON 404 response is used.

Debugging: Print Routes

Use PrintRoutes to display all registered routes:

rakuda.PrintRoutes(os.Stdout, builder)
// Output:
// GET   /
// GET   /users/{id}
// POST  /users

This is useful for debugging and documentation. Many example applications include a -proutes flag to display routes without starting the server.

Design Philosophy

For detailed information about the design decisions and architecture, see docs/router-design.md.

Key design principles:

  • Fail Fast: Configuration errors are caught at compile-time or application startup, not at runtime
  • Immutability: Once built, the router cannot be modified
  • No Magic: Clear, explicit API with predictable behavior
  • Standard Compliance: Full compatibility with net/http ecosystem

Examples

The repository includes several example applications in the examples/ directory:

Each example can be run with go run and many include a -proutes flag to display registered routes.

Requirements

  • Go 1.24 or later

Installation

go get github.com/podhmo/rakuda

License

MIT License - see LICENSE file for details.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Lift

func Lift[O any](responder *Responder, action func(*http.Request) (O, error)) http.Handler

Lift converts a function that returns a value and an error into an http.Handler.

The action function has the signature: func(*http.Request) (O, error)

  • If the error is nil, the returned value of type O is encoded as a JSON response with a 200 OK status.
  • If the error is not nil:
  • To perform a redirect, return a `*RedirectError`. Lift will handle the redirect and no further response will be written.
  • If the error has a StatusCode() int method (like `APIError`), its status code is used for the response.
  • Otherwise, a 500 Internal Server Error is returned.
  • The error message is returned as a JSON object: {"error": "message"}.
  • For 5xx errors, the original error is logged, but a generic "Internal Server Error" message is returned to the client to avoid exposing internal details.
  • If both the returned value and the error are nil, it follows specific rules:
  • For `nil` maps, it returns `200 OK` with an empty JSON object `{}`.
  • For `nil` slices, it returns `200 OK` with an empty JSON array `[]`.
  • For other nillable types (e.g., pointers), it returns `204 No Content`.

func LoggerFromContext added in v0.0.3

func LoggerFromContext(ctx context.Context) *slog.Logger

LoggerFromContext retrieves the Logger from the context. If no logger is found, it falls back to slog.Default() and logs a warning on the first call.

func NewContextWithLogger added in v0.0.3

func NewContextWithLogger(ctx context.Context, l *slog.Logger) context.Context

NewContextWithLogger returns a new context with the provided Logger.

func PrintRoutes

func PrintRoutes(w io.Writer, b *Builder)

PrintRoutes prints a formatted table of all registered routes to the provided writer.

func SSE

func SSE[T any](responder *Responder, w http.ResponseWriter, req *http.Request, ch <-chan T)

SSE streams data from a channel to the client using the Server-Sent Events protocol. It sets the appropriate headers and handles the event stream formatting. The channel element type T can be any marshalable type. If T is of type Event[U] or *Event[U], it will be treated as a named event.

func WithLogger

func WithLogger(l *slog.Logger) func(*BuilderConfig)

WithLogger sets the logger for the Builder.

func WithOnConflict added in v0.0.4

func WithOnConflict(onConflict func(b *Builder, routeKey string) error) func(*BuilderConfig)

WithOnConflict sets the OnConflict handler for the Builder.

Types

type APIError

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

APIError is an error type that includes an HTTP status code.

func NewAPIError

func NewAPIError(statusCode int, err error) *APIError

NewAPIError creates a new APIError, capturing the caller's position. The default depth is 2, which points to the caller of NewAPIError.

func NewAPIErrorWithDepth added in v0.0.4

func NewAPIErrorWithDepth(statusCode int, err error, depth int) *APIError

NewAPIErrorWithDepth creates a new APIError with a specific call stack depth.

func NewAPIErrorf

func NewAPIErrorf(statusCode int, format string, args ...any) *APIError

NewAPIErrorf creates a new APIError with a formatted message. The default depth is 2, which points to the caller of NewAPIErrorf.

func (*APIError) Error

func (e *APIError) Error() string

Error implements the error interface.

func (*APIError) PC added in v0.0.4

func (e *APIError) PC() uintptr

PC returns the program counter where the error was created.

func (*APIError) StatusCode

func (e *APIError) StatusCode() int

StatusCode returns the HTTP status code.

func (*APIError) Unwrap

func (e *APIError) Unwrap() error

Unwrap supports errors.Is and errors.As.

type Builder

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

Builder is the configuration object for the router. It is used to define routes and middlewares. It does not implement http.Handler.

func NewBuilder

func NewBuilder(options ...func(*BuilderConfig)) *Builder

NewBuilder creates a new Builder instance with the given options.

func (*Builder) Build

func (b *Builder) Build() (http.Handler, error)

Build creates a new http.Handler from the configured routes. The returned handler is immutable.

func (*Builder) Delete

func (b *Builder) Delete(pattern string, handler http.Handler)

Delete registers a DELETE handler.

func (*Builder) Get

func (b *Builder) Get(pattern string, handler http.Handler)

Get registers a GET handler.

func (*Builder) Group

func (b *Builder) Group(fn func(b *Builder))

Group creates a new middleware-only group.

func (*Builder) NotFound

func (b *Builder) NotFound(handler http.Handler)

NotFound sets a custom handler for 404 Not Found responses. If not set, a default JSON response is used.

func (*Builder) Patch

func (b *Builder) Patch(pattern string, handler http.Handler)

Patch registers a PATCH handler.

func (*Builder) Post

func (b *Builder) Post(pattern string, handler http.Handler)

Post registers a POST handler.

func (*Builder) Put

func (b *Builder) Put(pattern string, handler http.Handler)

Put registers a PUT handler.

func (*Builder) Route

func (b *Builder) Route(pattern string, fn func(b *Builder))

Route creates a new routing group.

func (*Builder) Use

func (b *Builder) Use(middleware Middleware)

Use adds a middleware to the current builder's node.

func (*Builder) Walk

func (b *Builder) Walk(fn func(method string, pattern string))

Walk traverses the routing tree and calls the provided function for each registered handler. The traversal is done in DFS order.

type BuilderConfig added in v0.0.4

type BuilderConfig struct {
	Logger *slog.Logger
	// OnConflict defines a function to be called when a route conflict is detected.
	// It receives the builder and the conflicting route key. It can return an error
	// to halt the build process. If it returns nil, the conflict is ignored and the
	// duplicate route is not registered.
	OnConflict func(b *Builder, routeKey string) error
}

BuilderConfig holds the configuration for a Builder.

type Event

type Event[T any] struct {
	// Name is the event name. If empty, it will be omitted.
	Name string
	// Data is the payload for the event.
	Data T
}

Event represents a single Server-Sent Event.

type Middleware

type Middleware func(http.Handler) http.Handler

Middleware is a function that wraps an http.Handler.

type RedirectError added in v0.0.3

type RedirectError struct {
	URL  string
	Code int
}

RedirectError is a special error type used to signal an HTTP redirect. When this error is returned from a handler wrapped by Lift, the Lift function will perform the redirect and stop further processing.

func (*RedirectError) Error added in v0.0.3

func (e *RedirectError) Error() string

Error implements the error interface.

type Responder

type Responder struct{}

Responder handles writing JSON responses.

func NewResponder

func NewResponder() *Responder

NewResponder creates a new Responder.

func (*Responder) Error added in v0.0.3

func (r *Responder) Error(w http.ResponseWriter, req *http.Request, statusCode int, err error)

Error sends a JSON error response. It logs errors only under specific conditions: - If the status code is >= 500. - If the logger's level is Debug or lower. For 5xx errors, it sends a generic message to the client.

func (*Responder) HTML added in v0.0.3

func (r *Responder) HTML(w http.ResponseWriter, req *http.Request, code int, html []byte)

HTML sends an HTML response to the client. This method is intended for use in standard http.Handlers, not with Lift, which is designed for JSON APIs.

func (*Responder) JSON

func (r *Responder) JSON(w http.ResponseWriter, req *http.Request, statusCode int, data any)

JSON marshals the 'data' payload to JSON and writes it to the response.

func (*Responder) Redirect added in v0.0.3

func (r *Responder) Redirect(w http.ResponseWriter, req *http.Request, url string, code int)

Redirect performs an HTTP redirect.

Directories

Path Synopsis
Package binding provides a type-safe, reflect-free, and expression-oriented way to bind data from HTTP requests to Go structs.
Package binding provides a type-safe, reflect-free, and expression-oriented way to bind data from HTTP requests to Go structs.
examples
simple-rest-api command
spa-with-embed command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL