From cd77794651601fe87235fd5a5422941d1ad5e033 Mon Sep 17 00:00:00 2001 From: Derek Wright Date: Thu, 18 Dec 2025 12:36:29 -0500 Subject: [PATCH] added server module --- .gitignore | 63 ++++++++++++++++++++++ go.mod | 9 ++++ go.sum | 10 ++++ server/README.md | 116 +++++++++++++++++++++++++++++++++++++++++ server/middleware.go | 96 ++++++++++++++++++++++++++++++++++ server/options.go | 34 ++++++++++++ server/server.go | 121 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 449 insertions(+) create mode 100644 .gitignore create mode 100644 go.sum create mode 100644 server/README.md create mode 100644 server/middleware.go create mode 100644 server/options.go create mode 100644 server/server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c2ecb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Go module files +go.mod +go.sum + +# Compiled binaries and executables +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.out + +# Test binaries and coverage +*.test +*.prof +coverage.txt +coverage.html + +# Build artifacts and directories +/bin/ +/dist/ +/tmp/ +/build/ + +# Dependency directories (if not using go modules vendor) +/vendor/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Editor & IDE directories +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment and local config files +.env +.env.local +.envrc +.direnv/ + +# Log files +*.log +logs/ + +# Temporary files +tmp/ +temp/ + +# Go workspace file +go.work +go.work.sum + +# Optional: ignore local tooling binaries +tools/ diff --git a/go.mod b/go.mod index 806857a..35a6830 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,12 @@ module git.citc.tech/citc/web go 1.25.5 + +require ( + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/go-chi/chi/v5 v5.2.3 + github.com/google/uuid v1.6.0 + golang.org/x/oauth2 v0.34.0 +) + +require github.com/go-jose/go-jose/v4 v4.1.3 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0a42825 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..46e89a4 --- /dev/null +++ b/server/README.md @@ -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) \ No newline at end of file diff --git a/server/middleware.go b/server/middleware.go new file mode 100644 index 0000000..bac20c9 --- /dev/null +++ b/server/middleware.go @@ -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")) +} diff --git a/server/options.go b/server/options.go new file mode 100644 index 0000000..f7a183f --- /dev/null +++ b/server/options.go @@ -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 } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..9f9a5dd --- /dev/null +++ b/server/server.go @@ -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 +}