added server module
This commit is contained in:
116
server/README.md
Normal file
116
server/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# go-server
|
||||
|
||||
A minimal, production-ready, reusable HTTP server foundation for Go web services using **Chi** router and **slog** structured logging.
|
||||
|
||||
This package provides everything you need to spin up a secure, observable, and gracefully shutdown-capable web server with almost zero boilerplate.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic TLS support (via env vars)
|
||||
- Graceful shutdown on `SIGINT` and `SIGTERM`
|
||||
- Structured JSON logging with `log/slog`
|
||||
- Request ID generation and propagation
|
||||
- Panic recovery with stack traces
|
||||
- Structured access logging (method, path, duration, status, bytes, request_id)
|
||||
- Built-in `/healthz` and `/readyz` endpoints
|
||||
- Configurable timeouts (read, write, idle, shutdown)
|
||||
- Functional options for clean configuration
|
||||
- Full request-scoped contextual logging via `slog.Logger.With()`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.citc.tech/go/web/server
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"git.citc.tech/go/web/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("Hello from your new service!"))
|
||||
})
|
||||
|
||||
// Optional: custom logger, timeouts, etc.
|
||||
srv := server.New(
|
||||
server.WithRouter(r),
|
||||
// server.WithLogger(customLogger),
|
||||
// server.WithShutdownTimeout(20 * time.Second),
|
||||
)
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
srv.Log.Error("server stopped with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's it — your service is now running with full production features.
|
||||
|
||||
## Configuration (Environment Variables)
|
||||
|
||||
| Variable | Description | Default |
|
||||
|------------------------------|--------------------------------------|--------------|
|
||||
| `APP_SERVER_ADDR` | Listen address (host:port) | `:8080` |
|
||||
| `APP_SERVER_TLS_KEY_FILE` | Path to TLS private key | (none) |
|
||||
| `APP_SERVER_TLS_CERT_FILE` | Path to TLS certificate | (none) |
|
||||
|
||||
TLS is automatically enabled if both key and cert files exist and are readable.
|
||||
|
||||
## Logging
|
||||
|
||||
- Uses `log/slog` with JSON output by default
|
||||
- All logs include timestamps and levels
|
||||
- Access logs include duration, status, bytes, and request_id
|
||||
- Panics are recovered and logged with full stack trace
|
||||
- Request-scoped logging: use `server.LoggerFromContext(r.Context())` in handlers for logs that automatically include `request_id`, `method`, `path`, etc.
|
||||
|
||||
### Example Handler with Request-Scoped Logging
|
||||
|
||||
```go
|
||||
func protectedHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log := server.LoggerFromContext(r.Context())
|
||||
|
||||
log.Info("handling protected request", "action", "load_dashboard")
|
||||
|
||||
// All logs here automatically include request_id, method, path, etc.
|
||||
log.Debug("fetching user data", "user_id", 123)
|
||||
|
||||
w.Write([]byte("Protected content"))
|
||||
}
|
||||
```
|
||||
|
||||
## Middleware Stack (Applied by Default)
|
||||
|
||||
1. Panic recovery (with structured error logging)
|
||||
2. Request ID generation (`X-Request-ID` header)
|
||||
3. Structured request logging
|
||||
|
||||
You can override the router completely with `WithRouter()` — middleware will still apply unless you replace the router after `New()`.
|
||||
|
||||
## Options
|
||||
|
||||
```go
|
||||
server.WithLogger(logger *slog.Logger)
|
||||
server.WithRouter(router chi.Router)
|
||||
server.WithReadTimeout(duration time.Duration)
|
||||
server.WithWriteTimeout(duration time.Duration)
|
||||
server.WithIdleTimeout(duration time.Duration)
|
||||
server.WithShutdownTimeout(duration time.Duration)
|
||||
```
|
||||
|
||||
## Health Endpoints
|
||||
|
||||
- `GET /healthz` → returns "ok" (200)
|
||||
- `GET /readyz` → returns "ready" (200)
|
||||
96
server/middleware.go
Normal file
96
server/middleware.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type requestLoggerKey struct{}
|
||||
|
||||
func LoggerFromContext(ctx context.Context) *slog.Logger {
|
||||
if l, ok := ctx.Value(requestLoggerKey{}).(*slog.Logger); ok && l != nil {
|
||||
return l
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
func MiddlewareRecovery(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
stack := debug.Stack()
|
||||
log.Error("panic recovered",
|
||||
"panic", rec,
|
||||
"stack", string(stack),
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
"path", r.URL.Path,
|
||||
"method", r.Method,
|
||||
)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
func MiddlewareRequestID() func(http.Handler) http.Handler {
|
||||
return middleware.RequestID
|
||||
}
|
||||
|
||||
func MiddlewareLogging(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
|
||||
requestID := middleware.GetReqID(r.Context())
|
||||
if requestID != "" {
|
||||
requestID = uuid.New().String()
|
||||
}
|
||||
|
||||
reqLog := log.With(
|
||||
"request_id", requestID,
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
)
|
||||
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
reqLog = reqLog.With("scheme", scheme)
|
||||
|
||||
ctx := context.WithValue(r.Context(), requestLoggerKey{}, requestID)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
defer func() {
|
||||
reqLog.Info("request completed",
|
||||
"duration_sec", time.Since(start).Seconds(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"status", ww.Status(),
|
||||
"bytes_written", ww.BytesWritten(),
|
||||
)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func HealthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func ReadinessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ready"))
|
||||
}
|
||||
34
server/options.go
Normal file
34
server/options.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(server *Server) { server.Log = logger }
|
||||
}
|
||||
|
||||
func WithRouter(router chi.Router) Option {
|
||||
return func(server *Server) { server.Router = router }
|
||||
}
|
||||
|
||||
func WithShutdownTimeout(d time.Duration) Option {
|
||||
return func(s *Server) { s.shutdownTimeout = d }
|
||||
}
|
||||
|
||||
func WithReadTimeout(d time.Duration) Option {
|
||||
return func(server *Server) { server.readTimeout = d }
|
||||
}
|
||||
|
||||
func WithWriteTimeout(d time.Duration) Option {
|
||||
return func(server *Server) { server.writeTimeout = d }
|
||||
}
|
||||
|
||||
func WithIdleTimeout(d time.Duration) Option {
|
||||
return func(server *Server) { server.idleTimeout = d }
|
||||
}
|
||||
121
server/server.go
Normal file
121
server/server.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
TLSKeyFile string
|
||||
TLSCertFile string
|
||||
}
|
||||
type Server struct {
|
||||
srv *http.Server
|
||||
Log *slog.Logger
|
||||
Router chi.Router
|
||||
|
||||
readTimeout time.Duration
|
||||
writeTimeout time.Duration
|
||||
idleTimeout time.Duration
|
||||
shutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
func New(opts ...Option) *Server {
|
||||
s := &Server{
|
||||
Log: slog.New(slog.NewJSONHandler(os.Stdout, nil)),
|
||||
Router: chi.NewRouter(),
|
||||
shutdownTimeout: 10 * time.Second,
|
||||
readTimeout: 5 * time.Second,
|
||||
writeTimeout: 10 * time.Second,
|
||||
idleTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
||||
s.Router.Use(
|
||||
MiddlewareRecovery(s.Log),
|
||||
MiddlewareRequestID(),
|
||||
MiddlewareLogging(s.Log),
|
||||
)
|
||||
|
||||
s.Router.Get("/healthz", HealthHandler)
|
||||
s.Router.Get("/readyz", ReadinessHandler)
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: getAddr(),
|
||||
Handler: s.Router,
|
||||
ReadTimeout: s.readTimeout,
|
||||
WriteTimeout: s.writeTimeout,
|
||||
IdleTimeout: s.idleTimeout,
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func getAddr() string {
|
||||
addr := os.Getenv("APP_SERVER_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8080"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func getTLSKey() string { return os.Getenv("APP_SERVER_TLS_KEY_FILE") }
|
||||
func getTLSCert() string { return os.Getenv("APP_SERVER_TLS_CERT_FILE") }
|
||||
|
||||
func isTLSEnabled() bool {
|
||||
return getTLSKey() != "" && getTLSCert() != ""
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
s.Log.Info("starting server", "addr", s.srv.Addr)
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
if isTLSEnabled() {
|
||||
err = s.srv.ListenAndServeTLS(getTLSCert(), getTLSKey())
|
||||
} else {
|
||||
err = s.srv.ListenAndServe()
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.Log.Error("server failed to start", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return s.waitForShutdown()
|
||||
}
|
||||
|
||||
func (s *Server) LoggerWithContext(ctx context.Context) *slog.Logger {
|
||||
return LoggerFromContext(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) waitForShutdown() error {
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
s.Log.Info("shutting down server")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := s.srv.Shutdown(ctx); err != nil {
|
||||
s.Log.Error("server failed to shutdown", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.Log.Info("server shut down successfully")
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user