added server module
This commit is contained in:
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal 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
9
go.mod
@@ -1,3 +1,12 @@
|
|||||||
module git.citc.tech/citc/web
|
module git.citc.tech/citc/web
|
||||||
|
|
||||||
go 1.25.5
|
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
10
go.sum
Normal 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
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