added server module

This commit is contained in:
2025-12-18 12:36:29 -05:00
parent 528778753c
commit cd77794651
7 changed files with 449 additions and 0 deletions

63
.gitignore vendored Normal file
View File

@@ -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/

9
go.mod
View File

@@ -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

10
go.sum Normal file
View File

@@ -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=

116
server/README.md Normal file
View 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
View 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
View 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
View 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
}