BREAKING CHANGES:
- Renamed HandleGET -> MemberGET for member-level routes
- Renamed HandlePOST -> MemberPOST for member-level routes
- Renamed HandlePUT -> MemberPUT for member-level routes
- Renamed HandlePATCH -> MemberPATCH for member-level routes
- Renamed HandleDELETE -> MemberDELETE for member-level routes
New Features:
- Added collection-level route methods: GET, POST, PUT, PATCH, DELETE
- Clear distinction between collection (/pattern/action) and member (/pattern/{id}/action) routes
- Comprehensive documentation (README, CONTRIBUTING, QUICKSTART, DOCS)
- Development tooling (Makefile, check.sh script)
- AI coding assistant guidelines (.cursorrules)
- GitHub Actions CI/CD pipeline
- golangci-lint configuration
Code Quality:
- Optimized struct field alignment for better memory usage
- All code passes go vet, staticcheck, and fieldalignment
- All tests pass with race detector
- Go 1.25+ requirement enforced
Documentation:
- Complete README rewrite with examples
- CONTRIBUTING.md with development guidelines
- QUICKSTART.md for new users
- CHANGELOG.md with version history
- SUMMARY.md documenting all changes
- DOCS.md as documentation index
582 lines
15 KiB
Markdown
582 lines
15 KiB
Markdown
# Mux - A Lightweight HTTP Router for Go
|
|
|
|
[](https://go.dev/)
|
|
[](LICENSE)
|
|
|
|
Mux is a simple, lightweight HTTP router for Go that wraps around the standard `http.ServeMux` to provide additional functionality and a more ergonomic API for building web applications and APIs.
|
|
|
|
## Features
|
|
|
|
- 🚀 Built on top of Go's standard `http.ServeMux` (Go 1.22+ routing enhancements)
|
|
- 🎯 HTTP method-specific routing (GET, POST, PUT, DELETE, PATCH, etc.)
|
|
- 🔌 Flexible middleware support with stackable composition
|
|
- 📦 Route grouping for organization and shared middleware
|
|
- 🎨 RESTful resource routing with collection and member routes
|
|
- 🔗 URL parameter extraction using Go's standard path values
|
|
- 🛡️ Graceful shutdown support with signal handling
|
|
- 📋 Route listing and debugging
|
|
- ⚡ Zero external dependencies (only Go standard library)
|
|
- 🪶 Minimal overhead and excellent performance
|
|
|
|
## Requirements
|
|
|
|
- Go 1.25 or higher
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
go get code.patial.tech/go/mux
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"code.patial.tech/go/mux"
|
|
)
|
|
|
|
func main() {
|
|
// Create a new router
|
|
m := mux.New()
|
|
|
|
// Define routes
|
|
m.GET("/", func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "Hello, World!")
|
|
})
|
|
|
|
m.GET("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, "User ID: %s", id)
|
|
})
|
|
|
|
// Start server with graceful shutdown
|
|
m.Serve(func(srv *http.Server) error {
|
|
srv.Addr = ":8080"
|
|
return srv.ListenAndServe()
|
|
})
|
|
}
|
|
```
|
|
|
|
## Table of Contents
|
|
|
|
- [Basic Routing](#basic-routing)
|
|
- [URL Parameters](#url-parameters)
|
|
- [Middleware](#middleware)
|
|
- [Route Groups](#route-groups)
|
|
- [RESTful Resources](#restful-resources)
|
|
- [Inline Middleware](#inline-middleware)
|
|
- [Graceful Shutdown](#graceful-shutdown)
|
|
- [Route Debugging](#route-debugging)
|
|
- [Complete Example](#complete-example)
|
|
|
|
## Basic Routing
|
|
|
|
Mux supports all standard HTTP methods:
|
|
|
|
```go
|
|
m := mux.New()
|
|
|
|
// HTTP method routes
|
|
m.GET("/users", listUsers)
|
|
m.POST("/users", createUser)
|
|
m.PUT("/users/{id}", updateUser)
|
|
m.PATCH("/users/{id}", partialUpdateUser)
|
|
m.DELETE("/users/{id}", deleteUser)
|
|
m.HEAD("/users", headUsers)
|
|
m.OPTIONS("/users", optionsUsers)
|
|
m.CONNECT("/proxy", connectProxy)
|
|
m.TRACE("/debug", traceDebug)
|
|
```
|
|
|
|
## URL Parameters
|
|
|
|
Extract URL parameters using Go's standard `r.PathValue()`:
|
|
|
|
```go
|
|
m.GET("/users/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
userID := r.PathValue("id")
|
|
fmt.Fprintf(w, "Fetching user: %s", userID)
|
|
})
|
|
|
|
m.GET("/posts/{year}/{month}/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
|
year := r.PathValue("year")
|
|
month := r.PathValue("month")
|
|
slug := r.PathValue("slug")
|
|
// ... handle request
|
|
})
|
|
```
|
|
|
|
## Middleware
|
|
|
|
Middleware functions follow the standard `func(http.Handler) http.Handler` signature, making them compatible with most Go middleware libraries.
|
|
|
|
### Global Middleware
|
|
|
|
Apply middleware to all routes:
|
|
|
|
```go
|
|
// Logging middleware
|
|
func logger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
slog.Info("request", "method", r.Method, "path", r.URL.Path)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Authentication middleware
|
|
func auth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token := r.Header.Get("Authorization")
|
|
if token == "" {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
m := mux.New()
|
|
m.Use(logger)
|
|
m.Use(auth)
|
|
|
|
// All routes will use logger and auth middleware
|
|
m.GET("/protected", protectedHandler)
|
|
```
|
|
|
|
### Compatible with Popular Middleware
|
|
|
|
Works with any middleware following the standard signature:
|
|
|
|
```go
|
|
import (
|
|
"github.com/gorilla/handlers"
|
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
m.Use(handlers.CompressHandler)
|
|
m.Use(chimiddleware.RealIP)
|
|
m.Use(chimiddleware.Recoverer)
|
|
```
|
|
|
|
## Route Groups
|
|
|
|
Organize routes and apply middleware to specific groups:
|
|
|
|
```go
|
|
m := mux.New()
|
|
|
|
// Public routes
|
|
m.GET("/", homeHandler)
|
|
m.GET("/about", aboutHandler)
|
|
|
|
// API routes with shared middleware
|
|
m.Group(func(api *mux.Mux) {
|
|
api.Use(jsonMiddleware)
|
|
api.Use(authMiddleware)
|
|
|
|
api.GET("/api/users", listUsersAPI)
|
|
api.POST("/api/users", createUserAPI)
|
|
api.DELETE("/api/users/{id}", deleteUserAPI)
|
|
})
|
|
|
|
// Admin routes with different middleware
|
|
m.Group(func(admin *mux.Mux) {
|
|
admin.Use(adminAuthMiddleware)
|
|
admin.Use(auditLogMiddleware)
|
|
|
|
admin.GET("/admin/dashboard", dashboardHandler)
|
|
admin.GET("/admin/users", adminUsersHandler)
|
|
})
|
|
```
|
|
|
|
## RESTful Resources
|
|
|
|
Define RESTful resources with conventional routing:
|
|
|
|
```go
|
|
m.Resource("/posts", func(res *mux.Resource) {
|
|
// Standard RESTful routes
|
|
res.Index(listPosts) // GET /posts
|
|
res.CreateView(newPostForm) // GET /posts/create
|
|
res.Create(createPost) // POST /posts
|
|
res.View(showPost) // GET /posts/{id}
|
|
res.Update(updatePost) // PUT /posts/{id}
|
|
res.UpdatePartial(patchPost) // PATCH /posts/{id}
|
|
res.Delete(deletePost) // DELETE /posts/{id}
|
|
})
|
|
```
|
|
|
|
### Custom Resource Routes
|
|
|
|
Add custom routes at collection or member level:
|
|
|
|
```go
|
|
m.Resource("/posts", func(res *mux.Resource) {
|
|
// Standard routes
|
|
res.Index(listPosts)
|
|
res.View(showPost)
|
|
|
|
// Collection-level custom routes (on /posts/...)
|
|
res.POST("/search", searchPosts) // POST /posts/search
|
|
res.GET("/archived", archivedPosts) // GET /posts/archived
|
|
res.GET("/trending", trendingPosts) // GET /posts/trending
|
|
|
|
// Member-level custom routes (on /posts/{id}/...)
|
|
res.MemberPOST("/publish", publishPost) // POST /posts/{id}/publish
|
|
res.MemberPOST("/archive", archivePost) // POST /posts/{id}/archive
|
|
res.MemberGET("/comments", getComments) // GET /posts/{id}/comments
|
|
res.MemberDELETE("/cache", clearCache) // DELETE /posts/{id}/cache
|
|
})
|
|
```
|
|
|
|
#### Collection vs Member Routes
|
|
|
|
- **Collection routes** (`POST`, `GET`, `PUT`, `PATCH`, `DELETE`): Operate on `/pattern/action`
|
|
- Example: `res.POST("/search", handler)` → `POST /posts/search`
|
|
|
|
- **Member routes** (`MemberPOST`, `MemberGET`, `MemberPUT`, `MemberPATCH`, `MemberDELETE`): Operate on `/pattern/{id}/action`
|
|
- Example: `res.MemberPOST("/publish", handler)` → `POST /posts/{id}/publish`
|
|
|
|
### Resource Middleware
|
|
|
|
Apply middleware to all resource routes:
|
|
|
|
```go
|
|
m.Resource("/posts", func(res *mux.Resource) {
|
|
// Middleware for all routes in this resource
|
|
res.Use(postAuthMiddleware)
|
|
res.Use(postValidationMiddleware)
|
|
|
|
res.Index(listPosts)
|
|
res.Create(createPost)
|
|
// ... other routes
|
|
}, resourceSpecificMiddleware) // Can also pass middleware as arguments
|
|
```
|
|
|
|
## Inline Middleware
|
|
|
|
Apply middleware to specific routes without affecting others:
|
|
|
|
```go
|
|
m := mux.New()
|
|
|
|
// Route without middleware
|
|
m.GET("/public", publicHandler)
|
|
|
|
// Route with inline middleware
|
|
m.With(authMiddleware, rateLimitMiddleware).
|
|
GET("/protected", protectedHandler)
|
|
|
|
// Another route with different middleware
|
|
m.With(adminMiddleware).
|
|
POST("/admin/action", adminActionHandler)
|
|
```
|
|
|
|
## Graceful Shutdown
|
|
|
|
Built-in graceful shutdown with signal handling:
|
|
|
|
```go
|
|
m := mux.New()
|
|
|
|
// Define routes...
|
|
m.GET("/", homeHandler)
|
|
|
|
// Serve with graceful shutdown
|
|
m.Serve(func(srv *http.Server) error {
|
|
srv.Addr = ":8080"
|
|
srv.ReadTimeout = 10 * time.Second
|
|
srv.WriteTimeout = 10 * time.Second
|
|
|
|
slog.Info("Server starting", "addr", srv.Addr)
|
|
return srv.ListenAndServe()
|
|
})
|
|
```
|
|
|
|
**Features:**
|
|
- Listens for `SIGINT` and `SIGTERM` signals
|
|
- Drains existing connections (10 second grace period)
|
|
- Propagates shutdown signal to handlers via `m.IsShuttingDown`
|
|
- Automatic OPTIONS handler for all routes
|
|
- Hard shutdown after grace period to prevent hanging
|
|
|
|
### Checking Shutdown Status
|
|
|
|
```go
|
|
m.GET("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
if m.IsShuttingDown.Load() {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte("Server is shutting down"))
|
|
return
|
|
}
|
|
w.Write([]byte("OK"))
|
|
})
|
|
```
|
|
|
|
## Route Debugging
|
|
|
|
List all registered routes:
|
|
|
|
```go
|
|
// Print routes to stdout
|
|
m.PrintRoutes(os.Stdout)
|
|
|
|
// Get routes as slice
|
|
routes := m.RouteList()
|
|
for _, route := range routes {
|
|
fmt.Println(route)
|
|
}
|
|
|
|
// Expose routes via HTTP endpoint
|
|
m.GET("/debug/routes", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
m.PrintRoutes(w)
|
|
})
|
|
```
|
|
|
|
Output example:
|
|
```
|
|
GET /
|
|
GET /users
|
|
POST /users
|
|
GET /users/{id}
|
|
PUT /users/{id}
|
|
DELETE /users/{id}
|
|
GET /posts
|
|
POST /posts/search
|
|
GET /posts/{id}
|
|
POST /posts/{id}/publish
|
|
```
|
|
|
|
## Complete Example
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"code.patial.tech/go/mux"
|
|
)
|
|
|
|
func main() {
|
|
m := mux.New()
|
|
|
|
// Global middleware
|
|
m.Use(loggingMiddleware)
|
|
m.Use(recoveryMiddleware)
|
|
|
|
// Public routes
|
|
m.GET("/", homeHandler)
|
|
m.GET("/about", aboutHandler)
|
|
|
|
// API group
|
|
m.Group(func(api *mux.Mux) {
|
|
api.Use(jsonMiddleware)
|
|
|
|
// Users resource
|
|
api.Resource("/users", func(res *mux.Resource) {
|
|
res.Index(listUsers)
|
|
res.Create(createUser)
|
|
res.View(showUser)
|
|
res.Update(updateUser)
|
|
res.Delete(deleteUser)
|
|
|
|
// Custom user actions
|
|
res.POST("/search", searchUsers)
|
|
res.MemberPOST("/activate", activateUser)
|
|
res.MemberGET("/posts", getUserPosts)
|
|
})
|
|
|
|
// Posts resource
|
|
api.Resource("/posts", func(res *mux.Resource) {
|
|
res.Index(listPosts)
|
|
res.Create(createPost)
|
|
res.View(showPost)
|
|
|
|
res.MemberPOST("/publish", publishPost)
|
|
res.MemberGET("/comments", getPostComments)
|
|
})
|
|
})
|
|
|
|
// Debug route
|
|
m.GET("/debug/routes", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
m.PrintRoutes(w)
|
|
})
|
|
|
|
// Start server
|
|
m.Serve(func(srv *http.Server) error {
|
|
srv.Addr = ":8080"
|
|
srv.ReadTimeout = 30 * time.Second
|
|
srv.WriteTimeout = 30 * time.Second
|
|
|
|
slog.Info("Server listening", "addr", srv.Addr)
|
|
return srv.ListenAndServe()
|
|
})
|
|
}
|
|
|
|
func loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
next.ServeHTTP(w, r)
|
|
slog.Info("request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"duration", time.Since(start))
|
|
})
|
|
}
|
|
|
|
func recoveryMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if err := recover(); err != nil {
|
|
slog.Error("panic recovered", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func jsonMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// Handler implementations
|
|
func homeHandler(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "Welcome Home!")
|
|
}
|
|
|
|
func aboutHandler(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "About Us")
|
|
}
|
|
|
|
func listUsers(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"users": []}`)
|
|
}
|
|
|
|
func createUser(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"message": "User created"}`)
|
|
}
|
|
|
|
func showUser(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"id": "%s"}`, id)
|
|
}
|
|
|
|
func updateUser(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"message": "User %s updated"}`, id)
|
|
}
|
|
|
|
func deleteUser(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"message": "User %s deleted"}`, id)
|
|
}
|
|
|
|
func searchUsers(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"results": []}`)
|
|
}
|
|
|
|
func activateUser(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"message": "User %s activated"}`, id)
|
|
}
|
|
|
|
func getUserPosts(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"user_id": "%s", "posts": []}`, id)
|
|
}
|
|
|
|
func listPosts(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"posts": []}`)
|
|
}
|
|
|
|
func createPost(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"message": "Post created"}`)
|
|
}
|
|
|
|
func showPost(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"id": "%s"}`, id)
|
|
}
|
|
|
|
func publishPost(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"message": "Post %s published"}`, id)
|
|
}
|
|
|
|
func getPostComments(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
fmt.Fprintf(w, `{"post_id": "%s", "comments": []}`, id)
|
|
}
|
|
```
|
|
|
|
## Custom 404 Handler
|
|
|
|
Handle 404 errors with a catch-all route:
|
|
|
|
```go
|
|
m.GET("/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.Error(w, "404 - Page Not Found", http.StatusNotFound)
|
|
return
|
|
}
|
|
// Handle root path
|
|
fmt.Fprint(w, "Home Page")
|
|
})
|
|
```
|
|
|
|
## Testing
|
|
|
|
Testing routes is straightforward using Go's `httptest` package:
|
|
|
|
```go
|
|
func TestHomeHandler(t *testing.T) {
|
|
m := mux.New()
|
|
m.GET("/", homeHandler)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
m.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", rec.Code)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Performance
|
|
|
|
Mux has minimal overhead since it wraps Go's standard `http.ServeMux`:
|
|
|
|
- Zero heap allocations for simple routes
|
|
- Efficient middleware chaining using composition
|
|
- Fast pattern matching powered by Go's stdlib
|
|
- No reflection or runtime code generation
|
|
|
|
## Contributing
|
|
|
|
Contributions are welcome! Please feel free to submit issues, fork the repository, and create pull requests.
|
|
|
|
## License
|
|
|
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
|
|
## Credits
|
|
|
|
Built with ❤️ using Go's excellent standard library.
|